Skip to content

Commit 3df41cf

Browse files
authored
Merge pull request #10 from forwards/may-july-2026
Add May-July 2026 workshops
2 parents 7844449 + 49a3f06 commit 3df41cf

23 files changed

Lines changed: 774 additions & 144 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
title: localtime
2+
author: Ella Kaye
3+
version: 0.3.0
4+
quarto-required: ">=1.2.0"
5+
contributes:
6+
shortcodes:
7+
- localtime.lua
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
$schema: https://m.canouil.dev/quarto-wizard/assets/schema/v1/extension-schema.json
2+
3+
shortcodes:
4+
localtime:
5+
description: Display a source date and time converted to the reader's local timezone using Luxon.
6+
arguments:
7+
- name: date
8+
type: string
9+
required: true
10+
pattern: \d{4}-\d{2}-\d{2}
11+
pattern-exact: true
12+
description: Source date in YYYY-MM-DD format.
13+
- name: time
14+
type: string
15+
required: true
16+
pattern: \d{1,2}:\d{2}([AaPp][Mm])?
17+
pattern-exact: true
18+
description: Source time in 24-hour H:MM or HH:MM format, or 12-hour format with am/pm suffix (e.g. 1:00pm, 1:00 PM); a space before am/pm is optional.
19+
- name: timezone
20+
type: string
21+
default: UTC
22+
description: Source timezone as an abbreviation (e.g. UTC, EST, CET), IANA name (e.g. America/New_York, Europe/Paris), or UTC offset (e.g. +05:30, UTC+8, -08:00); DST is handled automatically in the browser.
23+
completion:
24+
type: freeform
25+
placeholder: UTC | EST | America/New_York | +05:30
26+
attributes:
27+
format:
28+
type: string
29+
default: datetime
30+
description: Output format preset or a custom strftime-like token string; supported tokens are %Y, %m, %-m, %d, %-d, %H, %-H, %I, %-I, %M, %-M, %p, %P, %A, %a, %B, %b, and %Z.
31+
completion:
32+
type: enum
33+
values: [datetime, date, time, time12, datetime12, full, full12]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"Local time shortcode": {
3+
"prefix": "time",
4+
"body": [
5+
"{{< localtime ${1:2026-01-30} ${2:13:00} ${3:UTC} >}}"
6+
],
7+
"description": "Insert a localtime shortcode with date, time, and source timezone."
8+
},
9+
"Local time with format": {
10+
"prefix": "time-format",
11+
"body": [
12+
"{{< localtime ${1:2026-01-30} ${2:13:00} ${3:UTC} format=\"${4:full}\" >}}"
13+
],
14+
"description": "Insert a localtime shortcode with a preset or custom format value."
15+
}
16+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
-- localtime.lua
2+
-- Quarto shortcode extension to display times in the reader's local timezone.
3+
-- Usage: {{< localtime YYYY-MM-DD HH:MM TZ >}}
4+
5+
local counter = 0
6+
local luxon_script_injected = false
7+
8+
-- Timezone abbreviations mapped to either:
9+
-- a string → IANA timezone name (browser Intl handles DST automatically)
10+
-- a number → fixed UTC offset in minutes (positive = east of UTC)
11+
-- Where an abbreviation is ambiguous, the most widely-used interpretation is chosen.
12+
local TZ_ZONES = {
13+
-- Universal
14+
UTC = "UTC", GMT = "UTC",
15+
16+
-- North America (DST-aware → IANA)
17+
NST = "America/St_Johns", NDT = "America/St_Johns",
18+
AST = "America/Halifax", ADT = "America/Halifax",
19+
EST = "America/New_York", EDT = "America/New_York",
20+
CST = "America/Chicago", CDT = "America/Chicago",
21+
MST = "America/Denver", MDT = "America/Denver",
22+
PST = "America/Los_Angeles", PDT = "America/Los_Angeles",
23+
AKST = "America/Anchorage", AKDT = "America/Anchorage",
24+
HST = -600, HDT = -570,
25+
26+
-- South America (fixed offsets, minutes)
27+
VET = -240, BOT = -240, PYT = -240, CLT = -240,
28+
AMT = -240, GYT = -240,
29+
COT = -300, PET = -300, ECT = -300,
30+
BRT = -180, ART = -180, UYT = -180, SRT = -180,
31+
PYST = -180, CLST = -180,
32+
BRST = -120,
33+
34+
-- Europe (DST-aware → IANA)
35+
WET = "Europe/Lisbon", WEST = "Europe/Lisbon",
36+
BST = "Europe/London",
37+
CET = "Europe/Paris", CEST = "Europe/Paris",
38+
EET = "Europe/Helsinki", EEST = "Europe/Helsinki",
39+
MSK = 180, TRT = 180,
40+
41+
-- Africa (fixed offsets)
42+
WAT = 60, CAT = 120, SAST = 120, EAT = 180,
43+
44+
-- Middle East
45+
IDT = 180,
46+
IRST = 210, IRDT = 270,
47+
48+
-- Asia (fixed offsets)
49+
GST = 240, AZT = 240,
50+
AFT = 270,
51+
PKT = 300, UZT = 300,
52+
IST = 330, SLST = 330,
53+
NPT = 345,
54+
BDT = 360, BTT = 360,
55+
MMT = 390,
56+
ICT = 420, WIB = 420, HOVT = 420,
57+
HKT = 480, SGT = 480, MYT = 480,
58+
PHT = 480, WITA = 480, AWST = 480,
59+
JST = 540, KST = 540, WIT = 540, TLT = 540,
60+
61+
-- Australia & Pacific (DST-aware → IANA, others fixed)
62+
ACST = "Australia/Adelaide", ACDT = "Australia/Adelaide",
63+
AEST = "Australia/Sydney", AEDT = "Australia/Sydney",
64+
LHST = 630, LHDT = 660,
65+
SBT = 660, NCT = 660, NFT = 660,
66+
NZST = "Pacific/Auckland", NZDT = "Pacific/Auckland",
67+
FJT = 720, TOT = 780, LINT = 840,
68+
SST = -660, WST = -660,
69+
MART = -570, GAMT = -540,
70+
}
71+
72+
-- Parse a timezone string.
73+
-- Returns a string (IANA name) or number (offset in minutes) for Luxon, or nil if unrecognised.
74+
local function parse_tz(tz_str)
75+
if not tz_str or tz_str == "" then return "UTC" end
76+
77+
local upper = tz_str:upper()
78+
79+
-- Direct abbreviation lookup
80+
local zone = TZ_ZONES[upper]
81+
if zone ~= nil then return zone end
82+
83+
-- Already an IANA name (contains "/")
84+
if tz_str:find("/", 1, true) then
85+
return tz_str
86+
end
87+
88+
-- Convert h/m strings + sign to total minutes
89+
local function to_minutes(sign, h, m)
90+
local mins = tonumber(h) * 60 + (tonumber(m) or 0)
91+
return sign == "+" and mins or -mins
92+
end
93+
94+
-- Handle UTC+X or GMT+X (e.g. UTC+5, UTC+5:30, GMT-8)
95+
local after_prefix = upper:match("^UTC(.+)$") or upper:match("^GMT(.+)$")
96+
if after_prefix then
97+
local sign, h, m = after_prefix:match("^([%+%-])(%d+):?(%d*)$")
98+
if sign and h then
99+
return to_minutes(sign, h, m ~= "" and m or "0")
100+
end
101+
end
102+
103+
-- Handle bare ±HH:MM or ±HHMM
104+
local sign2, h2, m2 = tz_str:match("^([%+%-])(%d%d):?(%d%d)$")
105+
if sign2 and h2 and m2 then
106+
return to_minutes(sign2, h2, m2)
107+
end
108+
109+
-- Handle bare ±H or ±HH
110+
local sign3, h3 = tz_str:match("^([%+%-])(%d+)$")
111+
if sign3 and h3 then
112+
return to_minutes(sign3, h3, "0")
113+
end
114+
115+
return nil
116+
end
117+
118+
return {
119+
["localtime"] = function(args, kwargs, meta, raw_args)
120+
-- Collect positional args as strings
121+
local parts = {}
122+
for _, arg in ipairs(args) do
123+
table.insert(parts, pandoc.utils.stringify(arg))
124+
end
125+
126+
if #parts < 2 then
127+
io.stderr:write("[localtime] Error: expected at least date and time arguments\n")
128+
return pandoc.RawInline("html", "<span class='localtime-error'>[localtime: invalid args]</span>")
129+
end
130+
131+
local date_str = parts[1] -- e.g. "2026-01-30"
132+
local time_str = parts[2] -- e.g. "13:00" or "1:00"
133+
local tz_str
134+
local next_idx = 3
135+
136+
-- Handle space-separated AM/PM: "1:00 PM EST" → parts = ["1:00", "PM", "EST"]
137+
if parts[3] and (parts[3]:upper() == "AM" or parts[3]:upper() == "PM") then
138+
time_str = parts[2] .. " " .. parts[3]
139+
next_idx = 4
140+
end
141+
tz_str = parts[next_idx]
142+
143+
-- Parse date
144+
local year, month, day = date_str:match("^(%d%d%d%d)-(%d%d)-(%d%d)$")
145+
if not year then
146+
io.stderr:write("[localtime] Error: invalid date format '" .. date_str .. "' (expected YYYY-MM-DD)\n")
147+
return pandoc.RawInline("html", "<span class='localtime-error'>[localtime: bad date]</span>")
148+
end
149+
year, month, day = tonumber(year), tonumber(month), tonumber(day)
150+
151+
-- Parse time (12-hour or 24-hour)
152+
local time_str_lower = time_str:lower()
153+
local hour, minute, ampm = time_str_lower:match("^(%d%d?):(%d%d)%s*([ap]m)$")
154+
if hour then
155+
hour, minute = tonumber(hour), tonumber(minute)
156+
if hour < 1 or hour > 12 then
157+
io.stderr:write("[localtime] Error: 12-hour clock hour " .. hour .. " is out of range (1-12)\n")
158+
return pandoc.RawInline("html", "<span class='localtime-error'>[localtime: bad time]</span>")
159+
end
160+
if ampm == "am" then
161+
if hour == 12 then hour = 0 end
162+
else
163+
if hour ~= 12 then hour = hour + 12 end
164+
end
165+
else
166+
local h24, m24 = time_str:match("^(%d%d?):(%d%d)$")
167+
if not h24 then
168+
io.stderr:write("[localtime] Error: invalid time format '" .. time_str .. "' (expected HH:MM or H:MMam/pm)\n")
169+
return pandoc.RawInline("html", "<span class='localtime-error'>[localtime: bad time]</span>")
170+
end
171+
hour, minute = tonumber(h24), tonumber(m24)
172+
end
173+
174+
-- Validate ranges
175+
if month < 1 or month > 12 then
176+
io.stderr:write("[localtime] Warning: month " .. month .. " is out of range (1-12)\n")
177+
end
178+
if day < 1 or day > 31 then
179+
io.stderr:write("[localtime] Warning: day " .. day .. " is out of range (1-31)\n")
180+
end
181+
if hour < 0 or hour > 23 then
182+
io.stderr:write("[localtime] Warning: hour " .. hour .. " is out of range (0-23)\n")
183+
end
184+
if minute < 0 or minute > 59 then
185+
io.stderr:write("[localtime] Warning: minute " .. minute .. " is out of range (0-59)\n")
186+
end
187+
188+
-- Parse timezone
189+
local zone = parse_tz(tz_str or "UTC")
190+
if zone == nil then
191+
io.stderr:write("[localtime] Warning: unrecognised timezone '" .. (tz_str or "") .. "', assuming UTC\n")
192+
zone = "UTC"
193+
tz_str = "UTC"
194+
end
195+
196+
-- zone is either a string (IANA name) or a number (offset in minutes).
197+
-- JS detects which via isNaN(Number(tz)).
198+
local zone_attr = type(zone) == "number" and tostring(zone) or zone
199+
200+
-- ISO datetime string without timezone (Luxon applies zone separately)
201+
local datetime_iso = string.format("%04d-%02d-%02dT%02d:%02d", year, month, day, hour, minute)
202+
203+
-- Fallback text (shown when JS is disabled)
204+
local fallback = string.format("%s %s %s", date_str, time_str, tz_str or "UTC")
205+
206+
-- Optional format kwarg (empty → JS uses its default)
207+
local fmt_attr = ""
208+
if kwargs["format"] then
209+
fmt_attr = pandoc.utils.stringify(kwargs["format"])
210+
end
211+
212+
-- Unique element ID
213+
counter = counter + 1
214+
local id = "localtime-" .. counter
215+
216+
-- Inject Luxon CDN script once, before the first localtime element
217+
local luxon_tag = ""
218+
if not luxon_script_injected then
219+
luxon_tag = '<script src="https://cdn.jsdelivr.net/npm/luxon@3/build/global/luxon.min.js"></script>'
220+
luxon_script_injected = true
221+
end
222+
223+
-- Inline JS: reads data attributes, converts timezone, formats with Luxon.
224+
-- Zone attribute is either an IANA name (string) or offset in minutes (number).
225+
-- Format string uses strftime-style tokens (%Y, %m, %H, %-H, etc.) substituted
226+
-- directly — avoiding Luxon's toFormat() for the full string, which would
227+
-- misinterpret literal text containing Luxon token letters (e.g. "at" → a=AM/PM, t=time).
228+
local js = [[(function(){var el=document.getElementById(']] .. id .. [[');
229+
if(typeof luxon==='undefined'){return;}
230+
var tz=el.getAttribute('data-tz');
231+
var zone=isNaN(Number(tz))?tz:Number(tz);
232+
var dt=luxon.DateTime.fromISO(el.getAttribute('data-datetime'),{zone:zone}).toLocal();
233+
if(!dt.isValid){return;}
234+
var fmt=el.getAttribute('data-format')||'%Y-%m-%d %H:%M';
235+
var P={datetime:'%Y-%m-%d %H:%M',date:'%Y-%m-%d',time:'%H:%M',time12:'%-I:%M%P',datetime12:'%Y-%m-%d %-I:%M%P',full:'%A, %-d %B %Y at %H:%M %Z',full12:'%A, %-d %B %Y at %-I:%M%P %Z'};
236+
if(P[fmt])fmt=P[fmt];
237+
var pad=function(n){return String(n).padStart(2,'0');};
238+
var h=dt.hour,mi=dt.minute;
239+
el.textContent=fmt
240+
.replace(/%Y/g,String(dt.year))
241+
.replace(/%-m/g,String(dt.month)).replace(/%m/g,pad(dt.month))
242+
.replace(/%-d/g,String(dt.day)).replace(/%d/g,pad(dt.day))
243+
.replace(/%-H/g,String(h)).replace(/%H/g,pad(h))
244+
.replace(/%-I/g,String(h%12||12)).replace(/%I/g,pad(h%12||12))
245+
.replace(/%-M/g,String(mi)).replace(/%M/g,pad(mi))
246+
.replace(/%P/g,h<12?'am':'pm').replace(/%p/g,h<12?'AM':'PM')
247+
.replace(/%A/g,dt.toFormat('EEEE')).replace(/%a/g,dt.toFormat('EEE'))
248+
.replace(/%B/g,dt.toFormat('MMMM')).replace(/%b/g,dt.toFormat('MMM'))
249+
.replace(/%Z/g,(Intl.DateTimeFormat(undefined,{timeZoneName:'short'}).formatToParts(dt.toJSDate()).find(function(p){return p.type==='timeZoneName';})||{value:''}).value);})();]]
250+
251+
local html = luxon_tag ..
252+
'<span id="' .. id .. '" class="localtime"' ..
253+
' data-datetime="' .. datetime_iso .. '"' ..
254+
' data-tz="' .. zone_attr .. '"' ..
255+
' data-format="' .. fmt_attr .. '">' ..
256+
fallback .. '</span>' ..
257+
'<script>' .. js .. '</script>'
258+
259+
return pandoc.RawInline("html", html)
260+
end
261+
}

_freeze/slides/03-data-testing/index/execute-results/html.json

Lines changed: 19 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)