Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
7 changes: 3 additions & 4 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions tmp/test_tooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 39 additions & 57 deletions tools/translate_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading