ADFA-4739: Kotlin docs DB pipeline + Build Kotlin Docs GitHub Action - #24
ADFA-4739: Kotlin docs DB pipeline + Build Kotlin Docs GitHub Action#24alexmmiller wants to merge 38 commits into
Conversation
…, generate and add templates to display it to database, optimize and insert Kotlin website media. Script to sync current kotlin-stdlib documentation against a newly-generated documentation set (for now, used to do pruning for ADFA-4737 https://appdevforall.atlassian.net/browse/ADFA-4737)
- README: templates/assets are already included in the repo, not left for the user to place separately. - sync_kdoc_json_to_db.py: default --db to documentation.db in cwd instead of a personal /home/alex path; add explicit BEGIN to match sibling scripts' transaction style. - insert_optimized_media.py: rewrite_pages now substitutes renamed image references in a single regex pass over each row's original text, instead of sequential str.replace calls on a mutating buffer, which could double-rewrite a reference if one rename's new name chained into another rename's old name. - run_e2e_pipeline_test.sh: add a verification step that reuses populate_db.py's own pruning logic to confirm blacklisted topic pages are actually absent from the database, replacing a weaker proxy check that only tested for k/html/wasm% paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Brings in the updated KDoc-to-JSON Dokka plugin (sourceSet whitelisting, package-index/structure verification scripts) so this branch has the current plugin used to generate the kotlin-stdlib docs consumed by sync_kdoc_json_to_db.py.
- run_e2e_pipeline_test.sh: replace the manually-supplied STDLIB_ALL_LIBS
path with STDLIB_DOCS_DIR (a kotlin repo's libraries/tools/kotlin-stdlib-docs
checkout). New Step 4/5 derives the repo root and calls
build-stdlib-json-docs.sh to freshly build/publish the kdoc-to-json plugin
and generate kotlin-stdlib/-reflect/-test JSON docs (common+jvm source
sets only, per the existing plugin config) before Step 5/5 syncs them into
the database.
- Add build-stdlib-json-docs.sh: builds a fresh copy of the plugin, swaps in
the JSON-plugin-enabled build.gradle.kts for the duration of the build
(restored on exit via trap), and prints only the resulting all-libs path
to stdout so it composes as STDLIB_ALL_LIBS="$(build-stdlib-json-docs.sh
<kotlin-repo-root>)" - every gradlew invocation inside it is redirected to
stderr so that capture isn't polluted by build console output.
- build.gradle.kts: re-add the Dokka dev-snapshot Maven repo (same
dokka_repository property/default kotlin-stdlib-docs' own
settings.gradle.kts uses) - kotlin-stdlib-docs' own
dokka-samples-transformer-plugin subproject needs a Dokka dev build that
isn't on Maven Central, and the allprojects{} repositories block here had
removed access to it for every subproject.
Verified end-to-end against a real kotlin checkout: fresh plugin build,
JSON generation (common+jvm only), and sync into a scratch documentation.db
all succeed, with blacklist pruning verification passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Documents what OfflineDocumentationTools is (the offline tooling that builds/edits the documentation database consumed by Code on the Go), how its pieces relate to each other, and — most importantly — the gap between the schema this repo's tools expect and the schema the current production documentation.db actually has (added templateId/Templates/ Bookshelf/BookCategories/PUCC_* tables, dropped the legacy ide_tooltip_table). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI counterpart to run_e2e_pipeline_test.sh: builds kotlin-stdlib/-reflect/-test JSON docs and the Kotlin website docs, then loads both into a Drive-hosted copy of documentation.db and uploads the result back to the same file. Google Drive file IDs (database and Writerside's webHelpImages.zip) resolve from secrets/inputs by default, with hard-codeable TEST_*_FILE_ID overrides for one-off manual testing. Posts "Grabbing baton"/"...Dropping baton" to Slack (SLACK_WEBHOOK_URL) around the run, since it mutates a single shared Drive file. Defaults to dry_run so a first run never touches production. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
…739) Adds the code that loads converted Kotlin website content (build_nav.py, populate_db.py, media insertion) and kotlin-stdlib/-reflect/-test JSON content (sync_kdoc_json_to_db.py) into documentation.db, the local e2e test script for that pipeline, and the CI workflow that runs it end-to-end against a Drive-hosted copy of the database. Split out of the larger Kotlin-docs pipeline PR (#21) so the DB-manipulation side (this ticket) can be reviewed separately from producing the raw JSON data for the Kotlin website (ADFA-5039, PR #23). This PR depends on ADFA-5039 merging first - populate_db.py, build_nav.py, and find_missing_assets.py all import md_to_json.py, which isn't included here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
hal-eisen-adfa
left a comment
There was a problem hiding this comment.
Review scoped to the files that are NEW in this PR. I deliberately skipped populate_db.py, build_nav.py, insert_optimized_media.py, sync_kdoc_json_to_db.py and build-kotlin-docs.yaml — I verified by blob hash that those are byte-identical to #21, where I've already left 11 findings. Everything below is specific to this PR.
Blocking (2):
CLAUDE.mdmakes a factual claim abouttemplateIdthat the same commit falsifies — and reasons from it to a scope conclusion.nav.pebemits anav-hiddenclass that nothing consumes, so entries meant to be hidden render visible.
Non-blocking (3) — real, but lower severity or arguably intentional; flagged for your judgment rather than as merge blockers.
Context worth stating plainly: this PR is a clean split from #21, and the split itself is well executed — README.md correctly documents the ADFA-5039 dependency, and I checked that this PR is not shipping a stale pre-fix version of anything. A number of things I went looking for came back clean: build-stdlib-json-docs.sh's restore trap genuinely fires on both failure and SIGINT (verified with timeout -s INT) and is registered before the file swap, so there's no race window; optimize_media.py handles pngquant failures and animated images with explicit fallback-and-log rather than silent data loss, and returns a non-zero exit on errors; docs.css/sidebar.js/tabs.js/nav.peb agree on breakpoints and class names; and the README's documented CLI invocations match the argparse definitions exactly.
I also considered and did not file the page.peb "lone <tab>" workaround here — the root cause is md_to_json.py's tag regex, which isn't in this PR, and I've already filed it as blocking on #23.
| scope for this repo. (A repo-wide search for `templateId`, `Templates`, `Bookshelf`, | ||
| `BookCategories`, or `PUCC` turns up zero matches outside `WebServer.kt` itself, which is | ||
| consistent with that division of responsibility.) Concretely, relative to the schema above: |
There was a problem hiding this comment.
Blocking — this claim is false as of the commit that adds it, and it's load-bearing.
The parenthetical asserts a repo-wide search for templateId "turns up zero matches outside WebServer.kt", and the surrounding sentence uses that to conclude the concern "is not a gap to fill; it's out of scope for this repo."
But this same commit adds populate_db.py, which references templateId at lines 87, 98, 145, 261, 266, 276 and 286 — including the actual insert:
INSERT INTO Content (path, languageID, content, contentTypeID, templateId) VALUES (...)and insert_optimized_media.py, which filters on it at lines 214-215 and 256 (... WHERE ... AND templateId != 0). sync_kdoc_json_to_db.py:13 mentions it too.
So the grep this sentence invites the reader to trust returns numerous in-repo hits the moment this PR lands.
What makes this worth blocking rather than a doc nit: CLAUDE.md exists to orient future readers and agents, and this isn't a stale aside — the false premise is used to justify a scope boundary. An agent reading this will conclude the repo doesn't touch templateId and may "helpfully" strip it from exactly the INSERT that needs it.
Suggest either dropping the parenthetical or narrowing it to the Templates/Bookshelf/BookCategories/PUCC names, which I did not find outside WebServer.kt.
There was a problem hiding this comment.
Fixed in 66da59d - dropped templateId from the "zero matches outside WebServer.kt" list, keeping only Templates, Bookshelf, BookCategories, PUCC (the names that actually are absent), and added a clarifying note that populate_db.py/insert_optimized_media.py do read/write templateId directly since it's a plain column on Content they populate.
| </nav> | ||
|
|
||
| {% macro renderNavNode(node) %} | ||
| <li class="nav-item{% if node.hidden %} nav-hidden{% endif %}"> |
There was a problem hiding this comment.
Blocking — the nav-hidden class is inert, so hidden nav entries render fully visible.
This line emits nav-hidden for nodes the tree marks hidden, and the header comment at line 10 documents hidden as "true for entries Writerside hides from the primary tree." But nothing anywhere consumes the class. Verified:
grep -c 'nav-hidden' assets/docs.css -> 0
grep -n 'hidden' assets/sidebar.js -> (no matches)
grep -c 'nav-hidden' templates/nav.html -> 29
I read docs.css in full (227 lines): the only display: none rules are for .nav-item > .nav-subtree, .nav-toggle and .nav-backdrop — none for .nav-hidden. And page.peb links only /assets/docs.css, so there's no other stylesheet that could supply the rule, and nothing injects CSS elsewhere.
Concrete effect: the committed nav.html already carries 29 nav-hidden entries — the Kotlin tour steps, e.g. "Hello world" — and every one of them shows in the sidebar identically to a normal entry. Writerside hides those individual tour steps from the primary nav by design, so the shipped sidebar gets cluttered with dozens of entries that were never meant to be listed.
One .nav-hidden { display: none; } rule in docs.css closes it. Worth deciding deliberately though — if these should actually be visible, the class and its comment are the thing to remove.
There was a problem hiding this comment.
Fixed in 66da59d - added .nav-hidden { display: none; } to assets/docs.css, matching the documented intent ("entries Writerside hides from the primary tree"). Went with hiding them rather than removing the class.
| design plus a flowchart image; there is no code here yet. The actual implementation (the Dokka | ||
| `JsonRenderer`/`ModelMapper`/`LinkPostProcessor` plugin, its test suite, and the | ||
| `kotlin-stdlib-docs` build scripts) exists only on the unmerged branch **`fix/ADFA-4514`**. That | ||
| branch's diff against `main` also shows it removing recent `docdb-studio` work and all of | ||
| `scripts/pdfjs/` — almost certainly because the branch was cut before those were added and hasn't | ||
| been rebased, not because it intends to delete them. **Flagged: rebase `fix/ADFA-4514` onto |
There was a problem hiding this comment.
Non-blocking — this section describes a branch state that is already out of date.
The text says Dokka-plugin-kdoc2json/ on main is "just a README.md describing the intended design plus a flowchart image; there is no code here yet", that the implementation "exists only on the unmerged branch fix/ADFA-4514", and flags "rebase fix/ADFA-4514 onto current main before merging". Line 183 repeats the rebase item in the decisions log.
fix/ADFA-4514 is already merged — 4c6b8aef ("Merge pull request #18 from appdevforall/fix/ADFA-4514") is on main, and git ls-tree -r main shows JsonOutputPlugin.kt, JsonRenderer.kt, ModelMapper.kt, LinkPostProcessor.kt, the test suite and the kotlin-stdlib-docs build scripts all present on main today.
So a reader is told a whole plugin implementation is missing from main and that a rebase is still outstanding, when both are resolved.
I've marked this non-blocking because it's a point-in-time note that was presumably true when drafted, and unlike the templateId claim above nothing reasons from it. But it's the kind of staleness that a repo-orientation doc is specifically supposed to avoid, and it'll mislead the next agent that reads it.
There was a problem hiding this comment.
Fixed in 66da59d - rewrote the Dokka-plugin-kdoc2json/ bullet to describe it as merged (fix/ADFA-4514, 4c6b8aef) with the actual files present on main, and removed the now-resolved rebase item from the decisions log (was line 183).
| - `pip install markdown-it-py Pillow scour brotli` | ||
| - `cairosvg` (only needed if an optimized SVG exceeds `--svg-rasterize-threshold`): `pip install cairosvg` |
There was a problem hiding this comment.
Non-blocking — bare pip install / python3, against the repo's own established pattern.
This is the same objection I left as blocking on #23's review_build_json.sh, so flagging it here for consistency rather than re-litigating it.
Our convention (~/.claude/CLAUDE.md) is explicit: "Always use uv for Python project management. Never use pip, pip3, or the uv pip compatibility shim", and "never invoke python/python3 directly outside a uv run context."
In this PR that shows up in several places:
- these two lines (
pip install markdown-it-py Pillow scour brotli,pip install cairosvg) - README lines 53, 56, 59, 72, 90, 109, 115 — every usage example is
python3 <script>.py ... optimize_media.py:47-48(docstring) and:302(a runtimeRuntimeErrortelling the user topip install cairosvg)run_e2e_pipeline_test.shlines 100, 105, 125, 141, 145, 175 — six barepython3invocations
This isn't only a global-preference thing: docdb-studio/ and check-tools/ in this same repo each ship a pyproject.toml + uv.lock and document uv sync / uv run, and docdb-studio/CLAUDE.md says "Dependencies are managed with uv. Always use uv run."
Related and independently worth fixing: scour and cairosvg are not in requirements.txt (unchanged by this PR), so they exist only in these prose instructions.
I've left this non-blocking because these are developer-facing helper scripts and you may have deliberately kept them dependency-light — but if so, that's worth a line in the README saying so.
There was a problem hiding this comment.
Non-blocking, addressed in 66da59d rather than just documented as intentional - converted every pip install/bare python3 reference (README usage examples, optimize_media.py's docstring + RuntimeError message, and all 6 invocations in run_e2e_pipeline_test.sh) to uv run --with-requirements <repo-root>/requirements.txt, matching the review_build_json.sh fix on #23. Also added scour/cairosvg to requirements.txt since they were only ever in prose.
| except Exception as exc: # noqa: BLE001 - surface which file broke, keep auditing the rest | ||
| print(f"error scanning {md_path}: {exc}", file=sys.stderr) |
There was a problem hiding this comment.
Non-blocking — per-file scan failures are swallowed and the script still exits 0.
The except catches everything from converter.convert_file(...), prints to stderr, and continues — but nothing counts the failures, and main() has no sys.exit(1) path except the directory-validation check at line 82. So if every file in the corpus raised, this still exits 0 and prints a report claiming nothing is broken.
That matters because of how the script is positioned: it's step 1/5 in run_e2e_pipeline_test.sh:100, and the README describes it as the pre-flight gate where you "fix anything broken in the source before converting it." set -euo pipefail in the shell script can't help — exit 0 is exit 0.
Same shape I flagged as blocking on populate_db.py:483 (#21) and md_to_json.py:607 (#23). I've scored it lower here because this tool only writes an advisory markdown report and doesn't mutate the database, so a bad run misleads rather than corrupts.
Counting failures and exiting non-zero — or at minimum printing a N files failed to scan line into the report itself — would make a broken run distinguishable from a clean one.
There was a problem hiding this comment.
Fixed in 66da59d - added a failure counter and --allow-failures flag mirroring md_to_json.py's pattern from #23: sys.exit(1) if any file failed to scan (unless the flag is passed), and a "N file(s) failed to scan" line (with an explicit "this report is incomplete" marker when non-zero) in the emitted report itself. Covered by new tests in tests/test_find_missing_assets.py, which stub the not-yet-merged md_to_json import via PYTHONPATH since that module isn't on this branch until #23 lands.
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>
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 66da59d addressing your review. Replied inline to each of the 5 line comments individually; summary here. Blocking (2)
Non-blocking (3)
TestsAdded Verified the |
Sent prematurely, apologies
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>
# Conflicts: # ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/README.md # requirements.txt
Its own read_text(encoding="utf-8") sat outside any try/except, so a malformed file raised uncaught and killed the whole process - bypassing --allow-failures entirely, unlike every other file read in this script. Now catches, counts, and reports it the same way the main conversion loop already does; find_include_warnings returns (warnings, failed) and main() folds that count into the existing failure total. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
populate_db.py trains a zstd fast-cover dictionary (256 KiB) from this run's own pages/nav on first use and stores it in a new CompressionDictionary table, then compresses every page/nav/image/asset row against it via the brotli CLI's -D flag (the installed Python brotli package has no dictionary API). Never retrains an existing dictionary: a dictionary-compressed row is only decodable against the exact dictionary it was compressed with, verified empirically to fail silently-wrong rather than loudly on a mismatch, so retraining would orphan every already-migrated row. insert_optimized_media.py rewrites the same rows populate_db.py writes (image optimization, in-place URL rewrites), so it now loads and reuses the same dictionary instead of the old plain-Brotli calls it would otherwise silently corrupt those rows with. ADFA-5153.
populate_db.py and insert_optimized_media.py only ever touch their own subset of Content (k/html/%, assets/%). Every other Content row -- reference docs, tooltip-linked pages, whatever else -- was still plain Brotli, no dictionary. migrate_content_to_dictionary_brotli.py recompresses every remaining 'brotli' row against the shared CompressionDictionary (training one from a representative whole-corpus sample if none exists yet), so the "every brotli row uses the dictionary" assumption WebServer.kt's reader depends on actually holds. Idempotent by construction: a plain decode reliably fails once a row is already dictionary-compressed (verified over 200 trials), so re-running is always a safe no-op. Backs up first (VACUUM INTO), runs in one transaction. Run against the real documentation.db: 29,748/29,751 brotli rows migrated, 131.1MB -> 85.6MB compressed, 299.0MB -> 255.3MB overall. ADFA-5153.
Every 'brotli' Content row in the real database is now compressed against the shared CompressionDictionary (see the prior two commits), but docdb_studio.py still read and wrote plain Brotli in three places: get_html_anchors_for_path, fetch_content_for_path (both decode), and compress_for_storage via import_content_files (encode). Against the migrated database this wasn't a latent risk -- it was already broken: a plain decode of dictionary-compressed content reliably fails, so anchor validation and content preview were silently erroring on every real page, and any new import would have written dictionary-incompatible plain Brotli back into a database that assumes there is none left. get_compression_dictionary(db_path) reads and caches a database's CompressionDictionary (or None, for a database that predates ADFA-5153) -- docdb-studio never creates or retrains one itself, only ever reads whatever another tool already produced. compress_for_storage/decompress_brotli shell out to the brotli CLI's -D flag when a dictionary is present, matching populate_db.py's approach, and fall back to the plain brotli package otherwise. decompress_brotli deliberately raises brotli.error on failure so the two existing call sites' `except brotli.error:` handling didn't need to change. Verified against the real (migrated) documentation.db: anchor lookup and content fetch both now work on real pages that previously would have errored. ADFA-5153.
Each row's recompress spawns its own `brotli` subprocess, so the ~30,000-row real migration was dominated by process-spawn overhead running strictly sequentially. Retrospective feedback: this should have been parallelized from the start rather than accepting a slow serial run. migrate() now runs reassemble+plain-decompress+dictionary-recompress on a ThreadPoolExecutor (defaults to ThreadPoolExecutor's own min(32, cpu_count+4), tuned for exactly this I/O/subprocess-bound shape); each worker opens its own read-only connection (a single sqlite3.Connection isn't safe across threads) and reuses one DictionaryCompressor per thread rather than one per row. The actual delete+insert writes stay serialized on the caller's connection, which SQLite requires anyway. Measured 3-6x faster than sequential on synthetic benchmarks. DictionaryCompressor gets an atexit safety-net close(), since a per-thread instance has no single call site that can cleanly scope a `with` block around it the way populate_db.py's/insert_optimized_media.py's own single-threaded usage already does. Test fixture switched from :memory: to a real temp file, since worker threads need an actual db_path to open their own connections against - an in-memory database has none and can't be shared across connections at all. ADFA-5153.
This is the pipeline that actually produces the live documentation.db (scripts/DocumentationDatabase.py, fixed earlier on this ticket, turned out to be dead code -- its tag-triggered workflow hasn't fired since db-2025-07-16b). populate_db.py has always run its own bare VACUUM with no page_size pin, so the real fix belongs here. Extracted vacuum_and_pin_page_size(), mirroring docdb_studio.py's vacuum_database(): pins page_size via PRAGMA before VACUUM, and works around WAL journal mode silently preventing PRAGMA page_size from taking effect (this file's own backup_database docstring already anticipates a live/WAL-mode database). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
vacuum_and_pin_page_size (commit b203500) mirrored docdb-studio.py's original vacuum_database(): in-place VACUUM + a journal_mode round-trip, which requires exclusive access to db_path. SQLite refuses to switch a WAL-mode database away from WAL while ANY other connection has it open -- even one from a function that has already returned, since Python's `with sqlite3.connect(...) as conn:` does not close conn on exit. Empirically reproduced and fixed the identical bug in docdb-studio.py's vacuum_database (PR #25); this mirrors that fix here since this pipeline's own VACUUM is the one actually run against the live documentation.db. Rewritten on VACUUM INTO: rebuild into a temp file next to db_path (read-only snapshot of the source, no exclusive access needed), then atomically swap it into place with os.replace. journal_mode=WAL is reapplied to the new file's final path (VACUUM INTO always produces a plain rollback-journal file), and stale sidecars from the replaced file are cleaned up. Two new tests: the fix succeeds with both an unrelated open connection and an unclosed caller-style connection present at once (the actual scenario the old design was fragile against), and the original file is left untouched if VACUUM INTO fails partway (temp file cleaned up, no partial swap). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tempfile.mkstemp() always creates its file mode 0600 regardless of the original's mode or the process umask. The VACUUM INTO rewrite swaps that temp file into db_path's place via os.replace, which never restored the original permissions -- alexmmiller's QA of the mirrored docdb-studio.py fix caught this silently dropping documentation.db from 644 to 600 on every vacuum; same bug here since this pipeline's vacuum_and_pin_page_size uses the identical mkstemp+replace pattern. Capture db_path's mode before the rewrite and os.chmod it back after the swap. New test confirms a 644 file stays 644 across vacuum_and_pin_page_size (and fails against the pre-fix code, dropping to 600). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nal_mode-read connection Same fix as the mirrored docdb-studio.py version: VACUUM INTO's target accepts a bound parameter (already used by this file's own backup_database for the same reason), sidestepping SQL string-literal escaping for a path containing a single quote (e.g. "David's Docs") rather than hand-rolling it. Also explicitly closes the journal_mode -read connection instead of relying on it being reassigned by the next `with` block. New test: a quote in db_path's parent directory no longer breaks the statement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same findings as the mirrored docdb-studio.py fix's third self-review: - chmod the temp file to the original permissions before os.replace, not after -- fixing it up afterward left a real window where db_path was visible at mkstemp's 0600, and left permissions permanently wrong if the chmod itself failed. - Explicitly close the VACUUM INTO and WAL-reapply connections, and give the WAL-reapply connection the same 30s timeout as its siblings in the same function. 19/19 local tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…to ADFA-5153 Benchmarking showed page_size=1024 vs 2048 has essentially the same performance and a negligible size difference before compression (and likely less after this PR's dictionary compression) -- adding complexity without benefit. ADFA-5141 is declined; this PR is only about the Brotli dictionary compression (ADFA-5153) and the page_size work rode along on this branch by coincidence of timing, not by scope. Restores populate_db.py's original plain VACUUM call and removes vacuum_and_pin_page_size, SQLITE_PAGE_SIZE_BYTES, the now-unused os/stat imports, and their dedicated test file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WebServer.kt's reassembly loop always probes "<path>-1" first, but 14 of 19 chunked Content rows in the real documentation.db number their continuations starting at "-2" instead, with no "-1" row at all. The first lookup misses, the loop stops after the base 1 MB chunk, and the row is served short: a corrupt image (compression='none', silent 200) or a decode failure (compression='brotli', 500) - confirmed against a local copy of the shipped database (md5 34c879595bd6fb87e5b68989369680a8). No writer in this tool ever produced that numbering - populate_db.py, insert_optimized_media.py, and migrate_content_to_dictionary_brotli.py all go through insert_chunked_content, which has always started fragments at -1. This is inherited data older than this pipeline, not something it can regenerate correctly by re-running existing tools. renumber_misnumbered_fragments.py finds base rows whose fragment chain (via LIKE, sorted on the parsed numeric suffix rather than assumed paths) doesn't start at 1, and renumbers it to a contiguous run starting at -1, lowest-suffix first so each rename's target is the path just vacated by the previous one. A chain with an actual gap (a genuinely missing chunk, a different failure) is reported and left alone rather than guessed at. Content bytes are never touched, only paths, so it's safe regardless of a row's compression. Verified against a scratch copy of the real database: renumbers exactly the 14 chains the ticket found, and the two example rows (the devsite gif, the Javadoc index) reassemble and decode correctly afterward.
…bering ADFA-5171: Repair chunked Content rows misnumbered from -2
Every finding from the three reviews on PR #26 that lands in files this PR touches, plus the one dictionary-consistency problem outside it that this PR itself creates. Data loss, both silent: * The migration deleted and re-inserted each base row. Content carries AddBook/DeleteBook triggers on '%.pdf' paths, so that cycle replaced every curated Bookshelf entry with 'CURRENT_TIMESTAMP || id' under a fresh Content.id -- verified on the real database: (53507, category 5, "Android Notes for Professionals") became (53508, category NULL, "2026-08-21 22:37:5553508"). 15 brotli-typed .pdf rows and all 7 Bookshelf rows are in scope. Writes are now UPDATE in place, with continuation rows reconciled by exact path. * delete_content interpolated a path straight into LIKE, where `_` is a wildcard and the `-%` suffix was not restricted to digits, so unrelated rows could be deleted permanently (hal-eisen-adfa). No write path goes through LIKE any more. Rows silently skipped while the run reported success: * reassemble_content probed "<path>-1", so an ADFA-5171 chain numbered from -2 reassembled truncated, failed to decode, and was counted as "already dictionary-compressed". The corpus has 29,751 base rows and exactly 3 with continuations; the run reported 29,748 migrated and 3 already-migrated in a first-ever migration, which is precisely those 3. Chain discovery is now shared with the repair script (populate_db.fragment_chain), so the two cannot drift apart again. * Any decode failure counted as "already migrated" (hal-eisen-adfa). Rows are now classified by decoding both ways: identical either way means the encoder never referenced the dictionary and there is nothing to gain (~0.5% of the real corpus, and the reason a second run used to re-migrate them -- alexmmiller); plain-only means migrate; dictionary-only means done; neither is an error, never a success. * Recompressed bytes are verified to round-trip before being written. Concurrency and memory: * Each worker opened its own read connection while the caller held one write transaction over the whole run, which deadlocks under journal_mode=delete -- documentation.db's actual mode (alexmmiller). All database access is now on the calling thread; workers receive bytes. Commits are batched, so an interrupted run keeps finished batches and resumes. * Blobs are no longer selected for every row up front (~130 MB held at once). Dictionary training, measured on the real corpus with only the sampling varied: first 300 rows by path (all under "a/") 36.2% smaller than plain 300 rows stratified across doc sets 33.2% <- worse stratified, 32 MiB plaintext budget 48.3% <- best first-by-path, same 32 MiB budget 36.4% <- volume alone: nil The docstring promised "a random sample drawn across the WHOLE Content table" and delivered the first 300 paths alphabetically -- 299 of them under "a/", while j/ (10,326 rows) and k/ (3,757) trained nothing (hal-eisen-adfa). Fixing it by stratifying alone makes things worse: quotas drawn from smaller doc sets starve the trainer, which then cannot even fill a 256 KiB dictionary. Both halves are needed, so sampling is now stratified by stored bytes and bounded by a plaintext budget, seeded for reproducibility since a stored dictionary is never retrained. renumber_misnumbered_fragments: * A chain numbered from -0 passed the "starts at 1?" guard and renamed onto an occupied slot, tripping UNIQUE(path) and rolling back every other repair in the pass (hal-eisen-adfa). Such a chain is repaired rather than skipped -- the app probes "-1", finds it, and serves the chain with "-0" dropped -- via a parking pass that is correct in either shift direction. docdb-studio: * sqlite3.OperationalError covers "database is locked", and caching that as "no dictionary" downgraded the whole session to plain Brotli (hal-eisen-adfa). Only definitive answers are cached now. * The new `brotli` CLI dependency raised RuntimeError/OSError out of paths that guard only `brotli.error` (hal-eisen-adfa). Missing-binary now raises a BrotliCliMissing subclass of brotli.error, with an actionable message. * decompress_brotli decoded dictionary-only, so it could not read plain rows -- which a dictionary database always contains: anything a plugin contributes on-device, anything written outside populate_db.py, and everything mid- migration. It now falls back to a plain decode, as WebServer.kt does. sync_kdoc_json_to_db (outside this PR's diff, but this PR is what makes documentation.db a dictionary database): * compress_for used plain brotli.compress, leaving every k/kotlin-stdlib row plain inside a dictionary database (hal-eisen-adfa). It now compresses against the database's dictionary when there is one. * "Source file missing => delete the row" had no floor: a Dokka layout change makes every lookup miss, and the script would delete every stdlib row plus its parent Tooltips and exit 0 (hal-eisen-adfa). Sources are resolved up front and a wholesale miss aborts. Corrected in populate_db's DictionaryCompressor docstring, because two reviews reasoned from it: the two mismatch directions are not alike. Decoding a dictionary row with NO dictionary is loud (398 of 400 real rows raised, 2 returned identical bytes, none wrong), which is what makes both this script's idempotency check and WebServer.kt's fallback sound. Decoding with the WRONG dictionary is the silent case (50% raised, 38% returned different bytes with no error, 12% identical). The test asserting a wrong-dictionary decode does not raise was asserting that coin flip; it now asserts the invariant that holds. Tests: 25 in ProcessKotlinWebsiteJSON (up from 21) and 173 in docdb-studio (up from 170) pass. New coverage for the -2 chain, an undecodable row, Bookshelf survival through the triggers, a never-referenced-dictionary row across two runs, stratified sample determinism and spread, zero-based renumbering, one bad chain not blocking other repairs, a locked database not being cached, a plain row in a dictionary database, and a missing brotli CLI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… brotli Both from hal-eisen-adfa's follow-up review of 838ac44. insert_optimized_media.delete_content still built the LIKE pattern the migration script had stopped using: `path = ? OR path LIKE '<path>-%'`, where `_` is a single-character wildcard and the suffix is not constrained to digits. Rows matched that way are never re-inserted, so the loss is permanent. It now deletes the base row by exact path and each continuation by the exact paths populate_db.fragment_chain returns, which does the over-matching query once and re-checks every candidate's parsed suffix. The claim "no write path constructs a LIKE pattern any more" is now true of the whole tree, not just one file. The `brotli` CLI became a required external binary in three independent paths (populate_db's DictionaryCompressor, sync_kdoc_json_to_db, docdb_studio) and nothing declared it. The Python `brotli` package the README asks for is a different artifact and exposes no custom-dictionary parameter, which is exactly why the CLI is unavoidable -- and what makes `pip install brotli` read as though it covers this. Declared in the four places that would tell someone: * build-kotlin-docs.yaml's apt-get line -- it runs populate_db, insert_optimized_media and sync_kdoc_json_to_db. * docdb-regression-test.yaml's apt-get line -- it runs docdb-studio against the downloaded production database, which is now a dictionary database, so its reads need the binary too. (CI previously depended on whatever the runner image happened to ship.) * ProcessKotlinWebsiteJSON/README.md, beside the existing `pngquant on PATH` bullet, spelling out that this is the CLI and not the Python package. * docdb-studio/README.md, noting `uv sync` cannot install it and that a database with no CompressionDictionary needs nothing extra. publish-doc-db.yaml is deliberately untouched: it runs the scripts/ingest.py pipeline, which does not reach for the CLI. Tests unchanged and passing: 25 in ProcessKotlinWebsiteJSON, 173 in docdb-studio. Both workflow files still parse as YAML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…base remint_dictionary.py trains a new shared dictionary for an already-migrated database and recompresses every 'brotli' row against it in one transaction, replacing the CompressionDictionary row. verify_remint_dictionary.py is the read-only gate: it decodes every row out of both databases and requires the plaintexts to match, exiting non-zero otherwise. These deliberately do what load_or_create_dictionary refuses to do, and the refusal is right for the pipeline: replacing a stored dictionary without recompressing the content orphans every row, since the dictionary decode fails and the plain fallback fails too. The only safe way to change a dictionary is to change the content with it, atomically, which is what this pair is for. Either every row converts and the dictionary is replaced, or nothing is written. Why it is worth having: the dictionary a database is first minted with is permanent for its content, so a poorly-sampled one stays expensive forever. Re-minting the 21-Aug database with the stratified, byte-budgeted sampler took its brotli content from 83.4 MiB to 65.6 MiB and the vacuumed file from 268 MB to 249 MB -- 18 MB -- with all 29,677 items verified byte-identical, and the result confirmed on device: pages served at their original byte counts through brotli4j, whose attachDictionary had never seen this dictionary before. The verifier is not ceremony. A row recompressed against a mismatched dictionary decodes with no error into *different* bytes 38% of the time (50% raises, 12% is identical because the perturbed region was never referenced), so nothing at runtime detects it and the check has to happen against the original before the file is put in place. collect_training_samples now takes an optional decoder, defaulting to plain Brotli. A re-mint's rows are dictionary-compressed, so it passes one that reads against the outgoing dictionary and falls back to plain -- the fallback is required, not defensive, because a dictionary database always holds some plain rows. read_item, write_item and load_base_rows are reused from the migration script rather than copied, which is what keeps the in-place write (never DELETE+INSERT on a base row, because of the '%.pdf' triggers) in one place. Four tests. Two of them exist because writing them corrected me: re-minting with the same seed and corpus reproduces the stored dictionary byte for byte, so a test asserting the dictionary changed has to vary the seed -- and an earlier assertion that the outgoing dictionary can no longer decode a re-minted row was asserting a coin flip, the same mistake as asserting that a wrong-dictionary decode raises. The remaining two cover the abort path leaving the database untouched, and the verifier actually objecting to a corrupted re-mint rather than passing vacuously. 29 pipeline tests (from 25) and 173 docdb-studio tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docdb-studio's README named `apt` and `brew` and left Windows users with nothing, on the one dependency `uv sync` cannot install for them. It now has a section of its own, following the per-OS shape the uv instructions already use: winget, scoop and choco, each preceded by the matching `search` command so a renamed package cannot strand the reader, plus MSYS2 for anyone who already has Git for Windows. Then the two things that actually go wrong on Windows: a changed PATH is only visible in newly-opened terminals, and a package manager can install the binary somewhere that is not on PATH at all -- so `where.exe brotli`, the usual shim directories, and where to edit PATH. It also states plainly that the `brotli` in `uv sync` is a different artifact from the `brotli` program, since `pip install brotli` succeeding is exactly what makes this confusing, and doubly so on Windows where there is no `brotli.exe` afterwards. Writing that section exposed a real defect in the BrotliCliMissing handling from 838ac44. Subclassing brotli.error kept a missing binary from escaping as an unhandled RuntimeError, which is what the review asked for -- but the two call sites catch brotli.error and return []/None, so the failure became a blank preview with nothing said anywhere. A corrupt row and a missing binary are not the same event: one is a single bad row, the other means nothing in this database will ever decode and is fixable in one command. The call sites now catch BrotliCliMissing separately and print which path failed and why, and the exception's message points at the README rather than listing two Unix package managers. The README says what actually happens -- blank preview plus an explanatory error in the launching terminal -- rather than claiming the UI reports it. 174 docdb-studio tests pass (from 173); the new one asserts both call sites log rather than swallow, and that the message names the path and points at the README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tionary ADFA-5153: Compress Content table with a trained Brotli dictionary
HIGH: slugify() deleted punctuation instead of hyphenating it, diverging
from Writerside's own anchor algorithm on any heading with "." "/" ":"
etc. - 209 dead #anchor links measured on the live kotlin-web-site
corpus, down to 15 with this fix.
MEDIUM: fold_image_attrs set folded_any on a regex match rather than a
successful parse, so a non-attribute "{...}" group (a CSS class
shorthand, or prose) was swallowed instead of left as visible text -
same guard merge_attr_lines/extract_trailing_attrs already apply.
Also extended fold_image_attrs to handle a trailing "{...}" group
after a *link*, not just an image/heading (real hit in
js/js-ir-compiler.md). group_containers now warns when a container is
still open at EOF, symmetric with the existing unmatched-closer warning.
LOW: MD_LINK_RE and resolve_image_src now resolve a ".md"/image
reference that carries a path prefix by bare filename (Writerside's own
convention) instead of silently failing to match/resolve with no
warning; also drops resolve_image_src's dead startswith("/") check.
Blank lines inside a raw HTML run (e.g. <pre>, or between <table> rows)
are preserved instead of dropped. main()'s output-aliases-source guard
now runs before theme.json/images are written, not just before the
topics rmtree. images/ now prunes stale files the same way topics/
JSON already did, via a new _prune_stale_files. Documented that "html"
blocks are passthrough fragments that may be structurally unbalanced
across block boundaries. markdown-it-py pinned with a >=2.0 floor
(Token.attrs was a list before that version).
13 new regression tests; 55/55 pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review: database-insertion pathFocused pass over the files that write to
Two things that are right, up frontContent types are handled correctly throughout. Every type is resolved by value through The big Findings1. HIGH — a conversion failure silently drops a live page and leaves a normal-looking nav link to it.
This is a regression against the standalone path — Minimum fix: 2. MEDIUM-HIGH — duplicate
3. MEDIUM — It deletes every Related blind spot: only page/nav rows are scanned, so an image referenced from 4. MEDIUM — It does 5. MEDIUM — unknown
6. LOW-MEDIUM — delete-after-insert in the rename loop can remove a just-inserted row. Inserts run first, then 7. LOW — unescaped LIKE wildcards.
8. LOW —
9. LOW — the three scripts back up two different ways. Nits
Merge orderThe PR body's caveat holds — |
Validated against ~/documentation.db (schema 2.0.0), now the source of truth.
populate_db.py
- A failed .md conversion left its stem in topic_index_db, so nav rendered
an ordinary, normally-styled link to a page the run had just deleted and
not replaced. Drop the stem (matching the blacklist path, so references
render as styled-broken) and refuse to modify the database at all unless
--allow-conversion-failures is passed - CI uploads this database straight
to production.
- Two same-stem .md files in different topics/ subdirectories both mapped to
k/html/<stem>, colliding on Content.path's UNIQUE constraint and aborting
the transaction mid-run. Defer to the keep-first choice build_topic_index
already makes and warns about.
- image_index_db keyed on the full zip entry name while Converter looks
images up by bare filename, so any nested zip entry would silently resolve
as a missing image. Key on the basename, matching Converter and
insert_optimized_media.py's own flattening, and warn on collisions.
insert_optimized_media.py
- delete_unreferenced_media deleted every image no page referenced, with no
floor check: run against a database whose k/html pages don't exist yet and
it wiped the entire image corpus, including rows inserted seconds earlier
in the same transaction. Raise instead when images are stored but nothing
references any of them, and document that CSS/template references are not
scanned.
- Added --dry-run (the most destructive of the three scripts was the only
one without one): does the whole run, then rolls back.
- Moved the renamed-away delete loop above the insert loop. With inserts
first, a rename whose new name equals another rename's old name deleted
the row just written - the chain-rename hazard rewrite_pages already
guards against for text substitution.
- delete_content built a LIKE pattern from a path without escaping, so "_"
and "%" acted as wildcards; NAV_CONTENT_PATH ("k/html/_nav.html") already
contains one. Escape via a new like_escape() and ESCAPE '\'.
sync_kdoc_json_to_db.py
- Wrote plain Brotli into a database whose every brotli row is compressed
against the shared CompressionDictionary (schema 2.0.0, ADFA-5153),
producing content the server cannot decode. Read the dictionary and
compress against it, falling back to plain Brotli only for older
databases; never create or retrain one. Needs the brotli CLI, now
installed in both workflows.
- Ignored the CHUNK_SIZE fragmentation contract: UPDATEd the full blob into
one row and deleted existing fragments individually. Split oversized
results into "<path>-N" continuations the way populate_db.py does, and
treat existing fragments as part of their base row.
- An unresolvable contentTypeID fell back to "uncompressed" and committed,
writing bytes that contradict the row's declared type. Now fatal.
- Backup used shutil.copy2; switched to VACUUM INTO, matching the other two
scripts and safe against a live database.
Also: corrected the now-stale claims that documentation.db ships without an
image/webp ContentTypes row (it has one, id 26) and that scour/cairosvg are
absent from requirements.txt; gitignored the timestamped *.db.backup-*/
*.db.bak.* files the three scripts write.
CLAUDE.md records the one review finding NOT fixed here: populate_db.py and
insert_optimized_media.py are still plain-Brotli and so broken against a
2.0.0 database. That fix already exists on fix/ADFA-4737 via merged PRs #26
and #27; reconciling with that branch is the right way to pick it up rather
than hand-porting it into a conflict.
Adds 30 regression tests covering each fix, including a dictionary
round-trip. Verified end-to-end on a copy of ~/documentation.db: 3,238 rows
rewritten, 12/12 sampled rows decode against the dictionary, untouched rows
unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…line) Brings in the ADFA-5153/ADFA-5171 work merged to fix/ADFA-4737 via PRs #26 and #27, which this branch forked from #21 too early to receive. Without it the pipeline cannot run against the current production database at all: ~/documentation.db is schema 2.0.0, every "brotli" Content row is compressed against the shared 256 KiB raw LZ77 dictionary in CompressionDictionary, and plain Brotli cannot decode any of it (measured: 0 of 24 sampled rows). Conflict resolution - all twelve were add/add, so each was decided per file rather than 3-way merged: Took theirs (the dictionary lineage is strictly ahead on these three), then re-applied this branch's review fixes on top: - populate_db.py: DictionaryCompressor, train/load_or_create_dictionary, fragment_chain, page_size pinning. Re-applied the conversion-failure abort, the same-stem dedupe, and the basename-keyed image index. - insert_optimized_media.py: dictionary-aware reads/writes. Re-applied the delete_unreferenced_media floor check, the delete-before-insert ordering, and --dry-run. - sync_kdoc_json_to_db.py: DictionaryBrotli, load_compression_dictionary, MAX_DELETE_FRACTION. Re-applied CHUNK_SIZE fragmentation, the fatal unknown-contentTypeID, and the VACUUM INTO backup. Took ours (PR #23/#24 refined these after the split): md_to_json.py, find_missing_assets.py, optimize_media.py, assets/docs.css, README.md, run_e2e_pipeline_test.sh, .gitignore. Hand-merged: build-kotlin-docs.yaml (our corrected requirements/webp comments plus their brotli-CLI rationale); CLAUDE.md (ours, with the 2.0.0 blocker note rewritten as a description of how the three writers now handle the dictionary, since the merge resolves it). Two of this branch's own fixes were dropped as superseded: - like_escape/ESCAPE '\' is replaced by fragment_chain, which does the over-matching LIKE once and re-checks each candidate's digit suffix. That also handles ADFA-5171 chains numbered from -2, which escaping does not. sync_kdoc_json_to_db.fragment_paths was rewritten to match rather than probing "-1" and stopping at the first gap. - The hand-rolled DictionaryCompressor added to the sync script last commit is replaced by theirs. Tests updated for the merged APIs (collect_referenced_media and delete_unreferenced_media now take a compressor; DictionaryBrotli is compress-only, so its tests decode through the brotli CLI). 105 pass: 78 in ProcessKotlinWebsiteJSON, 27 in scripts/sync_kotlin_stdlib_docs. Verified against a copy of ~/documentation.db: 3,238 stdlib rows rewritten, 12/12 sampled decode against the dictionary, untouched trees unaffected, row count unchanged at 30,649. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All six are latent: the live kotlin-web-site corpus triggers none of them, and
converting all 303 pages before and after produces byte-identical JSON with an
identical warning set. Each is a case the corpus happens not to contain today.
- An explicit {id="..."} only beat a *later* auto-slug. In the other order
(`## Custom anchor` then `## Something {id="custom-anchor"}`) the auto-slug
claimed the id first and both headings shipped id="custom-anchor", so any
link to the explicit one landed on the auto one. reserve_explicit_heading_ids
now registers every explicit id up front, before any heading converts, so
explicit wins in both directions. Scoped to inlines directly inside a
heading - a paragraph merely ending in "{id=...}" is not an anchor. Two
genuinely identical explicit ids are a source bug this can't fix, so both
are kept as authored and it warns.
- A heading whose text is all punctuation ("## ...") slugified to "", i.e.
id="" - invalid HTML and unlinkable. Falls back to "section", numbered by
the existing de-dup loop.
- extract_title matched a `[//]: # (title: ...)` comment inside a fenced code
block, which both set a bogus title and deleted that line out of the code
sample being displayed. It now skips matches inside ``` / ~~~ fences,
handling tilde fences, longer fences nesting shorter ones, and an
unterminated fence running to EOF.
- A raw html_block run of nothing but blank lines emitted
{"type": "html", "html": ""} - a truthy list of empty strings. Blank lines
inside a run that has content are still preserved, which is what keeps
<pre>/<script> bodies intact.
- COLOR_RE's flat {3,8} accepted #12345 and #1234567, which no browser does.
Narrowed to the lengths CSS defines. This value is interpolated into a
style="" attribute, so the validator should mean what it says.
- build_tree popped an already-empty stack on a stray closing token, turning
a malformed stream into "IndexError: pop from empty list" with nothing
naming the cause. Defensive - markdown-it always balances - but it now
ignores the stray closer and says so.
Docstring: the "Block shapes" list omitted note/tip/warning, which
group_containers genuinely emits (README already documented them). Also records
that a <tab> written outside any <tabs> comes through as a bare "tab" block a
template won't render - verified, and it does not warn.
Tests: 55 -> 91, coverage 66% -> 81% (main() is subprocess-tested, so
under-reported either way). Beyond one regression test per fix, this closes the
coverage gaps the review named: %variables% substitution and load_variables had
no test at all; block-level <note> was covered by nothing, since the corpus only
uses the single-line form; and both <tabs> fallback paths - synthesizing tabs
from adjacent code fences, and dropping a wrapper with nothing tab-like - were
untested despite each having fixed a content-dropping bug. Adds direct tests for
the blockquote/list/table/hr converters and a guard that "_raw" never reaches
the JSON.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings this branch's copy of md_to_json.py up to PR #23's tip. It had been stale since the 1cf41d2 merge: 4fddb34 ("Fix 10 issues from Hal's automated corpus review") and 1d7f6a7 ("Fix 6 latent defects from the md_to_json.py code review") both landed on fix/ADFA-5039 afterwards, and populate_db.py, build_nav.py and find_missing_assets.py all import this module directly. md_to_json.py and tests/test_md_to_json.py are now identical on both branches. One conflict, in requirements.txt, resolved as the union: this branch added scour/cairosvg for optimize_media.py, #23 pinned markdown-it-py>=2.0. 158 tests pass (131 in ProcessKotlinWebsiteJSON, 27 in sync_kotlin_stdlib_docs). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
An end-to-end pipeline for loading Kotlin website content and
kotlin-stdlib/-reflect/-test JSON docs into
documentation.db, plus aGitHub Action (
Build Kotlin Docs) that runs it against a Drive-hosted copyof the database.
Scope
Split out of #21
into two ticket-scoped PRs:
md_to_json.py).This PR depends on ADFA-5039 merging first.
populate_db.py,build_nav.py, andfind_missing_assets.pyallimportmd_to_json.pydirectly, and that file isn't included here. Please review this PR's
content, but hold off merging until #23 lands -
README.mdin particularwill very likely conflict with #23's own new
README.mdat the same path(both add a new file there) and need a quick manual merge to combine the two
once both are in.
Changes
ProcessDocs/ProcessKotlinDocs/ProcessKotlinWebsiteJSON/:build_nav.py(sidebar nav from
kr.tree),find_missing_assets.py(source QA),populate_db.py(inserts pages/nav/media intodocumentation.db,supports pruning via
--blacklisted-element-titles),optimize_media.py/
insert_optimized_media.py(media optimization + DB update),templates/,assets/.scripts/sync_kotlin_stdlib_docs/sync_kdoc_json_to_db.py- syncsDokka-generated kotlin-stdlib/-reflect/-test JSON into the database.
ProcessDocs/ProcessKotlinDocs/run_e2e_pipeline_test.sh- local e2e testof the whole pipeline against a scratch copy of the database.
.github/workflows/build-kotlin-docs.yaml- CI counterpart: pullsdocumentation.db(and Writerside'swebHelpImages.zip) from GoogleDrive, runs the same pipeline, uploads the result back.
Dokka-plugin-kdoc2json/scripts/kotlin/build-stdlib-json-docs.sh+build.gradle.ktstweak - builds the kdoc-to-json plugin and generatesthe stdlib JSON docs the sync script consumes.
CLAUDE.md- repo-level orientation doc (schema drift between this repo'stooling and the live production database, repo tour, decisions log).
Test plan
run_e2e_pipeline_test.shlocally against a scratch database.Build Kotlin Docswithdry_run: trueand confirm thesummary/blacklist-verification steps pass.
made during the split, aside from
README.mdbeing trimmed to drop themd_to_json.py-only section now covered by ADFA-5039).