Repository navigation
fix(muya): inline export CSS + heading ids for offline export & TOC anchors (PG7/PG8) - #4412
Conversation
MarkdownToHtml.generate linked github-markdown-css, katex, and prism from
external CDNs via <link> tags, so a saved standalone .html (and any
offline / CSP-restricted / air-gapped viewer) rendered unstyled. The
legacy muyajs ExportHtml inlined those three core stylesheets as <style>
blocks via ?inline imports, producing fully self-contained output.
Restore that: inline the three base stylesheets by default. Keep the CDN
shell available behind generate({ inlineStyles: false }) for callers that
want a lighter document. Add github-markdown-css to muya deps (katex /
prismjs already present) and enable Vitest CSS processing (css: true) so
the inlined ?inline imports resolve to real content under test — without
it Vitest stubs every CSS import to an empty string and would silently
mask this regression.
Flips parityExportHtml PG7 specs (it.fails -> it).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@muyajs/core rendered exported headings via stock marked with no id, so
in-document [TOC] / getHtmlToc `<a href="#slug">` anchors pointed at
nonexistent targets — dead TOC links in exported HTML/PDF. The legacy
muyajs export emitted `<hN id="{slug}">` using the same slugger as the
TOC.
Inject a github-compatible slug id (reusing the engine's existing
generateGithubSlug) onto every h1..h6 in the export DOM, deduplicating
collisions with a `-N` suffix. Scoped to MarkdownToHtml's export path
only — renderToStaticHTML (the CommonMark/GFM conformance renderer) is
left untouched, so spec conformance output does not change.
Flips parityExportHtml PG8 specs (it.fails -> it).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Website preview ready: https://pr-4412-marktext-website.ransixi.workers.dev Built from |
There was a problem hiding this comment.
Pull request overview
This PR closes two export-fidelity gaps in the @muyajs/core engine export pipeline (packages/muya) by making exported HTML self-contained for offline viewing and by adding heading ids so in-document TOC anchors can target headings.
Changes:
- Inline core export stylesheets (GitHub Markdown CSS, KaTeX, Prism) by default, with an opt-out to keep CDN
<link>tags. - Inject slug-based
idattributes onto exported<h1>..</h6>elements to enable TOC/anchor navigation. - Update engine tests/config to validate the new export behavior (Vitest CSS processing + parity tests flipped to passing).
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Locks the newly added github-markdown-css dependency. |
| packages/muya/package.json | Adds github-markdown-css to engine dependencies for export inlining. |
| packages/muya/vite.config.ts | Enables test.css: true so ?inline CSS imports are real during Vitest runs. |
| packages/muya/src/state/markdownToHtml.ts | Implements inline base CSS and injects heading ids during export. |
| packages/muya/src/state/tests/parityExportHtml.spec.ts | Flips PG7/PG8 parity tests from expected-fail to passing assertions. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
packages/muya/src/state/tests/parityExportHtml.spec.ts:82
- The new heading-id logic includes non-trivial edge cases (fallback
heading, deduplication, and chained collisions likeheading→heading-1→heading-1-1), but the updated PG8 tests only cover simple ASCII headings. Adding a regression test for chained collisions would prevent future changes from re-breaking TOC anchors for common real-world documents.
it(
'PG8: the heading id matches the marktext slug of the heading text',
async () => {
const out = await generateExport(SAMPLE);
// The legacy export + getHtmlToc both slugged "Getting Started" to
// "getting-started"; the export must emit the same id so anchors
// line up.
expect(out).toMatch(/<h1[^>]*\sid="getting-started"/);
expect(out).toMatch(/<h2[^>]*\sid="installation"/);
},
);
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private _injectHeadingIds(container: HTMLElement) { | ||
| const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6'); | ||
| const seen = new Map<string, number>(); | ||
|
|
||
| for (const heading of headings) { | ||
| if (heading.id) | ||
| continue; | ||
|
|
||
| const base = generateGithubSlug(heading.textContent ?? '') || 'heading'; | ||
| const count = seen.get(base) ?? 0; | ||
| seen.set(base, count + 1); | ||
| heading.id = count === 0 ? base : `${base}-${count}`; | ||
| } | ||
| } |
There was a problem hiding this comment.
Good catch — this was a real bug. Verified # heading / ## heading / ## heading-1 produced heading-1 twice. Fixed in c08efeb: _injectHeadingIds now uses a Slugger-style full-slug seen-set, incrementing the suffix until the whole candidate id is unused, and seeds it with any pre-existing heading ids. Added a chained-collision regression test (heading, heading, heading-1 → heading, heading-1, heading-1-1).
| const base = generateGithubSlug(heading.textContent ?? '') || 'heading'; | ||
| const count = seen.get(base) ?? 0; | ||
| seen.set(base, count + 1); | ||
| heading.id = count === 0 ? base : `${base}-${count}`; | ||
| } |
There was a problem hiding this comment.
Correct, and this is intentionally deferred. This PR is engine-only (packages/muya) and reuses the engine's existing slug logic (generateGithubSlug, the same function getTOC already uses), per the task scope. Standardizing the desktop TOC generator onto the engine slugger is the wave-2 desktop change: packages/desktop/src/renderer/src/util/pdf.ts should swap its muya/lib/parser/marked/slugger import for generateGithubSlug so getHtmlToc's href="#slug" matches these engine-emitted ids exactly. The pdf.ts header comment already anticipates this ("This import moves to @muyajs/core together with the editor.vue swap"). For ASCII headings the two algorithms already agree; they diverge only on certain punctuation/whitespace-collapse cases and CJK downcoding, which the desktop swap resolves.
_injectHeadingIds deduplicated per base slug only, so a heading whose text already matched an earlier `-N` slug collided (e.g. `heading`, `heading`, `heading-1` emitted `heading-1` twice), breaking the anchor it was meant to fix. Switch to a Slugger-style "seen full slug" set, incrementing the suffix until the whole candidate id is unused, and seed it with any pre-existing heading ids. Add a chained-collision regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
Build artifacts for PR #4412: Run: https://github.com/marktext/marktext/actions/runs/27155849449
|
… heading-link, source cursor, saved indicator) (#4415) * fix(desktop): map copyAsRich to the engine copyAsRich method (PG9) The legacy "Copy as Rich Text" command was remapped to copyAsHtml, which blanks text/html and puts the HTML source into text/plain, so pasting into Word/email yielded raw HTML markup as literal text. #4411 added a real Muya.copyAsRich() that writes rendered HTML to text/html and plain text to text/plain; point COPY_PASTE_METHOD_MAP.copyAsRich at it. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * feat(desktop): wire heading-copy-link to copyGithubSlug (PG11) #4414 made the engine attach a hover-to-copy affordance to every heading and emit heading-copy-link { key } (key == the heading's stable slug) on click. Re-subscribe in editor.vue and forward the key to editorStore.copyGithubSlug, which copies `#<githubSlug>` to the clipboard — restoring the heading-anchor copy affordance that was a documented gap after the @muyajs/core migration. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(desktop): match exported-TOC anchors to engine heading ids (PG8) editor.vue now exports via @muyajs/core (#4406), and #4412 injects github-compatible heading ids onto exported headings (deduped in document order with a `-N` suffix). getHtmlToc still slugged via the legacy muyajs Slugger, so `href="#slug"` targets no longer matched the injected ids and TOC / [TOC] links were dead. Swap to @muyajs/core's generateGithubSlug and replicate the engine's whole-document `-N` dedup so the anchors resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(desktop): consume selection-change affiliation for menu state (PG1) adaptSelectionChange hardcoded affiliation:[] and copied changes.type ('Caret'/'Range') into start.type/end.type, so createApplicationMenuState's `start.type === 'span'` and functionType guards never fired — the native Paragraph-menu check marks, loose/task-list toggles, table/code-fence detection, and Format-disable-in-code all went dead after the @muyajs/core migration. #4410 added an `affiliation` chain (outermost-first) plus per-endpoint `anchorBlockInfo`/`focusBlockInfo` (`type: 'span'` + `functionType`) to the selection-change payload. Map them onto the legacy shape: - start/end `.type` and `.block.functionType` from the leaf block info, - affiliation passed through, with a derived `functionType` surfaced on `pre`/`figure` containers so table / code-fence detection lights up. Also fix the consumer's loose-list read: the engine affiliation entry carries `isLooseListItem` on the list block directly, not via a `children` chain. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(desktop): restore saved indicator on undo-to-disk (PG15) makeSyntheticHistory minted an ever-incrementing editSeq id on every json-change (including undo/redo), so after edit-then-undo-to-saved the id never matched lastSavedHistoryId and the tab stayed marked dirty even when its content matched disk. Derive the synthetic id from the engine undo-stack DEPTH instead — a stable position marker that returns to its saved value when an edit is undone back to the baseline. Seed lastSavedHistoryId to 0 (the engine's post-setContent baseline depth, since setContent clears history) so a freshly-loaded, never-saved document clears its dirty indicator when undone back to disk content, mirroring the legacy history-index behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * feat(muya): add setCursorByOffset for source-mode cursor restore (PG2) The source-code -> WYSIWYG handoff carries only a CodeMirror {line, ch} index cursor; @muyajs/core had no index-offset -> block-path conversion, so the WYSIWYG caret was lost. Add Muya#setCursorByOffset, reproducing the legacy muyajs approach: inject sentinel strings into the current markdown at the line/ch offsets, rebuild the tree (sentinels embed as literal text), find the content blocks they landed in, then rebuild the clean document and set the cursor by the resolved block paths + offsets. Both setContent calls run synchronously so no intermediate paint occurs, and the method is a no-op for stale/unresolvable cursors. Engine helper only — desktop wiring lands in a separate commit. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(desktop): restore WYSIWYG caret after source-mode edit (PG2) handleFileChange dropped the saved muyaIndexCursor on the source-code -> WYSIWYG handoff, so the caret was lost. Consume the new engine Muya#setCursorByOffset: when the tab has no key-based cursor but carries a CodeMirror {line, ch} index cursor, map it onto a block-key cursor so the caret lands where the source-mode cursor was. Restore the per-tab engine history afterwards (setCursorByOffset re-runs setContent internally, which clears history). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * test(desktop): flip parity e2e for PG1/PG2/PG15, defer PG14 Remove the `test.fail()` markers on the PG1 (Paragraph-menu check mark), PG2 (source-mode caret restore), and PG15 (saved indicator on undo-to-disk) parity e2e tests now that the desktop wiring lands — all three genuinely pass. PG14 (first undo after source mode reverts the bulk edit in one step) stays `test.fail()`: recording the source-mode change as a single undo boundary needs a general whole-document json1 diff through Editor.updateContents' pick/drop walker, which only handles specific op shapes and would risk corrupting the document. Deferred with an explanatory note here and in handleFileChange. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * docs(test): reconcile parity scoreboard after wave-2 (14/15 fixed) Update the Status column to the true post-merge state: all seven Wave-1 engine PRs (#4408-#4414) plus the Wave-2 desktop wiring close PG1-PG13 and PG15. Fix the "Gaps remaining" count (11 -> 1), add the PG2 setCursorByOffset engine spec, and document why PG14 is accept-deferred (single-undo-boundary across the source-mode handoff needs a whole-document json1 diff the op walker can't safely apply). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix: address Copilot review on parity wave-2 - editor.vue adaptSelectionChange: restore start/end block.text from the live anchorBlock/focusBlock so SELECTION_CHANGE can still slice the selected text (search prefill); the previous {functionType}-only block dropped it. - editor.vue isIndexCursor: validate both line AND ch are numbers (factored out isIndexPosition) so a missing ch no longer silently clamps to column 0. - muya setCursorByOffset: snapshot getHistory() and restore it after the internal setContent rebuild so the public API is caret-only and does not clear the undo stack; document the behaviour and cover it with a test. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Follow-up to #4406 (
@muyajs/coremigration), closing two export-fidelity parity gaps tracked on the #4407 scoreboard. Engine-only (packages/muya); no desktop changes.PG7 — inline export CSS (offline-safe output)
MarkdownToHtml.generatelinked github-markdown-css, katex, and prism from external CDNs via<link>tags, so a saved standalone.html(and any offline / CSP-restricted / air-gapped viewer) rendered unstyled. The legacy muyajsExportHtmlinlined those three core stylesheets as<style>blocks.?inlineimports), matching legacy behavior.generate({ inlineStyles: false })for callers that want a lighter document.github-markdown-cssto muya deps (katex / prismjs already present).css: true) so?inlineimports resolve to real content under test — without it Vitest stubs every CSS import to an empty string and silently masks this regression.PG8 — heading ids for live TOC anchors
@muyajs/corerendered exported headings via stock marked with noid, so in-document[TOC]/getHtmlToc<a href="#slug">anchors pointed at nonexistent targets (dead TOC links in exported HTML/PDF).id(reusing the engine's existinggenerateGithubSlug) onto everyh1..h6in the export DOM, deduplicating collisions with a-Nsuffix.MarkdownToHtml's export path only —renderToStaticHTML(the CommonMark/GFM conformance renderer) is untouched, so spec conformance output does not change.Tests
Flips the four
parityExportHtml.spec.tsmarkers (it.fails→it) for PG7 (×2) + PG8 (×2). Verified locally:lint— 0 errorslint:types— passcheck-circular— no circular depstest— 516 passed, 16 expected-fail (other gaps), 0 unexpectedtest:spec— 1347 passed (conformance unchanged)build— pass🤖 Generated with Claude Code