tools: Export - include "all" wählt die ganze KB, index: true schreibt index.md und INDEX.md in den Zielordner (#217); 10.1.0-beta.21
CI / verify (push) Successful in 8m17s
CI / pwsh (push) Successful in 2m54s
Release / converters (push) Successful in 28s
Release / release (push) Successful in 36s
Release / wiki-mirror (push) Skipped

Files changed:
- CHANGES.md
- VERSION
- docs/how-to/export-to-a-synced-folder.md
- docs/reference/commands/exports.md
- docs/reference/configuration.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/catalog.py
- tools/chemenu/commands/export_cmd.py
- tools/chemenu/commands/index_build.py
- tools/chemenu/config_shapes.py
- tools/chemenu/export_index.py
- tools/chemenu/guideline_export.py
- tools/chemenu/page_export.py
- tools/chemenu/tests/test_config_shapes.py
- tools/chemenu/tests/test_export_run.py
- tools/chemenu/tests/test_index_build.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Sonnet 5.5 committed 2026-10-16 15:44:21 +02:00
1 parent cc990dc89e
commit db09e89223
18 files changed
+693 -101

No files matched your search

+4 -3
View File
@@ -2441,13 +2441,13 @@ Write the derived, read-only reading copy of `kb/` into the folders the `export`
- 0 success
- 1 No `export` section, an unknown target name, or a broken section (a predicate that does not parse, a path inside the instance or in the way of another target, an `intake` folder or a folder the backup writes into: `<backup.target>/git` or this machine's folder)
- 1 `kb/` or `types/` has uncommitted changes, the instance is not a git checkout, or a page under `kb/` has unreadable frontmatter
- 1 A target failed: a foreign folder, a folder in backup data, a file changed in the target, an empty selection, an unknown field, a converter that did not exit 0
- 1 A target failed: a foreign folder, a folder in backup data, a file changed in the target, an empty selection, an unknown field, two files that differ only in case (also a page and a generated index file), a converter that did not exit 0
**ON FAILURE**
- No `export` section, an unknown target name, or a broken section (a predicate that does not parse, a path inside the instance or in the way of another target, an `intake` folder or a folder the backup writes into: `<backup.target>/git` or this machine's folder) -> Show the message to the user; the section is theirs to write by hand, never yours
- `kb/` or `types/` has uncommitted changes, the instance is not a git checkout, or a page under `kb/` has unreadable frontmatter -> Fix the named pages, or publish the changes first, then retry once
- A target failed: a foreign folder, a folder in backup data, a file changed in the target, an empty selection, an unknown field, a converter that did not exit 0 -> Show the target's reason to the user; the other targets were still served. Never delete or edit files in a target folder to make a run pass
- A target failed: a foreign folder, a folder in backup data, a file changed in the target, an empty selection, an unknown field, two files that differ only in case (also a page and a generated index file), a converter that did not exit 0 -> Show the target's reason to the user; the other targets were still served. Never delete or edit files in a target folder to make a run pass
**NEVER**
@@ -2456,7 +2456,8 @@ Write the derived, read-only reading copy of `kb/` into the folders the `export`
**NOTES**
- An export is derived: regenerated from `kb/` on every run, never edited, never read back into the wiki. Each target names its pages with an allowlist of `search --field` predicates (`include`: a list of selections, AND within one, OR across them), a handler (`markdown`, or `command` for the operator's converter) and the folder (`path`, outside the instance). Nothing is exported that no selection names.
- An export is derived: regenerated from `kb/` on every run, never edited, never read back into the wiki. Each target names its pages with `include`: either the string `"all"` (every page `search` knows, also one directly under `kb/`; never a generated or meta file) or an allowlist of `search --field` predicates (a list of selections, AND within one, OR across them), a handler (`markdown`, or `command` for the operator's converter) and the folder (`path`, outside the instance). A missing or empty `include` is an error, never "all": nothing is exported that the target does not name.
- With `index: true` a target also gets `index.md` in its root and an `INDEX.md` per collection (and per area the catalog gives a file of its own), built from the exported pages alone: collections and areas with their counts, then tables of page, type, summary and last modified, with relative Markdown links whatever `links` says. No date of the day and no hint for an agent; the marker is `kind=index` with the newest commit of the pages the file lists, so a commit to one collection rewrites that collection's `INDEX.md` and `index.md` only. The files are ordinary files of the run (manifest, atomic write, removed once no longer generated); the `command` handler receives them with the pages. A page `<collection>/index.md` beside the generated `INDEX.md` stops the target.
- Every Markdown file starts with a frontmatter block whose `wikitool_export` line says `kind=pages instance=<origin URL without userinfo, or local> commit=<newest commit touching this page>`, followed by `title`, `type`, `summary`, `modified` where the page has them (`frontmatter: summary`, the default; `minimal` keeps only the marker, `full` the whole frontmatter), then the page text. A file keeps its path below `kb/`. A wikilink to an exported page becomes a relative Markdown link (`links: relative`, the default) or plain text (`links: text`); a link to a page that is not exported is always plain text. Footnote markers stay; code is never rewritten. LF endings, no timestamp: the same pages give the same bytes, and a commit that touches one page rewrites that page's file alone - plus any file whose links change because a page entered or left the selection. The manifest's `commit` is the newest commit touching any selected page; all of them come from one `git log`, however many pages are selected.
- Handler `command`: the Markdown is rendered into a staging directory, the program of `export.commands` runs with no shell, stdin closed and a timeout of 300 s, and receives the Markdown directory and an empty output directory as its last two arguments. On exit 0 the regular files in the output directory (recursively) become the target's content; any other end leaves the target unchanged and prints the end of its stderr. The staging directory is always removed.
- The folder is only ever touched by what it holds in its manifest, `.wikitool-export.json`: the files an earlier run wrote, each with its sha256. A run removes only manifest files that are no longer exported (and folders it created, once empty), rewrites a manifest file that is missing, leaves every other file byte for byte as it was, and writes nothing - not even a modification time - for a file that did not change. A run that changes nothing writes nothing at all.
+3 -2
View File
@@ -116,7 +116,7 @@ tools/
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces
ownership.py the stack-vs-instance boundary under a content stage - one predicate, so no caller keeps a list of its own
type_resolver.py type-spec loading and schema resolution
catalog.py how the corpus groups into collections and areas, and the shard threshold - with no CLI attached
catalog.py how the corpus groups into collections and areas, the shard threshold and the heading-anchor and type-label helpers the catalog and the export index share - with no CLI attached
lint_core.py the lint checks and the report, with no CLI attached
types_core.py type-spec listing/description, with no CLI attached
review.py the weekly review's five checks - the read-time join of kb/gtd/ against the tracker, with no CLI attached
@@ -133,7 +133,8 @@ tools/
backup.py `backup init`/`backup run`'s core and the step `publish` runs after a push: the bare repository under `<target>/git`, the machine-data mirror under `<target>/<machine>/` with its manifest, and which synced folders `doctor` knows
repo_capture.py `raw capture`/`raw status`'s core: resolve a ref rule, fetch it into a bare cache, select files by glob as blobs, the `_capture.json` manifest - git with the host's credentials, never a prompt; plus the one write back, a single-file commit pushed without force for `export guidelines`
guideline_export.py `export guidelines`' core: select pages by `search` predicates, render them into one deterministic GUIDELINES.md, and recognise an export again (`is_export`, for both the guideline comment line and the page frontmatter key) - with no CLI attached
page_export.py `export run`'s core and the step `publish` runs after a commit: the `export` section, rendering selected pages as Markdown, the target folder with its manifest, and the converter handler - with no CLI attached
page_export.py `export run`'s core and the step `publish` runs after a commit: the `export` section, rendering the selected pages as Markdown (`include: "all"` or a list of selections), the target folder with its manifest, and the converter handler - with no CLI attached
export_index.py the routing files of a page export (`index: true`): `index.md` and the `INDEX.md` files, built from the exported pages alone and following the catalog's hierarchy - with no CLI attached
search/ pluggable search backends, plus service.py - the search core
tasks/ the task-tracker provider layer: protocol.py (TaskReader/TaskWriter), config.py (the tracker sections of the settings), one module per adapter - no instruction ever learns which provider it is
commands/ one module per command or command group: the terminal adapters - except `raw/`, a package with one module per subcommand and the helpers several of them share in `_common.py`
+24 -1
View File
@@ -15,6 +15,7 @@ looks like* - the tables, the map, the shard files - stays in
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
@@ -27,11 +28,33 @@ from chemenu.type_resolver import resolver
# number so growth is handled by arithmetic rather than by a judgment call.
SHARD_THRESHOLD = 50
# The name `group_pages` gives the pseudo-collection of pages sitting directly
# under `kb/`, which no collection directory holds.
ROOT_COLLECTION = "(kb root)"
# Display title for pages sitting directly in a collection root rather than in
# an area subdirectory.
UNGROUPED_TITLE = "All"
def anchor(title: str) -> str:
"""GitHub-style heading anchor, so a map can deep-link into a shard.
`\\w` rather than `a-z0-9`, which is not cosmetic: an area title follows the
KB language, and the first non-English one (`Abläufe`) had its umlaut
*deleted* rather than kept, so the map linked at `#ablufe` and the anchor it
was aiming at was `#abläufe`. Every deep link into a shard whose title
carries a non-ASCII letter was silently dead. Nothing surfaced it while the
only areas were entity ones, whose titles happen to be ASCII throughout.
"""
slug = re.sub(r"[^\w\s-]", "", title.lower(), flags=re.UNICODE)
return re.sub(r"\s+", "-", slug.strip())
def type_label(page: Page) -> str:
return page.subtype or page.kind or "unknown"
@dataclass
class Area:
"""One grouping inside a collection: a subdirectory, or the collection root
@@ -115,7 +138,7 @@ def group_pages(kb_dir: Path, pages: dict[str, Page]) -> list[Collection]:
except ValueError: # pragma: no cover - pages always live under kb_dir
continue
if len(parts) < 2:
collection_name, area_name = "(kb root)", ""
collection_name, area_name = ROOT_COLLECTION, ""
else:
collection_name = parts[0]
area_name = parts[1] if len(parts) > 2 else ""
+18 -5
View File
@@ -454,10 +454,22 @@ def export_guidelines_command(
),
notes=(
"An export is derived: regenerated from `kb/` on every run, never edited, never read back "
"into the wiki. Each target names its pages with an allowlist of `search --field` "
"predicates (`include`: a list of selections, AND within one, OR across them), a handler "
"(`markdown`, or `command` for the operator's converter) and the folder (`path`, outside "
"the instance). Nothing is exported that no selection names.",
"into the wiki. Each target names its pages with `include`: either the string `\"all\"` "
"(every page `search` knows, also one directly under `kb/`; never a generated or meta "
"file) or an allowlist of `search --field` predicates (a list of selections, AND within "
"one, OR across them), a handler (`markdown`, or `command` for the operator's converter) "
"and the folder (`path`, outside the instance). A missing or empty `include` is an error, "
"never \"all\": nothing is exported that the target does not name.",
"With `index: true` a target also gets `index.md` in its root and an `INDEX.md` per "
"collection (and per area the catalog gives a file of its own), built from the exported "
"pages alone: collections and areas with their counts, then tables of page, type, "
"summary and last modified, with relative Markdown links whatever `links` says. No date "
"of the day and no hint for an agent; the marker is `kind=index` with the newest commit "
"of the pages the file lists, so a commit to one collection rewrites that collection's "
"`INDEX.md` and `index.md` only. The files are ordinary files of the run (manifest, "
"atomic write, removed once no longer generated); the `command` handler receives them "
"with the pages. A page `<collection>/index.md` beside the generated `INDEX.md` stops "
"the target.",
"Every Markdown file starts with a frontmatter block whose `wikitool_export` line says "
"`kind=pages instance=<origin URL without userinfo, or local> commit=<newest commit "
"touching this page>`, followed by `title`, `type`, `summary`, `modified` where the "
@@ -518,7 +530,8 @@ def export_guidelines_command(
),
cli_contract.Failure(
cause="A target failed: a foreign folder, a folder in backup data, a file changed in "
"the target, an empty selection, an unknown field, a converter that did not exit 0",
"the target, an empty selection, an unknown field, two files that differ only in case "
"(also a page and a generated index file), a converter that did not exit 0",
reaction="Show the target's reason to the user; the other targets were still served. "
"Never delete or edit files in a target folder to make a run pass",
),
+3 -21
View File
@@ -24,7 +24,7 @@ from pathlib import Path
import typer
from chemenu import cli_contract, config
from chemenu.catalog import SHARD_THRESHOLD, Area, Collection, group_pages
from chemenu.catalog import Area, Collection, anchor, group_pages, type_label
from chemenu.commands._util import rel_path, success
from chemenu.page import Page
from chemenu.kb_scan import GENERATED_INDEX, find_nested_pages, load_kb_pages
@@ -60,33 +60,15 @@ def _last_modified(page: Page) -> str:
return date.fromtimestamp(page.path.stat().st_mtime).isoformat()
def _type_label(page: Page) -> str:
return page.subtype or page.kind or "unknown"
def _table(pages: list[Page]) -> list[str]:
lines = [TABLE_HEADER, TABLE_SEP]
for page in sorted(pages, key=lambda p: p.title.lower()):
lines.append(
f"| [[{page.title}]] | {_type_label(page)} | {_summary(page)} | {_last_modified(page)} |"
f"| [[{page.title}]] | {type_label(page)} | {_summary(page)} | {_last_modified(page)} |"
)
return lines
def _anchor(title: str) -> str:
"""GitHub-style heading anchor, so the map can deep-link into a shard.
`\\w` rather than `a-z0-9`, which is not cosmetic: an area title follows the
KB language, and the first non-English one (`Abläufe`) had its umlaut
*deleted* rather than kept, so the map linked at `#ablufe` and the anchor it
was aiming at was `#abläufe`. Every deep link into a shard whose title
carries a non-ASCII letter was silently dead. Nothing surfaced it while the
only areas were entity ones, whose titles happen to be ASCII throughout.
"""
slug = re.sub(r"[^\w\s-]", "", title.lower(), flags=re.UNICODE)
return re.sub(r"\s+", "-", slug.strip())
def build_area_shard(area: Area) -> str:
lines = [DO_NOT_EDIT, "", f"# {area.title}", "", f"{area.count} page(s).", ""]
lines.extend(_table(area.pages))
@@ -168,7 +150,7 @@ def build_index_map(collections: list[Collection]) -> str:
if area.own_shard:
target = f"{collection.name}/{area.name}/{GENERATED_INDEX}"
else:
target = f"{collection.name}/{GENERATED_INDEX}#{_anchor(area.title)}"
target = f"{collection.name}/{GENERATED_INDEX}#{anchor(area.title)}"
lines.append(f"| {area.title} | {area.count} | [{target}]({target}) |")
lines.append("")
+34 -15
View File
@@ -825,10 +825,15 @@ EXPORT_FRONTMATTER_MODES = ("minimal", "summary", "full")
EXPORT_DEFAULT_FRONTMATTER = "summary"
EXPORT_LINK_MODES = ("relative", "text")
EXPORT_DEFAULT_LINKS = "relative"
# The one spelling of "every page" an `include` takes besides the array of
# selections. A missing, empty or otherwise spelled `include` stays an error:
# reading it as "everything" would let a forgotten field write the whole `kb/`
# into a folder others read, at the next `publish` of a target with `after_publish`.
EXPORT_INCLUDE_ALL = "all"
# What each handler takes besides the keys every target has. A key outside
# both sets is an error, so a typo or a field of the other handler is not
# silently ignored.
_EXPORT_COMMON_KEYS = ("handler", "path", "include", "frontmatter", "links", "after_publish")
_EXPORT_COMMON_KEYS = ("handler", "path", "include", "frontmatter", "links", "index", "after_publish")
_EXPORT_HANDLER_KEYS = {
EXPORT_HANDLER_MARKDOWN: (),
EXPORT_HANDLER_COMMAND: ("command",),
@@ -836,13 +841,17 @@ _EXPORT_HANDLER_KEYS = {
def _export_include_problem(where: str, include: Any) -> Optional[str]:
if include == EXPORT_INCLUDE_ALL:
return None
forms = f"`include` is the string \"{EXPORT_INCLUDE_ALL}\" (every page) or a non-empty array of selections"
if not isinstance(include, list) or not include:
return f"{where}: `include` must be a non-empty array of selections"
return f"{where}: {forms}"
for number, selection in enumerate(include, start=1):
if not isinstance(selection, list) or not selection:
return f"{where}: `include` selection {number} must be a non-empty array of predicates"
return f"{where}: selection {number} of `include` must be a non-empty array of predicates; {forms}"
if not all(isinstance(predicate, str) and predicate.strip() for predicate in selection):
return f"{where}: `include` selection {number} holds an entry that is not a non-empty string"
return (f"{where}: selection {number} of `include` holds an entry that is not a non-empty "
f"string; {forms}")
return None
@@ -858,23 +867,23 @@ def _export_target_problem(name: str, target: Any) -> Optional[str]:
if stray:
return (f"{where}: {', '.join(f'`{key}`' for key in stray)} not taken by the handler "
f"{handler} (it takes {', '.join(f'`{key}`' for key in allowed)})")
for key in ("path", "include"):
if key not in target:
return f"{where}: `{key}` is required"
if "path" not in target:
return f"{where}: `path` is required"
path = target["path"]
if not isinstance(path, str) or not path.strip():
return f"{where}: `path` must be a non-empty string"
problem = absolute_path_problem(path)
if problem:
return f"{where}: `path` {problem}"
problem = _export_include_problem(where, target["include"])
problem = _export_include_problem(where, target.get("include"))
if problem:
return problem
for key, choices in (("frontmatter", EXPORT_FRONTMATTER_MODES), ("links", EXPORT_LINK_MODES)):
if key in target and target[key] not in choices:
return f"{where}: `{key}` must be one of {', '.join(choices)}"
if "after_publish" in target and not isinstance(target["after_publish"], bool):
return f"{where}: `after_publish` must be true or false"
for key in ("index", "after_publish"):
if key in target and not isinstance(target[key], bool):
return f"{where}: `{key}` must be true or false"
if handler == EXPORT_HANDLER_COMMAND:
ref = target.get("command")
if not isinstance(ref, str) or not _FETCHER_NAME.fullmatch(ref):
@@ -920,14 +929,19 @@ EXPORT = Shape(
"checkout), `path` (an absolute path outside the instance; it must not lie in or "
"contain another target or an `intake` folder, and not be, lie in or contain "
"`<backup.target>/git` or `<backup.target>/<machine>`, nor lie in any other "
"machine's folder or a bare repository) and `include` (an "
"array of selections, each an array of `search --field` predicates: all predicates of "
"a selection must hold, and a page that fits any selection is exported; there is no "
"\"everything\"). Optional: `frontmatter` (`minimal`: only the export marker; "
"machine's folder or a bare repository) and `include` (the string `\"all\"`: every "
"page `search` knows, a page directly under `kb/` included and no generated or "
"meta file; or an array of selections, each an array of `search --field` predicates: "
"all predicates of a selection must hold, and a page that fits any selection is "
"exported. A missing or empty `include` is an error, never \"all\"). Optional: "
"`frontmatter` (`minimal`: only the export marker; "
f"`summary`, the default: also `title`, `type`, `summary` and `modified` where the page "
"has them; `full`: the page's whole frontmatter), `links` (`relative`, the default: a "
"wikilink to an exported page becomes a relative Markdown link; `text`: plain text; a "
"link to a page that is not exported is always plain text) and `after_publish` "
"link to a page that is not exported is always plain text), `index` (default false; "
"true: also write `index.md` in the target's root and an `INDEX.md` per collection, "
"and per area the catalog gives a file of its own, listing the exported pages only, "
"with relative Markdown links whatever `links` says) and `after_publish` "
"(default false: refresh this target after every `publish` that committed). The "
"handler `command` takes `command` (required: the name of an entry of `commands`). A "
"field the handler does not take is an error.",
@@ -953,6 +967,11 @@ EXPORT = Shape(
"include": [["kind=system"], ["tags=shared", "!draft"]],
"frontmatter": "summary", "links": "relative", "after_publish": True,
},
"whole-kb": {
"handler": "markdown",
"path": "C:\\Users\\<user>\\OneDrive - <company>\\Chemenu-KB",
"include": "all", "index": True,
},
"as-word": {
"handler": "command", "command": "pandoc-docx",
"path": "C:\\Users\\<user>\\OneDrive - <company>\\Chemenu-Word",
+163
View File
@@ -0,0 +1,163 @@
"""The routing files of a page export, with no CLI attached: `index.md` at the
root of a target and an `INDEX.md` per collection and per large area, listing
the pages the target exports - and only those.
They follow the hierarchy of the catalog under `kb/` (`catalog.group_pages`,
`catalog.SHARD_THRESHOLD`) but are not copies of it. The catalog names pages a
partial selection does not export, so copied files would carry titles out of the
`kb/` and link into nothing; these are built from the selection instead.
What makes them safe to keep in a synced folder:
- **Deterministic.** No date of the day, no hint for an agent, no comment ahead
of the frontmatter. "Last Modified" comes from the page's own frontmatter and
is empty where it has none - never from the file's modification time.
- **Local.** A file's marker carries the newest commit of the pages *it lists*,
so one page's commit rewrites the files above it and no others.
- **Marked like a page.** The frontmatter holds one key, `wikitool_export`, with
`kind=index`, whatever the target's `frontmatter` says, so `is_export` and
`raw accept` treat it as what it is.
"""
from __future__ import annotations
import json
import posixpath
import re
from pathlib import Path
from urllib.parse import quote
from chemenu import guideline_export
from chemenu.catalog import ROOT_COLLECTION, Area, Collection, anchor, group_pages, type_label
from chemenu.kb_scan import GENERATED_INDEX
from chemenu.page import Page
ROOT_FILE = "index.md"
_BREAK = re.compile(r"\s+")
_LINK_TEXT_ESCAPES = re.compile(r"([\\\[\]|])")
_DATE_KEYS = ("modified", "date", "created")
PAGE_HEADER = "| Page | Type | Summary | Last Modified |"
PAGE_SEP = "|------|------|---------|---------------|"
def _cell(text: str) -> str:
return _BREAK.sub(" ", text).strip().replace("|", "\\|")
def _link(label: str, target: str, here: str, fragment: str = "") -> str:
"""A Markdown link to the file `target`, as written in a file in the folder
`here` (both below the target root); the destination is percent-encoded."""
destination = quote(posixpath.relpath(target, here or "."), safe="/")
if fragment:
destination += "#" + quote(fragment, safe="-")
return f"[{label}]({destination})"
def _last_modified(page: Page) -> str:
for key in _DATE_KEYS:
value = page.frontmatter.get(key)
if value:
return str(value)
return ""
class _Builder:
def __init__(self, pages: list[Page], kb_dir: Path, instance: str, commits: dict[Path, str],
rank: dict[str, int]) -> None:
self.pages = pages
self.kb_dir = kb_dir
self.instance = instance
self.commits = commits
self.rank = rank
self.files: dict[str, bytes] = {}
def add(self, rel: str, lines: list[str], pages: list[Page]) -> None:
"""Store `lines` as the file `rel` listing `pages`, with the marker of
the newest commit among them."""
newest = min((self.commits[page.path] for page in pages), key=lambda commit: self.rank[commit])
marker = guideline_export.export_marker(guideline_export.INDEX_KIND, self.instance, newest)
head = f"---\nwikitool_export: {json.dumps(marker, ensure_ascii=False)}\n---\n\n"
self.files[rel] = (head + "\n".join(lines) + "\n").encode("utf-8")
def table(self, pages: list[Page], here: str) -> list[str]:
lines = [PAGE_HEADER, PAGE_SEP]
for page in sorted(pages, key=lambda p: (p.title.casefold(), p.title)):
rel = page.path.relative_to(self.kb_dir).as_posix()
title = _LINK_TEXT_ESCAPES.sub(r"\\\1", page.title)
summary = page.frontmatter.get("summary")
lines.append(
f"| {_link(title, rel, here)} | {_cell(type_label(page))} "
f"| {_cell(str(summary)) if summary else ''} | {_cell(_last_modified(page))} |"
)
return lines
def area_file(self, area: Area, collection: str) -> None:
here = f"{collection}/{area.name}"
self.add(f"{here}/{GENERATED_INDEX}",
[f"# {area.title}", "", f"{area.count} page(s).", "", *self.table(area.pages, here)],
area.pages)
def collection_file(self, collection: Collection, pages: list[Page]) -> None:
name = collection.name
lines = [f"# {name}/ - Index", "", f"{collection.count} page(s).", ""]
for area in collection.areas:
lines += [f"## {area.title}", ""]
if area.own_shard:
shard = f"{name}/{area.name}/{GENERATED_INDEX}"
lines.append(f"{area.count} page(s) - listed in "
f"{_link(f'{area.name}/{GENERATED_INDEX}', shard, name)}.")
else:
lines += self.table(area.pages, name)
lines.append("")
self.add(f"{name}/{GENERATED_INDEX}", lines[:-1], pages)
def root_file(self, collections: list[Collection], strays: list[Page]) -> None:
lines = ["# Index", "", f"{len(self.pages)} page(s).", ""]
if collections:
lines += ["## Collections", "", "| Collection | Pages | Index |", "|------------|------:|-------|"]
for collection in collections:
target = f"{collection.name}/{GENERATED_INDEX}"
lines.append(f"| `{collection.name}/` | {collection.count} | {_link(target, target, '')} |")
lines.append("")
for collection in collections:
named = [area for area in collection.areas if area.name]
if not named:
continue
lines += [f"### {collection.name}/", "", "| Area | Pages | Index |", "|------|------:|-------|"]
for area in named:
if area.own_shard:
target, fragment = f"{collection.name}/{area.name}/{GENERATED_INDEX}", ""
else:
target, fragment = f"{collection.name}/{GENERATED_INDEX}", anchor(area.title)
shown = target + (f"#{fragment}" if fragment else "")
lines.append(f"| {_cell(area.title)} | {area.count} | {_link(shown, target, '', fragment)} |")
lines.append("")
if strays:
lines += ["## Pages outside any collection", "", *self.table(strays, ""), ""]
self.add(ROOT_FILE, lines[:-1], self.pages)
def build(pages: list[Page], kb_dir: Path, instance: str, commits: dict[Path, str],
rank: dict[str, int]) -> dict[str, bytes]:
"""The index files for `pages` - the pages a target exports - by path below
the target, each as the bytes to write. `commits` and `rank` come from
`guideline_export.page_commit_history`. No pages, no files."""
if not pages:
return {}
builder = _Builder(pages, kb_dir, instance, commits, rank)
strays: list[Page] = []
collections: list[Collection] = []
for collection in group_pages(kb_dir, {str(page.path): page for page in pages}):
if not collection.count:
continue
members = [page for area in collection.areas for page in area.pages]
if collection.name == ROOT_COLLECTION:
strays = members
continue
collections.append(collection)
builder.collection_file(collection, members)
for area in collection.areas:
if area.own_shard:
builder.area_file(area, collection.name)
builder.root_file(collections, strays)
return builder.files
+27 -12
View File
@@ -51,6 +51,7 @@ FRONTMATTER_FENCE = b"---"
SCAN_BYTES = 64 * 1024
GUIDELINES_KIND = "guidelines"
PAGES_KIND = "pages"
INDEX_KIND = "index"
GUIDELINES_PREFIX = f"<!-- wikitool:export kind={GUIDELINES_KIND}"
TARGET_PATH = "GUIDELINES.md"
LOCAL_INSTANCE = "local"
@@ -244,19 +245,20 @@ def last_commit(pages: list[Page], root: Optional[Path] = None) -> str:
return sha
def page_commits(pages: list[Page], root: Optional[Path] = None) -> tuple[str, dict[Path, str]]:
"""`last_commit(pages)`, and for each page's `path` the newest commit
touching that one file - from a single `git log`, however many pages there
are, since every process start costs on Windows. The paths go in on stdin,
so a whole corpus does not run into the command-line limit, and literally,
so a `[` in a title is not a glob. Without `--follow`, a page `rename` moved
gets the commit that moved it. A page no listed commit names - one that
only a merge's own resolution touched - falls back to the newest commit."""
def page_commit_history(pages: list[Page], root: Optional[Path] = None) -> tuple[dict[Path, str], dict[str, int]]:
"""For each page's `path` the newest commit touching that one file, and the
rank of every commit named - 0 for the newest, counting up with age - from a
single `git log`, however many pages there are, since every process start
costs on Windows. The paths go in on stdin, so a whole corpus does not run
into the command-line limit, and literally, so a `[` in a title is not a
glob. Without `--follow`, a page `rename` moved gets the commit that moved
it. A page no listed commit names - one that only a merge's own resolution
touched - falls back to the newest commit."""
root = root or config.ROOT
paths = _repo_paths(pages, root)
out = _git(["--literal-pathspecs", "log", "--stdin", "-z", "--format=commit %H", "--name-only"], root,
stdin="--\n" + "".join(f"{path}\n" for path in paths)) or ""
newest = ""
rank: dict[str, int] = {}
current = ""
first: dict[str, str] = {}
# `-z` ends each header and each name with NUL; a commit's first name
@@ -264,12 +266,25 @@ def page_commits(pages: list[Page], root: Optional[Path] = None) -> tuple[str, d
for token in out.split("\0"):
if token.startswith("commit "):
current = token[len("commit "):]
newest = newest or current
rank.setdefault(current, len(rank))
elif token.strip("\n"):
first.setdefault(token.lstrip("\n"), current)
if not newest:
if not rank:
raise ValidationError(_NOT_COMMITTED)
return newest, {page.path: first.get(path, newest) for page, path in zip(pages, paths)}
newest = next(iter(rank))
return {page.path: first.get(path, newest) for page, path in zip(pages, paths)}, rank
def page_commits(pages: list[Page], root: Optional[Path] = None) -> tuple[str, dict[Path, str]]:
"""`last_commit(pages)`, and for each page's `path` the newest commit
touching that one file - `page_commit_history` without the ranks."""
commits, rank = page_commit_history(pages, root)
return next(iter(rank)), commits
def export_marker(kind: str, instance: str, commit: str) -> str:
"""The value of the `wikitool_export` frontmatter key of an `export run` file."""
return f"kind={kind} instance={instance} commit={commit}"
# --- rendering ----------------------------------------------------------------
+36 -12
View File
@@ -40,7 +40,7 @@ from pathlib import Path
from typing import Any, Optional, Sequence
from urllib.parse import quote
from chemenu import backup, config, config_shapes, guideline_export, settings
from chemenu import backup, config, config_shapes, export_index, guideline_export, settings
from chemenu.errors import ValidationError
from chemenu.frontmatter_io import dump_frontmatter
from chemenu.kb_scan import LINK_RE, normalize_link_target
@@ -71,12 +71,18 @@ class Target:
name: str
handler: str
path: Path
include: tuple[tuple[Predicate, ...], ...]
include: "str | tuple[tuple[Predicate, ...], ...]"
frontmatter: str
links: str
after_publish: bool
command_name: str = ""
command: tuple[str, ...] = ()
index: bool = False
@property
def everything(self) -> bool:
"""Whether `include` is `"all"` rather than a list of selections."""
return self.include == config_shapes.EXPORT_INCLUDE_ALL
def _others_paths(root: Path) -> list[tuple[str, Path]]:
@@ -97,7 +103,10 @@ def _others_paths(root: Path) -> list[tuple[str, Path]]:
return found
def _parse_include(name: str, include: list[list[str]], problems: list[str]) -> tuple[tuple[Predicate, ...], ...]:
def _parse_include(name: str, include: "str | list[list[str]]",
problems: list[str]) -> "str | tuple[tuple[Predicate, ...], ...]":
if include == config_shapes.EXPORT_INCLUDE_ALL:
return include
selections = []
for number, selection in enumerate(include, start=1):
parsed = []
@@ -144,6 +153,7 @@ def read_config(root: "Path | str | None" = None) -> Optional[tuple[Target, ...]
links=raw.get("links", config_shapes.EXPORT_DEFAULT_LINKS),
after_publish=raw.get("after_publish", False),
command_name=command_name, command=tuple(commands.get(command_name, ())),
index=raw.get("index", False),
))
if not problems:
placed = [(f"export.targets.{t.name}", backup.real_path(t.path)) for t in targets]
@@ -221,8 +231,11 @@ def choose(targets: Sequence[Target], names: Sequence[str]) -> list[Target]:
def select(pages: dict[str, Page], target: Target, kb_dir: Path) -> list[Page]:
"""The pages of `target`: the union over its selections, each the pages
that satisfy every predicate of it, sorted by `(title.casefold(), title)`."""
"""The pages of `target`: every page `search` knows for `include: "all"`,
else the union over its selections, each the pages that satisfy every
predicate of it, sorted by `(title.casefold(), title)`."""
if target.everything:
return sorted(pages.values(), key=lambda p: (p.title.casefold(), p.title))
predicates = tuple(p for selection in target.include for p in selection)
filters.validate_fields(predicates, pages)
chosen: dict[str, Page] = {}
@@ -261,7 +274,7 @@ def _rewrite_links(text: str, here: str, destinations: dict[str, Optional[str]],
def marker_value(instance: str, commit: str) -> str:
return f"kind={guideline_export.PAGES_KIND} instance={instance} commit={commit}"
return guideline_export.export_marker(guideline_export.PAGES_KIND, instance, commit)
def render_page(page: Page, here: str, destinations: dict[str, Optional[str]], target: Target,
@@ -287,11 +300,14 @@ def render_page(page: Page, here: str, destinations: dict[str, Optional[str]], t
def render(pages: list[Page], target: Target, kb_dir: Path, instance: str,
commits: dict[Path, str]) -> dict[str, bytes]:
commits: dict[Path, str], rank: dict[str, int]) -> dict[str, bytes]:
"""The files of a target for `pages`, by path below the target, each
marked with its own page's commit from `commits`. A page
that cannot be written on every platform, or two that would share a file
name in a case-insensitive folder, is an `ExportError`."""
marked with its own page's commit from `commits`; with `target.index` also
the index files (`export_index`), whose commit is the newest of the pages
they list by `rank`. A page that cannot be written on every platform, or two
files that would share a name in a case-insensitive folder, is an
`ExportError` - an index file counts, so a page `<collection>/index.md`
beside the generated `<collection>/INDEX.md` stops the target."""
paths = {id(page): file_path(page, kb_dir) for page in pages}
seen: dict[str, str] = {}
for page in pages:
@@ -311,6 +327,13 @@ def render(pages: list[Page], target: Target, kb_dir: Path, instance: str,
for page in pages:
rel = paths[id(page)]
files[rel] = render_page(page, rel, by_title, target, instance, commits[page.path])
if target.index:
indexes = export_index.build(pages, kb_dir, instance, commits, rank)
for rel in indexes:
if rel.casefold() in seen:
raise ExportError(f"{rel} (generated by `index: true`) and {seen[rel.casefold()]} "
"are one file in a synced folder - rename the page or set `index` to false")
files.update(indexes)
return files
@@ -565,8 +588,9 @@ def run(targets: Sequence[Target], root: "Path | str | None" = None) -> list[Tar
if not selected:
raise ExportError("no page matches the selection - an empty export would delete the "
"earlier one, so nothing was changed")
result.commit, commits = guideline_export.page_commits(selected, root)
files = render(selected, target, kb_dir, instance, commits)
commits, rank = guideline_export.page_commit_history(selected, root)
result.commit = next(iter(rank))
files = render(selected, target, kb_dir, instance, commits, rank)
result.titles = {file_path(p, kb_dir): p.title for p in selected}
if target.handler == config_shapes.EXPORT_HANDLER_COMMAND:
files = convert(target, files, result)
+29
View File
@@ -312,3 +312,32 @@ def test_the_configuration_regions_are_checked_in_an_instance_too():
regions = [r for r in reference_doc.regions() if r.path == reference_doc.CONFIGURATION]
assert [r.name for r in regions] == [name for name, _ in reference_doc.CONFIG_REGIONS]
assert not any(r.origin_only for r in regions)
# --- export: include "all" and index ----------------------------------------------
def _export_target(**extra):
return {"main": {"handler": "markdown", "path": "/srv/export", "include": "all", **extra}}
def test_export_include_takes_all_or_selections_and_names_both_in_its_error():
assert config_shapes.export_targets_problem(_export_target()) is None
assert config_shapes.export_targets_problem(_export_target(include=[["tags=docs"]])) is None
for bad in ("All", "*", "everything", "", [], [[]], [["tags=docs"], []], [[""]], 7, None):
problem = config_shapes.export_targets_problem(_export_target(include=bad))
assert problem and '"all"' in problem and "array of selections" in problem, bad
def test_export_without_include_is_a_problem_and_not_all():
target = _export_target()
del target["main"]["include"]
assert "array of selections" in config_shapes.export_targets_problem(target)
def test_export_index_is_a_boolean_for_both_handlers():
assert config_shapes.export_targets_problem(_export_target(index=True)) is None
assert config_shapes.export_targets_problem(_export_target(index=False)) is None
assert "`index` must be true or false" in config_shapes.export_targets_problem(_export_target(index="yes"))
command = _export_target(handler="command", command="convert", index=True)
assert config_shapes.export_targets_problem(command) is None
+287 -1
View File
@@ -6,24 +6,30 @@ The target's path carries a space on purpose: the folder a sync client keeps on
Windows is `OneDrive - <company>`."""
from __future__ import annotations
import datetime
import hashlib
import json
import os
import posixpath
import re
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
from urllib.parse import unquote
import pytest
import typer
import yaml
from typer.testing import CliRunner
from chemenu import config, config_shapes, guideline_export, page_export
from chemenu import catalog, config, config_shapes, guideline_export, page_export
from chemenu.cli import app
from chemenu.commands import doctor
from chemenu.commands.git_publish import publish_command
from chemenu.frontmatter_io import write_page
from chemenu.search.service import load_pages_by_path
from chemenu.type_resolver import resolver
runner = CliRunner()
@@ -932,3 +938,283 @@ def test_the_marker_key_is_defined_once():
and 'b"wikitool_export:"' in path.read_text("utf-8")
]
assert definitions == [Path(guideline_export.__file__)]
# --- include "all" and the index files -----------------------------------------
META_FILES = {
"index.md": "# Wiki Index\n", "log.md": "# Log\n", "provenance.md": "# Provenance\n",
"CONTRACT.md": "# Contract\n", "CONVENTIONS.md": "# Conventions\n",
"concepts/INDEX.md": "# concepts/ - Index\n", "concepts/COLLECTION.md": "# Concepts\n",
}
INDEX_NAMES = ("index.md", "INDEX.md")
def _all(root: Path, **extra) -> dict:
return {"include": "all", **extra}
def _add_meta_and_stray(root: Path) -> None:
for rel, text in META_FILES.items():
(root / "kb" / rel).write_text(text, encoding="utf-8")
_page(root, "Stray.md", "\n# Stray\n\nDirectly under kb.\n")
_commit(root, "meta and stray")
def _links(folder: Path, rel: str) -> list[str]:
text = (folder / rel).read_text(encoding="utf-8")
return re.findall(r"\]\(([^)]*)\)", text)
def _resolve(rel: str, link: str) -> tuple[str, str]:
path, _, fragment = link.partition("#")
return posixpath.normpath(posixpath.join(posixpath.dirname(rel), unquote(path))), unquote(fragment)
def _headings(text: str) -> set[str]:
return {catalog.anchor(m.group(1)) for m in re.finditer(r"^#+ (.+)$", text, re.MULTILINE)}
def _index_files(folder: Path) -> list[str]:
return sorted(rel for rel in _snapshot(folder) if posixpath.basename(rel) in INDEX_NAMES
and guideline_export.file_is_export(folder / rel)
and "kind=index" in (folder / rel).read_text(encoding="utf-8").split("\n")[1])
def test_all_writes_every_page_search_knows_and_no_generated_or_meta_file(instance):
_add_meta_and_stray(instance)
_configure(instance, whole=_all(instance))
result = _run()
assert result.exit_code == 0, result.output
pages = load_pages_by_path(instance / "kb", instance)
expected = {page.path.relative_to(instance / "kb").as_posix() for page in pages.values()}
assert "Stray.md" in expected and "concepts/Gamma.md" in expected
written = set(_snapshot(_out(instance, "whole")))
assert written == expected | {page_export.MANIFEST_NAME}
for meta in META_FILES:
assert meta not in written
@pytest.mark.parametrize("include", [None, [], [[]], "All", "*", "everything", "", 1, True])
def test_every_other_include_than_all_or_selections_is_an_error_and_writes_nothing(instance, include):
_configure(instance, pages={})
path = instance / config.LOCAL_SETTINGS_FILENAME
data = json.loads(path.read_text(encoding="utf-8"))
if include is None:
del data["export"]["targets"]["pages"]["include"]
else:
data["export"]["targets"]["pages"]["include"] = include
path.write_text(json.dumps(data), encoding="utf-8")
result = _run()
assert result.exit_code == 1, result.output
message = _flat(result.output)
assert '"all"' in message and "array of selections" in message
assert _check("export").status == "FAIL"
assert not _out(instance, "pages").exists()
@pytest.mark.parametrize("value", ["yes", 1, "true", None])
def test_index_must_be_a_boolean(instance, value):
_configure(instance, pages={"index": value})
result = _run()
assert result.exit_code == 1 and "`index` must be true or false" in _flat(result.output)
assert _check("export").status == "FAIL"
def test_without_index_or_with_false_no_index_file_is_written(instance):
_add_meta_and_stray(instance)
_configure(instance, absent=_all(instance), off=_all(instance, index=False))
assert _run().exit_code == 0
for name in ("absent", "off"):
assert not [rel for rel in _snapshot(_out(instance, name))
if posixpath.basename(rel) in INDEX_NAMES]
def _many_pages(root: Path, count: int) -> None:
for number in range(count):
_page(root, f"concepts/protocols/P{number:02d}.md", f"\n# P{number:02d}\n\nText.\n")
_commit(root, "many")
def test_every_link_of_every_index_file_lands_and_every_page_is_reachable(instance):
_add_meta_and_stray(instance)
_many_pages(instance, catalog.SHARD_THRESHOLD)
_page(instance, "concepts/areas/Solo.md", "\n# Solo\n\nSmall area.\n")
_commit(instance, "small area")
_configure(instance, whole=_all(instance, index=True))
assert _run().exit_code == 0
folder = _out(instance, "whole")
files = _snapshot(folder)
indexes = _index_files(folder)
assert "index.md" in indexes and "concepts/INDEX.md" in indexes
assert "concepts/protocols/INDEX.md" in indexes # over the threshold: a file of its own
assert "concepts/areas/INDEX.md" not in indexes # under it: an anchor
reached, queue = {"index.md"}, ["index.md"]
while queue:
rel = queue.pop()
for link in _links(folder, rel):
target, fragment = _resolve(rel, link)
assert target in files, f"{rel} links to {link}, which is not in the target"
if fragment:
assert fragment in _headings(files[target].decode("utf-8")), f"{rel}: no heading for #{fragment}"
if target in indexes and target not in reached:
reached.add(target)
queue.append(target)
pages = set(files) - set(indexes) - {page_export.MANIFEST_NAME}
linked = {_resolve(rel, link)[0] for rel in reached for link in _links(folder, rel)}
assert pages <= linked
root_text = files["index.md"].decode("utf-8")
assert "concepts/INDEX.md#" in root_text and "concepts/protocols/INDEX.md" in root_text
assert "## Pages outside any collection" in root_text and "(Stray.md)" in root_text
def test_the_index_names_only_what_is_exported(instance):
_page(instance, "other/Delta.md", "\n# Delta\n\nNot shared.\n", tags=["private"])
_page(instance, "concepts/areas/Hidden.md", "\n# Hidden\n\nNot shared.\n", tags=["private"])
_commit(instance, "private pages")
_configure(instance, pages={"index": True})
assert _run().exit_code == 0
folder = _out(instance, "pages")
assert not (folder / "other").exists()
for rel in _index_files(folder):
text = (folder / rel).read_text(encoding="utf-8")
assert not any(word in text for word in ("Gamma", "Delta", "Hidden", "other/", "areas")), rel
assert "Alpha" in (folder / "concepts" / "INDEX.md").read_text(encoding="utf-8")
def test_an_index_file_is_marked_and_cannot_come_back_in(instance):
_configure(instance, pages={"index": True, "frontmatter": "full"})
assert _run().exit_code == 0
folder = _out(instance, "pages")
commit = _git(instance, "rev-parse", "HEAD")
for rel in ("index.md", "concepts/INDEX.md"):
text = (folder / rel).read_text(encoding="utf-8")
assert text.startswith("---\n")
frontmatter = text.split("---\n")[1]
assert yaml.safe_load(frontmatter) == {
"wikitool_export": f"kind=index instance=local commit={commit}"}
assert guideline_export.file_is_export(folder / rel)
(instance / "incoming" / "index.md").write_bytes((folder / "index.md").read_bytes())
before = (instance / "incoming" / "index.md").read_bytes()
result = _accept(instance / "incoming" / "index.md")
assert result.exit_code == 1 and "export" in _flat(result.output)
assert (instance / "incoming" / "index.md").read_bytes() == before
assert not any((instance / "raw").rglob("*.md"))
def test_a_second_run_touches_nothing_and_no_index_holds_a_date_of_its_own(instance):
_configure(instance, pages={"index": True})
assert _run().exit_code == 0
folder = _out(instance, "pages")
stamps, before = _mtimes(folder), _snapshot(folder)
assert _run().exit_code == 0
assert _mtimes(folder) == stamps and _snapshot(folder) == before
for rel in _index_files(folder):
text = (folder / rel).read_text(encoding="utf-8")
assert datetime.date.today().isoformat() not in text.replace("2026-10-01", "")
assert "Generated" not in text and "wikitool" not in text.split("---\n", 2)[2]
def test_a_page_without_dates_or_summary_has_empty_cells_and_never_the_file_time(instance):
path = instance / "kb" / "concepts" / "Bare.md"
write_page(path, {"type": "types/concept.md", "tags": ["docs"]}, "\n# Bare\n\nNothing.\n")
_commit(instance, "bare")
os.utime(path, (86400 * 365, 86400 * 365))
_configure(instance, pages={"index": True})
assert _run().exit_code == 0
text = (_out(instance, "pages") / "concepts" / "INDEX.md").read_text(encoding="utf-8")
row = next(line for line in text.splitlines() if "[Bare]" in line)
assert row.endswith("| | |") and "1971" not in text
def test_a_commit_to_one_collection_leaves_the_index_of_another_byte_equal(instance):
_page(instance, "other/Delta.md", "\n# Delta\n\nSecond collection.\n")
_commit(instance, "second collection")
_configure(instance, whole=_all(instance, index=True))
assert _run().exit_code == 0
folder = _out(instance, "whole")
before = _snapshot(folder)
_page(instance, "concepts/Alpha.md", ALPHA + "\nMore.\n")
_commit(instance, "touch concepts only")
assert _run().exit_code == 0
after = _snapshot(folder)
assert after["other/INDEX.md"] == before["other/INDEX.md"]
assert after["other/Delta.md"] == before["other/Delta.md"]
assert after["concepts/INDEX.md"] != before["concepts/INDEX.md"]
assert after["index.md"] != before["index.md"]
def test_switching_index_off_removes_the_index_files_and_nothing_else(instance):
_configure(instance, pages={"index": True})
assert _run().exit_code == 0
folder = _out(instance, "pages")
indexes = _index_files(folder)
assert indexes
kept = {rel: data for rel, data in _snapshot(folder).items()
if rel not in indexes and rel != page_export.MANIFEST_NAME}
_configure(instance, pages={"index": False})
result = _run()
assert result.exit_code == 0, result.output
after = _snapshot(folder)
assert not [rel for rel in after if posixpath.basename(rel) in INDEX_NAMES]
assert {rel: data for rel, data in after.items() if rel != page_export.MANIFEST_NAME} == kept
assert f"removed ({len(indexes)})" in _flat(result.output)
def test_a_page_index_md_beside_the_generated_one_stops_the_target_before_any_write(instance):
_configure(instance, pages={})
assert _run().exit_code == 0
folder = _out(instance, "pages")
before = _snapshot(folder)
_page(instance, "concepts/index.md", "\n# Index\n\nA page called index.\n")
_commit(instance, "index page")
_configure(instance, pages={"index": True})
result = _run()
assert result.exit_code == 1
message = _flat(result.output)
assert "concepts/index.md" in message and "concepts/INDEX.md" in message
assert _snapshot(folder) == before
def test_a_stray_page_that_differs_from_index_md_only_in_case_stops_the_target(instance):
_page(instance, "Index.md", "\n# Index\n\nStray.\n")
_commit(instance, "stray")
_configure(instance, whole=_all(instance, index=True))
result = _run()
assert result.exit_code == 1
assert "Index.md" in _flat(result.output) and "index.md" in _flat(result.output)
assert not _out(instance, "whole").exists()
def test_with_links_text_pages_hold_no_link_to_an_exported_page_but_the_index_links(instance):
_configure(instance, pages={"index": True, "links": "text"})
assert _run().exit_code == 0
folder = _out(instance, "pages")
indexes = set(_index_files(folder))
for rel in _snapshot(folder):
if rel in indexes or rel == page_export.MANIFEST_NAME:
continue
assert not re.search(r"\]\([^)]*\.md[^)]*\)", (folder / rel).read_text(encoding="utf-8")), rel
assert "](Alpha.md)" in (folder / "concepts" / "INDEX.md").read_text(encoding="utf-8")
def test_the_command_handler_gets_the_index_files_with_the_pages(instance):
record = instance.parent / "files.json"
program = _converter(instance, f"""
import json, pathlib, sys
src, out = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
pathlib.Path({str(record)!r}).write_text(json.dumps(sorted(
p.relative_to(src).as_posix() for p in src.rglob("*") if p.is_file())))
(out / "done.txt").write_text("done")
""")
_configure(instance, pages={"handler": "command", "command": "convert", "index": True})
_set_commands(instance, {"convert": program})
assert _run().exit_code == 0
seen = json.loads(record.read_text(encoding="utf-8"))
assert "index.md" in seen and "concepts/INDEX.md" in seen and "concepts/Alpha.md" in seen
def test_the_run_names_the_index_files_by_path(instance):
_configure(instance, pages={"index": True})
out = _flat(_run().output)
assert "index.md" in out and "concepts/INDEX.md" in out
+7 -8
View File
@@ -2,9 +2,8 @@ from pathlib import Path
import pytest
from chemenu.catalog import SHARD_THRESHOLD, anchor
from chemenu.commands.index_build import (
SHARD_THRESHOLD,
_anchor,
build_index,
index_rebuild,
plan_index,
@@ -86,12 +85,12 @@ def test_map_links_to_each_collection_shard(kb_dir):
assert f"[entities/{GENERATED_INDEX}](entities/{GENERATED_INDEX})" in content
def test_map_deep_links_an_inlined_area_by_anchor(kb_dir):
def test_map_deep_links_an_inlined_area_byanchor(kb_dir):
# The anchor is derived from the area's display title, so it follows the KB
# language along with it. Both sides of the link are generated in the same
# run, so they stay consistent; only a bookmark to an old anchor would break.
anchor = _anchor(_area_title("system"))
assert f"entities/{GENERATED_INDEX}#{anchor}" in build_index(kb_dir)
slug = anchor(_area_title("system"))
assert f"entities/{GENERATED_INDEX}#{slug}" in build_index(kb_dir)
# --- collection shards ------------------------------------------------------
@@ -120,11 +119,11 @@ def test_anchor_keeps_non_ascii_letters():
"""An area title follows the KB language, so it may carry a letter outside
`a-z`. Deleting it - which is what the old `[^a-z0-9\\s-]` did - produced a
map link (`#ablufe`) that pointed at no heading in the shard it named."""
assert _anchor("Abläufe") == "abläufe"
assert _anchor("Größere Muster") == "größere-muster"
assert anchor("Abläufe") == "abläufe"
assert anchor("Größere Muster") == "größere-muster"
# Punctuation is still dropped and any run of whitespace still collapses to
# a single hyphen.
assert _anchor("Tools & Utilities (v2)") == "tools-utilities-v2"
assert anchor("Tools & Utilities (v2)") == "tools-utilities-v2"
def test_area_titles_are_read_from_every_type_spec_not_only_entity(plan, kb_dir):