diff --git a/.githooks/pre-commit b/.githooks/pre-commit index dd5fa906d..f5139456e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,5 @@ #!/bin/sh -# Format and validate staged markdown before it is committed. +# Validate staged Markdown before it is committed. # # Enable with: npm run hooks:install (sets core.hooksPath to .githooks) # Skip once with: git commit --no-verify @@ -20,13 +20,9 @@ staged_md=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^content/ fail=0 -# --- formatting is deliberately NOT done here ------------------------------ -# Running prettier from the hook was tried and removed. The corpus is not -# prettier-clean (2,887 files would change), so formatting on commit rewrites -# whatever you happen to touch, and prettier's proseWrap: never merges soft -# line breaks - which shifts the block counts translate_check compares against -# the unformatted English source. Format deliberately with `npm run format`, -# as its own commit, not as a side effect of committing something else. +# Format deliberately with `npm run format`, as its own commit. Formatting +# here would add unrelated changes to the staged content. Translation checks +# count blank-line-separated blocks, so joining soft line breaks is harmless. # --- syntax guards, against the staged blobs ------------------------------ # Includes spaced links, translated anchors, broken emphasis, undefined diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cfd73b98d..d74aa776d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -288,10 +288,9 @@ link with a space, rejects a non-ASCII heading anchor, and runs much of the corpus carries known drift and blocking on that would stop anyone editing those files for unrelated reasons. Bypass once with `--no-verify`. -The hook deliberately does **not** format. Running prettier from it was tried -and removed: the corpus is not prettier-clean, so it rewrites whatever you -touch, and `proseWrap: never` merges soft line breaks, which shifts the block -counts `translate_check` compares against the unformatted English source. Run +The hook deliberately does **not** format: formatting would add unrelated +changes to the staged content. Translation checks count blank-line-separated +blocks, so joining soft line breaks does not change the fingerprint. Run `npm run format` deliberately, as its own commit. CI (`.github/workflows/check.yml`) enforces the two syntax guards corpus-wide diff --git a/tmp/test_tooling.py b/tmp/test_tooling.py index 5e8a37d53..62c388974 100644 --- a/tmp/test_tooling.py +++ b/tmp/test_tooling.py @@ -201,6 +201,48 @@ def test_tidy_keeps_literal_spans_while_unquoting_brands(self): 'Organic Maps says «hello»… `print("hello...")`', ) + def test_section_note_skips_only_body_checks(self): + source = """--- +render: false +title: Frequently Asked Questions +description: Questions about Organic Maps +extra: + menu_title: Help +--- + +### This page is replaced with taxonomy "faq" from templates/faq/list.html +""" + problems = check_translation(source, source, "de") + self.assertEqual( + {p.message for p in problems if p.code == "untranslated-frontmatter"}, + {f"'{key}' is identical to the English source" + for key in ("title", "description", "extra.menu_title")}, + ) + + translated = (source + .replace("title: Frequently Asked Questions", "title: Häufige Fragen") + .replace("description: Questions about Organic Maps", "description: Fragen zu Organic Maps") + .replace("menu_title: Help", "menu_title: Hilfe")) + self.assertEqual(check_translation(source, translated, "de"), []) + + formal = translated.replace("description: Fragen", "description: Ihre Fragen") + self.assertEqual( + [p.code for p in check_translation(source, formal, "de")], + ["register-frontmatter"], + ) + + declared = translated.replace( + " menu_title: Hilfe", + ' menu_title: Help\n translation_keeps_source: ["extra.menu_title"]', + ) + self.assertEqual(check_translation(source, declared, "de"), []) + + stale = declared.replace("menu_title: Help", "menu_title: Hilfe") + self.assertEqual( + [(p.level, p.code) for p in check_translation(source, stale, "de")], + [(ERROR, "translation-keeps-source")], + ) + def test_source_keep_declarations_are_verified(self): source = """--- title: Privacy diff --git a/tools/translate_check.py b/tools/translate_check.py index e36eed395..4c308c5f3 100755 --- a/tools/translate_check.py +++ b/tools/translate_check.py @@ -508,15 +508,48 @@ def _declared_source_keeps( def check_translation(src: str, out: str, lang: str) -> list[Problem]: - """Compare a translated markdown body against its English source.""" - src_body, _ = strip_frontmatter(src) + """Compare translated Markdown and reader-visible metadata against English.""" + src_body, src_meta = strip_frontmatter(src) out_body, out_meta = strip_frontmatter(out) problems: list[Problem] = [] - # A section _index body is a note to whoever maintains the templates — - # "This page is replaced with taxonomy \"faq\" from templates/faq/list.html". - # No template renders it, so its typography is nobody's concern, and the - # strings it quotes are identifiers that must stay as they are. + # Titles, descriptions and menu labels remain visible even when a section + # body is only a maintenance note, so validate metadata before body exemptions. + if out_meta: + src_fields = _frontmatter_prose(src_meta) + out_fields = _frontmatter_prose(out_meta) + + for key, value in out_fields.items(): + ok, msg = register_ok(value, lang) + if not ok: + problems.append(Problem(WARN, "register-frontmatter", f"'{key}': {msg}")) + + # A label can legitimately match English. Its declaration must name an + # existing field that still matches, so stale exemptions are rejected. + declared, faults = _declared_source_keeps(out_meta, src_fields, out_fields) + problems.extend(faults) + + for key, value in src_fields.items(): + # A value with no letters reads the same in every language. + if (key not in declared + and out_fields.get(key) == value + and any(c.isalpha() for c in value)): + problems.append(Problem( + WARN, "untranslated-frontmatter", + f"'{key}' is identical to the English source")) + + # Runtime placeholders are literal replacement keys in donation + # form strings; translating or dropping one breaks the label. + want = sorted(PLACEHOLDER_RE.findall(value)) + got = sorted(PLACEHOLDER_RE.findall(out_fields.get(key, ""))) + if want != got: + problems.append(Problem( + ERROR, "placeholder-mismatch", + f"'{key}' must keep the placeholder(s) verbatim", + f"source has {want or 'none'}, translation has {got or 'none'}")) + + # FAQ section bodies explain their taxonomy template to maintainers. The + # body is not rendered, and its quoted strings are template identifiers. if re.search(r"templates/\S+\.html", src_body) and "replaced with taxonomy" in src_body: return problems @@ -730,57 +763,6 @@ def kind(text: str) -> Counter: if not ok: problems.append(Problem(WARN, "register", msg)) - # 9. Untranslated frontmatter fields, `extra` included. - if out_meta: - src_fields = _frontmatter_prose(strip_frontmatter(src)[1] or {}) - out_fields = _frontmatter_prose(out_meta) - - for key, value in out_fields.items(): - ok, msg = register_ok(value, lang) - if not ok: - problems.append(Problem(WARN, "register-frontmatter", f"'{key}': {msg}")) - - # Some words really are the same in both languages — Dutch and Italian - # write "Privacy", German writes "Name (optional)" — and no textual test - # can tell those from a field the translator simply skipped: Italian - # "Donate" and Occitan "News" were exactly that, and looked identical. - # The page declares the fields whose source wording is intentional: - # - # extra: - # translation_keeps_source: ["extra.menu_title"] - # - # Paste the path out of the warning. The declaration is verified, not - # merely obeyed: it must name a field that exists and is still identical, - # so it cannot outlive the situation it describes. - declared, faults = _declared_source_keeps(out_meta, src_fields, out_fields) - problems.extend(faults) - - for key, value in src_fields.items(): - # A value with no letters reads the same in every language: post 39 - # is called "19:36", a timestamp. - if (key not in declared - and out_fields.get(key) == value - and any(c.isalpha() for c in value)): - problems.append(Problem( - WARN, "untranslated-frontmatter", - f"'{key}' is identical to the English source")) - - # 9a. Runtime placeholders in those same fields. `donate-subscribe.js` - # fills the submit label and the range error by literal string - # replace — `pattern.replace('{amount}', …)` — so a placeholder the - # translator inflected, spaced or dropped does not raise anything: - # the button just reads "Doe {amount}" to every donor in that - # language. Parity with the source is the whole contract, so a - # mismatch is an ERROR. - for key, value in src_fields.items(): - want = sorted(PLACEHOLDER_RE.findall(value)) - got = sorted(PLACEHOLDER_RE.findall(out_fields.get(key, ""))) - if want != got: - problems.append(Problem( - ERROR, "placeholder-mismatch", - f"'{key}' must keep the placeholder(s) verbatim", - f"source has {want or 'none'}, translation has {got or 'none'}")) - # 10. Straight ASCII quotes where the language wants its own marks — but # only in prose. HTML attributes, Tera expressions, code spans and # markdown link titles all *require* a straight quote, so warning about