diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md new file mode 100644 index 000000000..67cb27dad --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md @@ -0,0 +1,74 @@ +# Process Kotlin Website JSON + +Converts a `kotlin-web-site/docs` checkout (JetBrains Writerside-flavored +Markdown) into the JSON block schema this project's templating engine +renders. + +This PR (ADFA-5039) covers only [`md_to_json.py`](md_to_json.py) — the +conversion step itself. Building the sidebar nav from `kr.tree` +(`build_nav.py`), QA-ing the source tree for broken links/images +(`find_missing_assets.py`), and loading any of this into `documentation.db` +(`populate_db.py`, `insert_optimized_media.py`) are a separate ticket +(ADFA-4739) and land in a later PR. + +## Requirements + +- Python 3.10+ +- `markdown-it-py` (now in the repo's root `requirements.txt`) + +## Usage + +```bash +python3 md_to_json.py [--topics-subdir topics] [--images-subdir images] [--allow-failures] +``` + +- `` — a checkout of `kotlin-web-site/docs` (contains `v.list`, `topics/`, `images/`). +- `` — a JSON file with theming colors, e.g. [`config.json`](config.json): + ```json + {"broken-ext-link-color": "#cc0000", "menu-no-link-color": "#999999"} + ``` + +`` ends up containing: +- `topics/**/*.json` — one page per source `.md` file (schema below) +- `theme.json` — the two theming colors, carried through from `` +- `images/` — copied straight from `/images/` + +### Page JSON schema + +```json +{ + "id": "enum-classes", + "sourceFile": "topics/enum-classes.md", + "title": "Enum classes", + "blocks": [ { "type": "heading", "level": 2, "id": "...", "html": "..." }, "..." ] +} +``` + +Block types: `heading`, `paragraph`, `code`, `blockquote`, `list`, `table`, +`hr`, `tabs`, `note`/`tip`/`warning`, `html` (raw passthrough). See the +module docstring in [`md_to_json.py`](md_to_json.py) for full shapes and +known limitations (nested tabs, `` resolution, variable +substitution). There is no standalone `image` block type - an image is +always inline content inside whatever block contains it (typically +`paragraph`), rendered straight into that block's own `html` string. + +A heading's `id` is `slugify()`'d from its text, unless the source line has +an explicit `{id="..."}` (which overrides it directly). Cross-page links +that carry a source `#anchor` are passed through verbatim rather than +re-slugified, so a link and its target agree as long as both derive their id +the same way; a hand-written `#anchor` that doesn't match either path (e.g. +because Writerside's own anchor algorithm diverges from `slugify()` on +headings with inline code or punctuation) will resolve to the right page but +land on no anchor. Not currently detected - worth spot-checking if a page's +in-page anchors stop scrolling to the right place. + +## Trying it out + +[`review_build_json.sh`](review_build_json.sh) is a throwaway helper for +reviewers — it clones `kotlin-web-site` and runs `md_to_json.py` against it +via `uv run` so you can look at real output without any other setup. It's +not part of the actual pipeline (that's ADFA-4739): + +```bash +./review_build_json.sh +``` diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/config.json b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/config.json new file mode 100644 index 000000000..b69baed62 --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/config.json @@ -0,0 +1,4 @@ +{ + "broken-ext-link-color": "#cc0000", + "menu-no-link-color": "#999999" +} diff --git a/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py new file mode 100644 index 000000000..26067a1b4 --- /dev/null +++ b/ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py @@ -0,0 +1,875 @@ +#!/usr/bin/env python3 +""" +Converts JetBrains Writerside-flavored Markdown (as used by kotlin-web-site/docs) +into a simple JSON block schema suitable for a templating engine. + +Usage: + python3 md_to_json.py [--topics-subdir topics] + [--images-subdir images] [--allow-failures] + + is the checkout of kotlin-web-site/docs (contains v.list, topics/, ...). +One JSON file is written per input .md file, mirroring its relative path under +. + + is a JSON file with: + {"broken-ext-link-color": "#cc0000", "menu-no-link-color": "#999999"} +"broken-ext-link-color" colors tags in the rendered content that are +either off-site (any http(s)/mailto: link) or a same-tree ".md" reference +that doesn't resolve to a real page. "menu-no-link-color" isn't used here - +it's carried through to /theme.json for build_nav.py (which +builds the sidebar from kr.tree, a separate input this script doesn't read) +to pick up. + +Output schema (one object per page): +{ + "id": "enum-classes", + "sourceFile": "topics/enum-classes.md", + "title": "Enum classes", + "blocks": [ , ... ] +} + +Block shapes: + {"type": "heading", "level": 2, "id": "anonymous-classes", "html": "..."} + - "attrs" is present only if the heading's source line had a trailing + `{...}` attribute group (e.g. `## Title {id="custom-anchor"}`); an + explicit "id" in it overrides the auto-generated slug. + {"type": "paragraph", "html": "..."} + {"type": "code", "lang": "kotlin", "code": "...", "attrs": {"kotlin-runnable": "true"}} + {"type": "blockquote", "attrs": {"style": "note"}, "blocks": [...]} + {"type": "list", "ordered": false, "items": [{"blocks": [...]}]} + {"type": "table", "headers": ["a", "b"], "rows": [["1", "2"]]} + {"type": "hr"} + {"type": "tabs", "attrs": {"group": "build-system"}, + "tabs": [{"title": "Gradle", "attrs": {"group-key": "gradle"}, "blocks": [...]}]} + {"type": "html", "html": ""} + +There is no standalone "image" block type - CommonMark only ever produces +"image" as an inline token nested inside a paragraph/heading/etc., so a +`![alt](foo.png)` in the source always ends up as an inside that +block's own "html" string (via render_inline), never as a top-level block +of its own. + +Cross-page links (`[text](other-page.md#anchor)`) and images (`![alt](foo.png)`) +use Writerside's bare-filename convention - the referenced file is looked up +by name anywhere under / (links) or images/ (images), the same +way kr.tree's topic="..." references are resolved. Rendered HTML rewrites +these to root-relative URLs that only resolve once this JSON has been passed +through templates/page.peb and rendered by RenderDocs: links become +"/.html#anchor", and images become "/images/". +The images/ directory itself is copied to /images/ so the +resolved paths have something to point at. + +Known limitations (fine for a first pass, worth revisiting before production use): + - / grouping and attribute-line merging (the "{...}" line after a + fence/blockquote) only happen at the top level of a page and inside list + items/blockquotes/table cells one level deep; deeply nested tabs-in-tabs + are not handled. + - // admonitions are recognized as block-level tags. The + inline, single-line form seen inside HTML tables (e.g. roadmap.md) is passed + through as raw "html" blocks instead of being unpacked, since those pages + are basically hand-written HTML tables rather than prose. + - elements are passed through as raw "html" blocks; resolving them + to the referenced snippet is not implemented. + - %variables% (defined in v.list) are substituted textually in rendered HTML + and code, using simple %name% -> value replacement. + - A handful of images/ filenames collide across subdirectories (leftover + duplicates in the source tree); resolution keeps the first match in sorted + order and prints a warning rather than guessing which one is "correct". + - A heading's id is slugify()'d from its text unless the source overrides + it with a trailing "{id=...}". A hand-written #anchor that was authored + against Writerside's own anchor algorithm rather than either of those + could still resolve to the right page but land on no anchor, if the two + algorithms diverge on a case not yet seen in the corpus (inline code or + punctuation in the heading, duplicate heading text) - not currently + detected. +""" +import argparse +import json +import os +import re +import shutil +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +from markdown_it import MarkdownIt +from markdown_it.token import Token + +TITLE_RE = re.compile(r"^\[//\]:\s*#\s*\(title:\s*(.*?)\)\s*$", re.MULTILINE) +ATTR_LINE_RE = re.compile(r"^\{(.*)\}$") +TRAILING_ATTR_GROUPS_RE = re.compile(r"\s*((?:\{[^{}]*\})+)\s*$") + +# No leading "^": matched via .match(content, pos) below, which already +# anchors at pos - unlike search(), match() never scans forward - but "^" +# itself always means the absolute start of the *string*, not of pos, so +# keeping it here would silently stop the pos-advancing loop after the +# first brace group on every second-and-later iteration. +IMAGE_ATTR_GROUP_RE = re.compile(r"\{([^{}]*)\}") +ATTR_PAIR_RE = re.compile(r'([\w-]+)\s*=\s*(?:"([^"]*)"|(\S+))') +VAR_RE = re.compile(r"%([\w.-]+)%") +MD_LINK_RE = re.compile(r"^([\w.-]+)\.md(#.*)?$") +EXTERNAL_HREF_RE = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?//|^mailto:", re.I) +LINK_TAG_RE = re.compile(r']*\bhref="([^"]*)"[^>]*>') +STYLE_ATTR_RE = re.compile(r'\bstyle="([^"]*)"') +IMG_TAG_RE = re.compile(r"]*>", re.I) +IMG_SRC_RE = re.compile(r'src="([^"]*)"') +COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]+$") + +# TAG_RE is built from this set (rather than hardcoding the same five names +# twice) so the two can't silently diverge. The (?![\w-]) after the +# alternation is load-bearing: without it, "tab" matches as a prefix of +# "table", consuming "" as tag "tab" with attrs "le". +# +# Group 3 (attrs) is non-greedy and group 4 (self-closing "/") is anchored +# right before the final ">" - with a single greedy "([^>]*)/?>$" instead, +# the attrs group swallows a self-closing tag's trailing "/" before the +# optional "/?" ever gets a chance to match it, so "" came out +# indistinguishable from "": an opener with no matching closer, +# silently nesting the rest of the page inside it. +CONTAINER_TAGS = {"tabs", "tab", "note", "tip", "warning"} +TAG_RE = re.compile(r"^<(/?)(" + "|".join(CONTAINER_TAGS) + r")(?![\w-])([^>]*?)\s*(/?)>$", re.I) + + +def build_topic_index(topics_dir: Path) -> dict: + """Bare filename stem (e.g. "enum-classes") -> page id (e.g. "kotlin-tour/enum-classes"). + + Mirrors build_image_index's collision handling: a stem that exists more + than once under topics_dir keeps its first (sorted) page id and prints a + warning naming the others, rather than resolving to whichever sorts + first with no diagnostic at all.""" + index = {} + candidates = {} + for md_path in sorted(topics_dir.rglob("*.md")): + page_id = md_path.relative_to(topics_dir).with_suffix("").as_posix() + candidates.setdefault(md_path.stem, []).append(page_id) + index.setdefault(md_path.stem, page_id) + for stem, ids in sorted(candidates.items()): + if len(ids) > 1: + print(f"warning: ambiguous topic filename {stem!r}: " + f"using {ids[0]}.md, ignoring {', '.join(i + '.md' for i in ids[1:])}", file=sys.stderr) + return index + + +def build_image_index(images_dir: Path): + """Bare filename (e.g. "mascot-main.png") -> path relative to images_dir. + + Returns (index, collisions), where collisions is a list of + (filename, [candidate relative paths]) for filenames that exist in more + than one place under images_dir - index keeps the first (sorted) one.""" + index = {} + candidates = {} + if not images_dir.is_dir(): + return index, [] + for img_path in sorted(images_dir.rglob("*")): + if not img_path.is_file(): + continue + rel = img_path.relative_to(images_dir).as_posix() + candidates.setdefault(img_path.name, []).append(rel) + index.setdefault(img_path.name, rel) + collisions = [(name, rels) for name, rels in sorted(candidates.items()) if len(rels) > 1] + for name, rels in collisions: + print(f"warning: ambiguous image filename {name!r}: " + f"using images/{rels[0]}, ignoring {', '.join('images/' + r for r in rels[1:])}", file=sys.stderr) + return index, collisions + + +def load_variables(docs_root: Path) -> dict: + v_list = docs_root / "v.list" + if not v_list.exists(): + return {} + tree = ET.parse(v_list) + return {el.get("name"): el.get("value") for el in tree.getroot().findall("var")} + + +def substitute_vars(text: str, variables: dict) -> str: + if not text: + return text + return VAR_RE.sub(lambda m: variables.get(m.group(1), m.group(0)), text) + + +def parse_attrs(attr_str: str) -> dict: + """name="quoted value" or name=bare -> {"name": "quoted value"/"bare"}, + decided per pair. findall() coerces a non-participating group to "" (not + None), and exactly one of quoted/bare participates per match, so the + other is always "" - `quoted or bare` picks whichever one actually + matched, including a genuinely empty quoted value ("" or "" -> "").""" + attrs = {} + for name, quoted, bare in ATTR_PAIR_RE.findall(attr_str or ""): + attrs[name] = quoted or bare + return attrs + + +def extract_title(raw_text: str): + m = TITLE_RE.search(raw_text) + if not m: + return None, raw_text + title = m.group(1).strip() + remaining = raw_text[: m.start()] + raw_text[m.end():] + return title, remaining + + +def slugify(text: str) -> str: + slug = re.sub(r"[^\w\s-]", "", text.lower()).strip() + return re.sub(r"[\s_]+", "-", slug) + + +class Node: + """Generic open/close tree built from markdown-it's flat token stream.""" + + __slots__ = ("token", "children") + + def __init__(self, token: Token): + self.token = token + self.children = [] + + +def build_tree(tokens) -> list: + root = [] + stack = [root] + for tok in tokens: + if tok.nesting == 1: + node = Node(tok) + stack[-1].append(node) + stack.append(node.children) + elif tok.nesting == -1: + stack.pop() + else: + stack[-1].append(Node(tok)) + return root + + +class Converter: + def __init__(self, md: MarkdownIt, variables: dict, topic_index: dict = None, image_index: dict = None, + broken_ext_link_color: str = None, image_url_prefix: str = "/images/"): + self.md = md + self.variables = variables + self.topic_index = topic_index or {} + self.image_index = image_index or {} + self.broken_ext_link_color = broken_ext_link_color + # Overridable so a different deployment target (e.g. populate_db.py's + # database-backed site, which serves images from "/k/html/images/" + # rather than a bare "/images/") can retarget every image src without + # a separate rewrite pass - resolve_image_src just uses this prefix + # directly. + self.image_url_prefix = image_url_prefix + self.current_source = None + # Populated as a side effect of resolve_href/resolve_image_src failing + # to resolve a reference; find_missing_assets.py reuses this same + # resolution logic (rather than re-parsing links with regexes) by + # running convert_file over every page and reading this list back. + self.warnings = [] + # Reset per file in convert_file - two headings with the same text + # on the same page would otherwise share a slug, so an anchor to the + # second one lands on the first. + self.seen_heading_ids = set() + + def unique_heading_id(self, text: str) -> str: + """slugify(text), de-duped against every other heading id already + seen on this page - resolve_href points at these ids verbatim (via + the source's own #anchor), so two headings sharing a slug would + make any link to the second one land on the first instead.""" + base = slug = slugify(text) + n = 2 + while slug in self.seen_heading_ids: + slug = f"{base}-{n}" + n += 1 + self.seen_heading_ids.add(slug) + return slug + + def resolve_href(self, href: str): + """"other-page.md#anchor" -> "/.html#anchor", or None to leave href untouched.""" + m = MD_LINK_RE.match(href or "") + if not m: + return None + stem, anchor = m.groups() + page_id = self.topic_index.get(stem) + if page_id is None: + print(f"warning: link to unknown topic {href!r}", file=sys.stderr) + self.warnings.append({"kind": "link", "source": self.current_source, "reference": href}) + return None + return f"/{page_id}.html{anchor or ''}" + + def resolve_image_src(self, src: str): + """"foo.png" -> "", or None to leave src untouched.""" + if not src or "://" in src or src.startswith("/") or "/" in src: + return None + rel = self.image_index.get(src) + if rel is None: + print(f"warning: image not found: {src!r}", file=sys.stderr) + self.warnings.append({"kind": "image", "source": self.current_source, "reference": src}) + return None + return f"{self.image_url_prefix}{rel}" + + def rewrite_urls(self, html: str) -> str: + """Rewrites every href="...md" / src="foo.png" attribute found in a + blob of rendered/raw HTML. Applied to markdown-rendered HTML *and* to + Writerside's raw / passthrough HTML (which markdown-it never + tokenizes as links/images at all, so token-level rewriting alone + would miss it); resolve_href/resolve_image_src already leave anything + that isn't a bare same-tree ".md"/image reference untouched, so this + is safe to run unconditionally on any HTML string.""" + if not html: + return html + + def href_repl(m): + new_href = self.resolve_href(m.group(1)) + return f'href="{new_href}"' if new_href is not None else m.group(0) + + def src_repl(m): + new_src = self.resolve_image_src(m.group(1)) + return f'src="{new_src}"' if new_src is not None else m.group(0) + + html = re.sub(r'href="([^"]*)"', href_repl, html) + # Scoped to tags specifically - a bare src="..." regex + # would also rewrite ') + assert 'src="x.js">' in html + assert 'src="x.js">' in html + assert '/images/sub/x.js' in html + assert not any(w["kind"] == "image" for w in conv.warnings) + + +# --- load_config: reject an unsafe/invalid color ------------------------ + +def test_load_config_rejects_markup_injection_payload(tmp_path): + """broken-ext-link-color is interpolated directly into a style="..." + HTML attribute; an unvalidated value is a markup-injection hole.""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "broken-ext-link-color": 'red">', + "menu-no-link-color": "#999999", + })) + with pytest.raises(SystemExit) as exc_info: + m.load_config(config_path) + assert exc_info.value.code == 1 + + +def test_load_config_rejects_non_string_color_value(tmp_path): + """A JSON number (an unquoted hex-like value is a plausible hand-edit + slip, e.g. writing cc0000 instead of "#cc0000") used to raise a bare + TypeError from COLOR_RE.match(int) instead of the intended clean + "invalid ... value" error the line below is meant to produce.""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "broken-ext-link-color": 123, + "menu-no-link-color": "#999999", + })) + with pytest.raises(SystemExit) as exc_info: + m.load_config(config_path) + assert exc_info.value.code == 1 + + +def test_load_config_accepts_hex_and_named_colors(tmp_path): + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "broken-ext-link-color": "#cc0000", + "menu-no-link-color": "gray", + })) + config = m.load_config(config_path) + assert config["broken-ext-link-color"] == "#cc0000" + assert config["menu-no-link-color"] == "gray" + + +# --- heading ids: de-dup + explicit {id=...} override ------------------- + +def test_duplicate_heading_text_gets_deduped_ids(): + """Two headings with identical text used to share a slug, so an anchor + to the second one landed on the first.""" + conv = make_converter() + first = conv.unique_heading_id("Overview") + second = conv.unique_heading_id("Overview") + assert first == "overview" + assert second == "overview-2" + + +def test_heading_trailing_id_attr_overrides_slug_and_is_stripped_from_html(): + """## Checks with `is`/`!is` operators {id="is-and-is-operators"} used + to render the literal "{id="is-and-is-operators"}" as visible + heading text, with no id override and no attribute handling at all.""" + md = m.make_markdown_it() + conv = m.Converter(md, {}) + tokens = md.parse('## Checks with `is` {id="is-and-is-operators"}\n') + tree = m.build_tree(tokens) + block = conv.convert_node(tree[0]) + assert block["id"] == "is-and-is-operators" + assert "{id=" not in block["html"] + assert block["attrs"] == {"id": "is-and-is-operators"} + + +def test_heading_without_trailing_attrs_unaffected(): + md = m.make_markdown_it() + conv = m.Converter(md, {}) + tokens = md.parse("## Plain heading\n") + tree = m.build_tree(tokens) + block = conv.convert_node(tree[0]) + assert block["id"] == "plain-heading" + assert "attrs" not in block + + +# --- build_topic_index: collision warning mirrors build_image_index ----- + +def test_build_topic_index_warns_on_duplicate_stem(tmp_path, capsys): + """Two topics sharing a filename stem used to resolve first-wins with + no diagnostic at all, unlike the equivalent image-filename collision.""" + topics = tmp_path / "topics" + (topics / "native").mkdir(parents=True) + (topics / "js").mkdir(parents=True) + (topics / "native" / "basics.md").write_text("native") + (topics / "js" / "basics.md").write_text("js") + + index = m.build_topic_index(topics) + assert index["basics"] in ("native/basics", "js/basics") + assert "warning: ambiguous topic filename 'basics'" in capsys.readouterr().err + + +def test_build_topic_index_no_warning_without_collision(tmp_path, capsys): + topics = tmp_path / "topics" + topics.mkdir() + (topics / "a.md").write_text("a") + m.build_topic_index(topics) + assert capsys.readouterr().err == "" + + +# --- main(): exit code reflects partial failure ------------------------- + +def _write_minimal_docs_root(tmp_path): + docs_root = tmp_path / "docs" + (docs_root / "topics").mkdir(parents=True) + (docs_root / "topics" / "good.md").write_text("# Good\n\nHello.\n") + config = tmp_path / "config.json" + config.write_text(json.dumps({"broken-ext-link-color": "#cc0000", "menu-no-link-color": "#999999"})) + return docs_root, config + + +def _run_main(*args): + script = Path(__file__).resolve().parent.parent / "md_to_json.py" + return subprocess.run([sys.executable, str(script), *map(str, args)], capture_output=True, text=True) + + +def test_main_exits_zero_on_full_success(tmp_path): + docs_root, config = _write_minimal_docs_root(tmp_path) + out_dir = tmp_path / "out" + result = _run_main(docs_root, out_dir, config) + assert result.returncode == 0 + assert "Converted 1/1" in result.stdout + + +def test_main_exits_nonzero_when_a_file_fails(tmp_path, monkeypatch): + """A run that converts nothing (or partially fails) used to print + "Converted 0/N files" and still exit 0 - a CI step calling this + couldn't distinguish a complete run from a total failure.""" + docs_root, config = _write_minimal_docs_root(tmp_path) + # A .md file that isn't valid UTF-8 makes convert_file's read_text raise. + (docs_root / "topics" / "bad.md").write_bytes(b"\xff\xfe not utf-8") + out_dir = tmp_path / "out" + result = _run_main(docs_root, out_dir, config) + assert result.returncode == 1 + assert "Converted 1/2" in result.stdout + + +def test_main_allow_failures_exits_zero_despite_failure(tmp_path): + docs_root, config = _write_minimal_docs_root(tmp_path) + (docs_root / "topics" / "bad.md").write_bytes(b"\xff\xfe not utf-8") + out_dir = tmp_path / "out" + result = _run_main(docs_root, out_dir, config, "--allow-failures") + assert result.returncode == 0 + + +def test_main_uses_posix_separators_for_nested_page_ids(tmp_path): + """page_id/sourceFile used str(Path(...)) instead of .as_posix(), which + would disagree with build_topic_index's forward-slashed ids on Windows.""" + docs_root, config = _write_minimal_docs_root(tmp_path) + (docs_root / "topics" / "tour").mkdir() + (docs_root / "topics" / "tour" / "hello.md").write_text("# Hello\n") + out_dir = tmp_path / "out" + result = _run_main(docs_root, out_dir, config) + assert result.returncode == 0 + page = json.loads((out_dir / "topics" / "tour" / "hello.json").read_text()) + assert page["id"] == "tour/hello" + assert page["sourceFile"] == "topics/tour/hello.md" + + +def test_main_prunes_stale_topic_json(tmp_path): + """A topic removed upstream used to keep shipping its stale JSON + forever, since nothing ever cleared the output topics directory.""" + docs_root, config = _write_minimal_docs_root(tmp_path) + out_dir = tmp_path / "out" + assert _run_main(docs_root, out_dir, config).returncode == 0 + assert (out_dir / "topics" / "good.json").exists() + + (docs_root / "topics" / "good.md").unlink() + (docs_root / "topics" / "new.md").write_text("# New\n") + assert _run_main(docs_root, out_dir, config).returncode == 0 + assert not (out_dir / "topics" / "good.json").exists() + assert (out_dir / "topics" / "new.json").exists() + + +def test_main_refuses_when_output_dir_is_docs_root(tmp_path): + """output_dir == docs_root makes topics_out_dir the very same directory + as the source topics/ - the pruning rmtree used to delete it outright, + with every already-globbed source file then failing to convert.""" + docs_root, config = _write_minimal_docs_root(tmp_path) + result = _run_main(docs_root, docs_root, config) + assert result.returncode == 1 + assert (docs_root / "topics" / "good.md").exists() diff --git a/requirements.txt b/requirements.txt index 265d3c394..db54dac57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ brotli Pillow openpyxl>=3.1.0 tqdm-loggable>=0.1.0 +markdown-it-py