ADFA-5039: Convert kotlin-web-site docs to JSON - #23
Conversation
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
left a comment
There was a problem hiding this comment.
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).
{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
-
Heading
ids vs. the anchors links point at.slugify()derives ids from heading text whileresolve_hrefkeeps the source#anchorverbatim. Simple headings line up (First heading→first-headingmatched its link), but if Writerside's anchor algorithm diverges fromslugifyon 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. -
Tests. No automated test for
md_to_json.py(onlyreview_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
parse_attrsblanks 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_strcheck 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
-
slugifydoesn'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/-3suffixing handles it. -
Broken/external coloring appends
style="color: …"; on an<a>that already has astylethat'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 inload_configwould fail a typo loudly. -
page_idisn't posix-normalized (str(rel.with_suffix(""))), whilebuild_topic_indexnormalizes backslashes, so the id (and the resolved/….htmlpath) come out with backslashes on Windows..as_posix()keeps them consistent; no-op on Linux/CI. -
README usage shows
--topics-subdirbut not--images-subdir(both exist).
hal-eisen-adfa
left a comment
There was a problem hiding this comment.
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:
TAG_REeats<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.merge_attr_linesdeletes any{...}paragraph (line 427) even when it recovers zero attributes — the content is dropped and nothing is gained.<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 #21 — git 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.
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>
|
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 One more bug found during verification (not flagged by either review)While re-running the corpus to confirm the 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 - On #21/#23/#24@hal-eisen-adfa flagged that Worth flagging for @hal-eisen-adfa and @luisguzman-adfa specifically: |
hal-eisen-adfa
left a comment
There was a problem hiding this comment.
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.pydoes 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.
group_containersnow crashes on a top-level unmatched closer (line 588) — droppedlen(stack) > 1guard,KeyError.rmtreecan delete the source docs (line 812) — nooutput_dir != docs_rootguard. Reproduced.- The
style=merge emits invalid CSS (line 352) — no;separator; both rules get dropped. fold_image_attrseats the separating space (line 382) — minor.COLOR_RE.matchraisesTypeErroron 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"]: |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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):] |
There was a problem hiding this comment.
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.
| 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):] |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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: {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:
| 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.
There was a problem hiding this comment.
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]): |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
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>
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>
|
Pushed 5ec1f60 addressing round 2. Replied inline to each of the 5 new line comments individually; summary here. Fixed (all 5 round-2 findings)
TestsOne regression test per fix, plus tightened |
Code reviewFound 3 issues:
Frequency note, so these can be triaged rather than taken at face value: against a Also confirmed: all round-1 and round-2 review findings are genuinely fixed at 🤖 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>
|
Pushed 5336ee5 addressing all 3.
38/38 tests pass (34 previous + 4 new). |
Summary
Adds
md_to_json.py, which converts akotlin-web-site/docscheckout(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:
build_nav.py,find_missing_assets.py,populate_db.py, media insertion, and the GitHub Action that loadseverything into
documentation.db. That PR depends on this one mergingfirst, since
populate_db.py/build_nav.pyimportmd_to_json.py.Changes
ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/md_to_json.py- the converter.config.json- theming config it takes as input.markdown-it-pyadded to rootrequirements.txt.README.mdscoped to this script's usage and output schema.review_build_json.sh- a throwaway reviewer helper (installsrequirements, clones
kotlin-web-site, runs the converter) so you can seereal JSON output with no other setup. Not part of the actual pipeline.
Test plan
review_build_json.shlocally end-to-end against a realkotlin-web-siteclone - converted 304/304 files, output matches thedocumented schema (
topics/**/*.json,theme.json,images/).md_to_json.py/config.jsonmatch their originalsin PR ADFA-4739: Pipeline for producing template-based Kotlin documentation #21 (no edits made during the split).