Keep every negation and every identifier, and let no short English through
Review fixes to the translations and to the gates that hold them. Negations. The Irish draft of the API secret note said the secret is in that response "and anywhere else"; it now ends "tá an rún sa fhreagra sin amháin". Every Irish draft was re-read for the same slip: the money blurb's "riamh ón léitheoir" said "ever from the reader" and now says "agus ní thagann sé riamh ón léitheoir". Every draft whose English carries never, nowhere, nothing, not or no was read in the other ten locales; the negation holds in each. Identifiers. Each code token in a draft's English was checked verbatim in every translation. Fixed: the Arabic date-range blurb keeps fieldset, name and id; placeholder stays as written in the Arabic and Japanese search-field note; value (of <data>) in the Arabic and Irish shortened-number note; symbol in the Arabic sprite blurb; and the sidebar and console shell names in the stylesheets paragraph in ar, bn, hi, yue and zh-Hans, as Rastrillo's carried strings write them. The other hits were ordinary hyphenated English. All thirteen are still machine drafts nobody has reviewed. The exporter was run again at 5bb38c0ab31b9e3f5a698c61d73161cd1246471f and wrote the catalogs byte for byte as committed here: 298 carried and 98 drafted per locale, as before, and no todo list. The leak sweep no longer lets short English through. Every traced string is looked for as a whole run of text or a whole spoken attribute value (a string with placeholders as a pattern with them filled), beside the sentences looked for inside runs. The strings a locale rightly keeps are listed in leakUnchanged, beside leakExempt: Fiesta, HTML, the title pattern, and Tokens in es and pt-PT; the sweep checks each entry against the catalogs so the list cannot outlive its reason. Red first, by assertion: with the ja catalog's gallery.copy and gallery.kind.overview put back to English, the old sweep did not report "Copy" or "Overview" on day/ja/index.html; both are now controls in TestNoEnglishProseReachesATranslatedPage. The title check in TestEveryPageIsAWholeLocalisedDocument no longer trusts the catalog alone: a page name equal to its English fails unless leakUnchanged keeps it; the same Overview mutation reports "the ja catalog names the page "Overview", its English". The template gate reads templates with the HTML tokenizer instead of regular expressions: an action becomes a hole that cuts the run, SVG title and desc text is read, spoken attributes are read however they are quoted, and only script, style and drawing data (which sit in attributes no one hears) are left alone. The spoken attributes are one list for the gate and the sweep, now with the other ARIA attributes that carry words. Red first, by assertion: the old extractor read nothing from an SVG with an English title and desc, or from a single-quoted aria-label and an unquoted alt; those are now its controls, beside a script and a style it must not read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+269
−77
internal/gallery/i18n.go+24 −0internal/gallery/i18n/ar.json+5 −5internal/gallery/i18n/bn.json+1 −1internal/gallery/i18n/ga.json+3 −3internal/gallery/i18n/hi.json+1 −1internal/gallery/i18n/ja.json+1 −1internal/gallery/i18n/yue.json+1 −1internal/gallery/i18n/zh-Hans.json+1 −1internal/gallery/i18n_test.go+226 −63internal/gallery/tree_test.go+6 −1
CI passed — run details
CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok amadan.net/rastrillo/fiesta 0.110s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN TestTheOneLinkIsUnder25KiBGzipped
build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok amadan.net/rastrillo/fiesta/internal/build 0.019s
=== suite ===
--- shard 0 of 15 ---
==> shard 0 of 15: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
ok amadan.net/rastrillo/fiesta 1.153s
ok amadan.net/rastrillo/fiesta/check 5.557s
ok amadan.net/rastrillo/fiesta/cmd/fiesta 90.755s
ok amadan.net/rastrillo/fiesta/example 0.358s
? amadan.net/rastrillo/fiesta/hack/seed/english [no test files]
ok amadan.net/rastrillo/fiesta/internal/axe 0.140s
ok amadan.net/rastrillo/fiesta/internal/build 1.265s
ok amadan.net/rastrillo/fiesta/internal/codeview 0.022s
ok amadan.net/rastrillo/fiesta/internal/gallery 120.297s
? amadan.net/rastrillo/fiesta/internal/harness [no test files]
ok amadan.net/rastrillo/fiesta/internal/markup 0.011s
ok amadan.net/rastrillo/fiesta/internal/nodetest 0.376s
ok amadan.net/rastrillo/fiesta/internal/site 3.345s
ok amadan.net/rastrillo/fiesta/internal/tokens 0.020s
ok amadan.net/rastrillo/fiesta/internal/www 78.932s
--- shard 1 of 15 ---
==> shard 1 of 15: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 15 ---
==> shard 2 of 15: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 15 ---
==> shard 3 of 15: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
[90m8:48AM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net
[90m8:48AM[0m [32mINF[0m [1m1 commits scanned.[0m
[90m8:48AM[0m [32mINF[0m [1mscanned ~7205901 bytes (7.21 MB) in 5.01s[0m
[90m8:48AM[0m [32mINF[0m [1mno leaks found[0m
--- shard 4 of 15 ---
==> shard 4 of 15: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok amadan.net/rastrillo/fiesta/internal/harness 12.967s
--- shard 5 of 15 ---
==> shard 5 of 15: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 227.308s
--- shard 6 of 15 ---
==> shard 6 of 15: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 95 of 286 tests
ok amadan.net/rastrillo/fiesta/check 241.666s
--- shard 7 of 15 ---
==> shard 7 of 15: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 96 of 286 tests
ok amadan.net/rastrillo/fiesta/check 246.516s
--- shard 8 of 15 ---
==> shard 8 of 15: make browser.internal.gallery~1of4
./hack/gotest.sh browser 'internal.gallery~1of4'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 4 of ./internal/gallery/ runs 1 of 151 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 315.004s
--- shard 9 of 15 ---
==> shard 9 of 15: make browser.internal.gallery~2of4
./hack/gotest.sh browser 'internal.gallery~2of4'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 4 of ./internal/gallery/ runs 49 of 151 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 210.952s
--- shard 10 of 15 ---
==> shard 10 of 15: make browser.internal.gallery~3of4
./hack/gotest.sh browser 'internal.gallery~3of4'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 4 of ./internal/gallery/ runs 51 of 151 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 236.499s
--- shard 11 of 15 ---
==> shard 11 of 15: make browser.internal.gallery~4of4
./hack/gotest.sh browser 'internal.gallery~4of4'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 4 of 4 of ./internal/gallery/ runs 50 of 151 tests
ok amadan.net/rastrillo/fiesta/internal/gallery 215.308s
--- shard 12 of 15 ---
==> shard 12 of 15: make browser.internal.gallery.history
./hack/gotest.sh browser 'internal.gallery.history'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok amadan.net/rastrillo/fiesta/internal/gallery/history 35.281s
--- shard 13 of 15 ---
==> shard 13 of 15: make browser.internal.www~1of2
./hack/gotest.sh browser 'internal.www~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/www/ runs 17 of 34 tests
ok amadan.net/rastrillo/fiesta/internal/www 134.901s
--- shard 14 of 15 ---
==> shard 14 of 15: make browser.internal.www~2of2
./hack/gotest.sh browser 'internal.www~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/www/ runs 17 of 34 tests
ok amadan.net/rastrillo/fiesta/internal/www 120.509s
Intent
diff --git a/internal/gallery/i18n.go b/internal/gallery/i18n.go| index a5166fd..b44e00c 100644 |
| --- a/internal/gallery/i18n.go |
| +++ b/internal/gallery/i18n.go |
| @@ -7,6 +7,7 @@ import ( |
| "html/template" |
| "io/fs" |
| "regexp" |
| + "slices" |
| "sync" |
| "amadan.net/rastrillo/fiesta/example" |
| @@ -186,6 +187,29 @@ func leakExempt(en string) bool { |
| return false |
| } |
| +// leakUnchanged are the English strings a translation keeps exactly as |
| +// they are, each with the locales that keep it (nil is every locale): |
| +// the brand, the name of the HTML tab, and the title pattern, whose |
| +// only words are the brand's; and "Tokens", which Spanish and |
| +// Portuguese write as English does. Anything else a translated page |
| +// says in English is a leak, however short. Adding to this list is a |
| +// claim that the translation is right as it stands. |
| +var leakUnchanged = map[string][]string{ |
| + "Fiesta": nil, |
| + "HTML": nil, |
| + "{page} · Fiesta · {theme}": nil, |
| + "Tokens": {"es", "pt-PT"}, |
| +} |
| + |
| +// unchangedIn says whether locale keeps en as it is, by leakUnchanged. |
| +func unchangedIn(locale, en string) bool { |
| + locales, ok := leakUnchanged[en] |
| + if !ok { |
| + return false |
| + } |
| + return locales == nil || slices.Contains(locales, locale) |
| +} |
| + |
| // templateSources is every template the pages are drawn from, each |
| // registered by the file that owns it at init, so a page kind in a |
| // file of its own brings its body template with it and nothing here |
diff --git a/internal/gallery/i18n/ar.json b/internal/gallery/i18n/ar.json| index 9d5ae4a..8d4f550 100644 |
| --- a/internal/gallery/i18n/ar.json |
| +++ b/internal/gallery/i18n/ar.json |
| @@ -26,7 +26,7 @@ |
| "examples.field-date.blurb": "حقل تاريخ أصلي، ومعه صندوق التحرير والسرد الذي يضيفه datetime.js ويفهم اللغة الطبيعية حين يحمل الحقل data-rst-date. اكتب فيه \"غدًا\".", |
| "examples.field-date.states.enhanced-input-type-tomorrow-or-in-3-weeks": "الكلمات التي يحلّلها موجودة على data-rst-date-words، بالإنجليزية هنا. ويحملها i18n/<lang>.json باثنتي عشرة لغة، فيستطيع الحقل تحليل اللغة التي كُتبت بها الصفحة.", |
| "examples.field-date.states.plain-the-bare-native-input": "لا توجد أي سمات تحسين، فلا يجد datetime.js شيئًا. ومع ذلك يظل المنتقي الأصلي يفتح.", |
| - "examples.field-daterange.blurb": "بداية ونهاية ضمن مجموعة حقول واحدة بتسمية. أعطِ كل حقل إدخال اسمًا ومعرّفًا خاصّين به.", |
| + "examples.field-daterange.blurb": "بداية ونهاية ضمن fieldset واحد بتسمية. أعطِ كل حقل إدخال name وid خاصّين به.", |
| "examples.field-daterange.states.date-and-time-seeded": "ثبّت البداية والنهاية ما تزال فارغة، فيملأ datetime.js النهاية بعد ساعة. لا شيء في الترميز يفعل ذلك؛ السكربت هو من يفعله.", |
| "examples.field-datetime.blurb": "تاريخ ووقت في حقل واحد، وقيمة واحدة.", |
| "examples.field-datetime.states.enhanced-input-type-tomorrow-or-in-3-weeks": "الكلمات التي يحلّلها موجودة على data-rst-date-words، بالإنجليزية هنا. ويحملها i18n/<lang>.json باثنتي عشرة لغة، فيستطيع الحقل تحليل اللغة التي كُتبت بها الصفحة.", |
| @@ -68,7 +68,7 @@ |
| "examples.layout-stage.blurb": "بطاقة واحدة في وسط خلفية تملأ الصفحة، لشاشة قائمة بذاتها. صُمّمت لشاشة تسجيل الدخول.", |
| "examples.layout-topbar.blurb": "العلامة والتنقّل وقائمة الحساب عبر الأعلى، وتذييل أسفل الصفحة.", |
| "examples.list-bar-search.blurb": "نموذج بحث قائم بذاته للقوائم التي تبحث فقط. نموذج method=\"get\" عادي.", |
| - "examples.list-bar-search.states.empty-accessible-name-from-the-catalog": "بلا نص إرشادي. الاسم الذي يُتاح لحقل الإدخال هو aria-label الخاص به، ويكتبه التطبيق بلغة الصفحة.", |
| + "examples.list-bar-search.states.empty-accessible-name-from-the-catalog": "بلا placeholder. الاسم الذي يُتاح لحقل الإدخال هو aria-label الخاص به، ويكتبه التطبيق بلغة الصفحة.", |
| "examples.list-bar-search.states.with-a-query": "الرمز ✕ رابط إلى الشاشة نفسها بعد إسقاط الاستعلام. ويسافر مرشّح الحالة في حقل مخفي فيبقى.", |
| "examples.list-bar-search.states.with-a-query-on-page-4": "يسافر رقم الصفحة في حقل مخفي، فيحمل البحث الجديد page=4 إلى مجموعة نتائج لم تعد فيها أربع صفحات. ورابط ✕ يُسقطه: وجّهه إلى الصفحة الأولى من القائمة.", |
| "examples.list-bar.blurb": "شريط الأدوات أعلى بطاقة القائمة، مع البحث والتصفية.", |
| @@ -116,7 +116,7 @@ |
| "examples.signin.warnings.email-and-password.title": "لا نوصي بكلمات المرور", |
| "examples.stat-band.blurb": "صف من الأرقام على امتداد أعلى لوحة المعلومات.", |
| "examples.stat.blurb": "رقم واحد على لوحة المعلومات، مع تسميته ومقدار تغيّره.", |
| - "examples.stat.states.a-shortened-number": "تعرض 4.1k بدل 4120؟ ضع العدد الكامل في قيمة وسم <data>. قارئات الشاشة لا تقرأ إلا ما على الشاشة، فأبقِ كل ما يحتاجه الشخص في العدد نفسه.", |
| + "examples.stat.states.a-shortened-number": "تعرض 4.1k بدل 4120؟ ضع العدد الكامل في السمة value لوسم <data>. قارئات الشاشة لا تقرأ إلا ما على الشاشة، فأبقِ كل ما يحتاجه الشخص في العدد نفسه.", |
| "examples.stat.states.a-smaller-number": "تحتاج فقط إلى تسمية ورقم. وكل ما عدا ذلك اختياري.", |
| "examples.stat.states.the-big-number": "أعطِ إحصاءً واحدًا في الصف rst-stat=\"lead\" ليكبر. إنه المكوّن نفسه، أكبر فحسب.", |
| "examples.stat.states.when-going-down-is-good": "أنت من يحدد اللون، لا المكوّن. هنا انخفض الرقم وهذا خبر جيد، لذا يظهر التغيّر بالأخضر.", |
| @@ -215,7 +215,7 @@ |
| "gallery.started.file.select-js": "صندوق field-select القابل للبحث. خامل حتى ينضمّ إليه عنصر select عبر data-rst-select، ويُحذف وحده.", |
| "gallery.started.file.shell-css": "الانزلاق بين الصفحات على الهاتف، للهيكلين sidebar وconsole. يُحذف مع shell.js.", |
| "gallery.started.file.shell-js": "التنقّل على الهاتف للهيكلين sidebar وconsole: الانزلاق بين الصفحات، وزر الرجوع الذي يعيد استخدام السجلّ، وإعادة التركيز إلى القسم الذي غادرته. يُحذف وحده.", |
| - "gallery.started.file.sprite": "ملف sprite فيه رمز واحد لكل أيقونة، لموقع يقدّم نسخته الخاصة من Fiesta. ومن شبكة CDN، ضمّن ملف SVG من icons/<slug>.svg بدلًا منه.", |
| + "gallery.started.file.sprite": "ملف sprite فيه عنصر symbol واحد لكل أيقونة، لموقع يقدّم نسخته الخاصة من Fiesta. ومن شبكة CDN، ضمّن ملف SVG من icons/<slug>.svg بدلًا منه.", |
| "gallery.started.file.strings": "الكلمات التي يعرضها select.js وdatetime.js على الشاشة، بـ{language}. حمّل الملف الذي يطابق lang صفحتك.", |
| "gallery.started.file.theme": "اللون وعائلة الخط والشكل لسمة {theme}: كتلة :root واحدة يُعلَن فيها كل لون مرة واحدة كزوج light-dark().", |
| "gallery.started.file.tokens": "البنية: كل أصناف المكوّنات وكل السلالم، ولا قيمة لون صريحة في أي موضع. الملف نفسه تحت كل سمة.", |
| @@ -227,7 +227,7 @@ |
| "gallery.started.scripts": "النصوص البرمجية", |
| "gallery.started.scripts.body": "يغلق fiesta.js القوائم، ويتيح سحب مقبض المفتاح، ويمنح iOS حالة الضغط، ويشغّل قائمة نظام الألوان؛ حمّله أولًا. أما busy.js وlight.js وanchor.js وshell.js وselect.js وdatetime.js وcalendar.js فكل منها خامل حتى تستخدم الصفحة ما يحسّنه، ويمكن حذف كل منها وحده. حمّل calendar.js أخيرًا.", |
| "gallery.started.stylesheets": "أوراق الأنماط", |
| - "gallery.started.stylesheets.body": "fiesta.css هو tokens.css وسمة Day مدموجتين، بلا تعليقات، أقل من 25 KiB مضغوطًا بـ gzip. الرموز تحمل تخطيط كل مكوّن وسلوكه؛ والسمة تلوّنها. وfiesta-plain.css وfiesta-signal.css هما الرموز نفسها مع سمتي Plain وSignal. والصفحة التي تستخدم الهيكل ذا الشريط الجانبي أو هيكل وحدة التحكم تربط shell.css أيضًا؛ ولا شيء غيرها يحتاجه.", |
| + "gallery.started.stylesheets.body": "fiesta.css هو tokens.css وسمة Day مدموجتين، بلا تعليقات، أقل من 25 KiB مضغوطًا بـ gzip. الرموز تحمل تخطيط كل مكوّن وسلوكه؛ والسمة تلوّنها. وfiesta-plain.css وfiesta-signal.css هما الرموز نفسها مع سمتي Plain وSignal. والصفحة التي تستخدم الهيكل sidebar أو console تربط shell.css أيضًا؛ ولا شيء غيرها يحتاجه.", |
| "gallery.started.total": "الصفحة التي تربط fiesta.css وكل السكربتات تحصل على {raw} من CSS وJavaScript كما كُتبت، و{gzip} مضغوطة بـ gzip.", |
| "gallery.started.upgrading": "الترقية", |
| "gallery.started.upgrading.body": "الصفحة المربوطة بـ /1/ تلتقط كل إصدار 1.x يوم صدوره؛ وسجل التغييرات يقول ما الذي تغيّر. أما الصفحة المثبّتة على إصدار محدد فتتغير حين تغيّر الرابط، لا قبل ذلك.", |
diff --git a/internal/gallery/i18n/bn.json b/internal/gallery/i18n/bn.json| index 1606b73..c421922 100644 |
| --- a/internal/gallery/i18n/bn.json |
| +++ b/internal/gallery/i18n/bn.json |
| @@ -227,7 +227,7 @@ |
| "gallery.started.scripts": "স্ক্রিপ্ট", |
| "gallery.started.scripts.body": "fiesta.js মেনু বন্ধ করে, সুইচের নব টেনে সরাতে দেয়, iOS-কে তার চাপা অবস্থা দেয় আর রঙের ধরনের মেনু চালায়; এটি আগে লোড করুন। busy.js, light.js, anchor.js, shell.js, select.js, datetime.js আর calendar.js প্রতিটিই নিষ্ক্রিয় থাকে যতক্ষণ না পাতা সেগুলোর উন্নত করা জিনিস ব্যবহার করে, আর প্রতিটি আলাদাভাবে মুছে ফেলা যায়। calendar.js সবার শেষে লোড করুন।", |
| "gallery.started.stylesheets": "স্টাইলশিট", |
| - "gallery.started.stylesheets.body": "fiesta.css হলো tokens.css আর Day থিম জোড়া দেওয়া, মন্তব্য বাদ দিয়ে, gzip-এ 25 KiB-এর কম। টোকেনে থাকে প্রতিটি উপাদানের বিন্যাস আর আচরণ; থিম সেগুলো রাঙায়। fiesta-plain.css আর fiesta-signal.css হলো একই টোকেন, Plain আর Signal থিমের সঙ্গে। যে পাতা সাইডবার বা কনসোল শেল ব্যবহার করে, সেটি shell.css-ও লিঙ্ক করে; আর কিছুর এটি লাগে না।", |
| + "gallery.started.stylesheets.body": "fiesta.css হলো tokens.css আর Day থিম জোড়া দেওয়া, মন্তব্য বাদ দিয়ে, gzip-এ 25 KiB-এর কম। টোকেনে থাকে প্রতিটি উপাদানের বিন্যাস আর আচরণ; থিম সেগুলো রাঙায়। fiesta-plain.css আর fiesta-signal.css হলো একই টোকেন, Plain আর Signal থিমের সঙ্গে। যে পাতা sidebar বা console শেল ব্যবহার করে, সেটি shell.css-ও লিঙ্ক করে; আর কিছুর এটি লাগে না।", |
| "gallery.started.total": "যে পাতা fiesta.css আর সব স্ক্রিপ্ট লিঙ্ক করে, সে লেখা অবস্থায় {raw} CSS আর JavaScript পায়, gzip-এ {gzip}।", |
| "gallery.started.upgrading": "আপগ্রেড করা", |
| "gallery.started.upgrading.body": "/1/-এ থাকা পাতা প্রতিটি 1.x রিলিজ বের হওয়ার দিনই পেয়ে যায়; পরিবর্তনের তালিকা বলে কী বদলেছে। নির্দিষ্ট সংস্করণে বাঁধা পাতা বদলায় যখন আপনি লিঙ্ক বদলান, তার আগে নয়।", |
diff --git a/internal/gallery/i18n/ga.json b/internal/gallery/i18n/ga.json| index e0f522a..e70dc16 100644 |
| --- a/internal/gallery/i18n/ga.json |
| +++ b/internal/gallery/i18n/ga.json |
| @@ -3,7 +3,7 @@ |
| "examples.actor.states.an-agent-acting-for-a-person": "Ainm an ghníomhaire, ansin an duine ar ghníomhaigh sé thar a cheann. Nuair is í an aip féin a rinne rud, ná taispeáin rud ar bith.", |
| "examples.api-tokens.blurb": "Leathanach na gcomharthaí API: an nochtadh aon uaire, an fhoirm chruthaithe, an liosta agus na cnaipí cúlghairme.", |
| "examples.api-tokens.states.four-tokens-two-of-them-live": "Postálann na foirmeacha cúlghairme chuig /tokens/revoke, /tokens/revoke-agent agus /tokens/revoke-all, faoi chonair an leathanaigh féin. Dírigh iad ar cibé áit a láimhseálann d'aip iad.", |
| - "examples.api-tokens.states.just-created-the-secret-shown-once": "Taispeáin é seo sa fhreagra ar an bhfoirm chruthaithe féin, agus ná hatreoraigh é: tá an rún sa fhreagra sin agus in aon áit eile.", |
| + "examples.api-tokens.states.just-created-the-secret-shown-once": "Taispeáin é seo sa fhreagra ar an bhfoirm chruthaithe féin, agus ná hatreoraigh é: tá an rún sa fhreagra sin amháin.", |
| "examples.back-nav.blurb": "An nasc ar ais leibhéal amháin suas, os cionn scáileáin taispeána nó eagair.", |
| "examples.badge.blurb": "Slis le himlíne i gceannlitreacha do mharcóirí beaga statacha. Rud eile é seachas status-pill, arb é stádas taifid é.", |
| "examples.box.blurb": "Cárta stuáilte na rannóige, agus an ceannteideal a shuíonn lasmuigh de.", |
| @@ -52,7 +52,7 @@ |
| "examples.format-durations.blurb": "Bain úsáid as <time> le haghaidh fad ama chomh maith. Tá cuma aisteach ar an leagan meaisín-inléite: ciallaíonn PT2H30M 2 uair agus 30 nóiméad.", |
| "examples.format-filesizes.blurb": "Taispeáin méid slánaithe ar nós 4 MB, agus cuir líon beacht na mbeart i gclib <data>. Léann léitheoirí scáileáin a bhfuil ar an scáileán agus déanann siad neamhaird den chlib, mar sin caithfidh an méid slánaithe a bheith úsáideach as féin.", |
| "examples.format-identifiers.blurb": "Ná roinn iad seo ina ngrúpaí. Ordú 4471, ní ordú 4,471. Is mar an gcéanna do bhlianta, uimhreacha leagain agus uimhreacha poirt. Bain úsáid as rst-mono le go mbeidh cuma cóid orthu, agus ná cuir ceann acu i gclib <time> riamh, fiú nuair a bhíonn cuma dáta air.", |
| - "examples.format-money.blurb": "Stóráil gach méid mar shlánuimhir d'aonad is lú an airgeadra, mar cheintanna, agus cód an airgeadra in aice leis. Scríobh é mar a dhéanann teanga an léitheora. Tagann an t-airgeadra i gcónaí ó do shonraí, riamh ón léitheoir.", |
| + "examples.format-money.blurb": "Stóráil gach méid mar shlánuimhir d'aonad is lú an airgeadra, mar cheintanna, agus cód an airgeadra in aice leis. Scríobh é mar a dhéanann teanga an léitheora. Tagann an t-airgeadra i gcónaí ó do shonraí, agus ní thagann sé riamh ón léitheoir.", |
| "examples.format-numbers.blurb": "Roinn uimhreacha fada ina ngrúpaí le go léifear iad de spléachadh: 54,173 i mBéarla, 54.173 i nGearmáinis, ٥٤٬١٧٣ in Araibis. Ná taispeáin uimhir fhada mar dhigití loma riamh. I dtábla, bain úsáid as digití atá ar comhleithead, sa chaoi go luífidh na colúin ar a chéile.", |
| "examples.format-output.blurb": "Nuair a shuimíonn do leathanach rud, cuir an freagra i gclib <output>. Iomlán reatha, praghas tiontaithe, comhaireamh síos.", |
| "examples.format-people.blurb": "Is féidir le hainm a bheith in aon teanga, teangacha a léitear ó dheas go clé san áireamh. Nuair a bhíonn ainm i lár líne, cuir <bdi> timpeall air, nó cuirfidh sé na focail máguaird trína chéile. Bain úsáid as person le hainm a thaispeáint agus pictiúr lena thaobh.", |
| @@ -116,7 +116,7 @@ |
| "examples.signin.warnings.email-and-password.title": "Ní mholaimid pasfhocail", |
| "examples.stat-band.blurb": "Sraith staitisticí trasna barr painéil.", |
| "examples.stat.blurb": "Uimhir amháin ar phainéal, le lipéad agus a mhéid a d'athraigh sí.", |
| - "examples.stat.states.a-shortened-number": "Ag taispeáint 4.1k in ionad 4120? Cuir an uimhir iomlán i luach clib <data>. Ní léann léitheoirí scáileáin ach a bhfuil ar an scáileán, mar sin coinnigh aon rud a theastaíonn ó dhuine san uimhir féin.", |
| + "examples.stat.states.a-shortened-number": "Ag taispeáint 4.1k in ionad 4120? Cuir an uimhir iomlán i value clib <data>. Ní léann léitheoirí scáileáin ach a bhfuil ar an scáileán, mar sin coinnigh aon rud a theastaíonn ó dhuine san uimhir féin.", |
| "examples.stat.states.a-smaller-number": "Níl uait ach lipéad agus uimhir. Tá gach rud eile roghnach.", |
| "examples.stat.states.the-big-number": "Tabhair rst-stat=\"lead\" do staitistic amháin sa ró chun é a mhéadú. Is é an chomhpháirt chéanna é, níos mó.", |
| "examples.stat.states.when-going-down-is-good": "Is tusa a shocraíonn an dath, ní an chomhpháirt. Anseo thit an uimhir agus is dea-scéal é sin, mar sin tá an t-athrú glas.", |
diff --git a/internal/gallery/i18n/hi.json b/internal/gallery/i18n/hi.json| index 7dcfb1b..26097f8 100644 |
| --- a/internal/gallery/i18n/hi.json |
| +++ b/internal/gallery/i18n/hi.json |
| @@ -227,7 +227,7 @@ |
| "gallery.started.scripts": "स्क्रिप्ट", |
| "gallery.started.scripts.body": "fiesta.js मेन्यू बंद करता है, स्विच के नॉब को खींचने देता है, iOS को उसकी दबी हुई स्थिति देता है और रंग योजना का मेन्यू चलाता है; इसे सबसे पहले लोड करें। busy.js, light.js, anchor.js, shell.js, select.js, datetime.js और calendar.js तब तक निष्क्रिय रहती हैं जब तक पेज वह इस्तेमाल न करे जिसे वे सुधारती हैं, और हर एक अलग से हटाई जा सकती है। calendar.js सबसे आख़िर में लोड करें।", |
| "gallery.started.stylesheets": "स्टाइलशीट", |
| - "gallery.started.stylesheets.body": "fiesta.css, tokens.css और Day थीम को जोड़कर बनी है, टिप्पणियाँ हटाकर, gzip में 25 KiB से कम। टोकन में हर घटक का लेआउट और व्यवहार होता है; थीम उन्हें रंगती है। fiesta-plain.css और fiesta-signal.css वही टोकन हैं, Plain और Signal थीम के साथ। जो पेज साइडबार या कंसोल शेल इस्तेमाल करता है, वह shell.css भी लिंक करता है; और किसी को इसकी ज़रूरत नहीं।", |
| + "gallery.started.stylesheets.body": "fiesta.css, tokens.css और Day थीम को जोड़कर बनी है, टिप्पणियाँ हटाकर, gzip में 25 KiB से कम। टोकन में हर घटक का लेआउट और व्यवहार होता है; थीम उन्हें रंगती है। fiesta-plain.css और fiesta-signal.css वही टोकन हैं, Plain और Signal थीम के साथ। जो पेज sidebar या console शेल इस्तेमाल करता है, वह shell.css भी लिंक करता है; और किसी को इसकी ज़रूरत नहीं।", |
| "gallery.started.total": "जो पेज fiesta.css और हर स्क्रिप्ट लिंक करता है, उसे लिखे रूप में {raw} CSS और JavaScript मिलता है, gzip में {gzip}।", |
| "gallery.started.upgrading": "अपग्रेड करना", |
| "gallery.started.upgrading.body": "/1/ पर टिका पेज हर 1.x रिलीज़ उसी दिन पा लेता है जिस दिन वह आती है; बदलावों की सूची बताती है कि क्या बदला। सटीक संस्करण पर टिका पेज तब बदलता है जब आप लिंक बदलते हैं, उससे पहले नहीं।", |
diff --git a/internal/gallery/i18n/ja.json b/internal/gallery/i18n/ja.json| index 7469c80..f3b1d2d 100644 |
| --- a/internal/gallery/i18n/ja.json |
| +++ b/internal/gallery/i18n/ja.json |
| @@ -68,7 +68,7 @@ |
| "examples.layout-stage.blurb": "ページ全体に広がる背景の中央に、カードがひとつ。単独で成り立つ画面のためのものです。サインイン画面のためにあります。", |
| "examples.layout-topbar.blurb": "上端にブランド、ナビゲーション、アカウントメニューを並べ、ページの下にフッターを置きます。", |
| "examples.list-bar-search.blurb": "検索だけの一覧のための、単独の検索フォーム。ふつうの method=\"get\" フォームです。", |
| - "examples.list-bar-search.states.empty-accessible-name-from-the-catalog": "プレースホルダーなし。入力欄のアクセシブルな名前は aria-label で、アプリがページの言語で書きます。", |
| + "examples.list-bar-search.states.empty-accessible-name-from-the-catalog": "placeholder なし。入力欄のアクセシブルな名前は aria-label で、アプリがページの言語で書きます。", |
| "examples.list-bar-search.states.with-a-query": "✕ はリンクで、検索語を外した同じ画面に移ります。状態の絞り込みは隠し入力に乗っているので残ります。", |
| "examples.list-bar-search.states.with-a-query-on-page-4": "ページ番号は隠し入力に乗っているので、新しい検索は、もう 4 ページもない結果に page=4 を持ち込みます。✕ リンクはそれを外します。一覧の最初のページに向けてください。", |
| "examples.list-bar.blurb": "一覧カードの上部にあるツールバー。検索と絞り込みが入ります。", |
diff --git a/internal/gallery/i18n/yue.json b/internal/gallery/i18n/yue.json| index 331ede3..3100acf 100644 |
| --- a/internal/gallery/i18n/yue.json |
| +++ b/internal/gallery/i18n/yue.json |
| @@ -227,7 +227,7 @@ |
| "gallery.started.scripts": "腳本", |
| "gallery.started.scripts.body": "fiesta.js 會閂選單、畀人拖開關個掣、畀 iOS 有撳住狀態,同埋運行配色選單;最先載入佢。busy.js、light.js、anchor.js、shell.js、select.js、datetime.js 同 calendar.js 喺頁面用到佢哋增強嘅嘢之前都唔會做嘢,每個都可以獨立刪除。calendar.js 最後先載入。", |
| "gallery.started.stylesheets": "樣式表", |
| - "gallery.started.stylesheets.body": "fiesta.css 係 tokens.css 同 Day 主題合埋,刪咗註解,gzip 之後少過 25 KiB。設計變數負責每個組件嘅版面同行為;主題幫佢哋上色。fiesta-plain.css 同 fiesta-signal.css 係同一套設計變數,配上 Plain 同 Signal 主題。用側欄或者控制台外殼嘅頁面要另外連結 shell.css;其他都唔使。", |
| + "gallery.started.stylesheets.body": "fiesta.css 係 tokens.css 同 Day 主題合埋,刪咗註解,gzip 之後少過 25 KiB。設計變數負責每個組件嘅版面同行為;主題幫佢哋上色。fiesta-plain.css 同 fiesta-signal.css 係同一套設計變數,配上 Plain 同 Signal 主題。用 sidebar 或者 console 外殼嘅頁面要另外連結 shell.css;其他都唔使。", |
| "gallery.started.total": "一版連結咗 fiesta.css 同所有腳本嘅頁面,會收到原樣 {raw} 嘅 CSS 同 JavaScript,gzip 之後 {gzip}。", |
| "gallery.started.upgrading": "升級", |
| "gallery.started.upgrading.body": "用 /1/ 嘅頁面會喺每個 1.x 版本推出當日收到佢;更新記錄會講改咗乜。鎖定咗準確版本嘅頁面,要你改連結先會變,之前唔會。", |
diff --git a/internal/gallery/i18n/zh-Hans.json b/internal/gallery/i18n/zh-Hans.json| index 28a02dd..1819d31 100644 |
| --- a/internal/gallery/i18n/zh-Hans.json |
| +++ b/internal/gallery/i18n/zh-Hans.json |
| @@ -227,7 +227,7 @@ |
| "gallery.started.scripts": "脚本", |
| "gallery.started.scripts.body": "fiesta.js 负责关闭菜单、让开关的旋钮可以拖动、给 iOS 按下状态,并运行配色菜单;最先加载它。busy.js、light.js、anchor.js、shell.js、select.js、datetime.js 和 calendar.js 在页面用到它们增强的东西之前都不做任何事,每个都可以单独删除。最后加载 calendar.js。", |
| "gallery.started.stylesheets": "样式表", |
| - "gallery.started.stylesheets.body": "fiesta.css 是 tokens.css 和 Day 主题合并而成,去掉了注释,gzip 后不到 25 KiB。设计变量承载每个组件的布局和行为;主题给它们上色。fiesta-plain.css 和 fiesta-signal.css 是同一套设计变量配上 Plain 和 Signal 主题。使用侧栏或控制台外壳的页面还要链接 shell.css;别的都不需要。", |
| + "gallery.started.stylesheets.body": "fiesta.css 是 tokens.css 和 Day 主题合并而成,去掉了注释,gzip 后不到 25 KiB。设计变量承载每个组件的布局和行为;主题给它们上色。fiesta-plain.css 和 fiesta-signal.css 是同一套设计变量配上 Plain 和 Signal 主题。使用 sidebar 或 console 外壳的页面还要链接 shell.css;别的都不需要。", |
| "gallery.started.total": "一个链接了 fiesta.css 和所有脚本的页面,会收到原样 {raw} 的 CSS 和 JavaScript,gzip 后 {gzip}。", |
| "gallery.started.upgrading": "升级", |
| "gallery.started.upgrading.body": "使用 /1/ 的页面会在每个 1.x 版本发布当天收到它;更新日志会说明改了什么。锁定到确切版本的页面,要等你改了链接才会变,之前不会。", |
diff --git a/internal/gallery/i18n_test.go b/internal/gallery/i18n_test.go| index 61fefce..3d50118 100644 |
| --- a/internal/gallery/i18n_test.go |
| +++ b/internal/gallery/i18n_test.go |
| @@ -221,19 +221,19 @@ func TestEveryCopyKeyIsRendered(t *testing.T) { |
| } |
| } |
| -// leakFloor is the length, in runes, of the shortest English this |
| -// sweep looks for, as Rastrillo's sweep has it. A shorter string is a |
| -// word, and a word occurs on a translated page for reasons that have |
| -// nothing to do with a missing translation: inside a longer word, as |
| -// its own translation (HTML, Fiesta, Error in Spanish), or as a name. |
| -// Those strings are the parity gate's: TestEveryProseKeyIsTranslated |
| -// fails the build when any lacks a translation. A string with |
| -// {placeholders} is swept for by each run of English between them. |
| +// leakFloor is the length, in runes, from which a string is a sentence |
| +// and is looked for anywhere inside a run of text, as Rastrillo's |
| +// sweep has it: a sentence does not occur inside other words by |
| +// chance. Every traced string, sentence or word, is also looked for as |
| +// a whole run of text or a whole spoken attribute value, which is how |
| +// a short word of the chrome ("Copy", "Overview") is caught without |
| +// matching inside the words around it. A string a locale rightly keeps |
| +// as it is, is in leakUnchanged. |
| const leakFloor = 12 |
| -// leakSentences is the English the sweep looks for: each traced |
| -// string's runs between placeholders, of leakFloor runes or more, |
| -// whitespace collapsed, bar leakExempt. |
| +// leakSentences is the English the sweep looks for inside runs: each |
| +// traced string's runs between placeholders, of leakFloor runes or |
| +// more, whitespace collapsed, bar leakExempt. |
| func leakSentences(ids map[string]bool, english map[string]string) []string { |
| seen := map[string]bool{} |
| var out []string |
| @@ -254,12 +254,95 @@ func leakSentences(ids map[string]bool, english map[string]string) []string { |
| return out |
| } |
| +// leakSweep is everything the sweep looks for: the sentences, inside |
| +// any run; every traced string without placeholders, as a whole run; |
| +// and every traced string with them, as a whole run with the |
| +// placeholders filled by anything. |
| +type leakSweep struct { |
| + sentences []string |
| + whole map[string]bool |
| + patterns []leakPattern |
| +} |
| + |
| +// leakPattern is one string with placeholders: its English, the longest |
| +// run of it between placeholders (a cheap test before the expression), |
| +// and the expression a whole run matches when it is that string filled. |
| +type leakPattern struct { |
| + en, key string |
| + re *regexp.Regexp |
| +} |
| + |
| +// newLeakSweep builds the sweep from the traced ids. A string whose |
| +// only text between placeholders is punctuation ("{example}: {state}") |
| +// says no English of its own and is not looked for. |
| +func newLeakSweep(ids map[string]bool, english map[string]string) leakSweep { |
| + w := leakSweep{sentences: leakSentences(ids, english), whole: map[string]bool{}} |
| + for id := range ids { |
| + en := squash(english[id]) |
| + if en == "" || leakExempt(en) { |
| + continue |
| + } |
| + if !placeholderName.MatchString(en) { |
| + w.whole[en] = true |
| + continue |
| + } |
| + parts := placeholderName.Split(en, -1) |
| + if !templateLetter.MatchString(strings.Join(parts, "")) { |
| + continue |
| + } |
| + quoted := make([]string, len(parts)) |
| + key := "" |
| + for i, part := range parts { |
| + quoted[i] = regexp.QuoteMeta(part) |
| + if len(strings.TrimSpace(part)) > len(key) { |
| + key = strings.TrimSpace(part) |
| + } |
| + } |
| + w.patterns = append(w.patterns, leakPattern{en: en, key: key, re: regexp.MustCompile("^" + strings.Join(quoted, ".+") + "$")}) |
| + } |
| + sort.Slice(w.patterns, func(i, j int) bool { return w.patterns[i].en < w.patterns[j].en }) |
| + return w |
| +} |
| + |
| +// leaks is the English a page in locale carries, given its runs, bar |
| +// what leakUnchanged says the locale keeps. |
| +func (w leakSweep) leaks(locale string, runs []string) []string { |
| + var out []string |
| + seen := map[string]bool{} |
| + add := func(en string) { |
| + if !seen[en] && !unchangedIn(locale, en) { |
| + seen[en] = true |
| + out = append(out, en) |
| + } |
| + } |
| + words := strings.Join(runs, "\x00") |
| + for _, s := range w.sentences { |
| + if strings.Contains(words, s) { |
| + add(s) |
| + } |
| + } |
| + for _, r := range runs { |
| + if w.whole[r] { |
| + add(r) |
| + } |
| + for _, p := range w.patterns { |
| + if strings.Contains(r, p.key) && p.re.MatchString(r) { |
| + add(p.en) |
| + } |
| + } |
| + } |
| + return out |
| +} |
| + |
| // leakSpoken are the attributes whose values a person reads or hears: |
| -// Rastrillo's spoken attributes, alt, and the Copy live region's three |
| -// words, which the script speaks. |
| +// Rastrillo's spoken attributes, alt, the other ARIA attributes that |
| +// carry words, and the Copy live region's three words, which the |
| +// script speaks. The page sweep and the template gate read the same |
| +// list. |
| var leakSpoken = map[string]bool{ |
| "aria-label": true, "title": true, "placeholder": true, "label": true, "data-tip": true, "alt": true, |
| "data-copy": true, "data-copied": true, "data-failed": true, |
| + "aria-description": true, "aria-roledescription": true, "aria-valuetext": true, "aria-placeholder": true, |
| } |
| // leakSkipped says whether the sweep leaves an element and everything |
| @@ -282,17 +365,22 @@ func leakSkipped(n *xhtml.Node) bool { |
| return false |
| } |
| -// pageWords is every run of text on a page and every spoken attribute |
| +// pageRuns is every run of text on a page and every spoken attribute |
| // value, as a reader gets them (entities decoded, whitespace |
| -// collapsed), outside what leakSkipped leaves alone, joined with NULs |
| -// so a match cannot span two runs. |
| -func pageWords(t *testing.T, doc []byte) string { |
| +// collapsed), outside what leakSkipped leaves alone, empty runs left |
| +// out. |
| +func pageRuns(t *testing.T, doc []byte) []string { |
| t.Helper() |
| root, err := xhtml.Parse(strings.NewReader(string(doc))) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - var b strings.Builder |
| + var out []string |
| + keep := func(s string) { |
| + if s = squash(s); s != "" { |
| + out = append(out, s) |
| + } |
| + } |
| var walk func(*xhtml.Node) |
| walk = func(n *xhtml.Node) { |
| switch n.Type { |
| @@ -302,20 +390,25 @@ func pageWords(t *testing.T, doc []byte) string { |
| } |
| for _, a := range n.Attr { |
| if leakSpoken[a.Key] { |
| - b.WriteString(squash(a.Val)) |
| - b.WriteByte(0) |
| + keep(a.Val) |
| } |
| } |
| case xhtml.TextNode: |
| - b.WriteString(squash(n.Data)) |
| - b.WriteByte(0) |
| + keep(n.Data) |
| } |
| for c := n.FirstChild; c != nil; c = c.NextSibling { |
| walk(c) |
| } |
| } |
| walk(root) |
| - return b.String() |
| + return out |
| +} |
| + |
| +// pageWords is pageRuns joined with NULs, so a sentence found in it |
| +// cannot span two runs. |
| +func pageWords(t *testing.T, doc []byte) string { |
| + t.Helper() |
| + return strings.Join(pageRuns(t, doc), "\x00") |
| } |
| // englishLeaks is which of sentences the page's words carry. |
| @@ -345,17 +438,19 @@ func translatedPages(s *site, files map[string][]byte) []string { |
| // TestNoEnglishProseReachesATranslatedPage: no English the gallery says |
| // in its own voice, chrome, prose or label, is in the text or the |
| // spoken attributes of any page in a language that is not English, |
| -// outside the lifted scheme menu and the HTML tab's source. Sample |
| -// documents are not swept here; their words are the examples' own. |
| -// The control is a catalog with one value put back to its English, |
| -// which the sweep must report on the page that says it. |
| +// outside the lifted scheme menu and the HTML tab's source: a sentence |
| +// anywhere in a run, any string as a whole run, bar what leakUnchanged |
| +// says a locale keeps. Sample documents are not swept here; their |
| +// words are the examples' own. The controls are catalog values put |
| +// back to their English, a sentence and two short words, which the |
| +// sweep must report on the page that says them. |
| func TestNoEnglishProseReachesATranslatedPage(t *testing.T) { |
| files := render(t, "") |
| s := newTestSite(t, fiesta.FS(), "") |
| english := englishStrings(t) |
| - sentences := leakSentences(tracedIDs(t), english) |
| - if len(sentences) < 100 { |
| - t.Fatalf("sweeping for %d English sentences; the sweep has stopped reading the traced strings", len(sentences)) |
| + sweep := newLeakSweep(tracedIDs(t), english) |
| + if len(sweep.sentences) < 100 || len(sweep.whole) < 200 || len(sweep.patterns) < 5 { |
| + t.Fatalf("sweeping for %d sentences, %d whole strings and %d patterns; the sweep has stopped reading the traced strings", len(sweep.sentences), len(sweep.whole), len(sweep.patterns)) |
| } |
| // Every file under a theme and locale is one class or the other, |
| @@ -365,30 +460,62 @@ func TestNoEnglishProseReachesATranslatedPage(t *testing.T) { |
| t.Errorf("%s is neither a page nor a sample document", name) |
| } |
| } |
| + // Each string leakUnchanged lets through is one a render says, and |
| + // each locale it names does keep it, so the list cannot outlive the |
| + // reason for an entry. |
| + for en, locales := range leakUnchanged { |
| + if locales == nil { |
| + locales = nonEnglish() |
| + } |
| + kept := false |
| + for id := range tracedIDs(t) { |
| + if english[id] != en { |
| + continue |
| + } |
| + for _, l := range locales { |
| + if tr := loadedCatalogs(t)[l][id]; tr != en { |
| + t.Errorf("leakUnchanged keeps %q in %s, whose catalog says %q for %s", en, l, tr, id) |
| + } |
| + } |
| + kept = true |
| + } |
| + if !kept { |
| + t.Errorf("leakUnchanged keeps %q, which no render says", en) |
| + } |
| + } |
| pages := translatedPages(s, files) |
| if want := len(fiesta.ThemeNames()) * len(nonEnglish()) * (len(s.pageKinds()) + len(s.m.Examples)); len(pages) != want { |
| t.Errorf("sweeping %d translated pages, want %d", len(pages), want) |
| } |
| for _, name := range pages { |
| - for _, en := range englishLeaks(pageWords(t, files[name]), sentences) { |
| + for _, en := range sweep.leaks(strings.Split(name, "/")[1], pageRuns(t, files[name])) { |
| t.Errorf("%s carries the English %q", name, en) |
| } |
| } |
| - // The control: the Overview's intro, said in English on the |
| - // Japanese Overview by a catalog that has it as its English. |
| - const id = "gallery.overview.intro" |
| + // The controls: three strings put back to their English in the ja |
| + // catalog, a sentence and two short words of the chrome (the Copy |
| + // live region's word and the Overview's name), each of which the |
| + // sweep must report on the Japanese Overview. |
| cs := loadedCatalogs(t) |
| - orig := cs["ja"][id] |
| - cs["ja"][id] = text[id] |
| + controls := []string{"gallery.overview.intro", "gallery.copy", "gallery.kind.overview"} |
| + orig := map[string]string{} |
| + for _, id := range controls { |
| + orig[id] = cs["ja"][id] |
| + cs["ja"][id] = text[id] |
| + } |
| scratch, err := Render(fiesta.FS(), Options{}) |
| - cs["ja"][id] = orig |
| + for _, id := range controls { |
| + cs["ja"][id] = orig[id] |
| + } |
| if err != nil { |
| t.Fatal(err) |
| } |
| - found := englishLeaks(pageWords(t, scratch["day/ja/index.html"]), sentences) |
| - if !slices.Contains(found, squash(text[id])) { |
| - t.Errorf("with %s put back to its English in the ja catalog, the sweep reports %q on day/ja/index.html, and not the intro", id, found) |
| + found := sweep.leaks("ja", pageRuns(t, scratch["day/ja/index.html"])) |
| + for _, id := range controls { |
| + if !slices.Contains(found, squash(text[id])) { |
| + t.Errorf("with %s put back to its English in the ja catalog, the sweep does not report %q on day/ja/index.html; it reports %q", id, text[id], found) |
| + } |
| } |
| } |
| @@ -474,38 +601,61 @@ func TestTheLeakSweepSeesEscapedPunctuation(t *testing.T) { |
| } |
| // literalText is every run of letters a template writes outside its |
| -// actions: the text between tags and the spoken attributes' values. |
| -// Script and style elements and inline SVG are taken out first; an |
| -// action is a hole, since what it prints is a lookup or a field. |
| +// actions: the text between tags, an SVG's title and desc included, |
| +// and the values of the spoken attributes, however they are quoted. |
| +// The template is read by the HTML tokenizer with each action replaced |
| +// by actionHole, a private-use character no template writes, and a run |
| +// is cut at each hole: what an action prints is a lookup or a field. Script and style say nothing; an SVG's drawing data (d, |
| +// points, viewBox) is in attributes that are not spoken, so it is |
| +// never read. |
| func literalText(src string) []string { |
| - s := templateBlock.ReplaceAllString(src, "\x00") |
| - s = templateAction.ReplaceAllString(s, "\x00") |
| + z := xhtml.NewTokenizer(strings.NewReader(templateAction.ReplaceAllString(src, actionHole))) |
| var out []string |
| keep := func(chunk string) { |
| - if c := squash(chunk); c != "" && templateLetter.MatchString(c) { |
| - out = append(out, c) |
| + for _, piece := range strings.Split(chunk, actionHole) { |
| + if c := squash(piece); c != "" && templateLetter.MatchString(c) { |
| + out = append(out, c) |
| + } |
| } |
| } |
| - for _, m := range templateSpokenAttr.FindAllStringSubmatch(s, -1) { |
| - keep(m[2]) |
| - } |
| - for _, chunk := range strings.Split(templateTag.ReplaceAllString(s, "\x00"), "\x00") { |
| - keep(chunk) |
| + silent := false |
| + for { |
| + switch tt := z.Next(); tt { |
| + case xhtml.ErrorToken: |
| + return out |
| + case xhtml.StartTagToken, xhtml.SelfClosingTagToken: |
| + name, more := z.TagName() |
| + for more { |
| + var k, v []byte |
| + k, v, more = z.TagAttr() |
| + if leakSpoken[string(k)] { |
| + keep(string(v)) |
| + } |
| + } |
| + if tt == xhtml.StartTagToken && (string(name) == "script" || string(name) == "style") { |
| + silent = true |
| + } |
| + case xhtml.EndTagToken: |
| + if name, _ := z.TagName(); string(name) == "script" || string(name) == "style" { |
| + silent = false |
| + } |
| + case xhtml.TextToken: |
| + if !silent { |
| + keep(string(z.Text())) |
| + } |
| + } |
| } |
| - return out |
| } |
| var ( |
| // An action, non-greedy: a say call's arguments carry no braces. |
| templateAction = regexp.MustCompile(`(?s)\{\{.*?\}\}`) |
| - // A script, a style, or an inline SVG (the Lucide path data), |
| - // taken whole. |
| - templateBlock = regexp.MustCompile(`(?s)<(style|script|svg)\b.*?</(style|script|svg)>`) |
| - templateTag = regexp.MustCompile(`<[^>]*>`) |
| - templateSpokenAttr = regexp.MustCompile(`\b(title|aria-label|alt|placeholder|label|data-tip|data-copy|data-copied|data-failed)="([^"]*)"`) |
| - templateLetter = regexp.MustCompile(`\p{L}`) |
| + templateLetter = regexp.MustCompile(`\p{L}`) |
| ) |
| +// actionHole stands where an action was. |
| +const actionHole = "\ue000" |
| + |
| // templateFixtures are the runs of letters a template may write as |
| // they are, because they are the same in every language: the type |
| // specimen beside each font-size token, as Rastrillo's list has it, |
| @@ -523,8 +673,11 @@ var templateFixtures = map[string]bool{ |
| // {{say …}} or a field, so no English is written straight into a |
| // template where the catalogs cannot reach it; templateFixtures names |
| // the few runs that are not words. The demo's documents are not among |
| -// these templates: their words are sample content. The control is a |
| -// scratch template with two literal words, which the extractor finds. |
| +// these templates: their words are sample content. The controls are a |
| +// set of scratch templates: literal words in text, in an SVG's title |
| +// and desc, and in spoken attributes quoted either way or not at all, |
| +// which the extractor finds, and words in a script or a style and SVG |
| +// drawing data, which it does not. |
| func TestNoUnregisteredEnglishInThePageTemplates(t *testing.T) { |
| if len(templateSources) < 8 { |
| t.Fatalf("%d registered templates; the page, the view and the six bodies are eight", len(templateSources)) |
| @@ -543,7 +696,17 @@ func TestNoUnregisteredEnglishInThePageTemplates(t *testing.T) { |
| if fixtures != len(templateFixtures) { |
| t.Errorf("the templates write %d of the %d fixtures; a fixture no template writes any more is to be dropped", fixtures, len(templateFixtures)) |
| } |
| - if got := literalText(`{{define "x"}}<p title="Hello">{{say "gallery.brand"}} there</p><svg><path d="M1 2"/></svg>{{end}}`); !slices.Equal(got, []string{"Hello", "there"}) { |
| - t.Errorf("the extractor reads %q from a template with two literal words, want [Hello there]", got) |
| + for _, c := range []struct { |
| + src string |
| + want []string |
| + }{ |
| + {`{{define "x"}}<p title="Hello">{{say "gallery.brand"}} there</p>{{end}}`, []string{"Hello", "there"}}, |
| + {`<svg viewBox="0 0 24 24" aria-hidden="true"><title>Settings</title><desc>A cog</desc><path d="M12 2a10 10 0 1 0 0 20"/></svg>`, []string{"Settings", "A cog"}}, |
| + {`<button aria-label='Close'>{{icon "x"}}</button><img alt=Logo src="{{.Src}}">`, []string{"Close", "Logo"}}, |
| + {`<script>const label = "Hidden";</script><style>p::after { content: "Gone" }</style><p>{{.Name}}</p>`, nil}, |
| + } { |
| + if got := literalText(c.src); !slices.Equal(got, c.want) { |
| + t.Errorf("the extractor reads %q from %s, want %q", got, c.src, c.want) |
| + } |
| } |
| } |
diff --git a/internal/gallery/tree_test.go b/internal/gallery/tree_test.go| index 6fe274f..764f60e 100644 |
| --- a/internal/gallery/tree_test.go |
| +++ b/internal/gallery/tree_test.go |
| @@ -497,7 +497,9 @@ func TestEveryAssetIsWrittenHashedAndPlainAndPagesLinkTheHash(t *testing.T) { |
| // <theme>/<locale>/ has <html lang="<locale>" dir="<Dir(locale)>">, |
| // one <title>, and exactly one <h1> outside [rst-shell-title]; every |
| // frame has the lang and dir and one <title>. Each page's <title> |
| -// names it in its language: a page kind as the catalog has its title. |
| +// names it in its language: a page kind as the catalog has its title, |
| +// which is not the English unless leakUnchanged says the locale keeps |
| +// it. |
| func TestEveryPageIsAWholeLocalisedDocument(t *testing.T) { |
| files := render(t, "") |
| pages, frames := 0, 0 |
| @@ -555,6 +557,9 @@ func TestEveryPageIsAWholeLocalisedDocument(t *testing.T) { |
| t.Errorf("%s: the %s catalog has no name for the page, %s", name, locale, pk.Title) |
| continue |
| } |
| + if want == english[pk.Title] && !unchangedIn(locale, want) { |
| + t.Errorf("%s: the %s catalog names the page %q, its English", name, locale, want) |
| + } |
| } |
| if got := titleOf(t, parse(t, files[name])); !strings.Contains(got, want) { |
| t.Errorf("%s is titled %q, which does not name the page as %q", name, got, want) |