Keep section anchors in the original language on translated pages

This commit is contained in:
2026-09-03 23:59:10 +00:00
parent 54f8c8e09b
commit 0c1349b037
4 changed files with 74 additions and 8 deletions
+4
View File
@@ -88,6 +88,10 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
### Rendering ### Rendering
- The translated Markdown goes through the same `markdown.render` pipeline. - The translated Markdown goes through the same `markdown.render` pipeline.
- Section anchors (`#hash` ids on h1/h2 headings) stay in the original
language: render(anchors_from=...) pins the translated render's heading
ids to the original text's slugs, matched by heading position, so links
to sections don't break across languages.
- Navigation/sidebar titles come from the translation's title map, with - Navigation/sidebar titles come from the translation's title map, with
per-node fallback to the original title (a partially translated tree must per-node fallback to the original title (a partially translated tree must
still render). still render).
+8
View File
@@ -503,6 +503,14 @@ async def editor_ws(ws: WebSocket) -> None:
# The title is injected as h1 when the markdown has # The title is injected as h1 when the markdown has
# none; the editor's title field edits live-preview. # none; the editor's title field edits live-preview.
title=msg.get("title") or (node.title if node else ""), title=msg.get("title") or (node.title if node else ""),
# Pin section anchors to the original language so the
# preview of a translation matches the served page
# (no-op when the previewed markdown is the original).
anchors_from=(
(node_markdown(data, node) or "", node.title)
if node
else None
),
) )
await ws.send_json( await ws.send_json(
{ {
+55 -7
View File
@@ -402,7 +402,9 @@ def _heading_ids(state) -> None:
its self-link is ``href=""`` (back to the top of the page). An its self-link is ``href=""`` (back to the top of the page). An
author-set `{#id}` always wins; auto ids slugify the heading text author-set `{#id}` always wins; auto ids slugify the heading text
(python-slugify, mirroring the editor's slugify.js) and dedupe with (python-slugify, mirroring the editor's slugify.js) and dedupe with
-2/-3 suffixes per render. Headings that already contain a link are -2/-3 suffixes per render — unless env["anchor_ids"] presets them, as
render(anchors_from=...) does for translated pages so section URLs
stay in the original language. Headings that already contain a link are
``data-line`` records the heading's markdown source line (0-based, after ``data-line`` records the heading's markdown source line (0-based, after
undoing the render(title=...) injection offset via ``env``) — the page undoing the render(title=...) injection offset via ``env``) — the page
editor uses it for section pens and piecewise-linear scroll sync. editor uses it for section pens and piecewise-linear scroll sync.
@@ -443,15 +445,25 @@ def _heading_ids(state) -> None:
if len(heads) < ANCHOR_MIN_HEADINGS: if len(heads) < ANCHOR_MIN_HEADINGS:
return return
seen: set[str] = set() seen: set[str] = set()
for i, token in heads: preset = state.env.get("anchor_ids")
for k, (i, token) in enumerate(heads):
inline = tokens[i + 1] inline = tokens[i + 1]
hid = token.attrGet("id") hid = token.attrGet("id")
if not isinstance(hid, str) or not hid: if not isinstance(hid, str) or not hid:
# Slug the visible text, not the raw markdown (`## [a](url)`). if preset is not None and k < len(preset):
text = "".join( # Translated render: the original language's slug, matched
c.content for c in inline.children if c.type in ("text", "code_inline") # by heading position (a translation never adds, removes or
) # reorders headings; a patched one that does falls back to
base = slugify(text) or "section" # slugging its own text past the end of the list).
base = preset[k]
else:
# Slug the visible text, not the raw markdown (`## [a](url)`).
text = "".join(
c.content
for c in inline.children
if c.type in ("text", "code_inline")
)
base = slugify(text) or "section"
hid, n = base, 2 hid, n = base, 2
while hid in seen: while hid in seen:
hid = f"{base}-{n}" hid = f"{base}-{n}"
@@ -463,6 +475,36 @@ def _heading_ids(state) -> None:
wrap(i, token, f"#{hid}") wrap(i, token, f"#{hid}")
def anchor_ids(text: str, title: str | None = None) -> list[str]:
"""The section anchor ids of text, in heading order.
render(anchors_from=...) feeds these to _heading_ids via
env["anchor_ids"], pinning a translated render's anchors to the
original language's slugs. The selection mirrors _heading_ids exactly
(the same md instance assigns the ids during this parse, author-set
{#id} included as-is); the in-body title h1 is excluded.
"""
if title and not has_h1(text):
text = f"# {title}\n\n{text}"
tokens = md.parse(text, {"page_path": ""})
first_h1 = next(
(
i
for i, t in enumerate(tokens)
if t.type == "heading_open" and t.tag == "h1" and t.level == 0
),
None,
)
return [
t.attrGet("id")
for i, t in enumerate(tokens)
if t.type == "heading_open"
and t.tag in ("h1", "h2")
and t.level == 0
and i != first_h1
]
def make_md(*, verbatim: bool = False) -> MarkdownIt: def make_md(*, verbatim: bool = False) -> MarkdownIt:
"""A fully configured parser. The module-level ``md`` (below) is the """A fully configured parser. The module-level ``md`` (below) is the
render instance; ``verbatim=True`` builds the segmentation instance for render instance; ``verbatim=True`` builds the segmentation instance for
@@ -618,12 +660,16 @@ def render(
created: datetime | None = None, created: datetime | None = None,
modified: datetime | None = None, modified: datetime | None = None,
title: str | None = None, title: str | None = None,
anchors_from: tuple[str, str] | None = None,
) -> Rendered: ) -> Rendered:
"""Render Markdown text to the article body's HTML and layout flags. """Render Markdown text to the article body's HTML and layout flags.
``title`` injects a ``# {title}`` line at the top when the markdown has ``title`` injects a ``# {title}`` line at the top when the markdown has
no h1 of its own, so the implicit page title goes through the exact no h1 of its own, so the implicit page title goes through the exact
same pipeline as an explicit one (first-h1 anchor treatment included). same pipeline as an explicit one (first-h1 anchor treatment included).
``anchors_from`` is the (markdown, title) of the ORIGINAL language when
rendering a translation: section anchors are pinned to its slugs so
localized pages keep the original #hash URLs.
The top-level blocks are grouped into column segments: boundary blocks The top-level blocks are grouped into column segments: boundary blocks
(h1/h2 headings, .wide — see _is_boundary) are rendered bare, the runs (h1/h2 headings, .wide — see _is_boundary) are rendered bare, the runs
@@ -641,6 +687,8 @@ def render(
right after the article's h1. right after the article's h1.
""" """
env = {"page_path": page_path, "line_offset": 0} env = {"page_path": page_path, "line_offset": 0}
if anchors_from is not None:
env["anchor_ids"] = anchor_ids(*anchors_from)
if title and not has_h1(text): if title and not has_h1(text):
text = f"# {title}\n\n{text}" text = f"# {title}\n\n{text}"
# The injected title shifts source lines by two; _heading_ids # The injected title shifts source lines by two; _heading_ids
+7 -1
View File
@@ -783,7 +783,11 @@ def page_content(
node = resolve(menu, path)[-1] node = resolve(menu, path)[-1]
content = node_markdown(data, node) or "" content = node_markdown(data, node) or ""
title = node.title title = node.title
# The original text pins the section anchors: on a translated page the
# heading slugs (and thus #hash URLs) stay in the original language.
anchors_from = None
if translation: if translation:
anchors_from = (content, title)
if translation.markdown is not None: if translation.markdown is not None:
content = translation.markdown content = translation.markdown
title = ( title = (
@@ -793,7 +797,9 @@ def page_content(
) )
# The title is injected into the markdown (as # title when it has no # The title is injected into the markdown (as # title when it has no
# h1 of its own), so title and content render as one article. # h1 of its own), so title and content render as one article.
rendered = render(content, path, node.created, node.modified, title=title) rendered = render(
content, path, node.created, node.modified, title=title, anchors_from=anchors_from
)
# Long articles get .multicol: the article column cap lifts (see the # Long articles get .multicol: the article column cap lifts (see the
# #content grid in pagerite.css) and the .cols segments lay out in at # #content grid in pagerite.css) and the .cols segments lay out in at
# most two columns. The html is already segmented by render() — the # most two columns. The html is already segmented by render() — the