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
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
Context
learn/**/*.mdhas two consumers, and a link that is correct for one is broken for the other:#/learn/<id>where<id>is inlearn/tree.jsonPortal.view.learn.MainContainerControllerresolves a page withstore.get(itemId)— an exact id lookup againstlearn/tree.json, no normalization. 112 of 137learn/**/*.mdfiles are served by the portal. A relative.mdhref inside one of them is rendered verbatim bymarkedand then resolved by the browser against the document URL, which lands outside the app.Measured in a running portal (
neo-dev-server), not inferred: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 againstlearn/tree.json:Dominated by relative
.mdpaths. Two smaller classes ride along:#/learn/<dotted-id>links that missstore.get(dotted ids do not exist — 0 of 133tree.jsonids 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.Nsites sharing one broken idiom is evidence about the engine, notNcontent defects.The renderer already owns this seam.
Neo.component.Markdown#renderrunsmarked.parse, thenwrapMarkdownTables, then an img-wrapping pass — a post-parse HTML rewrite chain that a link rewrite joins naturally. Rewriting the parser's output also keepsmarkedthe 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.issuesUrlwas 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 byPortal.view.learn.Component.Not in scope
../../../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.https://github.com/neomjs/neo-agent-brain/blob/dev/…). Live, out of scope here.Neo.component.Markdown's#N→issuesUrlrewrite, 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
Neo.component.MarkdownrewriteLinks(html)hook, identity by defaultNeo.app.content.ComponentcontentRoute_config,nulldefault; resolves relative.mdhrefs against the currentrecord.idPortal.view.learn.ComponentcontentRoute: '#/learn/'.mdlinks in served guides become navigableAcceptance Criteria
.mdlink in a portal-served guide renders as a navigable route, and the same link still resolves for a GitHub reader — both surfaces, not a tradesrc/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 existsEvidence
test/playwright/unit/app/content/LearnCorpusLinks.spec.mjstest/playwright/component/component/MarkdownLinkNavigability.spec.mjstest/playwright/util/learnLinkNavigability.mjsLive 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