Skip to content

fix(muya): inline export CSS + heading ids for offline export & TOC anchors (PG7/PG8) - #4412

Merged
Jocs merged 3 commits into
developfrom
fix/muya-export-inline-css-heading-ids
Jun 8, 2026
Merged

Jocs merged 3 commits into
developfrom
fix/muya-export-inline-css-heading-ids

Conversation

@Jocs

@Jocs Jocs commented Jun 8, 2026

Copy link
Copy Markdown
Member

Follow-up to #4406 (@muyajs/core migration), 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.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.

  • Inline the three base stylesheets by default (?inline imports), matching legacy behavior.
  • Keep the CDN shell behind an opt-in generate({ inlineStyles: false }) for callers that want a lighter document.
  • Add github-markdown-css to muya deps (katex / prismjs already present).
  • Enable Vitest CSS processing (css: true) so ?inline imports 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/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).

  • 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 untouched, so spec conformance output does not change.

Tests

Flips the four parityExportHtml.spec.ts markers (it.fails → it) for PG7 (×2) + PG8 (×2). Verified locally:

  • lint — 0 errors
  • lint:types — pass
  • check-circular — no circular deps
  • test — 516 passed, 16 expected-fail (other gaps), 0 unexpected
  • test:spec — 1347 passed (conformance unchanged)
  • build — pass

🤖 Generated with Claude Code

Jocs and others added 2 commits June 9, 2026 01:29
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]>
Copilot AI review requested due to automatic review settings June 8, 2026 17:31
@github-actions

github-actions Bot commented Jun 8, 2026 •

Copy link
Copy Markdown

Website preview ready: https://pr-4412-marktext-website.ransixi.workers.dev

Built from c08efeb4dd04b48f7a792111b621d4817bce4d1a · Worker: marktext-website · Version: d03720fc-1f69-41cf-9829-440ecfff5b03 · Alias: pr-4412

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 id attributes 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 like heading → 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.

Comment on lines +135 to +148
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}`;
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +143 to +147
const base = generateGithubSlug(heading.textContent ?? '') || 'heading';
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
heading.id = count === 0 ? base : `${base}-${count}`;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
@Jocs
Jocs merged commit 9b6a017 into develop Jun 8, 2026
21 checks passed
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

Build artifacts for PR #4412:

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

Artifact Size Link
marktext-windows-arm64 256.2 MB Download
marktext-linux 556.3 MB Download
marktext-macos-x64 256.8 MB Download
marktext-windows-x64 257.5 MB Download
marktext-macos-arm64 246.6 MB Download

Jocs added a commit that referenced this pull request Jun 8, 2026
… 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]>
@Jocs
Jocs deleted the fix/muya-export-inline-css-heading-ids branch June 10, 2026 07:06
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.

2 participants