Skip to content

ADFA-5039: Convert kotlin-web-site docs to JSON - #23

Open
alexmmiller wants to merge 4 commits into
mainfrom
fix/ADFA-5039
Open

ADFA-5039: Convert kotlin-web-site docs to JSON#23
alexmmiller wants to merge 4 commits into
mainfrom
fix/ADFA-5039

Conversation

@alexmmiller

@alexmmiller alexmmiller commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds md_to_json.py, which converts a kotlin-web-site/docs checkout
(JetBrains Writerside-flavored Markdown) into the JSON block schema this
project's templating engine renders.

Scope

This is split out of the larger end-to-end Kotlin-docs pipeline in
#21,
which is being separated into two ticket-scoped PRs:

  • ADFA-5039 (this PR): producing the JSON data for the Kotlin website.
  • ADFA-4739 (follow-up PR): build_nav.py, find_missing_assets.py,
    populate_db.py, media insertion, and the GitHub Action that loads
    everything into documentation.db. That PR depends on this one merging
    first, since populate_db.py/build_nav.py import md_to_json.py.

Changes

  • ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py - the converter.
  • config.json - theming config it takes as input.
  • markdown-it-py added to root requirements.txt.
  • README.md scoped to this script's usage and output schema.
  • review_build_json.sh - a throwaway reviewer helper (installs
    requirements, clones kotlin-web-site, runs the converter) so you can see
    real JSON output with no other setup. Not part of the actual pipeline.

Test plan

Converts kotlin-web-site/docs (Writerside-flavored Markdown) into the JSON
block schema this project's templating engine renders - one JSON file per
topic, plus theme.json and a copy of images/. Split out of the larger
Kotlin-docs pipeline PR so this ticket's scope (producing the JSON) can be
reviewed independently of the database-insertion side (ADFA-4739).

Includes review_build_json.sh, a throwaway helper that clones
kotlin-web-site and runs the converter against it, for reviewers to see
real output without any other setup - not part of the actual pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@luisguzman-adfa luisguzman-adfa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #23 — review comments

Ran md_to_json.py against a small fixture with intentionally broken references (and cross-checked with review_build_json.sh): it converts as expected and the output matches the documented block schema:
title + %var% substitution, cross-page link resolution (b.md#first-heading/b.html#first-heading), external/broken-link coloring, image rewrite with {width=...} folding, and %var% inside code all come through, with the correct "unknown topic" warning for the deliberately-broken link.

Docstring and README are accurate and the "known limitations" are well-scoped.

Test block (Ubuntu)

# 1) get the repo on the PR branch
git clone https://github.com/appdevforall/OfflineDocumentationTools.git
cd OfflineDocumentationTools
git switch fix/ADFA-5039
cd ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON

# 2) run md_to_json.py against a tiny fixture with intentionally broken refs
python3 -m venv /tmp/mdvenv && source /tmp/mdvenv/bin/activate
pip install markdown-it-py --quiet
D=$(mktemp -d); mkdir -p "$D/docs/topics" "$D/docs/images"
printf '<vars><var name="v" value="2.0.0"/></vars>' > "$D/docs/v.list"
printf 'PNG' > "$D/docs/images/m.png"
cat > "$D/docs/topics/a.md" <<'MD'
[//]: # (title: Sample %v%)
## First heading
Para with [x-link](b.md#first-heading), [external](https://x.com), [broken](missing.md).
![alt](m.png){width="200"}
```kotlin
fun main() { println("%v%") }
```
MD
printf '[//]: # (title: B)\n## First heading\nhi\n' > "$D/docs/topics/b.md"
python3 md_to_json.py "$D/docs" "$D/out" config.json && python3 -m json.tool "$D/out/topics/a.json"

Decision points

  1. Heading ids vs. the anchors links point at. slugify() derives ids from heading text while resolve_href keeps the source #anchor verbatim. Simple headings line up (First headingfirst-heading matched its link), but if Writerside's anchor algorithm diverges from slugify on headings with inline code/punctuation, or on duplicate headings, a link resolves to the page but lands on no anchor, silently. Worth spot-checking those cases against a real cross-reference.

  2. Tests. No automated test for md_to_json.py (only review_build_json.sh). The block schema is the contract the templating step depends on, so a small fixture + a check on the emitted blocks would guard it, or, if that's landing with ADFA-4739, a one-line note saying so.

Suggestion

  1. parse_attrs blanks a bare attribute value when the tag also has a quoted one: parse_attrs('group-key=gradle title="Gradle"'){'group-key': '', 'title': 'Gradle'}. The '="' in attr_str check is whole-string, so any quoted attribute empties every bare one. Only fires for a block-level <tab>/<tabs> with a bare attr (Writerside usually quotes everything), so low-trigger, but real, deciding per-pair (use the bare group when the quoted one didn't match) fixes it.

Minor suggestions

  1. slugify doesn't de-dup ids — two headings with the same text share an id, so an anchor to the second lands on the first. The usual -2/-3 suffixing handles it.

  2. Broken/external coloring appends style="color: …"; on an <a> that already has a style that's a duplicate attribute (first wins in HTML5), so those links won't be colored — and the color is injected unescaped, so a hex check in load_config would fail a typo loudly.

  3. page_id isn't posix-normalized (str(rel.with_suffix(""))), while build_topic_index normalizes backslashes, so the id (and the resolved /….html path) come out with backslashes on Windows. .as_posix() keeps them consistent; no-op on Linux/CI.

  4. README usage shows --topics-subdir but not --images-subdir (both exist).

@hal-eisen-adfa hal-eisen-adfa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review of md_to_json.py and friends. 15 findings, left inline.

These aren't read-the-code guesses — I sparse-cloned JetBrains/kotlin-web-site (304 topics + v.list) and ran this converter over the whole corpus, so most findings below come with real file:line citations and occurrence counts from actual Kotlin docs.

Three separate content-loss bugs, all confirmed against the live corpus:

  1. TAG_RE eats <table> (line 84) — <table> appears 38 times in the source and 0 times in the generated JSON. 76 table lines across 18 files are silently consumed.
  2. merge_attr_lines deletes any {...} paragraph (line 427) even when it recovers zero attributes — the content is dropped and nothing is gained.
  3. <tabs> discards all non-tab children (line 469) — intro and trailing prose inside a tabs block vanish with no warning.

Findings 2 and 5 compound badly: { style = "note" } (spaces around =) parses to no attributes and gets swallowed, so tour/kotlin-tour-welcome.md — the first page of the Kotlin tour — renders its note as a plain blockquote. 7 files hit this.

Also flagging line 278 as a markup-injection hole: broken-ext-link-color is interpolated into an HTML attribute unvalidated. Config is repo-controlled so it isn't remotely exploitable, but it's a one-line fix.

Please note this file is byte-identical to the md_to_json.py in #21git diff between the two branches is empty for it. Findings on lines 84, 142 and 278 duplicate comments I left there. Fixing once fixes both, but whichever PR merges second will carry them if only one gets patched. Worth deciding how #21 and #23 relate before either lands.

In fairness, a number of things I went looking for turned out to be fine, and I want to be explicit so this doesn't read as a wall of doom: no _raw leakage into the output JSON (0 corpus-wide), no tag_marker leakage (0), no empty-string heading ids (0), no IndexError on empty table cells, no blank-line stripping in raw <pre> blocks, and the unpinned markdown-it-py is genuinely safe (1.1.0 already exposes Token.attrs as a dict). The overall shape of the converter is sound — the damage is concentrated in the Writerside tag/attribute parsing.

Comment thread ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py Outdated
Comment thread ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py Outdated
Comment thread ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py Outdated
Comment thread ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py Outdated
Comment thread ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/review_build_json.sh Outdated
Comment thread ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py Outdated
Fixes 12 correctness bugs found by review (verified against a live
kotlin-web-site corpus, not just read-the-code):

- TAG_RE matched "tab" as a prefix of "table", silently eating every raw
  HTML table in the corpus (38 occurrences / 18 files).
- merge_attr_lines deleted any {...}-shaped paragraph even when it parsed
  to zero attrs, dropping real content.
- <tabs> blocks discarded any non-<tab> sibling (intro/trailing prose).
- A mismatched closing tag_marker was silently ignored, leaving the wrong
  frame open and relocating later content into it.
- ATTR_PAIR_RE didn't allow whitespace around "=", and parse_attrs' whole-
  string quoted-vs-bare check blanked bare values whenever any sibling in
  the same group was quoted.
- fold_image_attrs only handled exactly one trailing {...} group and
  required the whole text token to be nothing else, so a second attribute
  group or trailing prose leaked as literal visible text.
- broken-ext-link-color was interpolated into a style="..." attribute
  unvalidated (markup-injection hole) and unconditionally appended even
  when the <a> already had a style=, producing a silently-ignored
  duplicate attribute.
- main() swallowed per-file conversion failures and still exited 0.
- The image-src regex rewrote src= on any element, not just <img>,
  producing false "image not found" warnings for <script>/<iframe>.
- page_id/sourceFile used str(Path(...)) instead of .as_posix(), which
  would disagree with build_topic_index's forward-slashed ids on Windows.
- build_topic_index resolved duplicate topic stems first-wins with no
  warning, unlike the equivalent image-filename collision handling.
- Heading lines with a trailing Writerside attribute suffix (most commonly
  {id="..."}, also seen as {completion-point=...}) rendered that suffix as
  literal visible garbage text, with no attribute handling and no
  anchor-override support - found via corpus verification, not flagged by
  either reviewer, but the same bug family and comparably common (~39
  affected pages).

Also: de-dupes heading ids that collide on identical text (anchor to the
second heading no longer lands on the first); skips re-copying unchanged
images and prunes stale topic JSON left over from a previous run; switches
review_build_json.sh from raw pip/python3 to `uv run` (PEP 668
externally-managed-environment installs were crashing it outright on
Homebrew/Debian/Ubuntu Python).

Adds tests/test_md_to_json.py (30 cases, one per fix above, several reusing
reviewers' own repro snippets) and tests/conftest.py so pytest can find the
module under test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@alexmmiller

Copy link
Copy Markdown
Collaborator Author

Pushed 6b25e26 addressing both reviews. Replied inline to each of the 15 line comments individually; summary + the two review-level threads here.

Fixed (all 12 correctness findings)

Every "blocking" item from @hal-eisen-adfa's review is fixed, plus @luisguzman-adfa's suggestion (#3, same bug as the line-142 finding) and minor points (#4 slugify de-dup, #5 duplicate style attr, #6 path separator, #7 README --images-subdir). Each is verified with a direct unit test in the new tests/test_md_to_json.py (30 cases), and re-confirmed against a real kotlin-web-site corpus run via review_build_json.sh where the original finding cited corpus numbers.

One more bug found during verification (not flagged by either review)

While re-running the corpus to confirm the fold_image_attrs fix, I noticed heading lines with a trailing Writerside attribute suffix - most commonly ## Title {id="custom-anchor"}, also seen as {completion-point=...} - were rendering that suffix as literal visible garbage text, with zero attribute handling. Same bug family as the image-attrs findings, just a different injection point the corpus checks happened not to exercise. Affected ~39 pages. Fixed the same way: Converter.extract_trailing_attrs strips it and, when an explicit id is given, uses it to override the auto-slug (which is the actual point of that syntax - a stable custom anchor) rather than just discarding it.

Decision point #1 (heading id vs. anchor mismatch)

Didn't fix - it's not a demonstrated bug, no concrete repro was given, and reproducing Writerside's own anchor-slugging algorithm to compare against is a bigger investigation than this PR's scope. Documented as a known limitation in both the module docstring and README instead, so it's tracked rather than silently dropped.

Decision point #2 (tests)

Added - tests/test_md_to_json.py, one case per fix above plus a few backward-compat checks, several reusing your own repro snippets directly (the getting-started.md:89 two-brace-group case, the missing-</tab> case, the {a.length}/{it.length} case). Run with:

uv run --with pytest --with-requirements ../../../requirements.txt python3 -m pytest tests/

On #21/#23/#24

@hal-eisen-adfa flagged that md_to_json.py is byte-identical between this PR and #21, so the same findings apply to both. That's expected - #21 is the original, unsplit branch (fix/ADFA-4737) that #23 and #24 (fix/ADFA-4739) were carved out of; #21 stays open for now purely as a reference for that split and will be closed as superseded once both land, never merged itself. So fixing it here is the right (and only) place - #21 inherits nothing further from this point on.

Worth flagging for @hal-eisen-adfa and @luisguzman-adfa specifically: build_nav.py, find_missing_assets.py, and populate_db.py (which import this file directly) live in #24, not here - if either of you is planning a similar pass over those, they depend on this PR's version of md_to_json.py/build_topic_index/build_image_index, which is why I kept both functions' return signatures unchanged even while fixing their internals (see the CONTAINER_TAGS/dead-code reply for specifics).

@hal-eisen-adfa hal-eisen-adfa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 — all 15 previous findings verified fixed; 5 new ones introduced by the fixes

I re-ran the converter over a fresh 304-topic kotlin-web-site corpus and ran your test suite (30/30 pass, 304/304 converted, exit 0). Every one of the 15 findings from my last pass is genuinely fixed — I verified against real output, not just the diff:

Finding Evidence
TAG_RE ate <table> 30 in source → 30 in output (was 0); orphan "type":"tab" blocks: 0
merge_attr_lines deleted {...} paragraphs guarded on non-empty parse_attrs
<tabs> dropped non-tab children whatsnew24.md intro prose survives
{ style = "note" } kotlin-tour-welcome.json now emits attrs: {"style": "note"}
bare attrs blanked 411 populated width="N", 0 blanked; group-key populates
image attr braces leaked 15 → 0 genuine
color injection / duplicate style= validated at load_config, merged not appended
main() exited 0 on failure exit 1 confirmed, --allow-failures works
src= on every element the 8 remaining warnings are all genuine missing .png — no false positives
Windows separators as_posix() throughout
image re-copy / stale JSON ctime unchanged on 2nd run; stale JSON pruned

Two things I want to be explicit about, because you pushed back and you were right:

  • I checked your "the 2 residual {width= hits are inside <!-- comments" claim. It holds — both are in commented-out markdown.
  • I checked converter.warnings / build_image_index's tuple against #24. find_missing_assets.py does read both. That's not dead code and I was wrong to call it that — keeping the signatures stable was the correct call.

Also: finding the heading {id=...} suffix bug yourself, during verification, on a corpus path neither reviewer's checks exercised, is exactly the right instinct. ~39 pages is not a small catch.


The 5 new findings

All five are introduced by this round's fixes — none existed in 7f67b2fb. Two are serious. The pattern worth noting: three of the five are cases where the fix is correct in exactly the case the new test exercises and wrong just outside it.

  1. group_containers now crashes on a top-level unmatched closer (line 588) — dropped len(stack) > 1 guard, KeyError.
  2. rmtree can delete the source docs (line 812) — no output_dir != docs_root guard. Reproduced.
  3. The style= merge emits invalid CSS (line 352) — no ; separator; both rules get dropped.
  4. fold_image_attrs eats the separating space (line 382) — minor.
  5. COLOR_RE.match raises TypeError on a non-string (line 741) — minor.

Details inline. 1 and 2 are each a couple of lines; I'd want both before this lands, especially since #24 wires this into CI. Everything else here is in good shape — this was a thorough pass and the corpus numbers back it up.

Resolving all 15 previous threads.

node = {"type": b["tag"], "attrs": b["attrs"], "blocks": []}
stack[-1]["blocks"].append(node)
stack.append(node)
elif stack[-1]["type"] == b["tag"]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this now crashes on a top-level unmatched closing tag.

The fix dropped the len(stack) > 1 guard that used to be part of this condition. root is {"blocks": []} (line 621) and has no "type" key, so when the stack is back down to just root, this raises:

'</note>\n\nAfter.\n'          -> KeyError: 'type'
'Intro.\n\n</tab>\n\nAfter.\n' -> KeyError: 'type'

Both verified against the converter as it stands on 6b25e26.

The previous code returned wrong-but-alive output here; this returns no output at all for the page. And it compounds with your own main() fix — the exception is caught per-file, failed increments, and the run now exits 1, so one upstream page with a stray closer fails the whole CI step once #24 wires this up.

test_unmatched_closing_tag_does_not_relocate_trailing_content only exercises the nested case (stack depth ≥ 2), which is why it passes. The full corpus has zero top-level strays today, so this is latent rather than live — but it's latent in source you don't control.

Suggested change
elif stack[-1]["type"] == b["tag"]:
elif len(stack) > 1 and stack[-1]["type"] == b["tag"]:

The else branch below already handles the no-matching-frame case correctly (match_depth is None → warn and ignore), so restoring the guard is the whole fix. Worth a test with a closer and no opener at all.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5ec1f60 - restored the len(stack) > 1 guard before the stack[-1]["type"] check, matching your suggestion exactly. Verified '</note>\n\nAfter.\n' and the nested 'Intro.\n\n</tab>\n\nAfter.\n' case both no longer raise, and both still emit the kind: "tag" warning. Covered by test_top_level_unmatched_closer_does_not_crash.

# forever, since nothing else here ever removes a file on its own.
topics_out_dir = args.output_dir / args.topics_subdir
if topics_out_dir.is_dir():
shutil.rmtree(topics_out_dir)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this can delete the user's source markdown.

Nothing checks that output_dir isn't the same tree as docs_root. Since topics_out_dir is output_dir / topics_subdir and the source is docs_root / topics_subdir, pointing both at the same place makes this rmtree the source topics directory. Reproduced on 6b25e26:

BEFORE: 1 source .md files
error converting .../docs/topics/a.md: [Errno 2] No such file or directory
error: 1 file(s) failed to convert
AFTER:  0 source .md files remain

md_files is globbed at line 791, before this line, so every path in the list is then unlinked and every conversion fails — the run reports the failure honestly (your exit-1 fix working as intended), but the checkout is already gone.

This is purely a consequence of the pruning fix; the old copytree-only version was harmless when handed the same directory twice. Two positional path arguments that must not be equal is a genuinely easy CLI to fumble, and in CI it'd be a variable expanding to the wrong thing rather than a typo.

Cheapest fix is an explicit guard before the rmtree:

if topics_out_dir.resolve() == topics_dir.resolve():
    print(f"error: output topics dir {topics_out_dir} is the source topics dir", file=sys.stderr)
    sys.exit(1)

(.resolve() on both so docs/../docs and symlinks don't slip past it.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5ec1f60 - added the topics_out_dir.resolve() == topics_dir.resolve() guard before the rmtree, exiting 1 with an explicit error instead of deleting the source. Covered by test_main_refuses_when_output_dir_is_docs_root, which runs main() with output_dir == docs_root end-to-end and asserts the source .md file still exists afterward.

# second one.
existing = STYLE_ATTR_RE.search(tag)
if existing:
return tag[:existing.start(1)] + existing.group(1) + " " + rule + tag[existing.end(1):]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplicate-style fix produces invalid CSS when the existing style has no trailing ;.

The merge concatenates with a space and no separator, so the existing declaration and the injected one fuse into a single malformed one:

conv.style_broken_and_external_links('<a href="https://x.com" style="font-weight: bold">x</a>')
# -> <a href="https://x.com" style="font-weight: bold color: red;">x</a>

font-weight: bold color: red; is one declaration with a garbage value. Per CSS error handling the whole thing is discarded — so both the author's original style and the color you're adding are lost. That's the same end state as the duplicate-attribute bug this replaced, just reached differently.

With a trailing ; already present it's fine (style="color: blue;"color: blue; color: red;), which is the case your test happens to use.

test_broken_link_style_merges_into_existing_style_attr asserts count('style="') == 1 and that both substrings appear — all true of the broken output, so the test passes while the rendering is wrong. Worth tightening it to assert the ; boundary.

Suggested change
return tag[:existing.start(1)] + existing.group(1) + " " + rule + tag[existing.end(1):]
sep = "" if not existing.group(1).strip() or existing.group(1).rstrip().endswith(";") else "; "
return tag[:existing.start(1)] + existing.group(1) + sep + " " + rule + tag[existing.end(1):]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5ec1f60, using your suggested sep logic verbatim - only inserts "; " when the existing style value is non-empty and doesn't already end in ;. test_broken_link_style_merge_inserts_semicolon_separator asserts both font-weight: bold; and color: #cc0000; are present as separate valid declarations, tightening the old count-only assertion you flagged as insufficient.

pos = m.end()
folded_any = True
if folded_any:
remainder = content[pos:].strip()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — the remainder .strip() eats the space that separated the image from the following prose.

content was already stripped at line 375, and stripping again here removes the leading space that was the word boundary. Using your own getting-started.md:89 case:

source:   ![Slack](slack.svg){width=25}{type="joined"} Slack: [get an invite](...)
rendered: <img src="slack.svg" alt="Slack" width="25" type="joined" />Slack:<a ...>invite</a>

Both spaces are gone — the text is jammed against the image and against the link. Much better than the literal {width=25}{type="joined"} that used to be there, so this is a real improvement, just not quite finished.

.strip() here is only needed to decide whether the remainder is non-empty; the content itself wants to keep its leading whitespace:

Suggested change
remainder = content[pos:].strip()
remainder = content[pos:]
if remainder.strip():

test_fold_image_attrs_preserves_trailing_prose asserts on the stripped text, so it won't catch this either way — an assertion on the exact rendered string would.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5ec1f60 - remainder is no longer re-.strip()'d after slicing; only the truthiness check strips, the content itself keeps its leading space. Tightened test_fold_image_attrs_preserves_trailing_prose to assert the exact string " Slack:" (was "Slack:") so it actually catches this - you were right that the old assertion wouldn't have caught it either way.

for key in CONFIG_KEYS:
if key not in config:
print(f"warning: config {config_path} is missing {key!r}; that styling will be skipped", file=sys.stderr)
elif not COLOR_RE.match(config[key]):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — a non-string config value raises TypeError instead of the clean error this branch is meant to produce.

# config.json: {"broken-ext-link-color": 123}
load_config(path)
# TypeError: expected string or bytes-like object, got 'int'

So the one input shape most likely to be a genuine hand-edit slip (unquoted value in JSON) escapes the validation you just added and comes out as an uncaught traceback rather than the error: config ... has an invalid ... message on the line below.

Suggested change
elif not COLOR_RE.match(config[key]):
elif not isinstance(config[key], str) or not COLOR_RE.match(config[key]):

The existing message already reads correctly for this case.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5ec1f60 - added isinstance(config[key], str) to the condition so a non-string value (e.g. 123) hits the clean error: config ... has an invalid ... message and sys.exit(1) instead of raising TypeError. Covered by test_load_config_rejects_non_string_color_value.

group_containers crashed (KeyError) on a top-level unmatched closing tag
since the len(stack) > 1 guard was dropped during the round-1 fix. The
topics-dir pruning rmtree could delete the source docs when output_dir
resolves to the same tree as docs_root. The duplicate-style merge could
produce invalid (dropped) CSS when the existing style had no trailing
";". fold_image_attrs re-stripped the remainder after an image, eating
the leading space before trailing prose. load_config raised a bare
TypeError instead of its own clean error on a non-string color value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
alexmmiller pushed a commit that referenced this pull request Aug 10, 2026
Blocking: CLAUDE.md's templateId "out of scope" claim was falsified by
this same PR (populate_db.py/insert_optimized_media.py both read/write
it) - narrowed the claim to the names that are actually absent. The
nav-hidden class nav.peb emits was inert (no consuming CSS rule),
rendering Writerside-hidden nav entries (e.g. individual tour steps)
visible - added the missing docs.css rule.

Non-blocking: rewrote the Dokka-plugin-kdoc2json bullet (and decisions
log) to reflect that fix/ADFA-4514 is merged, rather than describing it
as an outstanding rebase. find_missing_assets.py swallowed per-file
scan failures and always exited 0, so a totally broken corpus still
looked clean - added a failure counter, --allow-failures flag, and a
report line, mirroring md_to_json.py's pattern from #23. Converted
README.md, optimize_media.py, and run_e2e_pipeline_test.sh from bare
pip/python3 to uv run --with-requirements, and added scour/cairosvg to
requirements.txt, matching the repo's established uv convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@alexmmiller

Copy link
Copy Markdown
Collaborator Author

Pushed 5ec1f60 addressing round 2. Replied inline to each of the 5 new line comments individually; summary here.

Fixed (all 5 round-2 findings)

  1. group_containers crash on a top-level unmatched closer (line 588) - restored the len(stack) > 1 guard dropped during round 1, so stack[-1]["type"] is never evaluated against the bare root frame (which has no "type" key). Verified '</note>\n\nAfter.\n' no longer raises and still emits the kind: "tag" warning.
  2. rmtree could delete the source docs (line 812) - added an explicit topics_out_dir.resolve() == topics_dir.resolve() guard before the rmtree, exiting 1 with a clear error instead of deleting docs_root's topics/.
  3. Style-merge produced invalid CSS (line 352) - used your suggested sep logic verbatim: only inserts "; " when the existing style value is non-empty and doesn't already end in ;.
  4. fold_image_attrs ate the word-boundary space (line 382) - the remainder is no longer re-.strip()'d after slicing; only the truthiness check strips, the content itself keeps its leading space.
  5. load_config raised a bare TypeError on a non-string color value (line 741) - added an isinstance(config[key], str) check ahead of the regex match so it hits the intended clean error message instead.

Tests

One regression test per fix, plus tightened test_fold_image_attrs_preserves_trailing_prose to assert the exact string (" Slack:", not the .strip()'d "Slack:") - per your own note that the old assertion wouldn't have caught #4 either way. 34/34 tests pass (29 existing + 5 new/tightened).

@hal-eisen-adfa

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. Self-closing container tags open a container that never closes, silently nesting the rest of the page inside it. In TAG_RE, the greedy ([^>]*) absorbs the trailing / before the optional /? can match, so group 1 (closing) stays empty and <tab title="A"/> is recorded as an opening marker. Reproduced against HEAD: <tab title="A"/> followed by a paragraph yields [{"type": "tab", "blocks": [{"type": "paragraph", ...}]}] with no warning emitted. No test exercises a self-closing tag. This was flagged on PR ADFA-4739: Pipeline for producing template-based Kotlin documentation #21 against the byte-identical file (discussion_r3732882620, "A self-closing tag opens a container that never closes, swallowing the rest of the page") but was not carried over into this PR.

# "table", consuming "<table>" as tag "tab" with attrs "le".
CONTAINER_TAGS = {"tabs", "tab", "note", "tip", "warning"}
TAG_RE = re.compile(r"^<(/?)(" + "|".join(CONTAINER_TAGS) + r")(?![\w-])([^>]*)/?>$", re.I)

  1. Indented (4-space) code blocks are emitted as raw unescaped HTML instead of code. convert_node handles markdown-it's fence token but has no case for code_block, which CommonMark produces for indented code. Those fall through to the generic fallback below and become {"type": "html", "html": <raw content>} — so code containing < or & (e.g. List<String>) is passed through as markup rather than displayed as code, and the block loses its code typing. The string code_block does not appear anywhere in md_to_json.py or in tests/test_md_to_json.py.

# Fallback: anything not explicitly handled (images are inline-only,
# so plain "image" blocks don't occur at block level; captured via
# paragraph HTML instead).
return {"type": "html", "html": self.rewrite_urls(str(t.content or ""))}

  1. The documented image block type is never emitted, so the published schema is wrong. No code path in convert_node/convert_nodes constructs {"type": "image", ...} — markdown-it only ever produces image as an inline token nested in a paragraph, which render_inline turns into an <img> inside that block's html string. The fallback's own comment states this ("images are inline-only, so plain "image" blocks don't occur at block level; captured via paragraph HTML instead"), contradicting the docstring schema below and the matching image entry in README.md. Since producing this JSON schema is the stated deliverable of this PR, a template author on the ADFA-4739 follow-up would write dead handling for a block that never appears.

{"type": "list", "ordered": false, "items": [{"blocks": [...]}]}
{"type": "table", "headers": ["a", "b"], "rows": [["1", "2"]]}
{"type": "image", "src": "...", "alt": "..."}
{"type": "hr"}
{"type": "tabs", "attrs": {"group": "build-system"},

Frequency note, so these can be triaged rather than taken at face value: against a kotlin-web-site clone at 5aff3dc (298 files under docs/topics), issue 1 has 0 occurrences and issue 2 has 1 — in async-programming.md, whose content contains no < or & and so is not currently mangled. Both are latent rather than actively breaking output today. Issue 3 affects the contract the follow-up PR builds against.

Also confirmed: all round-1 and round-2 review findings are genuinely fixed at 5ec1f60a, re-derived from the current file rather than from the "fixed in X" replies.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Self-closing container tags (<tab .../>) had their trailing "/" eaten
by TAG_RE's greedy attrs group, so they were recorded as openers that
never close - silently nesting the rest of the page inside them. TAG_RE
now captures the self-close marker in its own group, and the html_block
dispatch emits an immediate open/close pair for one.

Indented (4-space) code blocks produced markdown-it's "code_block"
token, which convert_node had no case for - they fell through to the
generic fallback and rendered as unescaped raw HTML instead of a typed
code block. Added a code_block case alongside the existing fence one.

The documented "image" block type was never actually emitted - images
are inline-only in markdown-it, always folded into their containing
block's own html. Fixed the docstring/README to match reality instead
of documenting a block shape that can't occur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@alexmmiller

Copy link
Copy Markdown
Collaborator Author

Pushed 5336ee5 addressing all 3.

  1. Self-closing tags (TAG_RE) - fixed by splitting the self-close marker into its own capture group (([^>]*?)\s*(/?)>$ instead of a single greedy ([^>]*)/?>$), so it can no longer be swallowed by the attrs group. convert_node's html_block dispatch now emits an immediate open/close pair for a self-closing tag instead of a bare opener. Verified <tab title="A"/> followed by a paragraph no longer nests that paragraph inside the tab, and confirmed <table>/</table> still correctly don't match. Covered by test_tag_re_captures_self_closing_marker_separately, test_html_block_self_closing_tag_emits_open_and_close_markers, and test_self_closing_tag_does_not_swallow_trailing_content.
  2. Indented code blocks - added a code_block case to convert_node alongside the existing fence one, emitting the same {"type": "code", ...} shape. Verified List<String> x = ... (4-space indented) now comes out typed as code with the raw text intact, not as an unescaped html block. Covered by test_indented_code_block_renders_as_code_not_html.
  3. Documented image block type - agreed this was a docs-vs-reality mismatch rather than a code bug, so fixed the docstring and README instead of adding new "promote an image-only paragraph to a block" behavior that could change what ADFA-4739's templates receive. Both now state explicitly that images are always inline content inside their containing block's html, never a standalone block.

38/38 tests pass (34 previous + 4 new).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants