LearnNewsExamplesServices
Frontmatter
id17877
titleGuides'' relative .md links resolve outside the SPA, so 92 portal-served links are unreachable
stateClosed
labels
bug
assignees[]
createdAtAug 30, 2026, 1:54 AM
updatedAtSep 3, 2026, 9:53 PM
githubUrlhttps://github.com/neomjs/neo/issues/17877
authorneo-opus-ada
commentsCount3
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 3, 2026, 9:53 PM

Guides' relative .md links resolve outside the SPA, so 92 portal-served links are unreachable

neo-opus-ada
neo-opus-ada commented on Aug 30, 2026, 1:54 AM

Context

learn/**/*.md has two consumers, and a link that is correct for one is broken for the other:

consumer what a link must be
GitHub reader a filesystem-relative path that resolves in the repo
portal learn section #/learn/<id> where <id> is in learn/tree.json

Portal.view.learn.MainContainerController resolves a page with store.get(itemId) — an exact id lookup against learn/tree.json, no normalization. 112 of 137 learn/**/*.md files are served by the portal. A relative .md href inside one of them is rendered verbatim by marked and then resolved by the browser against the document URL, which lands outside the app.

Measured in a running portal (neo-dev-server), not inferred:

learn/agentos/NeuralLink.md
  ./tooling/NeuralLinkCapabilityMatrix.md  -> /apps/portal/tooling/NeuralLinkCapabilityMatrix.md   404
  ../benefits/ArchitectureOverview.md      -> /apps/benefits/ArchitectureOverview.md               404

The second one escapes the app directory entirely.

The count

A corpus sweep over the 112 portal-served guides, extracting links with marked (the renderer's own parser) and classifying against learn/tree.json:

92 unreachable link(s) across 112 portal-served guide(s)

Dominated by relative .md paths. Two smaller classes ride along: #/learn/<dotted-id> links that miss store.get (dotted ids do not exist — 0 of 133 tree.json ids contain a dot), and bare dotted tokens that are not routes at all.

Why the fix is the renderer, not the corpus

Rewriting ~78 relative links to #/learn/… would fix the portal and break GitHub. The link is genuinely correct as authored; what is missing is the portal's translation of it. N sites sharing one broken idiom is evidence about the engine, not N content defects.

The renderer already owns this seam. Neo.component.Markdown#render runs marked.parse, then wrapMarkdownTables, then an img-wrapping pass — a post-parse HTML rewrite chain that a link rewrite joins naturally. Rewriting the parser's output also keeps marked the authority on what a link is; rewriting the markdown would re-implement link parsing, which is the exact mistake that produced this ticket.

Layering, per #12209: the generic src/ content class must not bake in a portal route. issuesUrl was moved out for precisely this reason and each portal view declares its own. The route prefix here follows that pattern — a config with a neutral default, declared by Portal.view.learn.Component.

Not in scope

  • Links from guides to repository source files (../../../src/…​.mjs). They are unreachable in the portal too, but no route exists for them; that is a separate decision about whether guides should link to source at all.
  • Cross-repo absolute URLs (https://github.com/neomjs/neo-agent-brain/blob/dev/…). Live, out of scope here.
  • Neo.component.Markdown's #N → issuesUrl rewrite, which maps every ticket ref to one hardcoded repo and post-split can send a reader to a real ticket in the wrong repository. Real, separate, and worse than a dead link because it does not announce itself.

Contract Ledger

surface change consumer impact
Neo.component.Markdown new post-parse rewriteLinks(html) hook, identity by default none — existing subclasses inherit a no-op
Neo.app.content.Component new contentRoute_ config, null default; resolves relative .md hrefs against the current record.id none while unset
Portal.view.learn.Component declares contentRoute: '#/learn/' relative .md links in served guides become navigable

Acceptance Criteria

  • A relative .md link in a portal-served guide renders as a navigable route, and the same link still resolves for a GitHub reader — both surfaces, not a trade
  • The generic src/ content class carries no portal route; the prefix is declared by the portal view (the #12209 layering holds)
  • #/learn/<dotted-id> links and bare dotted tokens are repaired in content — they are navigable on neither surface, so no trade exists
  • The corpus sweep passes with no allowlist and no exemptions — an entry-based escape hatch is how the sibling burndown got its 16 entries
  • The sweep is proven able to fail: reverting any single repaired link reddens it
  • Links to repository source files are reported separately, not silently reclassified as acceptable

Evidence

  • Rendered/fetched proof and the full classification: PR #17859 discussion
  • Sweep implementation: test/playwright/unit/app/content/LearnCorpusLinks.spec.mjs
  • Routing-rule arm: test/playwright/component/component/MarkdownLinkNavigability.spec.mjs
  • Shared predicate: test/playwright/util/learnLinkNavigability.mjs

Live latest-open sweep: checked the latest 20 open issues at 2026-08-29T23:53:29Z; no equivalent found. A2A in-flight claim sweep: latest 30 messages, no overlapping [lane-claim].

⚖️ Ada · @neo-opus-ada · Claude Opus 5 · Claude Code

tobiu referenced in commit 147b2a9 - "feat(build): a guard for relative links under learn/, and the 8 it found (#17809) (#17859) on Aug 30, 2026, 2:23 PM
tobiu unassigned from @neo-opus-ada on Sep 1, 2026, 10:09 AM