Skip to content

fix(desktop): resolve local link paths without decoding the document folder (#4749) - #5506

Merged
Jocs merged 2 commits into
developfrom
fix/4749-local-link-percent-decode
Sep 22, 2026
Merged

Jocs merged 2 commits into
developfrom
fix/4749-local-link-percent-decode

Conversation

@Jocs

@Jocs Jocs commented Sep 22, 2026

Copy link
Copy Markdown
Member

Closes #4749. Supersedes #4773 (closed — it patched the pre-#5298 shape of this code and covered only one of the three triggers).

Summary

resolveLocalLinkTarget() joined the document folder with the link target and then ran decodeURIComponent() over the joined string, unguarded. That is wrong in two independent ways, and together they made Ctrl/Cmd-clicking a perfectly ordinary local link throw URIError: URI malformed out of the mt::format-link-click handler.

1. The document folder was being decoded. The link target is URL-encoded text taken from the document, but dirname (window.DIRNAME) is a raw filesystem path that may legally contain %. Decoding the joined string therefore misreads the document's own folder name:

document folder link before after
50%off/ [a](plain.md) throws URIError opens 50%off/plain.md
50%off/ [a](bad%20name.md) throws URIError opens 50%off/bad name.md
my%20docs/ [a](plain.md) resolves to my docs/plain.md (wrong folder) opens my%20docs/plain.md

This is the mirror image of the renderer-side asymmetry that encodeDirnameForUrl() already documents (#5212 / #5302). The first commit decodes the target alone and joins the raw dirname. isAbsolute keeps testing the encoded target, so a %2F cannot turn a relative link into an absolute one.

2. A target that is not valid percent-encoding threw. % is a legal filename character, so the second commit falls back to the target as written instead of rejecting the click — the same fallback the renderer already applies to anchors in SCROLL_TO_ANCHOR:

  • [broken](bad%zz.md)
  • [bare](%.md)
  • [partial](%E0%A4%A.md)
  • [frag](other.md#100%) — toPathname() runs before the # split, so an invalid escape anywhere in the destination was enough to throw. fix: #4749 handle malformed percent-encoded local links #4773 wrapped the whole link instead, which baked #100% into the path so the file never opened. With the guard inside toPathname() the case resolves correctly: the literal path does not exist, the # split runs, and other.md opens with 100% handed to the renderer, which tolerates an undecodable anchor.

Note that since #5298 made this handler async, the throw surfaced as an unhandledRejection (logged only in main/index.ts) rather than the uncaughtException error dialog described in #4749 — so on develop the symptom is currently a click that does nothing at all.

Type of change

  • Bug fix (non-breaking, fixes an issue)
  • New feature (non-breaking, adds functionality)
  • Breaking change (causes existing functionality to change)
  • Documentation update

Test plan

  • New tests added

Seven regression cases in packages/desktop/test/unit/specs/local-link-target.spec.ts, covering all four destinations listed in #4749 plus the three folder-name cases from the table above. Each was watched failing against develop first: the folder cases fail with URIError at filesystem/index.ts:41 (and my%20docs/ fails on the wrong resolved path), the malformed-target cases fail with URIError.

pnpm -C packages/desktop exec vitest run test/unit/specs/local-link-target.spec.ts   # 12 passed
pnpm run test        # 65 files, 875 tests passed
pnpm run lint        # clean
pnpm run typecheck   # clean

Notes for reviewers

Deliberately out of scope, both pre-existing and worth separate issues if wanted:

  • A click on a local target that does not exist remains a silent no-op (isMarkdownFile() requires the file to exist, so it falls through to shell.openPath() whose rejected promise is discarded). [Bug] Malformed percent-encoded local link throws an unhandled URIError in the main process #4749 asks for a "cannot open location" prompt; that would change behaviour for every broken local link, not just malformed escapes, so it is not bundled here.
  • FormatLinkPayload.dirname is optional in main/menu/actions/file.ts while ipc.ts declares it required.

Credit to @jianongHe, whose report in #4749 and analysis in #4773 established the right fallback semantics; the reproduction cases listed there are in the spec.

🤖 Generated with Claude Code

Jocs and others added 2 commits September 22, 2026 10:41
…nt folder

`resolveLocalLinkTarget` joined `dirname` and the link target and then ran
`decodeURIComponent` over the result. The target is URL-encoded text taken
from the document, but `dirname` is a raw filesystem path, so decoding the
joined string misread the document's own folder name:

- a folder containing a bare `%` (`50%off/`) made every ordinary link in the
  document throw `URIError` from the `mt::format-link-click` handler;
- a folder that merely looks percent-encoded (`my%20docs/`) resolved to the
  wrong directory (`my docs/`).

Decode the target alone and join the raw `dirname`, which mirrors
`encodeDirnameForUrl` on the renderer side (#5212). `isAbsolute` keeps
testing the encoded target so a `%2F` cannot turn a relative link into an
absolute one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ral path text (#4749)

Ctrl/Cmd-clicking a local link whose target is not valid percent-encoding
threw `URIError: URI malformed` out of the `mt::format-link-click` handler:
`[broken](bad%zz.md)`, `[bare](%.md)`, `[partial](%E0%A4%A.md)`, and also
`[frag](other.md#100%)` — `toPathname` runs before the `#` split, so an
invalid escape anywhere in the destination was enough.

`%` is a legal filename character, so fall back to the target as written
rather than rejecting the click. This matches the renderer, which already
guards the same call in `SCROLL_TO_ANCHOR`. Because the guard sits inside
`toPathname`, the fragment case now resolves the way it should: the literal
path does not exist, so the `#` split runs and `other.md` opens with `100%`
handed to the renderer, which tolerates an undecodable anchor.

The click on a target that does not exist stays a no-op, as it already is
for any other broken local link.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@github-actions

Copy link
Copy Markdown

Build artifacts for PR #5506:

Run: https://github.com/marktext/marktext/actions/runs/35680614125

Artifact Size Link
marktext-windows-x64 288.2 MB Download
marktext-linux 640.4 MB Download
marktext-windows-arm64 280.0 MB Download
marktext-macos-x64 298.4 MB Download
marktext-macos-arm64 288.1 MB Download

@Jocs
Jocs merged commit b564160 into develop Sep 22, 2026
11 checks passed
@Jocs
Jocs deleted the fix/4749-local-link-percent-decode branch September 22, 2026 03:00
wangty163 pushed a commit to wangty163/marktext that referenced this pull request Sep 22, 2026
Brings in 127 upstream commits (2026-09-01..2026-09-22): Pandoc export,
source-mode line numbers, selection word count, the pandoc TeX-math
preferences, soft-newline-as-space, Dutch/Russian locales, and the long
tail of cut/backspace/table/footnote/search fixes.

32 files conflicted. How the overlapping work was resolved:

- Code-block trailing newline (marktext#5114): took upstream's `mu-trailing-break`
  <br> in CodeBlockContent and dropped our zero-width caret anchor from
  `getHighlightHtml` (upstream removed its `handleLineEnding` parameter).
  Same coverage — math/HTML previews render through CodeBlockContent — and
  `<br>` contributes no text, so it is offset-neutral against our
  `getTextContent`/OFFSET_BLACKLIST mapping. Deleted our now-obsolete
  `highlightHTMLCaretAnchor.spec.ts` and `trailingNewlineCaret.spec.ts`;
  upstream's `trailingNewline.spec.ts` already asserts the trailing break
  adds no text, and its Chromium e2e covers caret painting and typing.

- Soft line breaks: kept OUR rendering (no block-level `mu-line-end`, a
  `mu-caret-anchor` instead) because literal paragraph line breaks make the
  trailing `\n` lay out its own line already (marktext#5282), and added upstream's
  `softNewlineAsSpace` branch on top. Repointed upstream's
  `softLineBreak.spec.ts` case that asserted `MU_LINE_END` at the anchor.

- Table column sizing (marktext#4894): both sides made the same `min-width` change.
  Took upstream's blanket `z-index: 2` on `.mu-table-cell-content`, which is
  a superset of our selected-cell-only rule and still satisfies our
  text-above-overlay stacking assertion. Kept our `.mu-table` overflow-x.

- Word counter: kept our localized badge label (mapped `all` to upstream's
  `menu.counter.allCharacters`, dropping our duplicate
  `charactersWithSpaces` key so every locale stays identical to upstream's)
  and took upstream's document/selection tooltip. Locales resolve to
  upstream's wholesale; `.min.json` are gitignored build output.

- Forward-delete merge (marktext#1845): took upstream's `getAnchor()` form, which
  carries our list-item-only narrowing plus the marktext#5423/marktext#5386 cases.

- `format.ts` markerLen: upstream's `token.marker.length` subsumes our
  `mark` special case (`==` is two wide) and covers the new math markers.

- Options rename: `math` -> `texMathDollars`, `isGitlabCompatibilityEnabled`
  -> `texMathGfm`; updated our blank-line specs to the new option set.

- `resolveLocalLinkTarget` (marktext#5506) now decodes link targets, so our manual
  `decodeURIComponent` went away; kept `isEditableFile` so links to plain
  text documents still open.

- Source mode: one `sourceMode()` helper feeds all three `setOption('mode')`
  call sites, so a plain-text document keeps `text/plain` even when a TeX
  math preference changes, while markdown documents get upstream's
  preference-driven `markdown-math` mode.

- E2E: kept our unobtrusive launcher (`visibleWindow`, MARKTEXT_E2E_UNOBTRUSIVE)
  alongside upstream's new `env` option; TOC spec takes upstream's viewport
  alignment assertions plus our `showToc` helper and highlight test.

- `test:e2e` points at the package root again: upstream moved
  `playwright.config.ts` out of `test/e2e/` (d3f6959).

Backup of the pre-merge state: branch `backup/pre-upstream-merge-95144c5c`.
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.

[Bug] Malformed percent-encoded local link throws an unhandled URIError in the main process

1 participant