Files
chemenu/tools/chemenu/blocks.py
T
torben 177c7e9ce8
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s
feat: Prosa ist kein Identifier - Link-Taxonomie als Enum, generierte Regionen mit Markern (4.0.0)
Files changed:
- .gitea/workflows/ci.yml
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/CONTRACT.md
- instructions/link-taxonomy.md
- instructions/migrations/4.0.0-link-taxonomy.md
- instructions/setup-instance.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/comparisons/COLLECTION.md
- kb/concepts/COLLECTION.md
- kb/entities/COLLECTION.md
- kb/sources/COLLECTION.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/blocks.py
- tools/chemenu/cli.py
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/links_cmd.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/xref.py
- tools/chemenu/conventions.py
- tools/chemenu/corpus_diff.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/kb_collections.py
- tools/chemenu/kb_state.py
- tools/chemenu/links.py
- tools/chemenu/lint_core.py
- tools/chemenu/provenance.py
- tools/chemenu/sections.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_blocks.py
- tools/chemenu/tests/test_cite_cmd.py
- tools/chemenu/tests/test_conventions.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_pipeline_l0.py
- tools/chemenu/tests/test_types_cmd.py
- tools/chemenu/tests/test_xref.py
- types/concept.schema.yaml
- types/entity.md
- types/entity.schema.yaml
- types/instruction.schema.yaml
- types/type-spec.md
- work/link-taxonomy-migration/README.md
- work/link-taxonomy-migration/plan.md
2026-09-02 18:39:22 +02:00

137 lines
5.0 KiB
Python

"""Generated regions inside a page body, found by delimiter rather than by prose.
`xref` owns the links block and `cite` owns the footnotes block. Both used to be
located by matching their **heading text** - `^## Beziehungen$` - which made a
translated heading a structural fact and put the KB language into the compiler.
It also made the region's *end* a guess: the footnotes block ran to the next
heading, and before that to the end of the file, which silently deleted whatever
sat after it on eight pages.
A marker pair answers both questions exactly:
<!-- wikitool:links -->
## Beziehungen
- **depends-on:** [[Hermes]]
<!-- /wikitool:links -->
Everything between the markers is generated and is replaced wholesale on the
next write - heading included, which is why the heading text is a *rendering*
value from `kb/CONVENTIONS.md` rather than something the tool searches for. An
author never edits inside the markers; anything they put there is overwritten
without warning, exactly like `kb/index.md`.
The markers are HTML comments: invisible in every renderer this corpus is read
through, and the same convention `dist:strip-start`/`-end` already uses in
`AGENTS.md`. They cost a reader nothing and cost an LLM about twenty tokens a
page - the price of not having to guess where a generated region ends.
"""
from __future__ import annotations
import re
from typing import Optional
# The two regions the tool owns. `see-also` is deliberately absent: it was the
# reciprocal half of the old bidirectional `xref add`, and under authored
# directional edges it is a *label* (`see-also`) inside the links block, not a
# section of its own.
LINKS = "links"
FOOTNOTES = "footnotes"
BLOCKS = (LINKS, FOOTNOTES)
_NAME = r"[a-z][a-z0-9-]*"
def open_marker(name: str) -> str:
return f"<!-- wikitool:{name} -->"
def close_marker(name: str) -> str:
return f"<!-- /wikitool:{name} -->"
def _region_re(name: str) -> re.Pattern[str]:
"""The whole region including both markers and the blank line around it."""
return re.compile(
r"\n*"
+ re.escape(open_marker(name))
+ r".*?"
+ re.escape(close_marker(name))
+ r"\n*",
re.DOTALL,
)
_ANY_OPEN_RE = re.compile(rf"<!-- wikitool:({_NAME}) -->")
_ANY_CLOSE_RE = re.compile(rf"<!-- /wikitool:({_NAME}) -->")
def find(body: str, name: str) -> Optional[str]:
"""The generated content of `name`'s region, markers excluded, or None."""
match = _region_re(name).search(body)
if not match:
return None
text = match.group(0)
start = text.index(open_marker(name)) + len(open_marker(name))
end = text.index(close_marker(name))
return text[start:end].strip("\n")
def render(name: str, heading: str, lines: list[str]) -> str:
"""A whole region, ready to place into a body. Empty `lines` renders "".
An empty region is no region at all rather than a heading with nothing under
it: a page that cites nothing should not carry an empty Footnotes section,
and the same holds for a page with no declared edges.
"""
if not lines:
return ""
parts = [open_marker(name), f"## {heading}", "", *lines, close_marker(name)]
return "\n".join(parts)
def replace(body: str, name: str, region: str) -> str:
"""Put `region` where `name`'s region is, or append it if there is none.
Appending at the end is right for both blocks: they are the page's trailing
machine-owned material, and an author's prose never follows them. A region
that is `""` removes what was there.
"""
existing = _region_re(name).search(body)
if existing:
replacement = f"\n\n{region}\n" if region else "\n"
return (body[: existing.start()] + replacement + body[existing.end():]).rstrip("\n") + "\n"
if not region:
return body
return body.rstrip("\n") + "\n\n" + region + "\n"
def strip(body: str, name: str) -> str:
"""The body with `name`'s region removed entirely."""
return replace(body, name, "")
def marker_pairs(body: str) -> dict[str, int]:
"""How many complete open/close pairs each region name has in `body`.
The invariant `migrate verify` checks. An agent rewriting prose next to a
boundary can drop or duplicate a marker, and the failure is otherwise silent:
a lost opening marker turns a generated region into ordinary prose that the
next write appends a second copy beside.
"""
opens = [m.group(1) for m in _ANY_OPEN_RE.finditer(body)]
closes = [m.group(1) for m in _ANY_CLOSE_RE.finditer(body)]
names = set(opens) | set(closes)
return {name: min(opens.count(name), closes.count(name)) for name in sorted(names)}
def unbalanced_markers(body: str) -> list[str]:
"""Region names whose open and close markers do not pair up."""
opens = [m.group(1) for m in _ANY_OPEN_RE.finditer(body)]
closes = [m.group(1) for m in _ANY_CLOSE_RE.finditer(body)]
return sorted(
name
for name in set(opens) | set(closes)
if opens.count(name) != closes.count(name)
)