Skip to content

fix(docs): upstream the harness docs-site fixes — nested anchors, dead graph links, baseUrl, ADR casing - #224

Merged
joestump merged 5 commits into
mainfrom
bug/nested-anchor-linkify
Aug 19, 2026
Merged

fix(docs): upstream the harness docs-site fixes — nested anchors, dead graph links, baseUrl, ADR casing#224
joestump merged 5 commits into
mainfrom
bug/nested-anchor-linkify

Conversation

@joestump

Copy link
Copy Markdown
Owner

Upstreams four fixes that stump.wtf/harness made in its vendored copy of sdd-content and never sent back, so every other consumer still carries them. Reconciles the template against harness's copy end to end — what is left diverging is harness's own homepage hero (branding, not a fix) and #222's spec-keying changes going the other way.

Stacked on #222 — its spec-references.test.js is the suite these tests extend. GitHub retargets this to main when #222 merges.

1. Nested anchors (the reported bug)

transformSpecReferences / transformAdrReferences rewrote every ADR-NNNN / SPEC-NNNN match on a line, including matches already inside inline code, a markdown link, or an anchor from an earlier pass. [ADR-0006](adr-0006-thing.md) had its label wrapped a second time:

<a href="/specs/gamma/spec#adr-0001"><a href="/decisions/ADR-0001-example">ADR-0001</a></a>

Invalid HTML; some minifiers reject it rather than repair it. Ports protectedRanges() / matchOffset() / isProtected() into all three copies. The guard is span-scoped, so a bare mention elsewhere on a protected line still resolves.

One deliberate change from harness: whole <a>…</a> elements are ranged as well as bare tags. <[^>]+> alone protects an ID sitting in an href but not one sitting in the link text — and the link text is the half that actually nests. Worth pulling back into harness.

2. Dead ADR-0023 / SPEC-0018 links

Those are the plugin's own artifacts. Three call sites linked them unconditionally, so any consumer repo without them got dead links on every ADR page, every index page, and the graph page. onBrokenLinks defaults to warn, so builds stayed green.

citeGraphArtifacts() links them when present and names them as plain text otherwise. Both lookups go by content — the ADR's filename slug, the spec's directory — rather than by number, so a repo that renumbered them still gets links.

Not applied to templates/integration/sync-spec-docs: that copy already emits plain text, and its pages are written under a pathPrefix these site-absolute links would not respect, so adding it there would introduce the dead links.

3. baseUrl never resolved — 944 broken links in this repo

Both readers looked for a baseUrl: '...' literal in docusaurus.config.ts and fell back to ''. Neither ever matched: this repo's config and templates/docusaurus's both assign baseUrl: BASE_URL from a const. Every cross-reference chip was emitted at the host root.

Measured on main before the fix, then after:

$ grep -rho 'href="[^"]*" className="rfc-ref"' docs-generated/ | grep -c '^.*"/claude-plugin-sdd'
before: 0 of 944
after:  944 of 944

Nothing caught it. The dev server and npm run serve both mount at baseUrl, so it looks right locally, and onBrokenLinks does not inspect raw href attributes, so CI stayed green. Markdown links masked it further — Docusaurus resolves those against baseUrl itself; the raw <a href> chips these transforms emit are not resolved and need the prefix baked in.

Two fixes, because the readers have different information:

  • The plugin reads context.siteConfig.baseUrl / .title. Docusaurus hands every plugin the fully-evaluated config; scraping the file was never the supported path. (Harness's fix.)
  • The docs-site/scripts run outside Docusaurus and have no siteConfig, so they keep parsing the file — through a shared readBaseUrl() that follows a const to its declaration.

templates/integration/sync-spec-docs already read context.siteConfig.baseUrl.

4. Lowercase ADR filenames, and the crash they were hiding

Repos name ADRs either ADR-0001-thing.md or adr-0001-thing.md. Every filename regex was case-sensitive, so a lowercase-naming repo silently lost all ADR graph nodes (hence all edges, every mini-DAG, and the graph page entirely), the badge header on every ADR page, and every ADR link mapping.

That third failure masked a fourth: the badge header renders decision-makers, which the MADR template writes as a list and YAML hands back as an array — escapeJsxAttr called .replace on it. Fixing the casing alone converts a silent omission into str.replace is not a function at build time, so toDisplayString() lands with it. Arrays join; Dates render as YYYY-MM-DD rather than gaining a midnight-UTC time the author never wrote.

Beyond harness: status.toUpperCase() gets the same coercion, since it crashes on a non-string status one call before escapeJsxAttr would.

Verification

  • make test lint scan green. Docs site builds; the only broken links reported are two pre-existing /skills/index ones, unchanged by this branch.
  • Test count 22 → 51.
  • Each fix was confirmed to fail without it: 9 tests fail without the anchor guard, 5 without the filename/frontmatter fix, and readBaseUrl's const case fails against the old regex.
  • New suites: base-url.test.js, adr-filenames.test.js. Both exercise every copy that has the defect, as spec-references.test.js already does.

@joestump joestump added the bug Something isn't working label Aug 19, 2026
joestump pushed a commit to stump-wtf/harness that referenced this pull request Aug 19, 2026
protectedRanges() guarded the reference transforms with `<[^>]+>`, which
ranges a bare tag only. That covers an artifact ID sitting in an anchor's
href but not one sitting in its link text — and the link text is the half
that actually produces a nested anchor, so

  Prior art: <a href="/decisions/ADR-0001-x" className="rfc-ref">ADR-0001</a>

still had its label wrapped a second time on a later pass. Range whole
<a>…</a> elements alongside bare tags.

Back-ported from joestump/claude-plugin-sdd#224, which upstreamed this
guard and made that one change to it; the two copies now match.

Tests: the plugin suite grows a `delta` fixture domain carrying every shape
the linkifier must leave alone — a reference in inline code, in a markdown
link, and inside an emitted anchor — plus a line mixing a protected span
with a bare mention, pinning the guard as span-scoped rather than
line-scoped. Only the anchor case fails without this change.
@joestump
joestump force-pushed the bug/nested-anchor-linkify branch from 597a1e7 to 326141e Compare August 19, 2026 10:26
@joestump
joestump changed the base branch from bug/spec-refs-keyed-by-prefix to main August 19, 2026 10:26
@github-actions

Copy link
Copy Markdown

Skill Eval Results

Mode: quick | No eval results found.

ℹ️ High-cost scenarios exist (full-chain-with-review) but did not run in this build. They run only on release PRs or manual pipeline dispatch.

joestump and others added 5 commits August 19, 2026 11:48
…an anchor

transformSpecReferences and transformAdrReferences rewrote every ADR-NNNN /
SPEC-NNNN match on a line, including matches that were already inside inline
code, a markdown link, or an anchor emitted by an earlier pass. The markdown
case is the common one: `[ADR-0006](adr-0006-thing.md)` had its label wrapped a
second time, producing

    <a href="/specs/gamma/spec#adr-0001"><a href="/decisions/ADR-0001">ADR-0001</a></a>

which is invalid HTML and which some minifiers reject outright rather than
repair.

Port protectedRanges/matchOffset/isProtected from the harness docs site, where
this was found and fixed, into all three copies of the transform: the docs-site
scripts, the vendored Docusaurus plugin template, and the sync-spec-docs
integration lib.

The guard is span-scoped, not line-scoped, so a bare mention elsewhere on a
line with a protected span still resolves.

Ranged separately from harness: whole <a>...</a> elements as well as bare tags.
`<[^>]+>` alone protects an ID sitting in an href but not one sitting in the
link text, and the link text is the half that actually nests.

Tests cover all three shapes in each of the three copies, plus an end-to-end
pass over the plugin template's own fixture. Nine of them fail without the
guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR-0023 and SPEC-0018 are the SDD plugin's own artifacts describing the
frontmatter DAG. The generators hardcoded links to them in three places — the
per-artifact "Related Artifacts" mini-DAG, the ADR/spec index "Hierarchy"
section, and the Architecture Graph page — so every consumer repo that does not
happen to have those two artifacts got dead links on every ADR page, every index
page, and the graph page. Docusaurus's onBrokenLinks is 'warn' by default, so
the build stayed green and nobody noticed.

Port citeGraphArtifacts() from the harness docs site: link them when the repo
actually has them, name them as plain text otherwise. The load-bearing half of
the sentence is the `/sdd:graph` hint, which is true everywhere.

Both lookups go by content rather than by number — the ADR by its filename slug,
the spec by its directory — so a repo that renumbered them still gets links.

Applied to the vendored Docusaurus plugin template and the docs-site scripts,
where the helper lives in the shared graph-data module. Deliberately not applied
to templates/integration/sync-spec-docs: that copy already emits plain text, and
its pages are written under a pathPrefix that these site-absolute links would
not respect, so adding it there would introduce the dead links rather than
remove them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…only regex

Cross-reference chips are emitted as raw `<a href>` attributes. Docusaurus does
not rewrite those — unlike markdown links, which it resolves against baseUrl
itself — so the chips have to carry the prefix already.

Both baseUrl readers looked for a `baseUrl: '...'` string literal in
docusaurus.config.ts and fell back to '' when they did not find one. Neither
ever found one: this repo's config and the one in templates/docusaurus both
assign `baseUrl: BASE_URL` from a const, so CI can override it per host, and
there is no literal on that line to capture. Every chip was therefore emitted at
the host root. In this repo that is 944 links, all 404 on the published site.

Nothing caught it. The dev server and `npm run serve` both mount at baseUrl, so
it looks right locally, and onBrokenLinks does not inspect raw href attributes,
so CI stayed green.

Two fixes, because the two readers have different information available:

- The vendored Docusaurus plugin now reads `context.siteConfig.baseUrl` and
  `context.siteConfig.title`. Docusaurus hands every plugin the fully-evaluated
  config; scraping the file was never the supported path. Ported from harness.
- The docs-site scripts run outside Docusaurus and have no siteConfig, so they
  keep parsing the file — but through a shared readBaseUrl() that follows a
  const assignment to its declaration.

templates/integration/sync-spec-docs already read context.siteConfig.baseUrl and
needed no change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repos name their ADR files either `ADR-0001-thing.md` or `adr-0001-thing.md`.
Every filename regex in the generators was case-sensitive, so a lowercase-naming
repo silently lost:

- all ADR graph nodes, hence all edges, every per-artifact mini-DAG, and the
  Architecture Graph page entirely
- the badge header on every ADR page (isNumberedAdr never matched)
- every entry in the ADR link mapping, so ADR-NNNN mentions stayed plain text

Make the filename patterns case-insensitive and normalize the extracted ID to
uppercase, since the graph keys its nodes by a constructed `ADR-NNNN` and a
lowercase key would miss on lookup.

That third failure was masking a fourth. The badge header renders
`decision-makers`, which the MADR template writes as a list — YAML hands that
back as an array, and escapeJsxAttr called .replace on it. Fixing the casing
alone converts a silent omission into "str.replace is not a function" at build
time, so toDisplayString() lands with it: arrays join, Dates render as
YYYY-MM-DD rather than gaining a midnight-UTC time the author never wrote.

Both ported from harness. The casing fix spans all three copies; toDisplayString
applies only to the plugin template, which is the copy that parses frontmatter
into real YAML types — the other two extract with line regexes and always get
strings back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
citeGraphArtifacts built the spec link as /specs/<dir>/spec, the nested
route. #223 made that route conditional: a domain holding only spec.md
renders flat at /specs/<dir>, and every other consumer -- the mapping,
the specs index, the transform itself -- now reads the layout instead of
assuming it. This one still assumed, so a project whose artifact-graph
domain carries no design.md got a dead link on every ADR page, every
index page, and the graph page: precisely the class of bug the citation
change was introduced to remove.

Both copies fixed, with a regression test covering nested and flat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@joestump
joestump force-pushed the bug/nested-anchor-linkify branch from 326141e to 64514d2 Compare August 19, 2026 10:48
@joestump

Copy link
Copy Markdown
Owner Author

Code review — /code-review --fix

Reviewed all four commits, rebased onto main (this branch was stacked on #222 and needed #223's changes underneath), resolved a fixture collision with #223, and pushed one fix. make test-unit is at 63 passing locally, npm run build-content clean.

Finding: citeGraphArtifacts hardcodes the nested spec route

docs-site/scripts/graph-data.js:325, templates/docusaurus/plugins/sdd-content/index.js:350

const specRef = spec ? `[${spec.id}](/specs/${spec.dir}/spec)` : 'SPEC-0018';

#223 (merged since this branch was cut) made /specs/<dir>/spec conditional — a domain holding only spec.md renders flat at /specs/<dir>, and every other consumer now reads getSpecLayout instead of assuming. This one still assumes, so in a project whose artifact-graph domain carries no design.md, the citation emits a route nothing writes.

That lands on every ADR page (mini-DAG), both index pages (hierarchy section) and the graph page — and it is the exact failure mode this commit exists to remove: onBrokenLinks is warn by default and these are markdown links, so the build stays green and nobody notices. Routed through getSpecLayout in both copies (spec.path is <specsSource>/<dir>/spec.md, so its grandparent is the specs root), with a regression test covering nested and flat.

Rebase resolution worth knowing about

#223 and this branch both added a delta fixture domain to spec-references.test.js, for different purposes — #223's is deliberately design-less (to pin the flat route), this one's needs to be nested (its assertions read /specs/delta/spec.mdx). Merging them into one domain would have quietly destroyed one of the two tests. Kept #223's delta as-is and moved this PR's linkifier fixture to epsilon / SPEC-0005. Both tests now pass together.

Verified, no change needed

Each of these looked like a possible defect and turned out not to be:

  • matchOffset(args)args[args.length - 2] is correct for both call sites. String.prototype.replace appends (offset, whole) after the capture groups, and neither specPattern nor adrPattern uses named groups — which would append a groups object and shift the offset to length - 3.
  • toDisplayString applied only to the plugin template, not docs-site/scripts/ is right, not an oversight. The plugin parses frontmatter with a real YAML parser (lib-artifact-transforms), so decision-makers: [Alice, Bob] arrives as an array; docs-site/scripts/transform-adrs.js uses a regex extractor (/^decision-makers:\s*"?([^"\n]+)"?/m then .trim()) that can only ever yield a string. The crash is genuinely plugin-only. The PR's reasoning about why the casing fix would otherwise convert a silent omission into a build failure checks out.
  • readBaseUrl follows a const only within the same file and returns '' otherwise. That is the pre-existing fallback, and the plugin path no longer depends on it at all now that it reads context.siteConfig. Covered by the "unresolvable or missing config" test.
  • protectedRanges ranges whole <a>…</a> elements separately from bare tags — necessary, and the comment says why: <[^>]+> alone covers an ID in an href but not one in the link text, which is the half that actually nests.
  • citeGraphArtifacts looks artifacts up by content (ADR filename slug, spec directory) rather than by number, so a consuming repo that renumbered them still links correctly, and one that lacks them degrades to plain text instead of a dead link.
  • .toUpperCase() on the matched ADR id in generateDecisionsIndex and transformAdr — needed, since the graph keys nodes by a constructed ADR-NNNN, so a lowercase filename must normalise or the mini-DAG lookup silently misses.

Follow-up worth its own issue (not blocking)

docs-site has 58 npm advisories, 1 critical (websocket-driver, via the dev-server tree). Out of scope here — see #225 for the shell-quote half.

🤖 Posted on behalf of @joestump by claude-opus-5 using Claude Code.

@joestump
joestump merged commit 453b596 into main Aug 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant