Fiesta's scripts read their strings from per-language tables, and find the calendar as fiestaCalendar
datetime.js and select.js each carry fiestaPickLang and fiestaWord, the same bytes in both (F14), so each enhancement stays one script tag. A label is read off the element's data-rst- attribute first, then the page's table (self.fiestaStrings, one object per language tag, which i18n/<lang>.js will fill, picked by the document's lang: an exact case-insensitive match, else the first table in the tag's primary language, else none), then the English fallback written at the call, which is unchanged in every case (F1). The old attr helper and the select's bare getAttribute are gone; date-words takes the attribute's JSON text or the table's object. The seam between datetime.js and calendar.js is window.fiestaCalendar in both files and in the two contract tests, in this commit. fiesta.go embeds i18n/ and answers Languages() and Strings(lang). The datetime suite is ported from rastrillo's ui/datetime_test.go: the browser's vocabulary now comes from each language's date-words, with retagged.json's pt reading pt-PT's words. TestEveryLanguageHasEveryKey is check/i18n_test.go's shape check folded in and read through fiesta.Strings over fiesta.Languages(); it reads committed JSON, so it was already passing; control is a mutation (a deleted date-set in a scratch copy, F18). driveCalendarOpen and calendarPages stand alone for Task 12 (F21). datetime.js's size cap moves 76 KiB to 78 KiB: SEED's file sat 45 bytes under it, and the lookup is 1.0 KB of code and 0.7 KB of prose (the seven quick-pick attributes written out in the header, which the contract pins and the removed attr calls were the only other mention of). The arithmetic is in TestDatetimeContract beside the earlier raises.
Summary
Languages() and Strings() over an embedded i18n/; fiestaPickLang and fiestaWord in datetime.js and select.js, attribute then table then fallback, exported to Node; window.fiestaCalendar; check/datetime_test.go ported with vocabStdin over fiesta.Strings; check/strings_test.go (shape, near misses, the table filling a missing attribute, the two copies identical); check/node/datetime_node.mjs and strings_node.mjs; the calendar-open and table-only browser drives.
Agent notes
Task 6. Preservation tests passed on their first run except the two seam contracts, red only on the fiestaCalendar string until the rename in this commit. Negative controls in a scratch copy: es.json tomorrow=xyz fails TestDatetimeParserFixtures/es on five cases; a deleted date-set fails TestEveryLanguageHasEveryKey; the stubs fail TestPickLangResolvesNearMisses, TestTheTableFillsAMissingAttribute, TestTableOnlyStringsReachTheScreen (English Type to filter on the Spanish page) and TestTheCalendarOpensThroughFiestaCalendar by name; a one-line change to select.js's lookup fails TestTheTwoLookupsAreTheSame. The 76 to 78 KiB cap raise is the one preservation threshold changed; see the report.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 files changed,
+1696
−217
check/calendar_browser_test.go+132 −0check/datetime_test.go+480 −0check/i18n_test.go+0 −158check/node/datetime_node.mjs+365 −0check/node/strings_node.mjs+91 −0check/strings_browser_test.go+122 −0check/strings_test.go+323 −0fiesta.go+26 −5fiesta_test.go+36 −0js/calendar.js+5 −5js/datetime.js+77 −43js/select.js+39 −6
diff --git a/check/calendar_browser_test.go b/check/calendar_browser_test.go| new file mode 100644 |
| index 0000000..dbfbc1e |
| --- /dev/null |
| +++ b/check/calendar_browser_test.go |
| @@ -0,0 +1,132 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The seam between datetime.js and calendar.js is one name on the |
| +// window, fiestaCalendar, looked up at enhance time. Nothing breaks |
| +// loudly if it moves: the button quietly falls back to the browser's |
| +// own picker, which is the control the overlay replaced. This drive |
| +// holds the rename end to end, and driveCalendarOpen is its body, |
| +// standalone so Task 12 can run it against the release's scripts on a |
| +// rig of its own built from calendarPages. |
| + |
| +// calendarPages is the drive's two pages: "/" is field-date's default |
| +// state, from the manifest, on the shared fixture page, which links |
| +// calendar.js and datetime.js; "/nocal" is the same page with |
| +// calendar.js's tag left out, so the button has only the native |
| +// picker to fall back to. |
| +func calendarPages(t *testing.T) map[string]string { |
| + t.Helper() |
| + m, err := example.Load(fiesta.FS()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + state, ok := m.Default("field-date") |
| + if !ok { |
| + t.Fatal("the manifest has no field-date example") |
| + } |
| + markup, err := example.Markup(fiesta.FS(), "field-date", state) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(markup, "data-rst-date") { |
| + t.Fatalf("field-date's default state %q is not an armed date field:\n%s", state, markup) |
| + } |
| + with := sizingDoc("calendar", markup) |
| + const tag = `<script defer src="/calendar.js"></script>` |
| + without := strings.Replace(with, tag, "", 1) |
| + if without == with { |
| + t.Fatalf("the fixture page does not link %s; the fallback leg has nothing to leave out", tag) |
| + } |
| + return map[string]string{"/": with, "/nocal": without} |
| +} |
| + |
| +// calendarOpening is what the page says once the button is pressed. |
| +type calendarOpening struct { |
| + Seam bool // window.fiestaCalendar is a function |
| + Haspopup string // the button's aria-haspopup, "" for none |
| + Dialogs int // [role=dialog] elements on the page |
| + Grids int // [role=grid] inside the dialog |
| + Cells int // [role=gridcell] inside the dialog |
| + Heads int // [role=columnheader] inside the dialog |
| +} |
| + |
| +const calendarOpeningJS = `JSON.stringify({ |
| + Seam: typeof window.fiestaCalendar === "function", |
| + Haspopup: document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-haspopup') ?? '', |
| + Dialogs: document.querySelectorAll('[role="dialog"]').length, |
| + Grids: document.querySelectorAll('[role="dialog"] [role="grid"]').length, |
| + Cells: document.querySelectorAll('[role="dialog"] [role="gridcell"]').length, |
| + Heads: document.querySelectorAll('[role="dialog"] [role="columnheader"]').length |
| +})` |
| + |
| +// driveCalendarOpen is TestTheCalendarOpensThroughFiestaCalendar's |
| +// body, on whatever rig serves calendarPages: with calendar.js on the |
| +// page, pressing the calendar button opens a dialog holding a month |
| +// grid of forty-two days under seven column headers; with it left |
| +// out, the button is the plain fallback to the native picker, with no |
| +// popup of its own and no dialog on the page after a press. |
| +func driveCalendarOpen(t *testing.T, rig *harness.Rig) { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery)) |
| + var before calendarOpening |
| + at(t, ctx, calendarOpeningJS, &before) |
| + if !before.Seam { |
| + t.Fatal("calendar.js is on the page, but window.fiestaCalendar is not a function; datetime.js has no grid to ask for") |
| + } |
| + if before.Haspopup != "dialog" { |
| + t.Errorf("with calendar.js, the button's aria-haspopup is %q, want dialog", before.Haspopup) |
| + } |
| + mustRun(t, ctx, chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[role="dialog"] [role="gridcell"]`, chromedp.ByQuery)) |
| + var opened calendarOpening |
| + at(t, ctx, calendarOpeningJS, &opened) |
| + if opened.Dialogs != 1 || opened.Grids != 1 || opened.Cells != 42 || opened.Heads != 7 { |
| + t.Errorf("after the press: %d dialogs, %d grids, %d cells, %d headers; want 1, 1, 42, 7", opened.Dialogs, opened.Grids, opened.Cells, opened.Heads) |
| + } |
| + rig.Screen("body", "the calendar open through fiestaCalendar") |
| + |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/nocal"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery)) |
| + var fallback calendarOpening |
| + at(t, ctx, calendarOpeningJS, &fallback) |
| + if fallback.Seam { |
| + t.Fatal("/nocal still has window.fiestaCalendar; the fallback leg is not running without calendar.js") |
| + } |
| + if fallback.Haspopup != "" { |
| + t.Errorf("without calendar.js, the button's aria-haspopup is %q, want none: it hands over to the browser's own panel", fallback.Haspopup) |
| + } |
| + // The press reaches native.showPicker(), which opens the browser's |
| + // own panel or throws (caught) where there is none. Either way the |
| + // page draws no dialog of its own. |
| + mustRun(t, ctx, chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery)) |
| + time.Sleep(300 * time.Millisecond) |
| + var pressed calendarOpening |
| + at(t, ctx, calendarOpeningJS, &pressed) |
| + if pressed.Dialogs != 0 || pressed.Cells != 0 { |
| + t.Errorf("without calendar.js, the press drew %d dialogs with %d cells; want none", pressed.Dialogs, pressed.Cells) |
| + } |
| +} |
| + |
| +// TestTheCalendarOpensThroughFiestaCalendar: see driveCalendarOpen. |
| +func TestTheCalendarOpensThroughFiestaCalendar(t *testing.T) { |
| + pages := calendarPages(t) |
| + rig := harness.New(t, func(string) http.Handler { return sizingMux(t, pages) }) |
| + driveCalendarOpen(t, rig) |
| +} |
diff --git a/check/datetime_test.go b/check/datetime_test.go| new file mode 100644 |
| index 0000000..d7bd282 |
| --- /dev/null |
| +++ b/check/datetime_test.go |
| @@ -0,0 +1,480 @@ |
| +package check |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/nodetest" |
| +) |
| + |
| +// Ported from rastrillo's ui/datetime_test.go at SEED. The parser and |
| +// the calendar's arithmetic run in Node (node/datetime_node.mjs); the |
| +// contracts read the scripts as text. The vocabulary the browser reads |
| +// off data-rst-date-words comes from i18n/<lang>.json's date-words |
| +// now, through fiesta.Strings, rather than from rastrillo's catalogs. |
| + |
| +// shippedScript returns one script by its file name, failing the test |
| +// when the tree does not ship it. |
| +func shippedScript(t *testing.T, name string) []byte { |
| + t.Helper() |
| + js, ok := fiesta.Script(name) |
| + if !ok { |
| + t.Fatalf("%s is not shipped", name) |
| + } |
| + return js |
| +} |
| + |
| +// retagged reads testdata/datetime/retagged.json: the tags a page may |
| +// still carry (plain pt) and the shipped language each one's table is. |
| +// No pt.json ships; the alias lives here, in test data, only. |
| +func retagged(t *testing.T) map[string]string { |
| + t.Helper() |
| + b, err := os.ReadFile(filepath.Join("testdata", "datetime", "retagged.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out map[string]string |
| + if err := json.Unmarshal(b, &out); err != nil { |
| + t.Fatalf("retagged.json: %v", err) |
| + } |
| + if len(out) == 0 { |
| + t.Fatal("retagged.json maps nothing; pt is the alias this suite expects") |
| + } |
| + return out |
| +} |
| + |
| +// dateWordsOf reads one shipped language's date-words, the seventeen |
| +// vocabulary names and nothing else, out of its table. |
| +func dateWordsOf(t *testing.T, lang string) map[string]string { |
| + t.Helper() |
| + b, ok := fiesta.Strings(lang) |
| + if !ok { |
| + t.Fatalf("fiesta.Strings(%q): not shipped", lang) |
| + } |
| + var table struct { |
| + Words map[string]string `json:"date-words"` |
| + } |
| + if err := json.Unmarshal(b, &table); err != nil { |
| + t.Fatalf("i18n/%s.json: %v", lang, err) |
| + } |
| + words := make(map[string]string, len(dateWords)) |
| + for _, name := range dateWords { |
| + value := table.Words[name] |
| + if value == "" { |
| + t.Fatalf("i18n/%s.json: date-words has no %s", lang, name) |
| + } |
| + words[name] = value |
| + } |
| + return words |
| +} |
| + |
| +// vocabStdin is what the browser reads off data-rst-date-words, built |
| +// the same way a page's table carries it: each shipped language's |
| +// date-words, the seventeen vocabulary keys, and nothing else. The |
| +// fixtures deliberately do not carry their own copy: words under test |
| +// have to be the words that ship, or a translator's improvement would |
| +// go green against a stale duplicate. |
| +// |
| +// Every shipped language goes over, not just the fixture's own: a case |
| +// may name its own language with a "lang" key, which is what |
| +// testdata/datetime/regressions.json is made of. The retagged aliases |
| +// go over too: a page whose lang is still pt gets pt-PT's words, on a |
| +// tag Intl writes as Brazil, and that pairing is one the parser has to |
| +// read as well. |
| +func vocabStdin(t *testing.T, locale string) []byte { |
| + t.Helper() |
| + catalogs := make(map[string]map[string]string) |
| + for _, lang := range fiesta.Languages() { |
| + catalogs[lang] = dateWordsOf(t, lang) |
| + } |
| + for from, to := range retagged(t) { |
| + words, ok := catalogs[to] |
| + if !ok { |
| + t.Fatalf("retagged.json maps %s to %s, which is not a shipped language", from, to) |
| + } |
| + catalogs[from] = words |
| + } |
| + if _, ok := catalogs[locale]; !ok { |
| + t.Fatalf("no table for %q", locale) |
| + } |
| + encoded, err := json.Marshal(map[string]any{"locale": locale, "catalogs": catalogs}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return encoded |
| +} |
| + |
| +// localeOf reads a fixture's default language off its filename. A file |
| +// not named after a shipped language is a cross-locale one, every case |
| +// in it names its own language, and English is its default. |
| +func localeOf(fixture string) string { |
| + name := strings.TrimSuffix(filepath.Base(fixture), ".json") |
| + if slices.Contains(fiesta.Languages(), name) { |
| + return name |
| + } |
| + return "en" |
| +} |
| + |
| +// datetimeFixtures lists the parser fixtures: every JSON file under |
| +// testdata/datetime except retagged.json, which is the alias table. |
| +func datetimeFixtures(t *testing.T) []string { |
| + t.Helper() |
| + all, err := filepath.Glob(filepath.Join("testdata", "datetime", "*.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var fixtures []string |
| + for _, f := range all { |
| + if filepath.Base(f) != "retagged.json" { |
| + fixtures = append(fixtures, f) |
| + } |
| + } |
| + if len(fixtures) == 0 { |
| + t.Fatal("no fixtures in testdata/datetime") |
| + } |
| + return fixtures |
| +} |
| + |
| +// The parser is the risk in datetime.js, and it is the half no Go test |
| +// can reach on its own: it reads month and weekday names out of Intl, |
| +// folds the digits a locale writes, and answers in wall-clock dates. |
| +// datetime_node.mjs runs it under plain Node against one fixture per |
| +// language. |
| +// |
| +// Node is not a build dependency, so its absence skips, naming the |
| +// test, and fails instead under FIESTA_TEST_REQUIRE_NODE (nodetest), |
| +// because a silent skip on a CI image without node would retire this |
| +// suite without anybody deciding to. |
| +func TestDatetimeParserFixtures(t *testing.T) { |
| + nodetest.Node(t) |
| + |
| + fixtures := datetimeFixtures(t) |
| + if _, err := os.Stat(filepath.Join("testdata", "datetime", "en.json")); err != nil { |
| + t.Fatalf("en.json is the reference fixture and must exist: %v", err) |
| + } |
| + |
| + for _, fixture := range fixtures { |
| + name := strings.TrimSuffix(filepath.Base(fixture), ".json") |
| + t.Run(name, func(t *testing.T) { |
| + out := nodetest.Run(t, nodetest.Cmd{ |
| + Args: []string{"node/datetime_node.mjs", fixture}, |
| + Stdin: vocabStdin(t, localeOf(fixture)), |
| + // The fixtures are wall-clock: a runner in a zone with a |
| + // summer-time jump inside a fixture's span would move an |
| + // expectation for reasons that have nothing to do with the |
| + // parser. UTC has no such jump. |
| + Env: []string{"TZ=UTC"}, |
| + }) |
| + t.Log(strings.TrimSpace(string(out))) |
| + }) |
| + } |
| +} |
| + |
| +// A field has to be able to read what it just wrote. Every fixture is a |
| +// phrase somebody chose to type, so no fixture could catch the opposite |
| +// failure: the combobox formatting a value with Intl and then refusing |
| +// its own formatting back. It was refusing it in five of the twelve |
| +// shipped languages (ja and zh-Hans write 年 and a bracketed weekday, |
| +// pt writes "de" linkers, ru writes a "г." suffix, yue writes a |
| +// dayPeriod (下晝) no table spells), which made an in-place edit |
| +// impossible in each of them and looked like nothing at all from here. |
| +// |
| +// So this drives datetime_node.mjs's --round-trip mode: format three |
| +// instants per kind with the display's own options, parse each back, |
| +// and compare. All twelve have to survive the trip. The first cut of |
| +// this gate allowed eleven, which meant a single-locale regression |
| +// exited 0 and left its only trace in a t.Log, a passing test nobody |
| +// reads. The harness now exits non-zero the moment one locale fails and |
| +// names the languages on the FAIL line, which is what lands in the |
| +// t.Fatalf below. |
| +func TestDatetimeReadsItsOwnDisplayBack(t *testing.T) { |
| + out := nodetest.Run(t, nodetest.Cmd{ |
| + Args: []string{"node/datetime_node.mjs", "--round-trip"}, |
| + Stdin: vocabStdin(t, "en"), |
| + Env: []string{"TZ=UTC"}, |
| + }) |
| + t.Log(strings.TrimSpace(string(out))) |
| +} |
| + |
| +// The calendar overlay draws six weeks of days, and the arithmetic |
| +// behind that is the half of it that can be quietly wrong: a grid that |
| +// drops a day in a zone that moved its clocks that morning, a week that |
| +// starts on the wrong day, column headings that do not describe their |
| +// own columns. None of that shows up in a screenshot (a calendar |
| +// missing 1 March looks exactly like a calendar) and none of it needs |
| +// a browser to catch, because js/calendar.js keeps that arithmetic in |
| +// front of its own document guard for precisely this reason. |
| +// |
| +// --calendar walks every shipped language over twenty-five months (both |
| +// of 2026 and 2027, plus a leap February) and holds each grid to its |
| +// invariants: forty-two cells, every one at local midnight, each one |
| +// day after the last, starting on that locale's own first day of the |
| +// week, with every day of the month present exactly once and the 1st in |
| +// the first row. Four undisputed week-start facts are pinned there too: |
| +// the derivation keeps no table, so the test carries the anchor. |
| +// |
| +// The DST case is not hypothetical: building the grid by adding |
| +// 86,400,000 milliseconds instead of counting whole days fails here in |
| +// every locale, on the two months a clock changes, and passes |
| +// everywhere else. TZ is deliberately NOT pinned to UTC, unlike the |
| +// fixture runs: a zone with no clock changes is the one zone that |
| +// cannot catch it, and the invariants hold in every zone by |
| +// construction. |
| +func TestCalendarGridHoldsInEveryLocale(t *testing.T) { |
| + out := nodetest.Run(t, nodetest.Cmd{ |
| + Args: []string{"node/datetime_node.mjs", "--calendar"}, |
| + Stdin: vocabStdin(t, "en"), |
| + }) |
| + t.Log(strings.TrimSpace(string(out))) |
| +} |
| + |
| +// calendar.js holds to the same contract as the other scripts: its own |
| +// vocabulary, a native-input-free widget built in the browser, a |
| +// document guard so Node can load its arithmetic, and no English month |
| +// or weekday name anywhere in it. |
| +func TestCalendarContract(t *testing.T) { |
| + js := string(shippedScript(t, "calendar.js")) |
| + for _, want := range []string{ |
| + // The seam datetime.js looks up at enhance time. If this name |
| + // moves, the button silently falls back to the browser's own |
| + // picker, which is the failure this whole overlay removed. |
| + "fiestaCalendar", |
| + // The roles a calendar has to carry to be usable without sight: |
| + // a named dialog, a grid, real column headers, the committed |
| + // day marked as selected and today marked as today. |
| + `"dialog"`, `"grid"`, `"columnheader"`, `"gridcell"`, |
| + "aria-selected", "aria-current", "aria-disabled", |
| + // The keys it has to answer to. |
| + "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End", "Escape", |
| + // The three strings it puts on screen all arrive translated. |
| + "data-rst-day", "tabIndex", |
| + } { |
| + if !strings.Contains(js, want) { |
| + t.Errorf("calendar.js does not mention %q", want) |
| + } |
| + } |
| + if !strings.Contains(js, `typeof document !== "undefined"`) { |
| + t.Error("calendar.js must keep its panel behind a document guard, or the Node harness cannot load it") |
| + } |
| + if !strings.Contains(js, "module.exports") { |
| + t.Error("calendar.js must export its arithmetic for node/datetime_node.mjs --calendar") |
| + } |
| + if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") { |
| + t.Error("calendar.js must stay CSP-clean") |
| + } |
| + if strings.Contains(js, "\t") { |
| + t.Error("calendar.js uses two-space indentation, not tabs") |
| + } |
| + // Its own budget, set where datetime.js's was first set and for the |
| + // same reason: an app owner has to be able to read the whole file in |
| + // one sitting, and the next few hundred bytes should be a decision |
| + // rather than a drift. It lands at about 15KB, roughly half of it |
| + // the prose explaining the other half. |
| + if n := len(js); n > 24*1024 { |
| + t.Fatalf("calendar.js is %d bytes; split something out before it stops being readable", n) |
| + } |
| +} |
| + |
| +// The same rule datetime.js lives under, for the same reason: a month |
| +// or weekday name written down here would work in English and fail |
| +// silently in every other language shipped. calendar.js asks Intl, in |
| +// the page's own lang, for all of it: the names, the digits, and the |
| +// day a week starts on. |
| +func TestCalendarDerivesItsNames(t *testing.T) { |
| + body := string(shippedScript(t, "calendar.js")) |
| + body = body[strings.Index(body, "(function ("):] |
| + for _, name := range []string{ |
| + "january", "february", "december", "\"jan\"", "\"dec\"", |
| + "monday", "friday", "sunday", "\"mon\"", "\"fri\"", |
| + } { |
| + if strings.Contains(body, name) { |
| + t.Errorf("calendar.js spells %s itself; month and weekday names come from Intl", name) |
| + } |
| + } |
| +} |
| + |
| +// Every shipped language gets its own fixture, because the vocabulary |
| +// is the part of this parser that cannot be derived: a missing fixture |
| +// is a language nobody has checked can type a date. Over |
| +// fiesta.Languages(), so a language added to the tree with no fixture |
| +// beside it fails here. |
| +func TestEveryLanguageHasADatetimeFixture(t *testing.T) { |
| + langs := fiesta.Languages() |
| + if len(langs) == 0 { |
| + t.Fatal("fiesta.Languages() is empty") |
| + } |
| + for _, lang := range langs { |
| + if _, err := os.Stat(filepath.Join("testdata", "datetime", lang+".json")); err != nil { |
| + t.Errorf("no parser fixture for shipped language %q: %v", lang, err) |
| + } |
| + } |
| +} |
| + |
| +// datetime.js holds to the same contract as the shim and select.js: the |
| +// vocabulary the docs promise, the ARIA convention, an idempotent scan, |
| +// and a native input that survives enhancement because it is what the |
| +// form submits. |
| +func TestDatetimeContract(t *testing.T) { |
| + raw := shippedScript(t, "datetime.js") |
| + js := string(raw) |
| + for _, want := range []string{ |
| + "data-rst-date", "data-rst-time", "data-rst-date-words", |
| + "data-rst-date-set", "data-rst-date-hint", "data-rst-date-pick", |
| + "data-rst-date-results", "data-rst-date-result-one", |
| + "data-rst-date-quick-today", "data-rst-date-quick-tomorrow", |
| + "data-rst-date-quick-next-week", "data-rst-date-quick-plus-1h", |
| + "data-rst-date-quick-plus-2h", "data-rst-date-quick-end-of-day", |
| + "data-rst-date-quick-next-day", "data-rst-range", |
| + // The calendar overlay's own strings, and a time field's own |
| + // button label: the overlay builds its markup in the browser, |
| + // so every word it shows has to arrive on an attribute or in |
| + // the page's table. |
| + "data-rst-date-pick-time", "data-rst-date-calendar", |
| + "data-rst-date-prev-month", "data-rst-date-next-month", |
| + // The seam to js/calendar.js. If this name moves and the other |
| + // file is not moved with it, nothing breaks loudly: the button |
| + // quietly falls back to the browser's own picker, which is the |
| + // unusable control this whole overlay replaced. |
| + "fiestaCalendar", |
| + // The completion layer, which is what lets "5j" read as a date |
| + // at all, and parse, which stays strict underneath it. |
| + "parseAll", "function parse(", |
| + // The convention, pinned: an ARIA combobox that mirrors rather |
| + // than replaces, announced to assistive tech. |
| + "combobox", "aria-activedescendant", "aria-expanded", "aria-live", |
| + "rst-sr-only", |
| + // The month and weekday names are Intl's, never a table. |
| + "formatToParts", |
| + // showPicker survives as the fallback for a page that links |
| + // this file without calendar.js, and nowhere else. |
| + "showPicker", |
| + // {example} is substituted here, in the locale's own format; |
| + // the markup emits the hint template verbatim. |
| + "{example}", "{n}", |
| + } { |
| + if !strings.Contains(js, want) { |
| + t.Errorf("datetime.js does not mention %q", want) |
| + } |
| + } |
| + for _, bad := range []string{".remove()", "removeChild", "outerHTML ="} { |
| + if strings.Contains(js, bad) { |
| + t.Errorf("datetime.js destroys DOM (%q); the native input must survive", bad) |
| + } |
| + } |
| + if !strings.Contains(js, "rstEnhanced") { |
| + t.Error("datetime.js is not idempotent; re-scanning would double-enhance") |
| + } |
| + if !strings.Contains(js, `typeof document !== "undefined"`) { |
| + t.Error("datetime.js must keep its page half behind a document guard, or the Node harness cannot load it") |
| + } |
| + if !strings.Contains(js, "module.exports") { |
| + t.Error("datetime.js must export its parser for node/datetime_node.mjs") |
| + } |
| + if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") { |
| + t.Error("datetime.js must stay CSP-clean") |
| + } |
| + if bytes.Contains(raw, []byte("\t")) { |
| + t.Error("datetime.js uses two-space indentation, not tabs") |
| + } |
| + // Bigger than the shim's and select.js's 8KB by design: this one |
| + // carries a parser as well as a combobox, and it lands within a few |
| + // lines of the implementation it was ported from. The cap is here |
| + // so the next few hundred bytes are a decision rather than a drift: |
| + // a file an app owner has to page through is a file they stop |
| + // owning. |
| + // |
| + // Raised twice, and the arithmetic is written down both times so |
| + // the next raise has to argue with real numbers. |
| + // |
| + // 48KB → 54KB, by the twelve-locale fixtures: writing a real date |
| + // in each of the eleven other languages bought four capabilities |
| + // this parser did not have. It folds the digits an Arabic or Hindi |
| + // keyboard makes (plain "ar" resolves to latn in ICU, so the |
| + // locale's default numbering system was not enough); it holds the |
| + // month name Intl only gives up when a day is in the format, which |
| + // is the form a Russian actually types; it reads a date written |
| + // with counter words rather than month names (12月25日); and it |
| + // tells a counted 月 from Monday. The honest split of that 6.6KB: |
| + // about 2.7KB of code and about 3.9KB of the prose explaining it. |
| + // An earlier wording here said "six kilobytes for eleven |
| + // languages", which read as six kilobytes of parser and was not. |
| + // |
| + // 54KB → 60KB, by the read-back wave: the parser now derives, from |
| + // the same Intl options the field displays with, the literals that |
| + // display threads between the numbers and the dayPeriod names it |
| + // picks, so it can read its own writing in all twelve languages |
| + // instead of five, and "25 de marzo" parses because "de" is a |
| + // literal Intl already told us about. Split again: about 2.7KB of |
| + // code, about 3.2KB of comment. More than half of both raises is |
| + // prose, which is the trade this file makes on purpose: the cap is |
| + // on what an owner has to page through, not on what runs. |
| + // |
| + // 60KB → 76KB, by the calendar overlay, and this raise came with a |
| + // SPLIT in front of it. The button used to call native.showPicker(), |
| + // which is one line and close to useless in a form: the panel |
| + // belongs to the browser, cannot be styled to match the page, does |
| + // not exist at all for a time input on some engines, and (measured, |
| + // not guessed) opens WITHOUT moving focus, so the guard on the |
| + // change listener here swallowed the pick and the field looked like |
| + // it had ignored the selection. Replacing it took a real widget, so |
| + // the widget went to calendar.js: 22KB that would otherwise have |
| + // landed on this number, in a file that needs none of the parser. |
| + // |
| + // What stayed is +15.1KB, an honest 7.3KB of code and 7.8KB of |
| + // prose, and none of it draws a calendar. The popup became three |
| + // states rather than one (the suggestions, the grid, and a time |
| + // field's half hours) so there is still only one thing to close and |
| + // one thing the arrow keys drive; the words drive the grid live |
| + // while it is open; min/max became bounds a range's end can tighten; |
| + // the panel flips above the field where there is no room below it; |
| + // and parseAll arrived, which offers a reading where the exact |
| + // grammar had none: "5j" is not a date, and 5 January, 5 June and |
| + // 5 July are three. |
| + // |
| + // The cap is on what an owner has to page through. Two files at 74KB |
| + // and 22KB is that; one file at 96KB is not, which is the whole |
| + // reason the split came first and the raise second. |
| + // |
| + // 76KB → 78KB, by the string tables (Fiesta Task 6). SEED's file |
| + // sat 45 bytes under the cap, so anything at all raised it. What |
| + // arrived is +1.7KB: fiestaPickLang and fiestaWord, the lookup that |
| + // reads a label off the attribute, then the page's table, then the |
| + // fallback, about 1.0KB of code with their exports and the |
| + // date-words branch that takes the table's object or the |
| + // attribute's JSON; and about 0.7KB of prose, the header's strings |
| + // paragraph rewritten for the new order, the seven quick-pick |
| + // attributes written out where "quick-*" stood, and the section |
| + // comment. The old attr helper and its seventeen calls went, which |
| + // is the 0.2KB the raise is smaller than the addition. The copy in |
| + // select.js is the same bytes; see TestTheTwoLookupsAreTheSame. |
| + if n := len(raw); n > 78*1024 { |
| + t.Fatalf("datetime.js is %d bytes; split something out before it stops being readable", n) |
| + } |
| +} |
| + |
| +// The half a machine CAN derive is derived. Month and weekday names |
| +// never appear in this file: they come out of Intl in the page's own |
| +// language, so a field enhanced in Hindi reads Hindi month names with |
| +// nothing added here. A spelling written down would work in English and |
| +// silently fail everywhere else, the exact half-failure the split |
| +// exists to prevent. (The vocabulary keys themselves, "tomorrow", |
| +// "next", "ago", do appear: they are the attribute's field names, and |
| +// their VALUES are what arrives translated.) |
| +func TestDatetimeDerivesItsCalendarNames(t *testing.T) { |
| + body := string(shippedScript(t, "datetime.js")) |
| + body = body[strings.Index(body, "(function () {"):] |
| + for _, name := range []string{ |
| + "january", "february", "december", "\"jan\"", "\"dec\"", |
| + "monday", "friday", "sunday", "\"mon\"", "\"fri\"", |
| + } { |
| + if strings.Contains(body, name) { |
| + t.Errorf("datetime.js spells %s itself; month and weekday names come from Intl", name) |
| + } |
| + } |
| +} |
diff --git a/check/i18n_test.go b/check/i18n_test.go| deleted file mode 100644 |
| index 4903dea..0000000 |
| --- a/check/i18n_test.go |
| +++ /dev/null |
| @@ -1,158 +0,0 @@ |
| -package check |
| - |
| -import ( |
| - "encoding/json" |
| - "os" |
| - "path/filepath" |
| - "slices" |
| - "sort" |
| - "strings" |
| - "testing" |
| -) |
| - |
| -// The interface strings datetime.js and select.js put on screen, one |
| -// flat JSON object per shipped language under i18n/, seeded from |
| -// rastrillo's catalogs at SEED. Task 6 reads them; this holds their |
| -// shape, so a language file that lost a key fails here rather than as |
| -// an English label on a Spanish page. |
| - |
| -// languages are the twelve files, en first. pt is not a file: it is |
| -// the test alias in check/testdata/datetime/retagged.json. |
| -var languages = []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"} |
| - |
| -// languageKeys are the twenty string keys, each an attribute's name |
| -// with data-rst- removed. |
| -var languageKeys = []string{ |
| - "date-set", "date-hint", "date-pick", "date-pick-time", "date-calendar", |
| - "date-prev-month", "date-next-month", "date-results", "date-result-one", |
| - "date-quick-today", "date-quick-tomorrow", "date-quick-next-week", |
| - "date-quick-plus-1h", "date-quick-plus-2h", "date-quick-end-of-day", "date-quick-next-day", |
| - "select-filter", "select-results", "select-result-one", "select-no-matches", |
| -} |
| - |
| -// dateWords are the seventeen names under date-words. |
| -var dateWords = []string{ |
| - "today", "tomorrow", "yesterday", "next", "last", "in", "ago", "at", |
| - "day", "week", "month", "hour", "minute", "noon", "midnight", "am", "pm", |
| -} |
| - |
| -// languageFileProblems reads one language file and lists what is |
| -// wrong with its shape: a missing or extra key, a missing or extra |
| -// word, an empty value, or a hint with no {example}. |
| -func languageFileProblems(data []byte) []string { |
| - var obj map[string]json.RawMessage |
| - if err := json.Unmarshal(data, &obj); err != nil { |
| - return []string{"not a JSON object: " + err.Error()} |
| - } |
| - var problems []string |
| - want := append([]string{"date-words"}, languageKeys...) |
| - var got []string |
| - for k := range obj { |
| - got = append(got, k) |
| - } |
| - sort.Strings(got) |
| - for _, k := range want { |
| - if _, ok := obj[k]; !ok { |
| - problems = append(problems, "missing key "+k) |
| - } |
| - } |
| - for _, k := range got { |
| - if !slices.Contains(want, k) { |
| - problems = append(problems, "unexpected key "+k) |
| - } |
| - } |
| - for _, k := range languageKeys { |
| - raw, ok := obj[k] |
| - if !ok { |
| - continue |
| - } |
| - var s string |
| - if err := json.Unmarshal(raw, &s); err != nil { |
| - problems = append(problems, k+" is not a string") |
| - } else if s == "" { |
| - problems = append(problems, k+" is empty") |
| - } |
| - } |
| - if raw, ok := obj["date-hint"]; ok { |
| - var s string |
| - if json.Unmarshal(raw, &s) == nil && s != "" && !strings.Contains(s, "{example}") { |
| - problems = append(problems, "date-hint has no {example}") |
| - } |
| - } |
| - if raw, ok := obj["date-words"]; ok { |
| - var words map[string]string |
| - if err := json.Unmarshal(raw, &words); err != nil { |
| - return append(problems, "date-words is not an object of strings: "+err.Error()) |
| - } |
| - for _, w := range dateWords { |
| - v, ok := words[w] |
| - if !ok { |
| - problems = append(problems, "date-words is missing "+w) |
| - } else if v == "" { |
| - problems = append(problems, "date-words."+w+" is empty") |
| - } |
| - } |
| - for w := range words { |
| - if !slices.Contains(dateWords, w) { |
| - problems = append(problems, "date-words has an unexpected "+w) |
| - } |
| - } |
| - } |
| - sort.Strings(problems) |
| - return problems |
| -} |
| - |
| -// TestEveryLanguageFileHasTheShape: twelve files, twenty-one keys, |
| -// seventeen words, no empty value. The first leg is the check's own |
| -// control: a file with one key dropped, one word empty and a hint |
| -// with no placeholder must be caught, or the pass below means nothing. |
| -func TestEveryLanguageFileHasTheShape(t *testing.T) { |
| - t.Run("control", func(t *testing.T) { |
| - bad := map[string]any{"date-words": map[string]string{}} |
| - for _, w := range dateWords { |
| - bad["date-words"].(map[string]string)[w] = w |
| - } |
| - bad["date-words"].(map[string]string)["noon"] = "" |
| - for _, k := range languageKeys[1:] { |
| - bad[k] = k |
| - } |
| - bad["date-hint"] = "Try:" |
| - bad["extra"] = "x" |
| - data, _ := json.Marshal(bad) |
| - got := languageFileProblems(data) |
| - want := []string{"date-hint has no {example}", "date-words.noon is empty", "missing key date-set", "unexpected key extra"} |
| - if !slices.Equal(got, want) { |
| - t.Fatalf("the control file's problems are %v, want %v", got, want) |
| - } |
| - }) |
| - |
| - entries, err := os.ReadDir(filepath.Join("..", "i18n")) |
| - if err != nil { |
| - t.Fatalf("reading i18n/: %v", err) |
| - } |
| - var files []string |
| - for _, e := range entries { |
| - if filepath.Ext(e.Name()) == ".json" { |
| - files = append(files, e.Name()) |
| - } |
| - } |
| - var want []string |
| - for _, l := range languages { |
| - want = append(want, l+".json") |
| - } |
| - sort.Strings(files) |
| - sort.Strings(want) |
| - if !slices.Equal(files, want) { |
| - t.Errorf("i18n/ holds %v, want %v", files, want) |
| - } |
| - for _, l := range languages { |
| - data, err := os.ReadFile(filepath.Join("..", "i18n", l+".json")) |
| - if err != nil { |
| - t.Errorf("%s: %v", l, err) |
| - continue |
| - } |
| - for _, p := range languageFileProblems(data) { |
| - t.Errorf("i18n/%s.json: %s", l, p) |
| - } |
| - } |
| -} |
diff --git a/check/node/datetime_node.mjs b/check/node/datetime_node.mjs| new file mode 100644 |
| index 0000000..7a11dff |
| --- /dev/null |
| +++ b/check/node/datetime_node.mjs |
| @@ -0,0 +1,365 @@ |
| +// datetime_node — drives datetime.js's parser over one fixture file, in |
| +// plain Node, with no browser anywhere. |
| +// |
| +// The parser is the risk in datetime.js and the half a Go test cannot |
| +// reach: it is a pure function of (text, vocabulary, locale tables, |
| +// now), and this harness is what lets it be developed and regressed at |
| +// the speed of `go test` instead of a browser drive. Everything that |
| +// touches a page stays behind datetime.js's `document` guard, which |
| +// Node never takes — so loading the file here runs the parser and |
| +// nothing else. |
| +// |
| +// Usage, which check/datetime_test.go is the only caller of: |
| +// |
| +// echo '{"locale":"en","catalogs":{"en":{…},…}}' | |
| +// node node/datetime_node.mjs testdata/datetime/en.json |
| +// |
| +// Two modes take the place of a fixture path. --round-trip runs the |
| +// read-back loop: every catalog on stdin, formatted with the combobox's |
| +// own display options and parsed straight back. --calendar holds |
| +// js/calendar.js's grid arithmetic to its invariants in every shipped |
| +// locale. See roundTrip and calendar below. |
| +// |
| +// The vocabulary comes in on stdin rather than out of the fixture, |
| +// because it is not test data: it is the shipped table's date-words |
| +// for that language, and a fixture carrying its own copy would go |
| +// stale the first time a translator improved a word. The Go test reads |
| +// it from fiesta.Strings and pipes it in — every shipped language, not |
| +// just the fixture's own, plus the retagged aliases — so the words |
| +// under test are the words that ship. |
| +// |
| +// A fixture named after a locale (en.json) is read in that locale. A |
| +// case may also name its own with a "lang" key, which is what |
| +// regressions.json is: one file of cross-locale regressions, each case |
| +// carrying the language it is about, so a bug found in Japanese has |
| +// somewhere to live before the Japanese fixture exists. |
| +// |
| +// This file lives under check/node/ beside the other Node drivers, so |
| +// `go test` in check/ finds it by name, and it is never embedded: |
| +// fiesta.go reaches for js/, never for *.mjs. |
| +import { readFileSync } from "node:fs"; |
| +import parser from "../../js/datetime.js"; |
| + |
| +const fixturePath = process.argv[2]; |
| +if (!fixturePath) { |
| + process.stderr.write("usage: datetime_node.mjs <fixture.json | --round-trip | --calendar>\n"); |
| + process.exit(2); |
| +} |
| + |
| +const chunks = []; |
| +for await (const chunk of process.stdin) chunks.push(chunk); |
| +const { locale, catalogs } = JSON.parse(Buffer.concat(chunks).toString("utf8")); |
| + |
| +// ── the read-back loop ─────────────────────────────────────────────── |
| +// |
| +// A field that cannot read its own writing is a field nobody can edit |
| +// in place: focus it, change the year, and the reading it wrote a |
| +// second ago comes back a refusal. That failed silently in five of the |
| +// twelve shipped languages — ja and zh wrote 年 and a bracketed |
| +// weekday, pt wrote "de" linkers, ru wrote a "г." suffix, and yue wrote |
| +// a dayPeriod no catalog spells — and no fixture could have caught it, |
| +// because every fixture is a phrase somebody chose to type. |
| +// |
| +// So this loop types nothing. It formats an instant with the SAME |
| +// options the combobox displays with, hands the result straight back to |
| +// the parser, and asks for the instant again. Run as |
| +// `datetime_node.mjs --round-trip`, over every catalog on stdin. |
| +const roundTrip = () => { |
| + const samples = [ |
| + new Date(2026, 11, 25, 15, 30), |
| + new Date(2026, 2, 3, 9, 5), |
| + new Date(2027, 7, 28, 0, 0), |
| + ]; |
| + const now = new Date(2026, 7, 28, 10, 30); |
| + const day = (d) => [d.getFullYear(), d.getMonth(), d.getDate()].join("-"); |
| + const clock = (d) => [d.getHours(), d.getMinutes()].join(":"); |
| + const lines = [], broken = []; |
| + for (const lang of Object.keys(catalogs)) { |
| + const tbl = parser.tables(lang); |
| + const misses = []; |
| + let ok = 0, total = 0; |
| + for (const kind of ["date", "datetime", "time"]) { |
| + let fmt; |
| + try { |
| + fmt = new Intl.DateTimeFormat(lang, parser.displayOptions(kind)); |
| + } catch { |
| + continue; |
| + } |
| + for (const want of samples) { |
| + total++; |
| + const shown = fmt.format(want); |
| + let read = null; |
| + try { |
| + read = parser.parse(shown, catalogs[lang], tbl, now, {}); |
| + } catch (e) { |
| + read = null; |
| + } |
| + // A date display carries no clock and a time display carries no |
| + // day, so each kind is asked back only for what it wrote. |
| + const same = read && |
| + (kind === "time" || day(read.date) === day(want)) && |
| + (kind === "date" || clock(read.date) === clock(want)); |
| + if (same) ok++; |
| + else misses.push(` ${kind} ${JSON.stringify(shown)} -> ${read ? read.date : "null"}`); |
| + } |
| + } |
| + lines.push(` ${lang}: ${ok}/${total}`); |
| + if (misses.length) { |
| + lines.push(...misses); |
| + broken.push(lang); |
| + } |
| + } |
| + // Every locale, not all-but-one. An earlier version of this gate |
| + // allowed eleven of twelve so a CLDR bump could not block the build, |
| + // and that is exactly the hole a reviewer walked through: one locale |
| + // could stop reading its own writing, the harness would still exit 0, |
| + // and the only trace was a name in a t.Log nobody reads on a passing |
| + // test. A language that cannot re-read its own date is broken for the |
| + // people who speak it, whoever caused it, so it fails — and the |
| + // failure line names the languages rather than making anyone go and |
| + // count the columns. |
| + const langs = Object.keys(catalogs).length; |
| + const good = langs - broken.length; |
| + process.stdout.write( |
| + `own-display read-back: ${good} of ${langs} locales round-trip fully\n` + |
| + `${lines.join("\n")}\n` + |
| + (broken.length |
| + ? `FAIL: ${broken.length} locale(s) cannot read their own display back: ${broken.join(", ")}\n` |
| + : ""), |
| + ); |
| + process.exit(broken.length ? 1 : 0); |
| +}; |
| + |
| +if (fixturePath === "--round-trip") roundTrip(); |
| + |
| +// ── the calendar's arithmetic ──────────────────────────────────────── |
| +// |
| +// js/calendar.js draws the month grid a date field opens. Everything in |
| +// it that touches a page is behind a `document` guard Node never takes, |
| +// and the three functions in front of that guard are the ones that can |
| +// be quietly WRONG: a grid that drops a day in a zone that moved its |
| +// clocks that morning, a week that starts on the wrong day, column |
| +// headings that do not describe their own columns. |
| +// |
| +// None of that needs a browser to catch, and none of it shows up in a |
| +// screenshot either — a calendar missing 1 March looks exactly like a |
| +// calendar. So this mode asserts the invariants directly, over every |
| +// locale the framework ships and over a span of months wide enough to |
| +// include the awkward ones: a leap February, both hemispheres' clock |
| +// changes, and a month beginning on each of the seven weekdays. |
| +// |
| +// Run as `datetime_node.mjs --calendar`, with the same catalogs on |
| +// stdin as every other mode — used here only for the list of locales. |
| +const calendar = async () => { |
| + const cal = (await import("../../js/calendar.js")).default; |
| + const two = (n) => String(n).padStart(2, "0"); |
| + const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`; |
| + const langs = Object.keys(catalogs); |
| + const broken = new Set(); |
| + const failures = []; |
| + const anchors = []; |
| + |
| + // Every month of 2026 and 2027, plus February 2028 for the leap day. |
| + // Wide enough that a month beginning on each of the seven weekdays |
| + // appears several times over, and that both hemispheres' clock |
| + // changes are covered whatever zone the runner sits in. |
| + const months = []; |
| + for (let y = 2026; y <= 2027; y++) for (let m = 0; m < 12; m++) months.push([y, m]); |
| + months.push([2028, 1]); |
| + |
| + // Four facts about the world, pinned in the TEST rather than in the |
| + // file under it. calendar.js derives the first day of the week from |
| + // Intl and keeps no table, which is right — but a derivation that |
| + // came back consistently wrong would still satisfy every invariant |
| + // below, because they all check the grid against the same number. |
| + // These four are the anchor: they are not in dispute, they are |
| + // stable in CLDR, and they belong here, where a table is evidence |
| + // rather than a thing to maintain. |
| + for (const [tag, want, who] of [ |
| + ["en-US", 0, "the United States starts its weeks on Sunday"], |
| + ["en-GB", 1, "the United Kingdom starts its weeks on Monday"], |
| + ["ar-EG", 6, "Egypt starts its weeks on Saturday"], |
| + ["ja-JP", 0, "Japan starts its weeks on Sunday"], |
| + ]) { |
| + const got = cal.firstDayOfWeek(tag); |
| + if (got !== want) anchors.push(` ${tag}: firstDayOfWeek = ${got}, want ${want} — ${who}`); |
| + } |
| + |
| + for (const lang of langs) { |
| + const say = (msg) => { |
| + broken.add(lang); |
| + if (failures.length < 40) failures.push(` ${lang}: ${msg}`); |
| + }; |
| + const first = cal.firstDayOfWeek(lang); |
| + const heads = cal.weekdayNames(lang, first); |
| + |
| + if (!Number.isInteger(first) || first < 0 || first > 6) { |
| + say(`firstDayOfWeek = ${first}, which is not a getDay number`); |
| + } |
| + if (heads.length !== 7) say(`weekdayNames gave ${heads.length} columns, want 7`); |
| + else { |
| + // The headings must describe the columns they sit over. This is |
| + // the failure that would look right and read wrong: a grid |
| + // starting on Monday under a heading row starting on Sunday is |
| + // off by one the whole way across, and every date in it is |
| + // announced on the wrong weekday. |
| + for (let i = 0; i < 7; i++) { |
| + if (heads[i].day !== (first + i) % 7) { |
| + say(`column ${i} heads day ${heads[i].day}, want ${(first + i) % 7}`); |
| + } |
| + if (!heads[i].short || !heads[i].long) say(`column ${i} has an empty name`); |
| + } |
| + if (new Set(heads.map((h) => h.long)).size !== 7) { |
| + say(`the seven spoken weekday names are not seven distinct names`); |
| + } |
| + } |
| + |
| + for (const [y, m] of months) { |
| + const grid = cal.monthGrid(y, m, first); |
| + const where = `${y}-${two(m + 1)}`; |
| + |
| + // Always 42 cells, so the panel keeps one height all year. |
| + if (grid.length !== 42) { |
| + say(`${where}: ${grid.length} cells, want 42`); |
| + continue; |
| + } |
| + |
| + // Every cell at local midnight, and each exactly one day after |
| + // the one before it. Building the grid by adding 86,400,000 |
| + // milliseconds instead of counting whole days breaks precisely |
| + // here, in the two months a clock changes, and nowhere else. |
| + let stop = false; |
| + for (let i = 0; i < 42 && !stop; i++) { |
| + const d = grid[i]; |
| + if (d.getHours() || d.getMinutes() || d.getSeconds()) { |
| + say(`${where}: cell ${i} is ${d}, not local midnight`); |
| + stop = true; |
| + } else if (i > 0) { |
| + const p = grid[i - 1]; |
| + const want = new Date(p.getFullYear(), p.getMonth(), p.getDate() + 1); |
| + if (day(d) !== day(want)) { |
| + say(`${where}: cell ${i} is ${day(d)}, want ${day(want)} — a day was dropped or repeated`); |
| + stop = true; |
| + } |
| + } |
| + } |
| + if (stop) continue; |
| + |
| + if (grid[0].getDay() !== first) { |
| + say(`${where}: the grid starts on day ${grid[0].getDay()}, want ${first}`); |
| + } |
| + |
| + // Every day of the month present, exactly once, with the 1st in |
| + // the first week. Six rows is enough for every month there is — |
| + // 31 days beginning on the last day of the week needs 37 cells — |
| + // but only when the leading run is the right length. |
| + const inMonth = grid.filter((d) => d.getMonth() === m && d.getFullYear() === y); |
| + const last = new Date(y, m + 1, 0).getDate(); |
| + if (inMonth.length !== last) { |
| + say(`${where}: ${inMonth.length} of the month's ${last} days are on the grid`); |
| + } |
| + const at = grid.findIndex((d) => d.getDate() === 1 && d.getMonth() === m); |
| + if (at < 0 || at > 6) say(`${where}: the 1st sits at cell ${at}, not in the first week`); |
| + } |
| + } |
| + |
| + process.stdout.write( |
| + `calendar grid: ${langs.length - broken.size} of ${langs.length} locales draw ` + |
| + `${months.length} months each with the right days in the right columns\n` + |
| + (anchors.length |
| + ? `FAIL: the first day of the week is derived wrongly in ${anchors.length} of 4 anchor locales:\n` + |
| + `${anchors.join("\n")}\n` |
| + : "") + |
| + (broken.size |
| + ? `FAIL: ${broken.size} locale(s) draw a month wrong: ${[...broken].join(", ")}\n` + |
| + `${failures.join("\n")}\n` |
| + : ""), |
| + ); |
| + process.exit(broken.size || anchors.length ? 1 : 0); |
| +}; |
| + |
| +if (fixturePath === "--calendar") await calendar(); |
| + |
| + |
| +const cases = JSON.parse(readFileSync(fixturePath, "utf8")); |
| + |
| +// One set of locale tables per language in the file, built once. |
| +const tablesFor = new Map(); |
| +const langOf = (c) => c.lang || locale; |
| +const setupFor = (lang) => { |
| + if (!tablesFor.has(lang)) { |
| + if (!catalogs[lang]) throw new Error(`no catalog for lang ${JSON.stringify(lang)}`); |
| + tablesFor.set(lang, parser.tables(lang)); |
| + } |
| + return { tables: tablesFor.get(lang), vocab: catalogs[lang] }; |
| +}; |
| + |
| +// Wall-clock strings, both ways, in the local zone the Go test pins — |
| +// deliberately not Date.parse, which reads a bare "2026-08-28T10:30" as |
| +// UTC and would move every expectation by the runner's offset. |
| +const at = (s) => { |
| + const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/.exec(String(s)); |
| + if (!m) throw new Error(`fixture: ${JSON.stringify(s)} is not a wall-clock date`); |
| + return new Date(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0); |
| +}; |
| +const pad = (n) => String(n).padStart(2, "0"); |
| +const wire = (d) => |
| + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; |
| + |
| +const failures = []; |
| + |
| +for (const [i, c] of cases.entries()) { |
| + const id = c.id || `#${i}`; |
| + // A missing "expected" is a typo, not an expectation of null. Read as |
| + // null it would pass against any refusal, which is exactly the answer |
| + // a broken parser gives — a whole case quietly asserting nothing. |
| + if (!Object.prototype.hasOwnProperty.call(c, "expected")) { |
| + throw new Error(`case ${id} in ${fixturePath} has no "expected" key`); |
| + } |
| + const lang = langOf(c); |
| + let got, err = null, override = null; |
| + try { |
| + const setup = setupFor(lang); |
| + // parseAll, not parse, because parseAll is what the FIELD reads |
| + // with: the exact grammar first, and where that refuses, the |
| + // readings a half-typed word could still be finished into. Its |
| + // first answer is the one Enter takes, so that is what "expected" |
| + // means here, and a case may name the rest with "alternatives". |
| + // |
| + // Every reading in the list is the strict grammar's own — a |
| + // completion only ever hands it a longer sentence to read — so a |
| + // fixture asserting null still asserts exactly what it used to. |
| + const reads = parser.parseAll(c.input, setup.vocab, setup.tables, at(c.now), { |
| + prev: c.prev ? at(c.prev) : null, |
| + base: c.base ? at(c.base) : null, |
| + }); |
| + got = reads.length ? wire(reads[0].date) : null; |
| + if (Object.prototype.hasOwnProperty.call(c, "alternatives")) { |
| + const rest = reads.slice(1).map((r) => wire(r.date)); |
| + if (JSON.stringify(rest) !== JSON.stringify(c.alternatives)) { |
| + got = `${got} + alternatives ${JSON.stringify(rest)}`; |
| + override = `${c.expected} + alternatives ${JSON.stringify(c.alternatives)}`; |
| + } |
| + } |
| + } catch (e) { |
| + err = e; |
| + got = `threw: ${e && e.message}`; |
| + } |
| + const want = override === null ? c.expected : override; |
| + if (got !== want) { |
| + failures.push( |
| + ` ${id} [${lang}] input=${JSON.stringify(c.input)} now=${c.now}` + |
| + `${c.prev ? ` prev=${c.prev}` : ""}${c.base ? ` base=${c.base}` : ""}\n` + |
| + ` got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}` + |
| + (err ? `\n ${err.stack}` : ""), |
| + ); |
| + } |
| +} |
| + |
| +if (failures.length) { |
| + process.stdout.write( |
| + `${failures.length} of ${cases.length} cases failed in ${fixturePath}:\n${failures.join("\n")}\n`, |
| + ); |
| + process.exit(1); |
| +} |
| +process.stdout.write(`${cases.length} cases passed in ${fixturePath}\n`); |
diff --git a/check/node/strings_node.mjs b/check/node/strings_node.mjs| new file mode 100644 |
| index 0000000..c17ff68 |
| --- /dev/null |
| +++ b/check/node/strings_node.mjs |
| @@ -0,0 +1,91 @@ |
| +// strings_node drives the string lookup one script carries, |
| +// fiestaPickLang and fiestaWord, in plain Node against a stub page. |
| +// Both datetime.js and select.js carry a copy (each enhancement is one |
| +// script tag), so the script's file name is the one argument: |
| +// |
| +// echo '{"registry":["en","pt-PT"],"tags":["pt-BR",""]}' | |
| +// node node/strings_node.mjs datetime.js |
| +// |
| +// check/strings_test.go is the only caller. It reads back one JSON |
| +// object: "pick", fiestaPickLang's answer for each tag on stdin plus |
| +// one for undefined under "<undefined>", against a registry holding |
| +// an (empty) table per tag named; and "word", fiestaWord's answer in |
| +// four scenarios on a stub element in a lang="es" document whose table |
| +// says "Tabla": "both" (attribute "Atributo" and the table), "empty" |
| +// (an empty attribute and the table), "table" (no attribute) and |
| +// "neither" (no attribute, no table, so the fallback "English"). |
| +// |
| +// The page is the least DOM either script touches on load: a document |
| +// that is still loading, with a root carrying lang, and a self holding |
| +// the registry. The script's page half registers its listeners and |
| +// waits for DOMContentLoaded, which never comes; the lookup is read |
| +// off module.exports, which both scripts fill behind their guard. |
| +import { readFileSync } from "node:fs"; |
| +import vm from "node:vm"; |
| + |
| +const name = process.argv[2]; |
| +if (!name) { |
| + process.stderr.write("usage: strings_node.mjs <datetime.js | select.js>\n"); |
| + process.exit(2); |
| +} |
| +const src = readFileSync(new URL("../../js/" + name, import.meta.url), "utf8"); |
| + |
| +const chunks = []; |
| +for await (const chunk of process.stdin) chunks.push(chunk); |
| +// Go encodes a nil slice as null, which a default would not fill. |
| +const stdin = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); |
| +const registry = stdin.registry || []; |
| +const tags = stdin.tags || []; |
| + |
| +// load evaluates the script in a fresh context whose document says |
| +// lang and whose self.fiestaStrings is strings, and returns what it |
| +// 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: {} }; |
| + const context = { document, self, window: self, module, exports: module.exports, setTimeout, clearTimeout }; |
| + vm.runInNewContext(src, context, { filename: name }); |
| + return module.exports; |
| +} |
| + |
| +// An element with attributes and nothing else. |
| +function element(attrs) { |
| + return { getAttribute: (k) => (Object.prototype.hasOwnProperty.call(attrs, k) ? attrs[k] : null) }; |
| +} |
| + |
| +const out = { pick: {}, word: {} }; |
| + |
| +{ |
| + const tables = {}; |
| + for (const tag of registry) tables[tag] = {}; |
| + const { fiestaPickLang } = load("en", tables); |
| + if (typeof fiestaPickLang !== "function") { |
| + process.stderr.write(name + " does not export fiestaPickLang\n"); |
| + process.exit(1); |
| + } |
| + for (const tag of tags) out.pick[tag] = fiestaPickLang(tag, tables); |
| + out.pick["<undefined>"] = fiestaPickLang(undefined, tables); |
| +} |
| + |
| +{ |
| + const table = { es: { "date-set": "Tabla" } }; |
| + const withTable = load("es", table).fiestaWord; |
| + const without = load("es", {}).fiestaWord; |
| + if (typeof withTable !== "function") { |
| + process.stderr.write(name + " does not export fiestaWord\n"); |
| + process.exit(1); |
| + } |
| + out.word.both = withTable(element({ "data-rst-date-set": "Atributo" }), "date-set", "English"); |
| + out.word.empty = withTable(element({ "data-rst-date-set": "" }), "date-set", "English"); |
| + out.word.table = withTable(element({}), "date-set", "English"); |
| + out.word.neither = without(element({}), "date-set", "English"); |
| +} |
| + |
| +process.stdout.write(JSON.stringify(out)); |
diff --git a/check/strings_browser_test.go b/check/strings_browser_test.go| new file mode 100644 |
| index 0000000..c22f8b4 |
| --- /dev/null |
| +++ b/check/strings_browser_test.go |
| @@ -0,0 +1,122 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The Node tests in strings_test.go hold the lookup on a stub; this |
| +// holds it on a real page, through real enhancement: a page that |
| +// carries no string attributes at all, only the table, still puts the |
| +// table's words on screen. |
| + |
| +// tableOnlyDoc is a lang="es" page that sets self.fiestaStrings = {es: |
| +// <es.json>} inline, ahead of the deferred scripts, and carries a date |
| +// field armed with data-rst-date and a twelve-option select armed with |
| +// data-rst-select: no data-rst-date-* and no data-rst-select-* |
| +// attribute anywhere, so every word the two enhancements show has to |
| +// come out of the table. |
| +func tableOnlyDoc(t *testing.T, es []byte) string { |
| + t.Helper() |
| + var b strings.Builder |
| + b.WriteString(`<div rst-field><label rst-field-label for="when">Fecha</label><input rst-input id="when" name="when" type="date" data-rst-date></div>` + "\n") |
| + b.WriteString(`<div rst-field><label rst-field-label for="city">Ciudad</label><select rst-input id="city" name="city" data-rst-select>`) |
| + for _, city := range []string{"Dublín", "Lisboa", "Madrid", "Berlín", "Roma", "Atenas", "Helsinki", "Reikiavik", "Varsovia", "Zagreb", "Tallin", "La Valeta"} { |
| + b.WriteString(`<option>` + city + `</option>`) |
| + } |
| + b.WriteString(`</select></div>`) |
| + doc := sizingDoc("strings", b.String()) |
| + if strings.Contains(doc, "data-rst-date-") || strings.Contains(doc, "data-rst-select-") { |
| + t.Fatalf("the table-only page carries a string attribute:\n%s", doc) |
| + } |
| + es0 := strings.Replace(doc, `<html lang="en"`, `<html lang="es"`, 1) |
| + if es0 == doc { |
| + t.Fatal("the fixture page does not say lang=\"en\" where this test expects to change it") |
| + } |
| + table := `<script>self.fiestaStrings = {es: ` + string(es) + `};</script>` |
| + withTable := strings.Replace(es0, `<head>`, `<head>`+table, 1) |
| + if withTable == es0 { |
| + t.Fatal("the fixture page has no <head> to put the table in") |
| + } |
| + return withTable |
| +} |
| + |
| +// TestTableOnlyStringsReachTheScreen: on the table-only page, the |
| +// date field's set label (the meta on the first reading) is es.json's |
| +// date-set, typing mañana resolves (the vocabulary came from the |
| +// table's date-words, not an attribute), the first quick pick is |
| +// es.json's date-quick-today, and the select's filter placeholder is |
| +// es.json's select-filter. |
| +func TestTableOnlyStringsReachTheScreen(t *testing.T) { |
| + es, ok := fiesta.Strings("es") |
| + if !ok { |
| + t.Fatal("no es table") |
| + } |
| + var want struct { |
| + Set string `json:"date-set"` |
| + Today string `json:"date-quick-today"` |
| + Filter string `json:"select-filter"` |
| + Words struct { |
| + Tomorrow string `json:"tomorrow"` |
| + } `json:"date-words"` |
| + } |
| + if err := json.Unmarshal(es, &want); err != nil { |
| + t.Fatal(err) |
| + } |
| + if want.Set == "" || want.Today == "" || want.Filter == "" || want.Words.Tomorrow == "" { |
| + t.Fatalf("es.json is missing a string this drive reads: %+v", want) |
| + } |
| + if want.Set == "Set" || want.Filter == "Type to filter" { |
| + t.Fatalf("es.json's strings are the English fallbacks, so this drive could not tell the table from the fallback: %+v", want) |
| + } |
| + tomorrow := strings.Split(want.Words.Tomorrow, "|")[0] |
| + |
| + pages := map[string]string{"/": tableOnlyDoc(t, es)} |
| + rig := harness.New(t, func(string) http.Handler { return sizingMux(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + |
| + mustRun(t, ctx, |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-dtp] input[role="combobox"]`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[rst-combo] input[role="combobox"]`, chromedp.ByQuery)) |
| + |
| + var placeholder string |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelector('[rst-combo] input[role="combobox"]').placeholder`, &placeholder)) |
| + if placeholder != want.Filter { |
| + t.Errorf("the select's filter placeholder reads %q, want es.json's select-filter %q", placeholder, want.Filter) |
| + } |
| + |
| + mustRun(t, ctx, |
| + chromedp.Click(`[rst-dtp] input[role="combobox"]`, chromedp.ByQuery), |
| + chromedp.SendKeys(`[rst-dtp] input[role="combobox"]`, tomorrow, chromedp.ByQuery)) |
| + // A reading arms the first row; text the parser refused arms |
| + // nothing, so a vocabulary that did not arrive stops here. |
| + settleUntil(t, ctx, `!!document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-label]')`) |
| + var got struct{ Set, Label, Quick string } |
| + at(t, ctx, `JSON.stringify({ |
| + Set: document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-set]')?.textContent ?? '', |
| + Label: document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-label]')?.textContent ?? '', |
| + Quick: document.querySelector('[rst-dtp] [rst-dtp-quick] [rst-dtp-label]')?.textContent ?? ''})`, &got) |
| + if got.Label == "" { |
| + t.Errorf("typing %q armed no reading; the table's date-words did not reach the parser", tomorrow) |
| + } |
| + if got.Set != want.Set { |
| + t.Errorf("the date field's set label reads %q, want es.json's date-set %q", got.Set, want.Set) |
| + } |
| + if got.Quick != want.Today { |
| + t.Errorf("the first quick pick reads %q, want es.json's date-quick-today %q", got.Quick, want.Today) |
| + } |
| + rig.Screen("body", "table-only strings on a Spanish page") |
| +} |
diff --git a/check/strings_test.go b/check/strings_test.go| new file mode 100644 |
| index 0000000..0d95c37 |
| --- /dev/null |
| +++ b/check/strings_test.go |
| @@ -0,0 +1,323 @@ |
| +package check |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "slices" |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/nodetest" |
| +) |
| + |
| +// The interface strings datetime.js and select.js put on screen, one |
| +// flat JSON object per shipped language under i18n/, seeded from |
| +// rastrillo's catalogs at SEED and read through fiesta.Strings. The |
| +// scripts look a string up in three places, in this order: the |
| +// element's own data-rst- attribute, then the page's table |
| +// (self.fiestaStrings, filled by i18n/<lang>.js, picked by the |
| +// document's lang), then the English fallback written in the script. |
| +// These tests hold the tables' shape and the lookup, in Node |
| +// (node/strings_node.mjs) and in a browser (strings_browser_test.go). |
| + |
| +// languageKeys are the twenty string keys, each an attribute's name |
| +// with data-rst- removed. |
| +var languageKeys = []string{ |
| + "date-set", "date-hint", "date-pick", "date-pick-time", "date-calendar", |
| + "date-prev-month", "date-next-month", "date-results", "date-result-one", |
| + "date-quick-today", "date-quick-tomorrow", "date-quick-next-week", |
| + "date-quick-plus-1h", "date-quick-plus-2h", "date-quick-end-of-day", "date-quick-next-day", |
| + "select-filter", "select-results", "select-result-one", "select-no-matches", |
| +} |
| + |
| +// dateWords are the seventeen names under date-words. |
| +var dateWords = []string{ |
| + "today", "tomorrow", "yesterday", "next", "last", "in", "ago", "at", |
| + "day", "week", "month", "hour", "minute", "noon", "midnight", "am", "pm", |
| +} |
| + |
| +// languageFileProblems reads one language file and lists what is |
| +// wrong with its shape: a missing or extra key, a missing or extra |
| +// word, an empty value, or a hint with no {example}. |
| +func languageFileProblems(data []byte) []string { |
| + var obj map[string]json.RawMessage |
| + if err := json.Unmarshal(data, &obj); err != nil { |
| + return []string{"not a JSON object: " + err.Error()} |
| + } |
| + var problems []string |
| + want := append([]string{"date-words"}, languageKeys...) |
| + var got []string |
| + for k := range obj { |
| + got = append(got, k) |
| + } |
| + sort.Strings(got) |
| + for _, k := range want { |
| + if _, ok := obj[k]; !ok { |
| + problems = append(problems, "missing key "+k) |
| + } |
| + } |
| + for _, k := range got { |
| + if !slices.Contains(want, k) { |
| + problems = append(problems, "unexpected key "+k) |
| + } |
| + } |
| + for _, k := range languageKeys { |
| + raw, ok := obj[k] |
| + if !ok { |
| + continue |
| + } |
| + var s string |
| + if err := json.Unmarshal(raw, &s); err != nil { |
| + problems = append(problems, k+" is not a string") |
| + } else if s == "" { |
| + problems = append(problems, k+" is empty") |
| + } |
| + } |
| + if raw, ok := obj["date-hint"]; ok { |
| + var s string |
| + if json.Unmarshal(raw, &s) == nil && s != "" && !strings.Contains(s, "{example}") { |
| + problems = append(problems, "date-hint has no {example}") |
| + } |
| + } |
| + if raw, ok := obj["date-words"]; ok { |
| + var words map[string]string |
| + if err := json.Unmarshal(raw, &words); err != nil { |
| + return append(problems, "date-words is not an object of strings: "+err.Error()) |
| + } |
| + for _, w := range dateWords { |
| + v, ok := words[w] |
| + if !ok { |
| + problems = append(problems, "date-words is missing "+w) |
| + } else if v == "" { |
| + problems = append(problems, "date-words."+w+" is empty") |
| + } |
| + } |
| + for w := range words { |
| + if !slices.Contains(dateWords, w) { |
| + problems = append(problems, "date-words has an unexpected "+w) |
| + } |
| + } |
| + } |
| + sort.Strings(problems) |
| + return problems |
| +} |
| + |
| +// TestEveryLanguageHasEveryKey: twelve languages, each read through |
| +// fiesta.Strings, with exactly twenty-one keys, exactly seventeen |
| +// date-words names, no empty value, and {example} in date-hint. The |
| +// precondition is not empty: fiesta.Languages() is the twelve tags |
| +// with en first, i18n/ on disk holds exactly those files, and the |
| +// alias pt is not one of them. The first leg is the check's own |
| +// control: a file with one key dropped, one word empty, a hint with |
| +// no placeholder and an extra key must be caught, or the pass below |
| +// means nothing. |
| +func TestEveryLanguageHasEveryKey(t *testing.T) { |
| + t.Run("control", func(t *testing.T) { |
| + bad := map[string]any{"date-words": map[string]string{}} |
| + for _, w := range dateWords { |
| + bad["date-words"].(map[string]string)[w] = w |
| + } |
| + bad["date-words"].(map[string]string)["noon"] = "" |
| + for _, k := range languageKeys[1:] { |
| + bad[k] = k |
| + } |
| + bad["date-hint"] = "Try:" |
| + bad["extra"] = "x" |
| + data, _ := json.Marshal(bad) |
| + got := languageFileProblems(data) |
| + want := []string{"date-hint has no {example}", "date-words.noon is empty", "missing key date-set", "unexpected key extra"} |
| + if !slices.Equal(got, want) { |
| + t.Fatalf("the control file's problems are %v, want %v", got, want) |
| + } |
| + }) |
| + |
| + languages := fiesta.Languages() |
| + if want := []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"}; !slices.Equal(languages, want) { |
| + t.Fatalf("fiesta.Languages() = %q, want %q", languages, want) |
| + } |
| + entries, err := os.ReadDir(filepath.Join("..", "i18n")) |
| + if err != nil { |
| + t.Fatalf("reading i18n/: %v", err) |
| + } |
| + var files, want []string |
| + for _, e := range entries { |
| + if filepath.Ext(e.Name()) == ".json" { |
| + files = append(files, e.Name()) |
| + } |
| + } |
| + for _, l := range languages { |
| + want = append(want, l+".json") |
| + } |
| + sort.Strings(files) |
| + sort.Strings(want) |
| + if !slices.Equal(files, want) { |
| + t.Errorf("i18n/ holds %v, want %v", files, want) |
| + } |
| + if _, ok := fiesta.Strings("pt"); ok { |
| + t.Error("fiesta.Strings(\"pt\") answers; pt is a test alias for pt-PT, not a shipped file") |
| + } |
| + for _, l := range languages { |
| + data, ok := fiesta.Strings(l) |
| + if !ok { |
| + t.Errorf("fiesta.Strings(%q): not shipped", l) |
| + continue |
| + } |
| + if len(data) == 0 { |
| + t.Errorf("fiesta.Strings(%q) is empty", l) |
| + continue |
| + } |
| + for _, p := range languageFileProblems(data) { |
| + t.Errorf("i18n/%s.json: %s", l, p) |
| + } |
| + } |
| +} |
| + |
| +// lookupScripts are the two scripts that carry the string lookup, each |
| +// its own copy (F14): every enhancement is one script tag, and a |
| +// shared file would be a third tag every page had to remember. |
| +var lookupScripts = []string{"datetime.js", "select.js"} |
| + |
| +// stringsReport is what node/strings_node.mjs reports for one script: |
| +// fiestaPickLang's answer for each tag asked (null for none), and |
| +// fiestaWord's answer in each scenario. |
| +type stringsReport struct { |
| + Pick map[string]*string |
| + Word map[string]string |
| +} |
| + |
| +// stringsNode runs node/strings_node.mjs against one script, with the |
| +// registry's tags and the tags to resolve on stdin. |
| +func stringsNode(t *testing.T, script string, registry []string, tags []string) stringsReport { |
| + t.Helper() |
| + in, err := json.Marshal(map[string]any{"registry": registry, "tags": tags}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out stringsReport |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/strings_node.mjs", script}, Stdin: in}), &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| +// TestPickLangResolvesNearMisses: with tables for en, pt-PT and |
| +// zh-Hans, a page's lang finds its table by exact match whatever its |
| +// case, then by primary language, and a language with no table is |
| +// null rather than English. Both copies of the lookup are asked. |
| +func TestPickLangResolvesNearMisses(t *testing.T) { |
| + registry := []string{"en", "pt-PT", "zh-Hans"} |
| + want := map[string]*string{ |
| + "pt-PT": ptr("pt-PT"), |
| + "PT-pt": ptr("pt-PT"), |
| + "pt-BR": ptr("pt-PT"), |
| + "zh-Hant": ptr("zh-Hans"), |
| + "EN": ptr("en"), |
| + "de": nil, |
| + "": nil, |
| + "<undefined>": nil, |
| + } |
| + var tags []string |
| + for tag := range want { |
| + if tag != "<undefined>" { |
| + tags = append(tags, tag) |
| + } |
| + } |
| + sort.Strings(tags) |
| + for _, script := range lookupScripts { |
| + t.Run(script, func(t *testing.T) { |
| + got := stringsNode(t, script, registry, tags).Pick |
| + if len(got) != len(want) { |
| + t.Fatalf("the driver resolved %d tags, want %d: %v", len(got), len(want), got) |
| + } |
| + for tag, w := range want { |
| + g, ok := got[tag] |
| + if !ok { |
| + t.Errorf("fiestaPickLang(%q) was not asked", tag) |
| + continue |
| + } |
| + if str(g) != str(w) { |
| + t.Errorf("fiestaPickLang(%q) = %s, want %s", tag, str(g), str(w)) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func ptr(s string) *string { return &s } |
| + |
| +func str(p *string) string { |
| + if p == nil { |
| + return "null" |
| + } |
| + return `"` + *p + `"` |
| +} |
| + |
| +// TestTheTableFillsAMissingAttribute: fiestaWord reads the attribute |
| +// first, the page's table second, and the fallback last (F1). On a |
| +// stub element in a lang="es" document whose table says "Tabla": with |
| +// the attribute "Atributo" and the table both present, the attribute; |
| +// with the attribute empty, the table; with only the table, the |
| +// table; with neither, the English fallback. |
| +func TestTheTableFillsAMissingAttribute(t *testing.T) { |
| + want := map[string]string{"both": "Atributo", "empty": "Tabla", "table": "Tabla", "neither": "English"} |
| + for _, script := range lookupScripts { |
| + t.Run(script, func(t *testing.T) { |
| + got := stringsNode(t, script, []string{"en", "es"}, nil).Word |
| + for scenario, w := range want { |
| + g, ok := got[scenario] |
| + if !ok { |
| + t.Errorf("the %s scenario was not run", scenario) |
| + continue |
| + } |
| + if g != w { |
| + t.Errorf("%s: fiestaWord gave %q, want %q", scenario, g, w) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// functionBody returns the text of `function name(` through its |
| +// closing brace, counting braces, or "" when the script has no such |
| +// function. The two lookups hold no brace in a string, so counting is |
| +// enough. |
| +func functionBody(js, name string) string { |
| + start := strings.Index(js, "function "+name+"(") |
| + if start < 0 { |
| + return "" |
| + } |
| + depth := 0 |
| + for i := start; i < len(js); i++ { |
| + switch js[i] { |
| + case '{': |
| + depth++ |
| + case '}': |
| + depth-- |
| + if depth == 0 { |
| + return js[start : i+1] |
| + } |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// TestTheTwoLookupsAreTheSame (F14): datetime.js and select.js each |
| +// carry fiestaPickLang and fiestaWord, so each enhancement stays one |
| +// script tag. The price of two copies is drift, so the two bodies are |
| +// held identical, character for character. |
| +func TestTheTwoLookupsAreTheSame(t *testing.T) { |
| + a, b := string(shippedScript(t, "datetime.js")), string(shippedScript(t, "select.js")) |
| + for _, name := range []string{"fiestaPickLang", "fiestaWord"} { |
| + fa, fb := functionBody(a, name), functionBody(b, name) |
| + if fa == "" || fb == "" { |
| + t.Errorf("%s: datetime.js has it %v, select.js has it %v; both must carry it", name, fa != "", fb != "") |
| + continue |
| + } |
| + if fa != fb { |
| + t.Errorf("%s differs between datetime.js and select.js:\n--- datetime.js\n%s\n--- select.js\n%s", name, fa, fb) |
| + } |
| + } |
| +} |
diff --git a/fiesta.go b/fiesta.go| index 5149d92..e1d4bb3 100644 |
| --- a/fiesta.go |
| +++ b/fiesta.go |
| @@ -12,12 +12,11 @@ import ( |
| const Version = "1.0.0" |
| // tree is the design system's files as shipped: the licence, the CSS, |
| -// the optional scripts, the icons and the examples. go:embed names |
| -// directories, so a file added under one is embedded without a change |
| -// here; it also refuses a directory with nothing in it, which is why |
| -// i18n/ joins this list only once it holds files. |
| +// the optional scripts, the icons, the examples and the interface |
| +// strings. go:embed names directories, so a file added under one is |
| +// embedded without a change here. |
| // |
| -//go:embed LICENSE css js icons examples |
| +//go:embed LICENSE css js icons examples i18n |
| var tree embed.FS |
| // FS returns the embedded tree. css/tokens.css, js/busy.js, |
| @@ -93,6 +92,28 @@ func Script(name string) ([]byte, bool) { |
| return b, err == nil |
| } |
| +// languageNames lists the shipped languages, en first: one |
| +// i18n/<lang>.json each, holding the strings datetime.js and select.js |
| +// put on screen. The slice matches the files in i18n/ exactly; adding |
| +// a language means adding both. Plain pt is not a language here: it |
| +// is a test alias for pt-PT (check/testdata/datetime/retagged.json), |
| +// kept for a page whose lang still says pt. |
| +var languageNames = []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"} |
| + |
| +// Languages returns the shipped languages' tags, en first. The |
| +// returned slice is a copy. |
| +func Languages() []string { return append([]string(nil), languageNames...) } |
| + |
| +// Strings returns one language's table, i18n/<lang>.json: a flat JSON |
| +// object whose keys are the string attributes' names with data-rst- |
| +// removed, plus date-words, the parser's vocabulary. It reports false |
| +// for a tag that is not shipped; a page's tag is matched to a shipped |
| +// one by the scripts themselves (fiestaPickLang), not here. |
| +func Strings(lang string) ([]byte, bool) { |
| + b, err := tree.ReadFile("i18n/" + lang + ".json") |
| + return b, err == nil |
| +} |
| + |
| // IconSlugs lists every icon Icon answers, sorted. The names are the |
| // design system's own vocabulary, which is Lucide's with five renamed |
| // (kebab for ellipsis-vertical; check-circle, alert-triangle, x-circle |
diff --git a/fiesta_test.go b/fiesta_test.go| index 83ef683..0ff91e8 100644 |
| --- a/fiesta_test.go |
| +++ b/fiesta_test.go |
| @@ -131,6 +131,41 @@ func TestEveryScriptIsEmbedded(t *testing.T) { |
| } |
| } |
| +func TestEveryLanguageIsEmbedded(t *testing.T) { |
| + want := []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"} |
| + if got := Languages(); !slices.Equal(got, want) { |
| + t.Fatalf("Languages() = %q, want %q", got, want) |
| + } |
| + for _, lang := range want { |
| + b, ok := Strings(lang) |
| + if !ok { |
| + t.Errorf("Strings(%q) reports no such language", lang) |
| + continue |
| + } |
| + if len(b) == 0 { |
| + t.Errorf("Strings(%q) is empty", lang) |
| + } |
| + if embedded := readFS(t, "i18n/"+lang+".json"); !bytes.Equal(b, embedded) { |
| + t.Errorf("Strings(%q) differs from FS()'s i18n/%s.json", lang, lang) |
| + } |
| + } |
| + // pt is the test alias, never a file; a miss is false, not a panic. |
| + for _, bad := range []string{"pt", "nope", "../LICENSE"} { |
| + if _, ok := Strings(bad); ok { |
| + t.Errorf("Strings(%q) reports a language that is not shipped", bad) |
| + } |
| + } |
| + files := listFS(t, "i18n") |
| + var sorted []string |
| + for _, lang := range want { |
| + sorted = append(sorted, lang+".json") |
| + } |
| + sort.Strings(sorted) |
| + if !slices.Equal(files, sorted) { |
| + t.Errorf("i18n/ holds %q, but Languages() lists %q", files, want) |
| + } |
| +} |
| + |
| func TestEveryIconIsOneLucideSVG(t *testing.T) { |
| want := []string{ |
| "alert-triangle", "check", "check-circle", "chevron-down", |
| @@ -194,6 +229,7 @@ func TestNamesAreCopies(t *testing.T) { |
| }{ |
| {"ThemeNames", ThemeNames}, |
| {"ScriptNames", ScriptNames}, |
| + {"Languages", Languages}, |
| {"IconSlugs", IconSlugs}, |
| } { |
| first := tc.list() |
diff --git a/js/calendar.js b/js/calendar.js| index f4767fa..fa0f29f 100644 |
| --- a/js/calendar.js |
| +++ b/js/calendar.js |
| @@ -18,7 +18,7 @@ |
| THE SEAM. This file publishes one function on the page: |
| - window.rastrilloCalendar(cfg) -> a panel |
| + window.fiestaCalendar(cfg) -> a panel |
| datetime.js reads it at enhance time and falls back to the native |
| picker when it is absent, so a page that links one script and not |
| @@ -38,7 +38,7 @@ |
| locale's own digits, and starts its weeks on the day that locale |
| starts weeks on. The three functions that decide all of it are pure |
| and exported for Node at the bottom of the file, behind a guard a |
| - browser never takes; ui/datetime_node.mjs drives them with |
| + browser never takes; check/node/datetime_node.mjs drives them with |
| --calendar. */ |
| (function (global) { |
| "use strict"; |
| @@ -517,11 +517,11 @@ |
| }; |
| } |
| - global.rastrilloCalendar = makePanel; |
| + global.fiestaCalendar = makePanel; |
| } |
| - // The pure half, for ui/datetime_node.mjs --calendar. A browser has |
| - // no `module` and never takes this branch. |
| + // The pure half, for check/node/datetime_node.mjs --calendar. A |
| + // browser has no `module` and never takes this branch. |
| if (typeof module !== "undefined" && module && module.exports) { |
| module.exports = { |
| firstDayOfWeek: firstDayOfWeek, |
diff --git a/js/datetime.js b/js/datetime.js| index 1c6ff9e..ac6697d 100644 |
| --- a/js/datetime.js |
| +++ b/js/datetime.js |
| @@ -18,8 +18,8 @@ |
| data-rst-time the same, on a time input — the script |
| reads a clock rather than a calendar |
| data-rst-date-words the parser vocabulary, one JSON object |
| - of |-separated spellings (see |
| - ui/funcs.go's dateWords) |
| + of |-separated spellings (the table's |
| + date-words, i18n/<lang>.json) |
| data-rst-date-set the commit affordance's label |
| data-rst-date-hint guidance under the list, {example} |
| substituted here with a date this |
| @@ -32,9 +32,11 @@ |
| data-rst-date-next-month label for the overlay's forward one |
| data-rst-date-results live-region text, {n} substituted |
| data-rst-date-result-one live-region text for exactly one |
| - data-rst-date-quick-* the seven quick-pick labels: today, |
| - tomorrow, next-week, plus-1h, plus-2h, |
| - end-of-day, next-day |
| + The seven quick-pick labels, one attribute each: |
| + data-rst-date-quick-today, data-rst-date-quick-tomorrow, |
| + data-rst-date-quick-next-week, data-rst-date-quick-plus-1h, |
| + data-rst-date-quick-plus-2h, data-rst-date-quick-end-of-day, |
| + data-rst-date-quick-next-day |
| data-rst-range on a WRAPPER around two armed inputs: |
| the two pair up by DOM order (first is |
| the start, second the end). The end |
| @@ -43,15 +45,16 @@ |
| an empty end to start + 1h when the |
| start is committed. |
| - ui/partials/field-date.html, field-time.html, field-datetime.html |
| - and field-daterange.html emit all of it. Every string comes from the |
| - framework's base catalog (rastrillo.ui.date_*) and rides out on |
| - attributes, because this markup is built in the browser where the |
| - catalog is out of reach — so this file carries no English |
| + Every string arrives translated, and is looked up in three places, |
| + in this order: the attribute on the input; the page's table, |
| + self.fiestaStrings[lang], one object per language with these same |
| + keys minus the data-rst- prefix, which i18n/<lang>.js fills and the |
| + document's lang picks (fiestaWord, at the end of the file); then the |
| + English fallback written here. So this file carries no English |
| VOCABULARY: not one word the parser matches on is written here. It |
| - does carry English fallbacks for the eleven labels it puts on |
| - screen, exactly as select.js does, so the file still works standing |
| - alone in a page that forgot an attribute. |
| + does carry English fallbacks for the labels it puts on screen, |
| + exactly as select.js does, so the file still works standing alone |
| + in a page that forgot an attribute and links no table. |
| The parser is a pure function of (text, vocabulary, locale tables, |
| now). It knows no month names and no weekday names by heart: it asks |
| @@ -62,8 +65,8 @@ |
| cannot derive, and that half arrives translated on the attribute. |
| The pure half is exported for Node at the bottom of the file, behind |
| - a guard a browser never takes; ui/datetime_node.mjs drives it |
| - against ui/testdata/datetime/*.json. |
| + a guard a browser never takes; check/node/datetime_node.mjs drives |
| + it against check/testdata/datetime/*.json. |
| Two things live NEXT to the strict grammar rather than inside it. |
| parseAll offers a reading where the grammar refuses one, by finishing |
| @@ -73,9 +76,9 @@ |
| outright — and every completion is shown with its date written out |
| before anyone commits it. |
| - And the calendar itself is ui/calendar.js, a sibling file. This one |
| + And the calendar itself is calendar.js, a sibling file. This one |
| reads and writes dates as TEXT; that one draws six weeks of them. |
| - The seam is window.rastrilloCalendar, looked up at enhance time, so |
| + The seam is window.fiestaCalendar, looked up at enhance time, so |
| an app that ships this file without that one still has a working |
| field — the button falls back to the browser's own picker, the same |
| way everything here falls back when an attribute is missing. */ |
| @@ -1115,11 +1118,6 @@ |
| // paragraph is where the fix starts. |
| function canPick(el) { return !!el.showPicker; } |
| - function attr(el, name, fallback) { |
| - var v = el.getAttribute(name); |
| - return v === null || v === "" ? fallback : v; |
| - } |
| - |
| function enhance(native) { |
| if (native.dataset.rstEnhanced) return null; // idempotent: safe to re-scan |
| native.dataset.rstEnhanced = "true"; |
| @@ -1130,30 +1128,32 @@ |
| var fmt = formatter(locale, kind); |
| var vocab = {}; |
| try { |
| - vocab = JSON.parse(attr(native, "data-rst-date-words", "{}")) || {}; |
| + // The attribute holds JSON text; the table holds the object. |
| + var words = fiestaWord(native, "date-words", {}); |
| + vocab = (typeof words === "string" ? JSON.parse(words) : words) || {}; |
| } catch (e) { |
| vocab = {}; |
| } |
| var id = native.id || "rst-dtp-" + ++seq; |
| var listId = id + "-listbox"; |
| - var setLabel = attr(native, "data-rst-date-set", "Set"); |
| - var hintText = attr(native, "data-rst-date-hint", ""); |
| - var pickLabel = attr(native, "data-rst-date-pick", "Open the calendar"); |
| - var clockLabel = attr(native, "data-rst-date-pick-time", "Open the clock"); |
| - var calLabel = attr(native, "data-rst-date-calendar", "Calendar"); |
| - var prevLabel = attr(native, "data-rst-date-prev-month", "Previous month"); |
| - var nextLabel = attr(native, "data-rst-date-next-month", "Next month"); |
| - var manyFmt = attr(native, "data-rst-date-results", "{n} suggestions"); |
| - var oneFmt = attr(native, "data-rst-date-result-one", "1 suggestion"); |
| + var setLabel = fiestaWord(native, "date-set", "Set"); |
| + var hintText = fiestaWord(native, "date-hint", ""); |
| + var pickLabel = fiestaWord(native, "date-pick", "Open the calendar"); |
| + var clockLabel = fiestaWord(native, "date-pick-time", "Open the clock"); |
| + var calLabel = fiestaWord(native, "date-calendar", "Calendar"); |
| + var prevLabel = fiestaWord(native, "date-prev-month", "Previous month"); |
| + var nextLabel = fiestaWord(native, "date-next-month", "Next month"); |
| + var manyFmt = fiestaWord(native, "date-results", "{n} suggestions"); |
| + var oneFmt = fiestaWord(native, "date-result-one", "1 suggestion"); |
| var quick = { |
| - today: attr(native, "data-rst-date-quick-today", "Today"), |
| - tomorrow: attr(native, "data-rst-date-quick-tomorrow", "Tomorrow"), |
| - nextWeek: attr(native, "data-rst-date-quick-next-week", "In a week"), |
| - plus1h: attr(native, "data-rst-date-quick-plus-1h", "An hour later"), |
| - plus2h: attr(native, "data-rst-date-quick-plus-2h", "Two hours later"), |
| - endOfDay: attr(native, "data-rst-date-quick-end-of-day", "End of that day"), |
| - nextDay: attr(native, "data-rst-date-quick-next-day", "Same time next day") |
| + today: fiestaWord(native, "date-quick-today", "Today"), |
| + tomorrow: fiestaWord(native, "date-quick-tomorrow", "Tomorrow"), |
| + nextWeek: fiestaWord(native, "date-quick-next-week", "In a week"), |
| + plus1h: fiestaWord(native, "date-quick-plus-1h", "An hour later"), |
| + plus2h: fiestaWord(native, "date-quick-plus-2h", "Two hours later"), |
| + endOfDay: fiestaWord(native, "date-quick-end-of-day", "End of that day"), |
| + nextDay: fiestaWord(native, "date-quick-next-day", "Same time next day") |
| }; |
| var wrap = document.createElement("div"); |
| @@ -1354,7 +1354,7 @@ |
| // the same posture this file takes towards a missing attribute. |
| // A page that links one script and not the other gets the old |
| // behaviour rather than a dead control. |
| - var makeCal = (typeof window !== "undefined" && window.rastrilloCalendar) || null; |
| + var makeCal = (typeof window !== "undefined" && window.fiestaCalendar) || null; |
| var cal = null; |
| if (makeCal && kind !== "time") { |
| @@ -1714,14 +1714,48 @@ |
| } |
| } |
| - // The pure half, for ui/datetime_node.mjs. A browser has no `module` |
| - // and never takes this branch. |
| + /* ── the strings ───────────────────────────────────────────────── |
| + The attribute, then the page's table, then the fallback (the |
| + header says why). select.js carries the same two functions, so |
| + each enhancement stays one script tag; check/strings_test.go holds |
| + the two copies identical. */ |
| + |
| + // The table for a tag: an exact match whatever its case, else the |
| + // first in its primary language (pt-BR reads pt-PT), else null. |
| + function fiestaPickLang(tag, registry) { |
| + var want = String(tag || "").toLowerCase(); |
| + if (!want) return null; |
| + var keys = Object.keys(registry || {}), i; |
| + for (i = 0; i < keys.length; i++) { |
| + if (keys[i].toLowerCase() === want) return keys[i]; |
| + } |
| + var primary = want.split("-")[0]; |
| + for (i = 0; i < keys.length; i++) { |
| + if (keys[i].toLowerCase().split("-")[0] === primary) return keys[i]; |
| + } |
| + return null; |
| + } |
| + |
| + function fiestaWord(el, key, fallback) { |
| + var v = el.getAttribute("data-rst-" + key); |
| + if (v !== null && v !== "") return v; |
| + var registry = (typeof self !== "undefined" && self.fiestaStrings) || {}; |
| + var lang = fiestaPickLang(document.documentElement.lang, registry); |
| + var table = lang ? registry[lang] : null; |
| + return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback; |
| + } |
| + |
| + // 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. |
| if (typeof module !== "undefined" && module && module.exports) { |
| module.exports = { |
| parse: parse, |
| parseAll: parseAll, |
| tables: tables, |
| - displayOptions: displayOptions |
| + displayOptions: displayOptions, |
| + fiestaPickLang: fiestaPickLang, |
| + fiestaWord: fiestaWord |
| }; |
| } |
| })(); |
diff --git a/js/select.js b/js/select.js| index 2e9383c..f1a1bb6 100644 |
| --- a/js/select.js |
| +++ b/js/select.js |
| @@ -42,8 +42,10 @@ |
| Content added after load is enhanced when the page dispatches |
| rst:select-scan on document ({detail: {root}} to look only there). |
| - field-select.html emits the strings from the base catalog |
| - (rastrillo.ui.select_*) on attributes: the browser has no catalog. */ |
| + Every string is looked up on the attribute first, then in the page's |
| + table (self.fiestaStrings[lang], the same keys minus data-rst-, |
| + which i18n/<lang>.js fills), then the English fallback written here |
| + (fiestaWord, below). */ |
| (function () { |
| "use strict"; |
| @@ -175,10 +177,41 @@ |
| const offered = (value, marked, required, searching, disabled) => |
| value !== "" || (marked ? !searching : !(required || !!disabled)); |
| - // The pure half, for ui/select_node.mjs. A browser has no `module` and |
| - // never takes this branch. |
| + // The strings: the attribute, then the page's table, then the |
| + // fallback (the header says why). datetime.js carries the same two |
| + // functions, so each enhancement stays one script tag; |
| + // check/strings_test.go holds the two copies identical. |
| + |
| + // The table for a tag: an exact match whatever its case, else the |
| + // first in its primary language (pt-BR reads pt-PT), else null. |
| + function fiestaPickLang(tag, registry) { |
| + var want = String(tag || "").toLowerCase(); |
| + if (!want) return null; |
| + var keys = Object.keys(registry || {}), i; |
| + for (i = 0; i < keys.length; i++) { |
| + if (keys[i].toLowerCase() === want) return keys[i]; |
| + } |
| + var primary = want.split("-")[0]; |
| + for (i = 0; i < keys.length; i++) { |
| + if (keys[i].toLowerCase().split("-")[0] === primary) return keys[i]; |
| + } |
| + return null; |
| + } |
| + |
| + function fiestaWord(el, key, fallback) { |
| + var v = el.getAttribute("data-rst-" + key); |
| + if (v !== null && v !== "") return v; |
| + var registry = (typeof self !== "undefined" && self.fiestaStrings) || {}; |
| + var lang = fiestaPickLang(document.documentElement.lang, registry); |
| + var table = lang ? registry[lang] : null; |
| + return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback; |
| + } |
| + |
| + // The pure half, for ui/select_node.mjs, and the lookup for |
| + // check/node/strings_node.mjs. A browser has no `module` and never |
| + // takes this branch. |
| if (typeof module !== "undefined" && module && module.exports) { |
| - module.exports = { fold: fold, rank: rank, order: order, settle: settle, isPrompt: isPrompt, offered: offered }; |
| + module.exports = { fold: fold, rank: rank, order: order, settle: settle, isPrompt: isPrompt, offered: offered, fiestaPickLang: fiestaPickLang, fiestaWord: fiestaWord }; |
| } |
| if (typeof document === "undefined") return; |
| @@ -222,7 +255,7 @@ |
| const id = native.id || "rst-select-" + Math.random().toString(36).slice(2); |
| const listId = id + "-listbox"; |
| - const say = (key, fallback) => native.getAttribute("data-rst-select-" + key) || fallback; |
| + const say = (key, fallback) => fiestaWord(native, "select-" + key, fallback); |
| const manyFmt = say("results", "{n} results"); |
| const oneFmt = say("result-one", "1 result"); |