|
| 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 | +} |
0 commit comments