Repository navigation
Open a repository-relative link as the file on GitHub - #788
Merged
Merged
Conversation
A link written as a path in the document's repository, such as `[review](docs/review.md)`, opened as https://docs/review.md. Lexical draws every link's href through `sanitizeUrl`, whose `formatUrl` puts https:// in front of anything that has no scheme and does not start with `/`, `.` or `#`, and reads one with an `@` as an email address. The link preview's Open, ⌘↵, a modified click and a reader's tap then opened that href, and `./docs/review.md` would have opened on the Chopin page it sits in. The Planner writes these links itself when it cites files, so most documents that reference code had broken ones.
`repositoryLinkUrl` in the dialect maps a target and `{ owner, name, ref }` to `https://github.com/{owner}/{name}/blob/{ref}/{path}`, keeping a query and an anchor (`docs/review.md#L10-L20`) and escaping only what a URL cannot hold. It leaves alone, by returning nothing, whatever is not a path in the repository: a target with a scheme (`https:`, `mailto:`, `javascript:`), an `#anchor` or `?query`, a bare email address, and a path starting with `/`, which is Chopin's own (a link to another document, as the phone reading tests use). It also returns nothing for what the dialect already refuses (`//host`, a backslash, hidden characters, padding or a protocol hidden behind whitespace) and for a path whose dot segments, encoded ones included, climb out of the repository root, so none of them is given an address on GitHub. The stored target is never rewritten and no validation changed; those targets draw and open as before.
The editor decorates Lexical's own `<a>` after Lexical sets it (a mutation listener on link and autolink nodes, as the GitHub pills do) and follows a link by the same function, so the link preview, ⌘↵, a modified click and a reader's tap agree with what is drawn. The repository reaches every editor through a context. RoomWorkspace provides the room's repository and its default branch, so the live document, an earlier version of it, Chat and decision cards all resolve the same way; Chat and card Markdown share one link component. `BARE_EMAIL`, the pattern the link editor already used to complete a typed address, moves to the dialect so both read an address the same way.
The design contract's reviewed hash for Chat's Markdown source is renewed: only its link renderer changed, not the `className` it forwards.
Fixes #760
Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 1e90a2e1-31c9-411e-9dcc-704e47cf6159)
Assistant-duration: 45m converged, estimated 2h
Assistant-verification: red before fix passed: with the source change stashed and the client rebuilt, all 5 new browser tests failed (anchors were https://docs/review.md and https://docs/guide.md, Chat's link stayed docs/review.md#L10-L20); with it they pass, 25 of 25 over 5 repeats
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test (whole repository) passed on the final tree: 7006 pass, 22 skip, 0 fail
Assistant-verification: bun run types passed on the final tree: whole workspace and e2e
Assistant-verification: bun run ci passed on the final tree: dprint, oxlint (72 warnings, 0 errors), token and design checks, with the one renewed hash
Assistant-verification: ad hoc fuzz of repositoryLinkUrl passed: 400,000 random targets, none threw and every address returned stayed under the repository's blob path
Assistant-verification: Playwright plain project (655 tests, own PostgreSQL 17, own port) failed on 32 tests none of which touch links: 617 passed, 6 skipped; 30 need git signing or databases that run lacked (git commit of a fixture under a global gpgsign, PostgresError on the other databases); document-entry's parent rename fails 3 of 3 and document-navigation's sidebar focus 1 of 3 on unmodified main too. The in-app link test of phone-read-mode caught a first version that resolved `/` paths, and passes now
Co-authored-by: Alex Lavaee <lavaman131@github.com>
A link such as `../secret.md` or `docs/%2e%2e/%2e%2e/secret.md` was given no GitHub address, but it was still accepted when written, and following it fell back to the stored text, which a browser resolves against the Chopin page. The dialect now refuses to store a scheme-less link whose dot segments, encoded ones included, climb above the repository root, and following one already in a document opens nothing. A dot segment that stays inside the repository, such as `docs/../README.md`, is stored and opens as before. Refs #760 Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test passed: packages/dialect and packages/editor, 1103 pass, 0 fail Assistant-verification: bun test failed without the change as intended: repository-link and link tests, 13 pass, 2 fail Assistant-verification: bun run types and bun run ci passed Co-authored-by: Alex Lavaee <lavaman131@github.com>
lavaman131
force-pushed
the
fix-760-repo-relative-links
branch
from
November 5, 2026 01:22
6d0ca08 to
a1b4188
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A link written as a path in the document's repository, such as
[review](docs/review.md), now openshttps://github.com/{owner}/{repo}/blob/{default branch}/{path}instead ofhttps://docs/review.md../docs/review.mdanddocs/review.md#L10-L20do the same, keeping the anchor. The stored target is still the path that was written, and no validation changed.Cause
Lexical's
LinkNode.createDOMdraws everyhrefthroughsanitizeUrl, whoseformatUrlputshttps://in front of anything with no scheme that does not start with/,.or#, and reads one with an@as an email address.followinpackages/editor/src/toolbar/link.tsxthen opened thathref(and./docs/review.mdwould have resolved against the Chopin page). The dialect accepts these links, and the Planner writes them when it cites files.Change
packages/dialect/src/repository-link.ts:repositoryLinkUrl(target, { owner, name, ref }), the one resolution every surface uses. It returns the blob URL, keeping a query and an anchor and escaping only what a URL cannot hold, or nothing when the target is not a path in the repository, so the caller keeps what it did before:https:,mailto:,javascript:), an#anchor, a?query, and a bare email address;/, which is Chopin's own (a link to another document);//host, a backslash, hidden characters, padding or a protocol hidden behind whitespace), by callingdisguisedUrl;../x,docs/%2e%2e/%2e%2e/x). The target is appended to a stand-in root rather than resolved against it, so nothing in it can name another host or make the parser throw.packages/editor/src/repository-links.tsx: a context carrying the repository, and a plugin that points Lexical's own<a>at the resolved URL after Lexical sets it (a mutation listener on link and autolink nodes, as the GitHub pills do). Nothing is stored, so editing, collaboration and the server's headless mirror see the link they always did.useLinkHrefis the same resolution for text the editor does not draw. The plugin is mounted bywidgetsPlugin, so the static editor of an earlier version gets it too.packages/editor/src/toolbar/link.tsx: the preview's Open, ⌘↵, a modified click and a reader's tap follow a link throughfollowHref, the same function, not through whatever the anchor happens to say.apps/web/src/room-workspace.tsx: provides{ owner, name, ref: defaultBranch }for the room, so the live document, an earlier version, Chat and decision cards resolve the same way.apps/web/src/chat/markdown.tsx: Chat and card Markdown share one link component,ChatLink.BARE_EMAIL, which the link editor already used to complete a typed address, moves to the dialect so both read an address the same way. The design contract's reviewed hash forchat/markdown.tsxis renewed: only its link renderer changed, not theclassNameit forwards.Decisions worth a look:
/is left alone. A first version resolved it from the repository root, as GitHub does; the existing "an in-app link navigates within Chopin" phone test failed, because a root-relative link is how a document links to another document.toHtml,renderToStaticMarkupoutside tests, download or print paths) and found none that draw a document's links, so the surfaces are the editor (live, read-only, earlier versions), the link preview and keyboard, Chat and decision cards.Fixes #760
Verification
e2e/repository-links.e2e.ts) failed. The anchors werehttps://docs/review.mdandhttps://docs/guide.md, and Chat's link stayeddocs/review.md#L10-L20. With the change they pass, 25 of 25 over 5 repeats.href, the stored MDX unchanged after an edit, Open, ⌘↵ and a modified click each opening a popup at the GitHub URL, a link added with ⌘K and one a collaborator sees (and one edited to another path), a phone reader's tap, and a Chat message. Absolute URLs,mailto:,#anchors, a/documents/...link and../secrets.mdare drawn as they were.repository-link.test.ts), the anchor anduseLinkHref(repository-links.test.tsx),followHref(link.test.ts), and Chat and decision-card Markdown rendered with and without a repository.git commitunder a globalcommit.gpgsign,PostgresErroron the other databases);document-entry's parent rename fails 3 of 3 anddocument-navigation's sidebar focus 1 of 3 on unmodifiedmaintoo. The full CI browser suite was not run here.Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 1e90a2e1-31c9-411e-9dcc-704e47cf6159)
Assistant-duration: 45m converged, estimated 2h
Assistant-verification: red before fix passed: with the source change stashed and the client rebuilt, all 5 new browser tests failed (anchors were https://docs/review.md and https://docs/guide.md, Chat's link stayed docs/review.md#L10-L20); with it they pass, 25 of 25 over 5 repeats
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test (whole repository) passed on the final tree: 7006 pass, 22 skip, 0 fail
Assistant-verification: bun run types passed on the final tree: whole workspace and e2e
Assistant-verification: bun run ci passed on the final tree: dprint, oxlint (72 warnings, 0 errors), token and design checks, with the one renewed hash
Assistant-verification: ad hoc fuzz of repositoryLinkUrl passed: 400,000 random targets, none threw and every address returned stayed under the repository's blob path
Assistant-verification: Playwright plain project (655 tests, own PostgreSQL 17, own port) failed on 32 tests none of which touch links: 617 passed, 6 skipped; 30 need git signing or databases that run lacked (git commit of a fixture under a global gpgsign, PostgresError on the other databases); document-entry's parent rename fails 3 of 3 and document-navigation's sidebar focus 1 of 3 on unmodified main too. The in-app link test of phone-read-mode caught a first version that resolved
/paths, and passes nowCo-authored-by: Alex Lavaee lavaman131@github.com
Decisions
example.comnow opens as a repository file, since it can't be told apart fromREADME.md. Writehttps://for an outside site.@, such asuser@host.md, is still read as an email address, as the link editor already completes it.