The English vocabulary is there before the first scan, a broken attribute reads the table, and a reset outranks a pending blur commit
The vocabulary fallback was a var assigned after the page half, and the page half scans at once when the document has already parsed, which is when a deferred script runs: every field such a script enhanced captured undefined, and "tomorrow" stayed a word. The standalone test loaded the script first and asked afterwards, so it could not see it. The block now sits ahead of the page half, with the reason beside it, and a drive types "tomorrow" into a field enhanced by the page's own deferred script and into one enhanced by a script injected once the page is complete. A reset in the tick after a blur: the blur queues the commit of the box's text; the reset, dispatched before that timer fires, restores the field and blanks the box; the commit then committed the blank, and the reset's own repaint painted the empty field. The reset listener now keeps its event, and the queued commit yields to it unless a listener cancelled the reset, in which case the text is committed as it would have been. A drive leaves the box by blur() and by moving focus to the form's button, resets in the same task each time, and wants the default back in both the field and the box; a third leg cancels the reset and wants the text committed. A data-rst-date-words attribute that is not JSON fell straight to English, past the page's table. It is now as good as absent: the lookup runs again on an element with no attribute, so a Spanish page reads its Spanish table. The Node driver's second scenario holds that. datetime.js is 83,347 bytes; the cap moves to 82KB and the paragraph gives both steps in bytes.
Human
Prompt
The second review of the release fixes: the English fallback was assigned after the immediate scan, a reset raced the blur's deferred commit, malformed attribute JSON skipped the page's table, and the datetime growth comment said 2.5KB for 2,910 bytes.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
js/datetime.js: ENGLISH_WORDS and vocabulary() move ahead of the page half, so a deferred or late-loaded script, which scans at once, no longer captures undefined; a data-rst-date-words attribute that is not JSON is as good as absent, so the table is read before English; and a form reset that lands in the tick between a blur and its queued commit wins, unless a listener cancelled it. Two browser drives (tomorrow at interactive and at complete; reset after blur() and after focus moved to a button), a Spanish malformed-attribute leg in the Node test, the cap to 82KB with the growth stated in bytes, and the regenerated 1.0.0 candidate.
Agent notes
lastReset keeps the event rather than a flag so that a cancelled reset still lets the pending commit through; the drive's third leg checks that. The timing drive strips sizingDoc's deferred datetime.js tag for its injected leg, the way the calendar drive strips calendar.js.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+304
−73
check/datetime_browser_test.go+161 −0check/datetime_test.go+39 −14check/node/vocab_node.mjs+34 −18js/datetime.js+44 −24releases/1.0.0/SHA256SUMS+1 −1releases/1.0.0/datetime.js+25 −16
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.131s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN TestTheOneLinkIsUnder25KiBGzipped
build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok amadan.net/rastrillo/fiesta/internal/build 0.024s
=== suite ===
--- shard 0 of 10 ---
==> shard 0 of 10: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok amadan.net/rastrillo/fiesta 1.110s
ok amadan.net/rastrillo/fiesta/check 6.580s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 6.604s
ok amadan.net/rastrillo/fiesta/example 0.317s
ok amadan.net/rastrillo/fiesta/internal/axe 0.251s
ok amadan.net/rastrillo/fiesta/internal/build 1.555s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.083s
ok amadan.net/rastrillo/fiesta/internal/gallery 3.442s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.071s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.547s
ok amadan.net/rastrillo/fiesta/internal/site 4.067s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.100s
--- shard 1 of 10 ---
==> shard 1 of 10: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 10 ---
==> shard 2 of 10: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 10 ---
==> shard 3 of 10: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
[90m12:43AM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m12:43AM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m12:43AM[0m [32mINF[0m [1mscanned ~5460176 bytes (5.46 MB) in 4.65s[0m
[90m12:43AM[0m [32mINF[0m [1mno leaks found[0m
--- shard 4 of 10 ---
==> shard 4 of 10: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok amadan.net/rastrillo/fiesta/internal/harness 13.047s
--- shard 5 of 10 ---
==> shard 5 of 10: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 285 tests
ok amadan.net/rastrillo/fiesta/check 229.484s
--- shard 6 of 10 ---
==> shard 6 of 10: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 285 tests
ok amadan.net/rastrillo/fiesta/check 230.135s
--- shard 7 of 10 ---
==> shard 7 of 10: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 95 of 285 tests
ok amadan.net/rastrillo/fiesta/check 239.925s
--- shard 8 of 10 ---
==> shard 8 of 10: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 13 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 104.324s
--- shard 9 of 10 ---
==> shard 9 of 10: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 12 of 13 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 34.811s
diff --git a/check/datetime_browser_test.go b/check/datetime_browser_test.go| index b3e15ac..dd207d5 100644 |
| --- a/check/datetime_browser_test.go |
| +++ b/check/datetime_browser_test.go |
| @@ -12,6 +12,7 @@ package check |
| import ( |
| "context" |
| "encoding/json" |
| + "strings" |
| "testing" |
| "time" |
| @@ -293,3 +294,163 @@ func TestAFormResetRepaintsTheDateBox(t *testing.T) { |
| t.Errorf("after a cancelled reset: box shows %q, form posts %q; want %q, 2026-12-25", got.ShownKept, got.PostedKept, got.ShownChanged) |
| } |
| } |
| + |
| +// A reset that lands in the tick between leaving the box and the |
| +// commit that leaving queues: the reset blanks the box and restores |
| +// the field, and the queued commit then committed that blank, emptying |
| +// the field the reset had just restored. Now the pending commit yields |
| +// to an uncancelled reset, and still runs after a cancelled one. The |
| +// focus leaves the box by blur() and by moving to a button, since |
| +// either way is a real focusout. |
| +func TestAResetInTheTickAfterABlurKeepsTheDefault(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + ShownLoad string `json:"shownLoad"` |
| + Changed string `json:"changed"` |
| + NativeBlur string `json:"nativeBlur"` |
| + ShownBlur string `json:"shownBlur"` |
| + NativeButton string `json:"nativeButton"` |
| + ShownButton string `json:"shownButton"` |
| + NativeKept string `json:"nativeKept"` |
| + ShownKept string `json:"shownKept"` |
| + } |
| + driveDate(t, dateMoves+` |
| + const out = {}; |
| + const f = field('plain'); |
| + out.shownLoad = f.input.value; |
| + const change = () => { |
| + f.input.focus(); |
| + f.input.value = '25 Dec 2026'; |
| + f.input.dispatchEvent(new Event('input', { bubbles: true })); |
| + f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + f.input.focus(); |
| + }; |
| + change(); |
| + await frame(); |
| + out.changed = f.native.value; |
| + // Leave by blur(), then reset in the same task. |
| + f.input.blur(); |
| + form.reset(); |
| + await tick(); |
| + await tick(); |
| + await frame(); |
| + out.nativeBlur = f.native.value; |
| + out.shownBlur = f.input.value; |
| + // Leave by moving focus to the button, then reset in the same task. |
| + change(); |
| + await frame(); |
| + document.querySelector('button[type="submit"]').focus(); |
| + form.reset(); |
| + await tick(); |
| + await tick(); |
| + await frame(); |
| + out.nativeButton = f.native.value; |
| + out.shownButton = f.input.value; |
| + // A cancelled reset in that tick still lets the commit through. |
| + change(); |
| + await frame(); |
| + f.input.value = '31 Dec 2026'; |
| + f.input.dispatchEvent(new Event('input', { bubbles: true })); |
| + f.input.blur(); |
| + form.addEventListener('reset', (e) => e.preventDefault(), { once: true }); |
| + form.reset(); |
| + await tick(); |
| + await tick(); |
| + await frame(); |
| + out.nativeKept = f.native.value; |
| + out.shownKept = f.input.value; |
| + return out; |
| + `, &got) |
| + if got.ShownLoad == "" || got.Changed != "2026-12-25" { |
| + t.Fatalf("setup: the box showed %q on load and the field is %q after typing 25 Dec 2026; the drive's setup did not take", got.ShownLoad, got.Changed) |
| + } |
| + if got.NativeBlur != "2026-10-20" || got.ShownBlur != got.ShownLoad { |
| + t.Errorf("blur() then reset in the same task: the field is %q and the box shows %q; want 2026-10-20 and %q", got.NativeBlur, got.ShownBlur, got.ShownLoad) |
| + } |
| + if got.NativeButton != "2026-10-20" || got.ShownButton != got.ShownLoad { |
| + t.Errorf("focus to a button then reset in the same task: the field is %q and the box shows %q; want 2026-10-20 and %q", got.NativeButton, got.ShownButton, got.ShownLoad) |
| + } |
| + if got.NativeKept != "2026-12-31" { |
| + t.Errorf("a cancelled reset in that tick: the field is %q, want 2026-12-31, the text the blur was committing", got.NativeKept) |
| + } |
| +} |
| + |
| +// The English vocabulary must be there before the first scan: a |
| +// deferred script runs once the document has parsed and scans at |
| +// once, and a script injected after load does the same at complete. |
| +// Before, the words were assigned after that scan, so every field such |
| +// a script enhanced captured no vocabulary and "tomorrow" was not a |
| +// date. Two legs: the page's own deferred script (sizingDoc's), and a |
| +// page with no datetime.js at all that injects it after load. |
| +func TestTomorrowParsesWhateverTheScriptsTiming(t *testing.T) { |
| + t.Parallel() |
| + pages := datePages(t) |
| + const tag = `<script defer src="/datetime.js"></script>` |
| + without := strings.Replace(pages["/"], tag, "", 1) |
| + if without == pages["/"] { |
| + t.Fatalf("the fixture page does not link %s; the injected leg has nothing to leave out", tag) |
| + } |
| + pages["/late"] = without |
| + rig := sizingRig(t, false, pages) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + const tomorrow = ` |
| + const f = document.querySelector('#plain'); |
| + const input = document.querySelector('#plain-combo'); |
| + const d = new Date(); |
| + d.setDate(d.getDate() + 1); |
| + const pad = (n) => String(n).padStart(2, '0'); |
| + const want = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); |
| + input.focus(); |
| + input.value = 'tomorrow'; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0))); |
| + return { ready: readyWhenRun, value: f.value, want }; |
| + ` |
| + type leg struct { |
| + Ready string `json:"ready"` |
| + Value string `json:"value"` |
| + Want string `json:"want"` |
| + } |
| + run := func(path, setup string) leg { |
| + t.Helper() |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + emulation.SetFocusEmulationEnabled(true), |
| + chromedp.Navigate(rig.Origin+path), |
| + chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+setup+tomorrow+`})()))()`, &raw, awaitPromise), |
| + ); err != nil { |
| + t.Fatalf("%s: drive failed: %v", path, err) |
| + } |
| + var got leg |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: decode %q: %v", path, raw, err) |
| + } |
| + return got |
| + } |
| + // The deferred script has run by the time the page script does; |
| + // the readyState it saw is what a deferred script sees. |
| + deferred := run("/", ` |
| + await new Promise((r) => { if (document.readyState === 'complete') r(); else window.addEventListener('load', r); }); |
| + if (!document.querySelector('#plain-combo')) throw new Error('the deferred datetime.js did not enhance the field'); |
| + const readyWhenRun = 'interactive (deferred)'; |
| + `) |
| + if deferred.Value != deferred.Want { |
| + t.Errorf("deferred: the field is %q after \"tomorrow\", want %q (%s)", deferred.Value, deferred.Want, deferred.Ready) |
| + } |
| + // No datetime.js on the page; it is injected once the page is complete. |
| + late := run("/late", ` |
| + await new Promise((r) => { if (document.readyState === 'complete') r(); else window.addEventListener('load', r); }); |
| + if (document.querySelector('#plain-combo')) throw new Error('/late already has an enhanced field; the injected leg is not set up'); |
| + const readyWhenRun = document.readyState; |
| + await new Promise((r, x) => { const s = document.createElement('script'); s.src = '/datetime.js'; s.onload = r; s.onerror = x; document.head.append(s); }); |
| + if (!document.querySelector('#plain-combo')) throw new Error('the injected datetime.js did not enhance the field'); |
| + `) |
| + if late.Ready != "complete" { |
| + t.Errorf("the injected leg ran at readyState %q, want complete", late.Ready) |
| + } |
| + if late.Value != late.Want { |
| + t.Errorf("injected at complete: the field is %q after \"tomorrow\", want %q", late.Value, late.Want) |
| + } |
| +} |
diff --git a/check/datetime_test.go b/check/datetime_test.go| index 561536c..21b39d7 100644 |
| --- a/check/datetime_test.go |
| +++ b/check/datetime_test.go |
| @@ -458,17 +458,23 @@ func TestDatetimeContract(t *testing.T) { |
| // it, with 0.4KB to spare. The copy in select.js is the same bytes; |
| // see TestTheTwoLookupsAreTheSame. |
| // |
| - // 78KB → 81KB (measured 82,376 bytes, +2.5KB), by three things the |
| - // release review asked for: the English vocabulary, ENGLISH_WORDS |
| - // and vocabulary(), so the three phrases the README advertises |
| - // parse with no table loaded (about 0.9KB, half of it the words |
| - // themselves); the native state, locked() and follow(), so a |
| - // readonly field refuses every way of changing it and a disabled |
| - // one is disabled throughout (about 1.0KB with its guards); and the |
| - // form-reset repaint (about 0.6KB, most of it the comment saying |
| - // why it waits a task). 80KB (81,920) does not hold it; 81KB |
| - // (82,944) does, with 568 to spare. |
| - if n := len(raw); n > 81*1024 { |
| + // 78KB → 82KB, in two steps the release review asked for. First to |
| + // 82,376 bytes (+2,910 on the 79,466 the string tables left): the |
| + // English vocabulary, ENGLISH_WORDS and vocabulary(), so the three |
| + // phrases the README advertises parse with no table loaded (about |
| + // 0.9KB, half of it the words themselves); the native state, locked() |
| + // and follow(), so a readonly field refuses every way of changing it |
| + // and a disabled one is disabled throughout (about 1.0KB with its |
| + // guards); and the form-reset repaint (about 0.6KB, most of it the |
| + // comment saying why it waits a task). Then to 83,347 (+971) when the |
| + // re-review found the vocabulary assigned after the first scan, so a |
| + // deferred script captured undefined: the block moved ahead of the |
| + // page half with a comment saying why, a malformed attribute now |
| + // falls to the table before English (NO_ATTRIBUTE and the catch), |
| + // and a reset that lands in the tick before a blur's commit wins |
| + // (lastReset). 81KB (82,944) does not hold it; 82KB (83,968) does, |
| + // with 621 to spare. |
| + if n := len(raw); n > 82*1024 { |
| t.Fatalf("datetime.js is %d bytes; split something out before it stops being readable", n) |
| } |
| } |
| @@ -503,7 +509,8 @@ func TestDatetimeDerivesItsCalendarNames(t *testing.T) { |
| // words must be i18n/en.json's date-words exactly, so a word improved |
| // in the table is improved in the script, and every phrase must read: |
| // tomorrow is the 29th, two weeks on is 11 November, and next Friday |
| -// at nine is a Friday at 09:00. |
| +// at nine is a Friday at 09:00. The driver's second scenario holds the |
| +// order when the attribute is broken: a Spanish page reads its table. |
| func TestTheEnglishVocabularyIsTheFallback(t *testing.T) { |
| nodetest.Node(t) |
| phrases := []string{"tomorrow", "next fri 9am", "in 2 weeks"} |
| @@ -513,8 +520,9 @@ func TestTheEnglishVocabularyIsTheFallback(t *testing.T) { |
| } |
| out := nodetest.Run(t, nodetest.Cmd{Args: []string{"node/vocab_node.mjs"}, Stdin: in, Env: []string{"TZ=UTC"}}) |
| var got struct { |
| - Words map[string]string `json:"words"` |
| - Parsed map[string]*string `json:"parsed"` |
| + Words map[string]string `json:"words"` |
| + Parsed map[string]*string `json:"parsed"` |
| + Malformed map[string]string `json:"malformed"` |
| } |
| if err := json.Unmarshal(out, &got); err != nil { |
| t.Fatalf("decode %q: %v", out, err) |
| @@ -554,4 +562,21 @@ func TestTheEnglishVocabularyIsTheFallback(t *testing.T) { |
| t.Errorf("%q read as %s, want a Friday at 09:00", phrase, at.Format(time.RFC3339)) |
| } |
| } |
| + // An attribute that is not JSON is as good as absent: on a Spanish |
| + // page with the Spanish table loaded, the field reads the table, not |
| + // English, so the order is attribute, then table, then English even |
| + // when the attribute is broken. |
| + es, ok := fiesta.Strings("es") |
| + if !ok { |
| + t.Fatal("i18n/es.json is not shipped") |
| + } |
| + var spanish struct { |
| + Words map[string]string `json:"date-words"` |
| + } |
| + if err := json.Unmarshal(es, &spanish); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !maps.Equal(got.Malformed, spanish.Words) { |
| + t.Errorf("a Spanish page with a malformed date-words attribute resolved to:\n %v\nwant the Spanish table:\n %v", got.Malformed, spanish.Words) |
| + } |
| } |
diff --git a/check/node/vocab_node.mjs b/check/node/vocab_node.mjs| index 8dffa99..3adcf0e 100644 |
| --- a/check/node/vocab_node.mjs |
| +++ b/check/node/vocab_node.mjs |
| @@ -2,9 +2,11 @@ |
| // against a stub page: a document whose lang is en, no |
| // self.fiestaStrings, and a field with no attributes, so the only |
| // vocabulary left is the script's own English. It reads back one JSON |
| -// object: "words", the vocabulary the field resolved to, and "parsed", |
| +// object: "words", the vocabulary the field resolved to; "parsed", |
| // each phrase on stdin to the ISO instant the parser read it as (null |
| -// for no reading), at a fixed now of 2026-10-28 10:30 local. |
| +// for no reading), at a fixed now of 2026-10-28 10:30 local; and |
| +// "malformed", the vocabulary a field with an attribute that is not |
| +// JSON resolves to on a Spanish page with the Spanish table loaded. |
| // |
| // echo '{"phrases":["tomorrow","next fri 9am","in 2 weeks"]}' | |
| // node node/vocab_node.mjs |
| @@ -21,26 +23,40 @@ const chunks = []; |
| for await (const chunk of process.stdin) chunks.push(chunk); |
| const { phrases = [] } = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); |
| -// The least DOM the script touches on load: a loading document whose |
| -// root carries lang, and a self holding no registry at all. |
| -const root = { lang: "en", getAttribute: (k) => (k === "lang" ? "en" : null) }; |
| -const document = { |
| - readyState: "loading", |
| - documentElement: root, |
| - addEventListener() {}, |
| - querySelectorAll: () => [], |
| -}; |
| -const self = {}; |
| -const module = { exports: {} }; |
| -vm.runInNewContext(src, { document, self, window: self, module, exports: module.exports, setTimeout, clearTimeout }, { filename: "datetime.js" }); |
| +// load evaluates the script in a fresh context: the least DOM it |
| +// touches on load, a loading document whose root carries lang, and a |
| +// self holding strings as its registry. It returns what the script |
| +// exported. |
| +function load(lang, strings) { |
| + const root = { lang, getAttribute: (k) => (k === "lang" ? lang : null) }; |
| + const document = { |
| + readyState: "loading", |
| + documentElement: root, |
| + addEventListener() {}, |
| + querySelectorAll: () => [], |
| + }; |
| + const self = { fiestaStrings: strings }; |
| + const module = { exports: {} }; |
| + vm.runInNewContext(src, { document, self, window: self, module, exports: module.exports, setTimeout, clearTimeout }, { filename: "datetime.js" }); |
| + return module.exports; |
| +} |
| + |
| +// An element with attributes and nothing else. |
| +const element = (attrs) => ({ getAttribute: (k) => (Object.prototype.hasOwnProperty.call(attrs, k) ? attrs[k] : null) }); |
| -const { vocabulary, parse, tables } = module.exports; |
| +const { vocabulary, parse, tables } = load("en", {}); |
| if (typeof vocabulary !== "function") { |
| process.stderr.write("datetime.js does not export vocabulary\n"); |
| process.exit(1); |
| } |
| -// A field with no attributes at all. |
| -const words = vocabulary({ getAttribute: () => null }); |
| +// A field with no attributes at all, on an English page with no table. |
| +const words = vocabulary(element({})); |
| + |
| +// A Spanish page whose table is the shipped i18n/es.json, and a field |
| +// whose attribute is not JSON: the attribute is as good as absent, so |
| +// the vocabulary must be the table's, not English. |
| +const es = JSON.parse(readFileSync(new URL("../../i18n/es.json", import.meta.url), "utf8")); |
| +const malformed = load("es", { es }).vocabulary(element({ "data-rst-date-words": "{not json" })); |
| const now = new Date(2026, 9, 28, 10, 30); |
| const tbl = tables("en"); |
| const parsed = {}; |
| @@ -53,4 +69,4 @@ for (const phrase of phrases) { |
| } |
| parsed[phrase] = read && read.date ? read.date.toISOString() : null; |
| } |
| -process.stdout.write(JSON.stringify({ words, parsed })); |
| +process.stdout.write(JSON.stringify({ words, parsed, malformed })); |
diff --git a/js/datetime.js b/js/datetime.js| index bac6e33..6ffe3f0 100644 |
| --- a/js/datetime.js |
| +++ b/js/datetime.js |
| @@ -1059,6 +1059,41 @@ |
| Everything below is behind the document guard, so Node loads the |
| parser above and nothing else. */ |
| + // The English vocabulary, i18n/en.json's date-words: the fallback |
| + // when neither the attribute nor a loaded table spells one, as the |
| + // labels fall back to their English. check/datetime_test.go holds |
| + // it to the shipped table, so a word improved there is improved |
| + // here. Before the page half, which scans at once on a document |
| + // that has already parsed (a deferred or late-loaded script): a var |
| + // assigned after that scan would be undefined to every field it |
| + // enhanced. |
| + var ENGLISH_WORDS = { |
| + ago: "ago", am: "am|a.m.", at: "at", day: "day|days", hour: "hour|hours|h", in: "in", |
| + last: "last", midnight: "midnight", minute: "minute|minutes|min|mins|m", |
| + month: "month|months", next: "next", noon: "noon|midday", pm: "pm|p.m.", |
| + today: "today", tomorrow: "tomorrow", week: "week|weeks", yesterday: "yesterday" |
| + }; |
| + |
| + // An element with no attributes, for a lookup that skips the one on |
| + // the field. |
| + var NO_ATTRIBUTE = { getAttribute: function () { return null; } }; |
| + |
| + // The parser's vocabulary for one field: the attribute (JSON text), |
| + // else the page's table (the object), else English. An attribute |
| + // that is not JSON is as good as absent, so a Spanish page with a |
| + // broken attribute still reads its Spanish table before English. |
| + function vocabulary(native) { |
| + var words = fiestaWord(native, "date-words", ENGLISH_WORDS); |
| + if (typeof words === "string") { |
| + try { |
| + words = JSON.parse(words); |
| + } catch (e) { |
| + words = fiestaWord(NO_ATTRIBUTE, "date-words", ENGLISH_WORDS); |
| + } |
| + } |
| + return words || ENGLISH_WORDS; |
| + } |
| + |
| if (typeof document !== "undefined") { |
| var CAL_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| @@ -1630,9 +1665,16 @@ |
| // say and slammed the panel shut under the cursor. A tick later |
| // the answer is not in doubt — document.activeElement is either |
| // back inside this field or it is not. |
| + // A reset that arrives in the tick between the blur and its |
| + // commit wins: the reset has blanked the box, and committing |
| + // that blank would empty the field the reset just restored. |
| + // The event is kept, not a flag, so a reset a listener cancelled |
| + // still lets the commit through. |
| + var lastReset = null; |
| wrap.addEventListener("focusout", function () { |
| setTimeout(function () { |
| if (wrap.contains(document.activeElement)) return; |
| + if (lastReset && !lastReset.defaultPrevented) return; |
| commitText(); |
| closeAll(); |
| }, 0); |
| @@ -1692,7 +1734,9 @@ |
| // reset happens, not when the field was enhanced. |
| document.addEventListener("reset", function (e) { |
| if (e.target !== native.form) return; |
| + lastReset = e; |
| setTimeout(function () { |
| + lastReset = null; |
| if (e.defaultPrevented) return; |
| closeAll(); |
| paint(); |
| @@ -1779,30 +1823,6 @@ |
| return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback; |
| } |
| - // The English vocabulary, i18n/en.json's date-words: the fallback |
| - // when neither the attribute nor a loaded table spells one, as the |
| - // labels fall back to their English. check/datetime_test.go holds |
| - // it to the shipped table, so a word improved there is improved |
| - // here. |
| - var ENGLISH_WORDS = { |
| - ago: "ago", am: "am|a.m.", at: "at", day: "day|days", hour: "hour|hours|h", in: "in", |
| - last: "last", midnight: "midnight", minute: "minute|minutes|min|mins|m", |
| - month: "month|months", next: "next", noon: "noon|midday", pm: "pm|p.m.", |
| - today: "today", tomorrow: "tomorrow", week: "week|weeks", yesterday: "yesterday" |
| - }; |
| - |
| - // The parser's vocabulary for one field: the attribute (JSON text), |
| - // else the page's table (the object), else English. An attribute |
| - // that does not parse is no vocabulary, and English again. |
| - function vocabulary(native) { |
| - try { |
| - var words = fiestaWord(native, "date-words", ENGLISH_WORDS); |
| - return (typeof words === "string" ? JSON.parse(words) : words) || ENGLISH_WORDS; |
| - } catch (e) { |
| - return ENGLISH_WORDS; |
| - } |
| - } |
| - |
| // The pure half, for check/node/datetime_node.mjs, and the lookup |
| // for node/strings_node.mjs. A browser has no `module` and never |
| // takes this branch. |
diff --git a/releases/1.0.0/SHA256SUMS b/releases/1.0.0/SHA256SUMS| index 35a1ca5..ece4b30 100644 |
| --- a/releases/1.0.0/SHA256SUMS |
| +++ b/releases/1.0.0/SHA256SUMS |
| @@ -3,7 +3,7 @@ b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57 NOTICE |
| 40bb2c04c08f57539ee293e0e26d421c108425dfdd0da0401ee989157fe57bab anchor.js |
| f5a0406b7b4aca24539dd8ababe0fdc45111f346cce7667ed56d4ce4858e700e busy.js |
| 6db97e886b0cf78806e88ffd8b4893e185387acb6d4d33924b20331a5ab6ced3 calendar.js |
| -0335befa2c954b92bb6ab99b5c994995a4ef49b4c67f171e48db3ace0869778d datetime.js |
| +f53d2ab11686cddf05d2e0e2efa57de19c1edad708298890993bb63017fee2df datetime.js |
| 369b0e6f349a8e40c80c0d510f0541e249f78aba5ffaf0362d9bc685dca3cfac day.css |
| 256285cd4933c9ec094c4a02c68c97cea6381b30add06885ff2d806768cf5aed fiesta-plain.css |
| eaf51f25dd3ad66d8f8e8511c005e19a70cc5306be6e125f046d2a5294256532 fiesta-signal.css |
diff --git a/releases/1.0.0/datetime.js b/releases/1.0.0/datetime.js| index 59a63af..7aa1aec 100644 |
| --- a/releases/1.0.0/datetime.js |
| +++ b/releases/1.0.0/datetime.js |
| @@ -673,6 +673,27 @@ |
| } |
| + var ENGLISH_WORDS = { |
| + ago: "ago", am: "am|a.m.", at: "at", day: "day|days", hour: "hour|hours|h", in: "in", |
| + last: "last", midnight: "midnight", minute: "minute|minutes|min|mins|m", |
| + month: "month|months", next: "next", noon: "noon|midday", pm: "pm|p.m.", |
| + today: "today", tomorrow: "tomorrow", week: "week|weeks", yesterday: "yesterday" |
| + }; |
| + |
| + var NO_ATTRIBUTE = { getAttribute: function () { return null; } }; |
| + |
| + function vocabulary(native) { |
| + var words = fiestaWord(native, "date-words", ENGLISH_WORDS); |
| + if (typeof words === "string") { |
| + try { |
| + words = JSON.parse(words); |
| + } catch (e) { |
| + words = fiestaWord(NO_ATTRIBUTE, "date-words", ENGLISH_WORDS); |
| + } |
| + } |
| + return words || ENGLISH_WORDS; |
| + } |
| + |
| if (typeof document !== "undefined") { |
| var CAL_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| @@ -1111,9 +1132,11 @@ |
| if (e.key === "Enter") e.preventDefault(); |
| }); |
| + var lastReset = null; |
| wrap.addEventListener("focusout", function () { |
| setTimeout(function () { |
| if (wrap.contains(document.activeElement)) return; |
| + if (lastReset && !lastReset.defaultPrevented) return; |
| commitText(); |
| closeAll(); |
| }, 0); |
| @@ -1146,7 +1169,9 @@ |
| document.addEventListener("reset", function (e) { |
| if (e.target !== native.form) return; |
| + lastReset = e; |
| setTimeout(function () { |
| + lastReset = null; |
| if (e.defaultPrevented) return; |
| closeAll(); |
| paint(); |
| @@ -1219,22 +1244,6 @@ |
| return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback; |
| } |
| - var ENGLISH_WORDS = { |
| - ago: "ago", am: "am|a.m.", at: "at", day: "day|days", hour: "hour|hours|h", in: "in", |
| - last: "last", midnight: "midnight", minute: "minute|minutes|min|mins|m", |
| - month: "month|months", next: "next", noon: "noon|midday", pm: "pm|p.m.", |
| - today: "today", tomorrow: "tomorrow", week: "week|weeks", yesterday: "yesterday" |
| - }; |
| - |
| - function vocabulary(native) { |
| - try { |
| - var words = fiestaWord(native, "date-words", ENGLISH_WORDS); |
| - return (typeof words === "string" ? JSON.parse(words) : words) || ENGLISH_WORDS; |
| - } catch (e) { |
| - return ENGLISH_WORDS; |
| - } |
| - } |
| - |
| if (typeof module !== "undefined" && module && module.exports) { |
| module.exports = { |
| parse: parse, |