Skip to content

Open a repository-relative link as the file on GitHub - #788

Merged
lavaman131 merged 2 commits into
mainfrom
fix-760-repo-relative-links
Nov 5, 2026
Merged

lavaman131 merged 2 commits into
mainfrom
fix-760-repo-relative-links

Conversation

@lavaman131

Copy link
Copy Markdown
Collaborator

Summary

A link written as a path in the document's repository, such as [review](docs/review.md), now opens https://github.com/{owner}/{repo}/blob/{default branch}/{path} instead of https://docs/review.md. ./docs/review.md and docs/review.md#L10-L20 do the same, keeping the anchor. The stored target is still the path that was written, and no validation changed.

Cause

Lexical's LinkNode.createDOM draws every href through sanitizeUrl, whose formatUrl puts https:// in front of anything with no scheme that does not start with /, . or #, and reads one with an @ as an email address. follow in packages/editor/src/toolbar/link.tsx then opened that href (and ./docs/review.md would 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:
    • a scheme (https:, mailto:, javascript:), an #anchor, a ?query, and a bare email address;
    • a path starting with /, which is Chopin's own (a link to another document);
    • what the dialect already refuses (//host, a backslash, hidden characters, padding or a protocol hidden behind whitespace), by calling disguisedUrl;
    • a path whose dot segments, encoded ones included, climb out of the repository root (../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. useLinkHref is the same resolution for text the editor does not draw. The plugin is mounted by widgetsPlugin, 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 through followHref, 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 for chat/markdown.tsx is renewed: only its link renderer changed, not the className it forwards.

Decisions worth a look:

  • A path starting with / 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.
  • The ref is the repository's default branch. The browser has no per-document ref; the resolver takes one, so a document's own can be passed when it is known.
  • No exports exist. I searched for HTML or Markdown renderers of a document (rehype, toHtml, renderToStaticMarkup outside 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

  • Red before the fix: with the source change stashed and the client rebuilt, all 5 new browser tests (e2e/repository-links.e2e.ts) failed. The anchors were https://docs/review.md and https://docs/guide.md, and Chat's link stayed docs/review.md#L10-L20. With the change they pass, 25 of 25 over 5 repeats.
  • The browser tests prove the drawn 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.md are drawn as they were.
  • Unit tests: the resolver (repository-link.test.ts), the anchor and useLinkHref (repository-links.test.tsx), followHref (link.test.ts), and Chat and decision-card Markdown rendered with and without a repository.
  • Playwright plain project, 655 tests, on its own PostgreSQL and port: 617 passed, 6 skipped, 32 failed, none touching links. 30 need git signing or databases that run lacked (a fixture's git commit under a global commit.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 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 now
Co-authored-by: Alex Lavaee lavaman131@github.com

Decisions

  • A link that climbs out of the repository is refused when written, and an old one opens nothing.
  • A scheme-less host-like target such as example.com now opens as a repository file, since it can't be told apart from README.md. Write https:// for an outside site.
  • A root-level file name containing @, such as user@host.md, is still read as an email address, as the link editor already completes it.

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
lavaman131 force-pushed the fix-760-repo-relative-links branch from 6d0ca08 to a1b4188 Compare November 5, 2026 01:22
@lavaman131
lavaman131 merged commit 059e0eb into main Nov 5, 2026
24 checks passed
@lavaman131
lavaman131 deleted the fix-760-repo-relative-links branch November 5, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repository-relative links open as https://<path>

1 participant