@mulmoclaude/markdown-utils 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -12,6 +12,9 @@ export * from "./image/resolve.js";
12
12
  export * from "./image/rewriteMarkdownImageRefs.js";
13
13
  export * from "./markdown/mermaidRender.js";
14
14
  export * from "./markdown/mermaidExtension.js";
15
+ export * from "./markdown/codeCopyExtension.js";
16
+ export * from "./markdown/codeCopyClipboard.js";
17
+ export * from "./markdown/rawHtmlPolicy.js";
15
18
  export * from "./markdown/mathExtension.js";
16
19
  export * from "./markdown/mathRender.js";
17
20
  export * from "./dom/adoptSvg.js";
package/dist/index.js CHANGED
@@ -12,6 +12,9 @@ export * from "./image/resolve.js";
12
12
  export * from "./image/rewriteMarkdownImageRefs.js";
13
13
  export * from "./markdown/mermaidRender.js";
14
14
  export * from "./markdown/mermaidExtension.js";
15
+ export * from "./markdown/codeCopyExtension.js";
16
+ export * from "./markdown/codeCopyClipboard.js";
17
+ export * from "./markdown/rawHtmlPolicy.js";
15
18
  export * from "./markdown/mathExtension.js";
16
19
  export * from "./markdown/mathRender.js";
17
20
  export * from "./dom/adoptSvg.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Reads the raw source a block should copy: `textContent` of the
3
+ * `<code>`, so highlight.js's `<span>` markup never reaches the
4
+ * clipboard. Exported for the tests.
5
+ */
6
+ export declare function codeTextOf(button: Element): string | null;
7
+ /**
8
+ * Attach the delegated listener. Idempotent — a second call on the same
9
+ * document is a no-op, so the host and a plugin bundle can both ask.
10
+ */
11
+ export declare function installCodeCopyHandler(doc: Document): void;
12
+ /** Test seam — lets an isolated test install into a fresh document twice. */
13
+ export declare function _resetCodeCopyHandlerForTests(doc: Document): void;
@@ -0,0 +1,172 @@
1
+ // Click behaviour for the copy buttons `codeCopyExtension` emits.
2
+ //
3
+ // ONE delegated listener for the whole document, not a listener per
4
+ // viewer: the buttons live inside `v-html`, so Vue cannot bind `@click`
5
+ // to them, and they are destroyed and recreated on every streamed chunk.
6
+ // Delegation is indifferent to both — the listener is attached to a node
7
+ // that outlives every re-render, and a button that appears mid-stream
8
+ // works without anyone re-running an attach pass.
9
+ //
10
+ // It takes no labels. Only the FIRST install on a document keeps its
11
+ // listener (host and plugin bundles both call this), so any provider
12
+ // captured here would caption the other bundle's buttons too. The
13
+ // renderer writes both label states into each button instead, which also
14
+ // keeps them following the locale for free: a language switch re-renders
15
+ // the markdown, and the new markup carries the new strings.
16
+ import { CODE_COPY_ATTR, CODE_COPY_BLOCK_ATTR, CODE_COPY_ICON, CODE_COPIED_ICON, CODE_COPY_IDLE_LABEL_ATTR, CODE_COPY_COPIED_LABEL_ATTR, CODE_BLOCK_STYLE_INDENTED, CODE_COPY_NONCE_UNAVAILABLE, codeCopyNonce, setCodeCopyNonce, } from "./codeCopyExtension.js";
17
+ /** How long the button stays in its "copied" state before reverting. */
18
+ const FEEDBACK_DURATION_MS = 2000;
19
+ /** Applied on success and removed on revert — the whole visual delta. */
20
+ const COPIED_TINT_CLASS = "text-green-600";
21
+ /** Pairs a document with the renderer, and returns the nonce they agree on.
22
+ *
23
+ * The value lives on the DOCUMENT for the same reason the install flag
24
+ * does: host and plugin each have their own module instance, and the
25
+ * document is the only thing they share.
26
+ *
27
+ * A document that has none adopts the RENDERER's current value rather
28
+ * than minting its own, and that direction is the whole point. The
29
+ * renderer's nonce is module state with no idea which document it is
30
+ * rendering for, so minting per document lets the two drift: pair A,
31
+ * pair B, then re-render A, and A's buttons carry B's nonce while A's
32
+ * listener still demands A's. Adopting makes every document in a realm
33
+ * converge on one value, which costs nothing — the nonce defends against
34
+ * markup that cannot read ANY of them (codex round 2, P3). */
35
+ function ensureNonce(doc) {
36
+ const target = doc;
37
+ const existing = target.__mulmoclaudeCodeCopyNonce;
38
+ if (existing !== undefined)
39
+ return existing;
40
+ const adopted = codeCopyNonce();
41
+ target.__mulmoclaudeCodeCopyNonce = adopted;
42
+ return adopted;
43
+ }
44
+ // `nodeType`, not `instanceof Element`: this package is browser-SAFE, not
45
+ // browser-ONLY, and the Node + jsdom harness installs `window`/`document`
46
+ // as globals without the DOM constructors — where a bare `Element` is a
47
+ // ReferenceError. Same reason `sanitizeMarkdownHtml` in core discriminates
48
+ // this way. nodeType 1 is the DOM standard's own element discriminant.
49
+ const ELEMENT_NODE = 1;
50
+ const isElement = (value) => typeof value === "object" && value !== null && "nodeType" in value && value.nodeType === ELEMENT_NODE;
51
+ // An element that can carry `classList` / `innerHTML` — everything the
52
+ // feedback swap touches. `Element` alone lacks them.
53
+ const isHtmlElement = (value) => isElement(value) && "classList" in value && "innerHTML" in value;
54
+ /**
55
+ * Reads the raw source a block should copy: `textContent` of the
56
+ * `<code>`, so highlight.js's `<span>` markup never reaches the
57
+ * clipboard. Exported for the tests.
58
+ */
59
+ export function codeTextOf(button) {
60
+ const block = button.closest(`[${CODE_COPY_BLOCK_ATTR}]`);
61
+ const code = block?.querySelector("pre > code");
62
+ const text = code?.textContent;
63
+ if (text === undefined || text === null)
64
+ return null;
65
+ // ONLY for an indented block. Marked leaves a trailing newline there
66
+ // that the author did not write, and strips it from a fenced block —
67
+ // so on a fence the very same `\n` IS content, the blank line before
68
+ // the closing ```. The two are identical once rendered, which is why
69
+ // the style has to travel in the markup rather than be guessed here.
70
+ const indented = block?.getAttribute(CODE_COPY_BLOCK_ATTR) === CODE_BLOCK_STYLE_INDENTED;
71
+ return indented ? stripTrailingNewline(text) : text;
72
+ }
73
+ function stripTrailingNewline(text) {
74
+ if (text.endsWith("\r\n"))
75
+ return text.slice(0, -2);
76
+ return text.endsWith("\n") ? text.slice(0, -1) : text;
77
+ }
78
+ /** The accessible name and the tooltip are the same string; setting them
79
+ * in one place is what stops the two drifting apart. */
80
+ function setLabel(button, label) {
81
+ if (label === null)
82
+ return;
83
+ button.setAttribute("aria-label", label);
84
+ button.setAttribute("title", label);
85
+ }
86
+ // One pending revert per button. Without this, clicking again while the
87
+ // confirmation is up leaves the FIRST timer running: it fires on the old
88
+ // schedule and clears the second click's feedback early. Keyed weakly so
89
+ // a button removed by the next streamed re-render is collectable.
90
+ const pendingReverts = new WeakMap();
91
+ function showCopied(button) {
92
+ const view = button.ownerDocument.defaultView;
93
+ // No window means a detached document nobody is looking at. Bail
94
+ // before mutating, rather than leaving a confirmation that can never
95
+ // revert because there is no timer to schedule.
96
+ if (view === null || view === undefined)
97
+ return;
98
+ setLabel(button, button.getAttribute(CODE_COPY_COPIED_LABEL_ATTR));
99
+ button.innerHTML = CODE_COPIED_ICON;
100
+ button.classList.add(COPIED_TINT_CLASS);
101
+ const pending = pendingReverts.get(button);
102
+ if (pending !== undefined)
103
+ view.clearTimeout(pending);
104
+ const handle = view.setTimeout(() => {
105
+ // Identity check, not just `clearTimeout`: a stale callback that
106
+ // still runs — a timer already dispatched when the second click
107
+ // cancelled it — must not clear the live confirmation.
108
+ if (pendingReverts.get(button) !== handle)
109
+ return;
110
+ pendingReverts.delete(button);
111
+ button.innerHTML = CODE_COPY_ICON;
112
+ button.classList.remove(COPIED_TINT_CLASS);
113
+ setLabel(button, button.getAttribute(CODE_COPY_IDLE_LABEL_ATTR));
114
+ }, FEEDBACK_DURATION_MS);
115
+ pendingReverts.set(button, handle);
116
+ }
117
+ async function handleClick(event) {
118
+ const { target } = event;
119
+ if (!isElement(target))
120
+ return;
121
+ const button = target.closest(`[${CODE_COPY_ATTR}]`);
122
+ if (!isHtmlElement(button))
123
+ return;
124
+ // The marker's VALUE is the check, not its presence. Author markup can
125
+ // carry the attribute — marked passes raw HTML through and DOMPurify
126
+ // keeps `data-*` — so a bare marker would let a rendered README drive
127
+ // the clipboard. An empty nonce never matches, which is what makes the
128
+ // no-CSPRNG case inert instead of guessable.
129
+ const expected = ensureNonce(button.ownerDocument);
130
+ if (expected === CODE_COPY_NONCE_UNAVAILABLE)
131
+ return;
132
+ if (button.getAttribute(CODE_COPY_ATTR) !== expected)
133
+ return;
134
+ const text = codeTextOf(button);
135
+ if (text === null)
136
+ return;
137
+ const clipboard = button.ownerDocument.defaultView?.navigator?.clipboard;
138
+ if (clipboard === undefined)
139
+ return;
140
+ try {
141
+ await clipboard.writeText(text);
142
+ }
143
+ catch {
144
+ // A denied clipboard permission is the user's answer, not an app
145
+ // error: leave the button in its idle state so the icon never claims
146
+ // a copy that did not happen. Nothing to log — the page is not the
147
+ // place to report a permission the user controls.
148
+ return;
149
+ }
150
+ showCopied(button);
151
+ }
152
+ /**
153
+ * Attach the delegated listener. Idempotent — a second call on the same
154
+ * document is a no-op, so the host and a plugin bundle can both ask.
155
+ */
156
+ export function installCodeCopyHandler(doc) {
157
+ const target = doc;
158
+ // Before the install guard, and on EVERY call: the second bundle's
159
+ // listener install is a no-op, but its renderer still has to learn the
160
+ // nonce the first bundle's listener will demand.
161
+ setCodeCopyNonce(ensureNonce(doc));
162
+ if (target.__mulmoclaudeCodeCopyInstalled === true)
163
+ return;
164
+ target.__mulmoclaudeCodeCopyInstalled = true;
165
+ doc.addEventListener("click", (event) => void handleClick(event));
166
+ }
167
+ /** Test seam — lets an isolated test install into a fresh document twice. */
168
+ export function _resetCodeCopyHandlerForTests(doc) {
169
+ const target = doc;
170
+ target.__mulmoclaudeCodeCopyInstalled = false;
171
+ delete target.__mulmoclaudeCodeCopyNonce;
172
+ }
@@ -0,0 +1,77 @@
1
+ import type { MarkedExtension } from "marked";
2
+ /** Accessible names for the copy control. Resolved at render time through
3
+ * the provider below, never captured at module load. */
4
+ export interface CodeCopyLabels {
5
+ /** Idle state, e.g. "Copy code". */
6
+ copy: string;
7
+ /** Post-click confirmation, e.g. "Copied". */
8
+ copied: string;
9
+ }
10
+ /**
11
+ * Point the renderer at the app's i18n. The provider is called on every
12
+ * render rather than once, so a host whose provider reads a REACTIVE
13
+ * locale gets live translations for free: the read lands inside the
14
+ * `renderedHtml` computed's evaluation and Vue re-runs it on locale
15
+ * change. Mirrors `setEmbedLocaleProvider`.
16
+ */
17
+ export declare function setCodeCopyLabelProvider(provider: () => CodeCopyLabels): void;
18
+ /** Test seam — restores the built-in English labels. */
19
+ export declare function _resetCodeCopyLabelsForTests(): void;
20
+ /** Marks the button for the delegated click listener. Its VALUE is a
21
+ * per-document nonce, and the listener copies nothing without a match.
22
+ *
23
+ * The attribute cannot be a bare marker: `marked` passes an author's raw
24
+ * HTML straight through, DOMPurify's defaults keep `<button>` and every
25
+ * `data-*`, and the listener is document-wide. A bare marker therefore
26
+ * lets any rendered markdown — a cloned repository's README, which is
27
+ * exactly what `sanitizeMarkdownHtml` exists for — mint a working copy
28
+ * control and put text of its choosing on the clipboard. Verified: the
29
+ * spoof reached `clipboard.writeText`, and with a `display:none` decoy
30
+ * block the user saw `npm install` while the clipboard took
31
+ * `curl … | bash`. Structure cannot be the check, because an author can
32
+ * reproduce any structure; only a secret they cannot read works, and
33
+ * they cannot read this one because the sanitizer strips scripts. */
34
+ export declare const CODE_COPY_ATTR = "data-code-copy";
35
+ /** Returned when no CSPRNG is reachable. The listener refuses it, so the
36
+ * copy button goes inert rather than becoming guessable — a feature that
37
+ * quietly stops working is recoverable, a clipboard anyone can drive is
38
+ * not. Every environment this ships to has one (browsers, Node >= 19,
39
+ * jsdom), so this is the unreachable branch, not a supported mode. */
40
+ export declare const CODE_COPY_NONCE_UNAVAILABLE = "";
41
+ /** A value author-supplied markup cannot guess. Static text is the whole
42
+ * threat: it never executes, so it can neither read nor predict this.
43
+ * CSPRNG only — `Math.random` is not a fallback here, it is a weaker
44
+ * version of the thing being defended. */
45
+ export declare function createCodeCopyNonce(): string;
46
+ /** Point the renderer at the document's nonce. `installCodeCopyHandler`
47
+ * calls this on EVERY invocation, including the ones whose listener
48
+ * install is a no-op, so a second bundle's renderer agrees with the one
49
+ * listener that is actually running. */
50
+ export declare function setCodeCopyNonce(value: string): void;
51
+ /** The value the renderer is currently stamping. */
52
+ export declare function codeCopyNonce(): string;
53
+ /** Marks the wrapper the listener searches for the block's `<code>`. Its
54
+ * VALUE is the block style, because the two are indistinguishable once
55
+ * rendered and they must be copied differently: marked leaves a
56
+ * trailing newline on an indented block, while the same newline on a
57
+ * FENCED block is a blank line the author actually wrote. */
58
+ export declare const CODE_COPY_BLOCK_ATTR = "data-code-copy-block";
59
+ /** Value of the above for a 4-space block — the only style that carries
60
+ * a renderer-added trailing newline. */
61
+ export declare const CODE_BLOCK_STYLE_INDENTED = "indented";
62
+ /** Value for every fenced block. */
63
+ export declare const CODE_BLOCK_STYLE_FENCED = "fenced";
64
+ /** The button carries BOTH label states, so the delegated listener needs
65
+ * no label provider of its own. It cannot have a correct one: only the
66
+ * first install on a document keeps its listener, so a plugin's buttons
67
+ * would otherwise be captioned by whichever provider registered first. */
68
+ export declare const CODE_COPY_IDLE_LABEL_ATTR = "data-code-copy-idle";
69
+ export declare const CODE_COPY_COPIED_LABEL_ATTR = "data-code-copy-copied";
70
+ /** The idle glyph. Exported so the click handler can swap it back. */
71
+ export declare const CODE_COPY_ICON: string;
72
+ /** The confirmation glyph shown for a moment after a successful copy. */
73
+ export declare const CODE_COPIED_ICON: string;
74
+ /** Idle button classes. The handler tints the button on success rather
75
+ * than rewriting this list, so both states stay greppable. */
76
+ export declare const CODE_COPY_BUTTON_CLASS: string;
77
+ export declare const codeCopyExtension: MarkedExtension;
@@ -0,0 +1,187 @@
1
+ // Marked `code` renderer override that gives every non-mermaid fenced
2
+ // block a copy control in its top-right corner. The click behaviour is
3
+ // NOT here — `codeCopyClipboard.ts` installs one delegated listener for
4
+ // the whole document. This file stays pure (marked + the zero-dep
5
+ // `@mulmoclaude/common` leaf) so tests can assert the html shape without
6
+ // a browser.
7
+ //
8
+ // Why the renderer and not a post-render DOM scan (the shape
9
+ // `useMermaidRenderer` uses): a viewer's `renderedHtml` is a computed
10
+ // over streaming text, so `v-html` replaces the container's innerHTML on
11
+ // every chunk. A scan-and-attach pass would re-insert a button into every
12
+ // block on every chunk, and an "already attached" marker cannot prevent
13
+ // it because the nodes it marked are gone after the next patch. Markup
14
+ // emitted here rides along in the string Vue already writes.
15
+ //
16
+ // Why the button floats over a wrapper instead of sitting in a header
17
+ // strip above the block: `pre` is styled by UNLAYERED css in at least
18
+ // three places (`.markdown-content pre` in `src/index.css`, plus scoped
19
+ // `:deep(pre)` in textResponse's and skill's Views). Unlayered rules beat
20
+ // Tailwind's `@layer utilities` outright, so a utility on the `pre` could
21
+ // not flatten its bottom corners — a header strip would mean editing the
22
+ // global stylesheet in two packages. A wrapper the existing selectors do
23
+ // not name collides with none of it. The button lives on the WRAPPER, not
24
+ // inside the `pre`, so it stays put when a wide block scrolls sideways.
25
+ //
26
+ // Registration order (see setup.ts): AFTER `markedHighlightExtension`,
27
+ // BEFORE `mermaidExtension`. Later `.use()` calls wrap earlier ones, so
28
+ // mermaid ends up outermost and a `mermaid` fence short-circuits into its
29
+ // placeholder without reaching this wrapper; every other fence falls
30
+ // through to here. Registering after highlight is what lets us emit the
31
+ // finished block: highlight's `walkTokens` has already rewritten
32
+ // `token.text` to highlighted html and stamped `token.escaped`.
33
+ import { escapeHtml } from "@mulmoclaude/common";
34
+ const DEFAULT_LABELS = { copy: "Copy code", copied: "Copied" };
35
+ let labelProvider = () => DEFAULT_LABELS;
36
+ /**
37
+ * Point the renderer at the app's i18n. The provider is called on every
38
+ * render rather than once, so a host whose provider reads a REACTIVE
39
+ * locale gets live translations for free: the read lands inside the
40
+ * `renderedHtml` computed's evaluation and Vue re-runs it on locale
41
+ * change. Mirrors `setEmbedLocaleProvider`.
42
+ */
43
+ export function setCodeCopyLabelProvider(provider) {
44
+ labelProvider = provider;
45
+ }
46
+ /** Test seam — restores the built-in English labels. */
47
+ export function _resetCodeCopyLabelsForTests() {
48
+ labelProvider = () => DEFAULT_LABELS;
49
+ }
50
+ /** Marks the button for the delegated click listener. Its VALUE is a
51
+ * per-document nonce, and the listener copies nothing without a match.
52
+ *
53
+ * The attribute cannot be a bare marker: `marked` passes an author's raw
54
+ * HTML straight through, DOMPurify's defaults keep `<button>` and every
55
+ * `data-*`, and the listener is document-wide. A bare marker therefore
56
+ * lets any rendered markdown — a cloned repository's README, which is
57
+ * exactly what `sanitizeMarkdownHtml` exists for — mint a working copy
58
+ * control and put text of its choosing on the clipboard. Verified: the
59
+ * spoof reached `clipboard.writeText`, and with a `display:none` decoy
60
+ * block the user saw `npm install` while the clipboard took
61
+ * `curl … | bash`. Structure cannot be the check, because an author can
62
+ * reproduce any structure; only a secret they cannot read works, and
63
+ * they cannot read this one because the sanitizer strips scripts. */
64
+ export const CODE_COPY_ATTR = "data-code-copy";
65
+ const isRandomSource = (value) => typeof value === "object" && value !== null;
66
+ const NONCE_BYTES = 16;
67
+ const HEX_RADIX = 16;
68
+ const HEX_PAIR = 2;
69
+ /** Returned when no CSPRNG is reachable. The listener refuses it, so the
70
+ * copy button goes inert rather than becoming guessable — a feature that
71
+ * quietly stops working is recoverable, a clipboard anyone can drive is
72
+ * not. Every environment this ships to has one (browsers, Node >= 19,
73
+ * jsdom), so this is the unreachable branch, not a supported mode. */
74
+ export const CODE_COPY_NONCE_UNAVAILABLE = "";
75
+ /** A value author-supplied markup cannot guess. Static text is the whole
76
+ * threat: it never executes, so it can neither read nor predict this.
77
+ * CSPRNG only — `Math.random` is not a fallback here, it is a weaker
78
+ * version of the thing being defended. */
79
+ export function createCodeCopyNonce() {
80
+ const source = globalThis.crypto;
81
+ if (!isRandomSource(source))
82
+ return CODE_COPY_NONCE_UNAVAILABLE;
83
+ if (typeof source.randomUUID === "function")
84
+ return source.randomUUID();
85
+ if (typeof source.getRandomValues !== "function")
86
+ return CODE_COPY_NONCE_UNAVAILABLE;
87
+ const bytes = source.getRandomValues(new Uint8Array(NONCE_BYTES));
88
+ return Array.from(bytes, (byte) => byte.toString(HEX_RADIX).padStart(HEX_PAIR, "0")).join("");
89
+ }
90
+ // Seeded so a renderer used without the click handler still emits a value
91
+ // no author can guess: the pairing then simply fails closed.
92
+ let nonce = createCodeCopyNonce();
93
+ /** Point the renderer at the document's nonce. `installCodeCopyHandler`
94
+ * calls this on EVERY invocation, including the ones whose listener
95
+ * install is a no-op, so a second bundle's renderer agrees with the one
96
+ * listener that is actually running. */
97
+ export function setCodeCopyNonce(value) {
98
+ nonce = value;
99
+ }
100
+ /** The value the renderer is currently stamping. */
101
+ export function codeCopyNonce() {
102
+ return nonce;
103
+ }
104
+ /** Marks the wrapper the listener searches for the block's `<code>`. Its
105
+ * VALUE is the block style, because the two are indistinguishable once
106
+ * rendered and they must be copied differently: marked leaves a
107
+ * trailing newline on an indented block, while the same newline on a
108
+ * FENCED block is a blank line the author actually wrote. */
109
+ export const CODE_COPY_BLOCK_ATTR = "data-code-copy-block";
110
+ /** Value of the above for a 4-space block — the only style that carries
111
+ * a renderer-added trailing newline. */
112
+ export const CODE_BLOCK_STYLE_INDENTED = "indented";
113
+ /** Value for every fenced block. */
114
+ export const CODE_BLOCK_STYLE_FENCED = "fenced";
115
+ /** The button carries BOTH label states, so the delegated listener needs
116
+ * no label provider of its own. It cannot have a correct one: only the
117
+ * first install on a document keeps its listener, so a plugin's buttons
118
+ * would otherwise be captioned by whichever provider registered first. */
119
+ export const CODE_COPY_IDLE_LABEL_ATTR = "data-code-copy-idle";
120
+ export const CODE_COPY_COPIED_LABEL_ATTR = "data-code-copy-copied";
121
+ // A fence tag is author-controlled text that lands in a class attribute,
122
+ // so anything outside the shape marked already treats as a language name
123
+ // is dropped rather than escaped — a tag is a word, and a "language"
124
+ // holding markup is a typo at best.
125
+ const SAFE_LANGUAGE = /^[A-Za-z0-9][A-Za-z0-9+#._-]{0,31}$/;
126
+ const languageOf = (token) => {
127
+ const lang = (token.lang ?? "").trim().split(/\s+/)[0] ?? "";
128
+ return SAFE_LANGUAGE.test(lang) ? lang : "";
129
+ };
130
+ // Two overlapping rounded rectangles — the copy glyph every editor and
131
+ // chat app uses. Inline rather than a `material-icons` class because the
132
+ // icon font is imported by the HOST (`src/main.ts`), while this markup
133
+ // also ships inside a plugin package a different host may load.
134
+ const ICON_ATTRS = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
135
+ /** The idle glyph. Exported so the click handler can swap it back. */
136
+ export const CODE_COPY_ICON = [
137
+ `<svg class="h-4 w-4" ${ICON_ATTRS}>`,
138
+ '<rect x="9" y="9" width="13" height="13" rx="2"></rect>',
139
+ '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>',
140
+ "</svg>",
141
+ ].join("");
142
+ /** The confirmation glyph shown for a moment after a successful copy. */
143
+ export const CODE_COPIED_ICON = [`<svg class="h-4 w-4" ${ICON_ATTRS}>`, '<path d="M20 6 9 17l-5-5"></path>', "</svg>"].join("");
144
+ /** Idle button classes. The handler tints the button on success rather
145
+ * than rewriting this list, so both states stay greppable. */
146
+ export const CODE_COPY_BUTTON_CLASS = [
147
+ "absolute top-2 right-2 z-10 inline-flex items-center justify-center",
148
+ "rounded-md border border-gray-300 bg-white/90 p-1.5 text-gray-600 shadow-sm",
149
+ "transition-colors hover:bg-gray-100 hover:text-gray-900",
150
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
151
+ ].join(" ");
152
+ export const codeCopyExtension = {
153
+ renderer: {
154
+ code(token) {
155
+ const language = languageOf(token);
156
+ // `escaped` is markedHighlight's flag that `text` is already html.
157
+ // Re-escaping it would turn `&quot;` into `&amp;quot;` and print the
158
+ // entity to the reader — the same trap documented in
159
+ // `mermaidExtension.ts`. Escape only when nobody has.
160
+ const body = token.escaped === true ? token.text : escapeHtml(token.text);
161
+ // Reproduces marked-highlight's own class shape (`langPrefix:
162
+ // "hljs language-"`, `emptyLangClass: "hljs"`) because this renderer
163
+ // replaces it rather than wrapping it — nothing calls through to
164
+ // highlight's renderer once we return a string.
165
+ const codeClass = language === "" ? "hljs" : `hljs language-${language}`;
166
+ const labels = labelProvider();
167
+ const idle = escapeHtml(labels.copy);
168
+ const copied = escapeHtml(labels.copied);
169
+ const style = token.codeBlockStyle === "indented" ? CODE_BLOCK_STYLE_INDENTED : CODE_BLOCK_STYLE_FENCED;
170
+ return [
171
+ `<div class="relative" ${CODE_COPY_BLOCK_ATTR}="${style}">`,
172
+ `<button type="button" ${CODE_COPY_ATTR}="${escapeHtml(nonce)}" ${CODE_COPY_IDLE_LABEL_ATTR}="${idle}" ${CODE_COPY_COPIED_LABEL_ATTR}="${copied}" class="${CODE_COPY_BUTTON_CLASS}" aria-label="${idle}" title="${idle}">`,
173
+ CODE_COPY_ICON,
174
+ "</button>",
175
+ // `dir="ltr"` isolates the block from an author's `dir="rtl"`
176
+ // wrapper. Without it the code renders right-aligned and a long
177
+ // line's LEFT end — where a payload would sit — scrolls out of
178
+ // view while the clipboard still takes the whole logical string.
179
+ // Measured: the rendered pixels change, the clipboard does not
180
+ // (codex round 2). Isolating the block rather than stripping the
181
+ // author's `dir` keeps legitimate right-to-left prose working.
182
+ `<pre dir="ltr"><code class="${codeClass}">${body}</code></pre>`,
183
+ "</div>\n",
184
+ ].join("");
185
+ },
186
+ },
187
+ };
@@ -23,7 +23,11 @@ async function loadMermaid() {
23
23
  // instead of letting mermaid walk the document on DOMContentLoaded.
24
24
  // `securityLevel: "strict"` — mermaid sanitises its own labels and
25
25
  // will not execute user-authored HTML/JS in diagram text.
26
- mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "default" });
26
+ // `layout` / `look` / `theme` mermaid 12 moved its defaults to ELK +
27
+ // redux-color + neo, which re-lays out and recolours every diagram that
28
+ // already exists. These three pin the pre-12 appearance; changing the
29
+ // look is a deliberate design decision, not a side effect of an upgrade.
30
+ mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "default", layout: "dagre", look: "classic" });
27
31
  return mermaid;
28
32
  });
29
33
  // Share the in-flight promise with parallel callers, but drop the
@@ -0,0 +1,14 @@
1
+ import type { MarkedExtension } from "marked";
2
+ /**
3
+ * Removes `class` and `style` from every tag in an HTML fragment, leaving
4
+ * text, comments and every other attribute byte-identical.
5
+ *
6
+ * Lexical on purpose: `marked` chunks raw HTML into pieces that are not
7
+ * well-formed — an opening `<div …>` and its `</div>` arrive as separate
8
+ * tokens — so parsing a chunk as a document and re-serialising it would
9
+ * close the first and delete the second.
10
+ */
11
+ export declare function stripPresentationAttributes(fragment: string): string;
12
+ /** Marked extension applying the rule. Register in every marked setup that
13
+ * renders markdown the app did not write. */
14
+ export declare const rawHtmlPolicyExtension: MarkedExtension;
@@ -0,0 +1,385 @@
1
+ // Author-supplied raw HTML in markdown may carry neither `class` nor
2
+ // `style`.
3
+ //
4
+ // WHY, and why the narrower rules do not work. Markdown passes raw HTML
5
+ // straight through, and DOMPurify's defaults keep both attributes. That
6
+ // lets a rendered file position content of its own over the app's output:
7
+ // a `<pre>` absolutely positioned across a fenced code block, with an
8
+ // opaque background, makes the reader see one command while the block
9
+ // underneath holds another — and both the copy button and a plain text
10
+ // selection then take the hidden one. Measured in a real browser
11
+ // (receptron/mulmoclaude#3151).
12
+ //
13
+ // Banning `style` alone does NOT close it. This app ships a utility-CSS
14
+ // framework, so the same overlay is available as `class="absolute inset-0
15
+ // bg-white pointer-events-none"` with no `style` anywhere — every one of
16
+ // those utilities is in the shipped stylesheet because the app's own UI
17
+ // uses them. `mathRender.ts` warned about exactly this: a utility-CSS
18
+ // framework turns a class name into positioning. A denylist of CSS
19
+ // properties or class names is the shape that comes back every time
20
+ // someone finds one more spelling, so the rule states what is PERMITTED:
21
+ // neither attribute, on author HTML only.
22
+ //
23
+ // It applies to author HTML ONLY, which is possible because `marked`
24
+ // routes raw HTML — and nothing else — through `renderer.html`. Highlight
25
+ // spans, the copy button, mermaid placeholders and wiki EMBEDS come from
26
+ // other renderers and keep their classes. The markdown-level sanitiser
27
+ // cannot make that distinction: by the time it runs, the two are one
28
+ // document.
29
+ //
30
+ // It once had to make an exception. `[[wiki-link]]` used to be a rewrite of the
31
+ // markdown SOURCE, so its span arrived here indistinguishable from author HTML
32
+ // and needed an unforgeable per-render nonce to keep its class. Wiki links are
33
+ // a marked extension now (#3164), so nothing app-generated reaches this
34
+ // renderer and the whole mechanism is gone — which is the point: a security
35
+ // control with one fewer moving part, rather than one more.
36
+ /** Attributes an author may not set. Presentation only — this is not the
37
+ * XSS boundary, which stays with DOMPurify. */
38
+ const FORBIDDEN = ["class", "style"];
39
+ const isAsciiLetter = (char) => (char >= "a" && char <= "z") || (char >= "A" && char <= "Z");
40
+ // HTML's whitespace is EXACTLY tab, LF, FF, CR and space. JavaScript's `\s`
41
+ // is wider — NBSP, vertical tab, U+2028 and more — and using it here made
42
+ // the scanner treat characters the tokenizer does not as attribute
43
+ // separators: `<div\u00a0class=x>` has no `class` attribute to a parser, yet
44
+ // it was being rewritten to `<div>` (codex round 6). Anywhere whitespace is
45
+ // HTML SYNTAX, it has to be this set and not `\s`.
46
+ const HTML_WHITESPACE = [" ", "\t", "\n", "\f", "\r"];
47
+ const isHtmlWhitespace = (char) => HTML_WHITESPACE.includes(char);
48
+ // Elements whose content the HTML parser reads as TEXT, not markup. Inside
49
+ // them a `<div class=x>` is characters a reader is meant to see, so
50
+ // rewriting it corrupts the document rather than protecting anyone —
51
+ // `<textarea><div class=foo></textarea>` was coming out as
52
+ // `<textarea><div></textarea>` (codex round 2). Only `textarea` currently
53
+ // survives the sanitiser; the rest are here because this helper is exported
54
+ // and must not depend on that staying true.
55
+ const RAW_TEXT_ELEMENTS = ["textarea", "title", "script", "style", "xmp", "iframe", "noembed", "noframes", "plaintext"];
56
+ /** `<plaintext>` has no end tag at all — it consumes the rest of the
57
+ * document, `</plaintext>` included. Treating it like the others resumed
58
+ * markup after a close tag no parser honours (codex round 3). */
59
+ const UNCLOSEABLE_RAW_TEXT = "plaintext";
60
+ /** True when `char` ends a TAG name. Deliberately NOT `endsAttributeName`:
61
+ * that also stops at `=`, which the tokenizer's tag-name state keeps as part
62
+ * of the name, so reusing it would leave `<style=foo>` reading as `style`. */
63
+ function endsTagName(char) {
64
+ if (char === undefined)
65
+ return true;
66
+ return isHtmlWhitespace(char) || char === "/" || char === ">";
67
+ }
68
+ /** Name of the tag starting at `start`, lower-cased; "" for a closing tag
69
+ * or anything that is not a plain element name.
70
+ *
71
+ * The name runs to whitespace, `/` or `>` — HTML's own boundary — and not to
72
+ * the end of a `[A-Za-z0-9-]` run. Stopping early made a name that merely
73
+ * BEGINS with a raw-text one read as that element: `<style:foo>` came back as
74
+ * `style`, so its contents were copied verbatim and a nested
75
+ * `<pre class="absolute" style="position:absolute">` survived (codex round 17).
76
+ * The attribute-name boundary had the identical bug in round 8 (`classé`); this
77
+ * is the same rule one level up. */
78
+ function openingTagName(fragment, start) {
79
+ if (fragment[start] !== "<" || !isAsciiLetter(fragment[start + 1] ?? ""))
80
+ return "";
81
+ let index = start + 1;
82
+ while (index < fragment.length && !endsTagName(fragment[index]))
83
+ index += 1;
84
+ return fragment.slice(start + 1, index).toLowerCase();
85
+ }
86
+ /** Index just past a `<!…>` run: `-->` for a real comment, the first `>`
87
+ * for a doctype or bogus comment.
88
+ *
89
+ * `<!-->` and `<!--->` are COMPLETE empty comments — the spec's
90
+ * abrupt-closing-of-empty-comment, reached from comment-start and
91
+ * comment-start-dash — so the markup after them is LIVE. Searching only for
92
+ * `-->` from `start + 4` misses both, and the scanner then copied the rest of
93
+ * the fragment verbatim: `<!---><pre class="absolute inset-0 bg-white">` kept
94
+ * its class all the way through marked and the sanitiser, which is the overlay
95
+ * this file exists to stop (codex round 21, P1). */
96
+ function commentEnd(fragment, start) {
97
+ if (fragment.startsWith("<!--", start)) {
98
+ if (fragment[start + 4] === ">")
99
+ return start + 5;
100
+ if (fragment[start + 4] === "-" && fragment[start + 5] === ">")
101
+ return start + 6;
102
+ return commentBodyEnd(fragment, start + 4);
103
+ }
104
+ const close = fragment.indexOf(">", start);
105
+ return close === -1 ? fragment.length : close + 1;
106
+ }
107
+ /** Index just past whichever closes the comment first: `-->`, or `--!>` — the
108
+ * spec's incorrectly-closed-comment, reached from comment-end-bang. Knowing
109
+ * only `-->` made `<!--a--!>` read as unterminated, so the live markup after it
110
+ * was swallowed and `<div class="absolute inset-0" style="position:absolute">`
111
+ * kept both attributes. Same shape as the `<!--->` bypass, one variant along. */
112
+ function commentBodyEnd(fragment, from) {
113
+ const plain = fragment.indexOf("-->", from);
114
+ const bang = fragment.indexOf("--!>", from);
115
+ if (plain === -1 && bang === -1)
116
+ return fragment.length;
117
+ if (bang === -1 || (plain !== -1 && plain <= bang))
118
+ return plain + 3;
119
+ return bang + 4;
120
+ }
121
+ /** Index just past the element's APPROPRIATE END TAG, searched from `from`.
122
+ *
123
+ * "Appropriate" is HTML's own definition, not a rule of mine: the name has
124
+ * to be followed by whitespace, `/` or `>`. A prefix match ends raw text at
125
+ * `</textareax>`, which no parser does — verified against jsdom, where
126
+ * `<textarea>keep </textareax><div class=foo></textarea>` has the VALUE
127
+ * `keep </textareax><div class=foo>` (codex round 3).
128
+ *
129
+ * The END of the fragment when there is no such tag, which is again what the
130
+ * parser does, and the right answer when `marked` has split the element
131
+ * across chunks. */
132
+ function rawTextEnd(fragment, from, name) {
133
+ if (name === UNCLOSEABLE_RAW_TEXT)
134
+ return fragment.length;
135
+ const lowered = fragment.toLowerCase();
136
+ const needle = `</${name}`;
137
+ let search = from;
138
+ for (;;) {
139
+ const close = lowered.indexOf(needle, search);
140
+ if (close === -1)
141
+ return fragment.length;
142
+ const after = fragment[close + needle.length] ?? ">";
143
+ if (after === ">" || after === "/" || isHtmlWhitespace(after)) {
144
+ const closeEnd = fragment.indexOf(">", close);
145
+ return closeEnd === -1 ? fragment.length : closeEnd + 1;
146
+ }
147
+ search = close + 1;
148
+ }
149
+ }
150
+ /** True when `</` at `index` is NOT an end tag. HTML's end-tag-open state takes
151
+ * an ASCII letter and nothing else: `>` drops the token, EOF emits the two
152
+ * characters, and anything else opens a BOGUS COMMENT that runs to the next
153
+ * `>`. All three want the bytes copied through untouched — and the last one
154
+ * matters, because the text inside is comment content a parser never treats as
155
+ * markup: `</é <span class=x>>` was having its inner span rewritten
156
+ * (codex rounds 19-20). */
157
+ function isBogusEndTag(fragment, index) {
158
+ return fragment[index + 1] === "/" && !isAsciiLetter(fragment[index + 2] ?? "");
159
+ }
160
+ /** True when `<?` opens a bogus comment. Tag-open takes `?` as an
161
+ * unexpected-question-mark-instead-of-tag-name parse error and reconsumes in
162
+ * bogus comment state, so everything to the next `>` is comment TEXT. Copying
163
+ * only the `<` and scanning on rewrote that text: `<?x <span class=y>>` lost
164
+ * the class a parser keeps (codex round 23). Every other non-letter after `<`
165
+ * is literal text and needs no special case — the bytes are copied either way. */
166
+ function isProcessingInstruction(fragment, index) {
167
+ return fragment[index + 1] === "?";
168
+ }
169
+ /** True when `<` at `index` opens a tag rather than being literal text.
170
+ * `a < b` in prose must survive untouched.
171
+ *
172
+ * A closing tag needs an ASCII LETTER after the `/`, which is HTML's own rule:
173
+ * end-tag-open only enters end-tag-name for a letter, and treats anything else
174
+ * as a bogus comment. Accepting any `</` rewrote parser COMMENT text as if it
175
+ * were a tag — `</ class=x>` came back as `</>`, while a parser reads the
176
+ * original as `<!-- class=x-->` (codex round 19). Left alone the bytes are
177
+ * copied through and the parser still sees its comment. */
178
+ function opensTag(fragment, index) {
179
+ const next = fragment[index + 1];
180
+ if (next === undefined)
181
+ return false;
182
+ if (next === "/")
183
+ return isAsciiLetter(fragment[index + 2] ?? "");
184
+ return isAsciiLetter(next);
185
+ }
186
+ /** Index just past the `>` that closes the tag starting at `start`, with
187
+ * quoted attribute values skipped so a `>` inside one does not end it.
188
+ * Returns the string length for an unterminated tag. */
189
+ function tagEnd(fragment, start) {
190
+ let quote = "";
191
+ // A quote opens a VALUE only after `=`. In attribute-name position a
192
+ // quote is a parse error that the tokenizer keeps as part of the name,
193
+ // so treating every quote as a value delimiter swallowed the rest of
194
+ // the tag: `<div ">text class=x</div>` lost its literal text, because
195
+ // the `"` was read as opening a value that ran past the `>`
196
+ // (codex round 5).
197
+ let afterEquals = false;
198
+ for (let index = start; index < fragment.length; index += 1) {
199
+ const char = fragment[index] ?? "";
200
+ if (quote !== "") {
201
+ if (char === quote)
202
+ quote = "";
203
+ continue;
204
+ }
205
+ if (char === ">")
206
+ return index + 1;
207
+ if (char === "=") {
208
+ afterEquals = true;
209
+ continue;
210
+ }
211
+ // Whitespace between `=` and the value does not end the wait for one.
212
+ if (isHtmlWhitespace(char))
213
+ continue;
214
+ if (afterEquals && (char === '"' || char === "'")) {
215
+ quote = char;
216
+ }
217
+ afterEquals = false;
218
+ }
219
+ return fragment.length;
220
+ }
221
+ /** Index just past an attribute value beginning at `start` (which may be
222
+ * quoted or bare). */
223
+ function valueEnd(tag, start) {
224
+ const first = tag[start];
225
+ if (first === '"' || first === "'") {
226
+ const close = tag.indexOf(first, start + 1);
227
+ return close === -1 ? tag.length : close + 1;
228
+ }
229
+ let index = start;
230
+ while (index < tag.length && !isHtmlWhitespace(tag[index] ?? ">") && tag[index] !== ">")
231
+ index += 1;
232
+ return index;
233
+ }
234
+ /** Length of the `=value` that may follow an attribute name at `from`,
235
+ * including surrounding whitespace. Zero when the attribute is bare. */
236
+ function assignmentLength(tag, from) {
237
+ let index = from;
238
+ while (index < tag.length && isHtmlWhitespace(tag[index] ?? ""))
239
+ index += 1;
240
+ if (tag[index] !== "=")
241
+ return { length: 0, quoted: false };
242
+ index += 1;
243
+ while (index < tag.length && isHtmlWhitespace(tag[index] ?? ""))
244
+ index += 1;
245
+ const first = tag[index];
246
+ return { length: valueEnd(tag, index) - from, quoted: first === '"' || first === "'" };
247
+ }
248
+ /** True when `char` ends an attribute name rather than continuing it. */
249
+ function endsAttributeName(char) {
250
+ if (char === undefined)
251
+ return true;
252
+ return isHtmlWhitespace(char) || char === "=" || char === "/" || char === ">";
253
+ }
254
+ /** Index just past `<`, an optional `/`, and the tag name.
255
+ *
256
+ * The attribute walk has to START here. Beginning at the `<` instead made the
257
+ * NAME of a closing tag read as an attribute, because `/` is an attribute
258
+ * separator: `</style=foo>` matched separator `/` + name `style`, which is
259
+ * FORBIDDEN, and the tag was eaten down to `<>`. It stayed invisible only
260
+ * because a real `</style>` is reached through `rawTextEnd` and never comes
261
+ * here — a name that merely begins with a raw-text one does. */
262
+ function tagNameEnd(tag) {
263
+ let index = tag[1] === "/" ? 2 : 1;
264
+ while (index < tag.length && !endsTagName(tag[index]))
265
+ index += 1;
266
+ return index;
267
+ }
268
+ /** Index just past a run of separators — whitespace, or a `/` that is not
269
+ * closing the tag. HTML's before-attribute-name state treats such a `/` as a
270
+ * parse error and then reads an attribute name anyway, so
271
+ * `<div /class="absolute">` really does set a class (codex round 4, P1). */
272
+ function separatorEnd(tag, from) {
273
+ let index = from;
274
+ while (index < tag.length && (isHtmlWhitespace(tag[index] ?? "") || tag[index] === "/"))
275
+ index += 1;
276
+ return index;
277
+ }
278
+ function readAttribute(tag, nameStart) {
279
+ // An `=` in before-attribute-name is a parse error that becomes the name's
280
+ // FIRST character, not a value separator — `<div ="x" class="y">` really has
281
+ // an attribute called `="x"` and then a separate `class`. Treating it as a
282
+ // boundary made the walk abandon the rest of the tag, so that `class`
283
+ // survived (found re-auditing the rewrite against the tokenizer, round 19).
284
+ let index = nameStart + (tag[nameStart] === "=" ? 1 : 0);
285
+ while (index < tag.length && !endsAttributeName(tag[index]))
286
+ index += 1;
287
+ return { end: index + assignmentLength(tag, index).length, name: tag.slice(nameStart, index) };
288
+ }
289
+ /** Whether the separators before a dropped attribute may go with it. They may,
290
+ * unless the next attribute has none of its own — which HTML allows directly
291
+ * after a quoted value, and where dropping them fuses `<div class="x"id="a">`
292
+ * into `<divid="a">`. */
293
+ function separatorsAreSpare(tag, after) {
294
+ const next = tag[after];
295
+ return next === undefined || next === ">" || next === "/" || isHtmlWhitespace(next);
296
+ }
297
+ /** Removes the forbidden attributes from ONE tag's source text. */
298
+ function stripFromTag(tag) {
299
+ let index = tagNameEnd(tag);
300
+ const out = [tag.slice(0, index)];
301
+ while (index < tag.length) {
302
+ const nameStart = separatorEnd(tag, index);
303
+ if (nameStart >= tag.length || tag[nameStart] === ">") {
304
+ out.push(tag.slice(index));
305
+ break;
306
+ }
307
+ const attribute = readAttribute(tag, nameStart);
308
+ if (FORBIDDEN.includes(attribute.name.toLowerCase()))
309
+ out.push(separatorsAreSpare(tag, attribute.end) ? "" : " ");
310
+ else
311
+ out.push(tag.slice(index, attribute.end));
312
+ index = attribute.end;
313
+ }
314
+ return out.join("");
315
+ }
316
+ /**
317
+ * Removes `class` and `style` from every tag in an HTML fragment, leaving
318
+ * text, comments and every other attribute byte-identical.
319
+ *
320
+ * Lexical on purpose: `marked` chunks raw HTML into pieces that are not
321
+ * well-formed — an opening `<div …>` and its `</div>` arrive as separate
322
+ * tokens — so parsing a chunk as a document and re-serialising it would
323
+ * close the first and delete the second.
324
+ */
325
+ export function stripPresentationAttributes(fragment) {
326
+ const out = [];
327
+ let index = 0;
328
+ while (index < fragment.length) {
329
+ const char = fragment[index] ?? "";
330
+ if (char !== "<") {
331
+ out.push(char);
332
+ index += 1;
333
+ continue;
334
+ }
335
+ // Comments, doctype, CDATA, bogus end tags and `<?…>` processing
336
+ // instructions are copied verbatim; they carry no attributes and their
337
+ // contents must not be treated as tags.
338
+ //
339
+ // `<![CDATA[` is read as HTML's bogus comment — ending at the first `>` —
340
+ // which is right everywhere markdown raw HTML actually lands. Inside
341
+ // FOREIGN content (`<svg>`, `<math>`) a parser would instead run it to
342
+ // `]]>`, so a `>` in the body diverges. Not fixed on purpose: knowing
343
+ // whether we are in foreign content needs element state that cannot survive
344
+ // marked handing raw HTML over in chunks, and the divergence only ever
345
+ // rewrites text a parser keeps — it cannot let an attribute through.
346
+ // Where each one ENDS is the whole question, and both directions have been
347
+ // wrong. Too early: a comment runs to `-->`, not to the first `>`, or its
348
+ // later text gets rewritten (round 3). Too late: `<!-->`, `<!--->` and
349
+ // `--!>` all end a comment where a `-->` search does not find one, and the
350
+ // live markup after them was swallowed and kept its class (rounds 21-22).
351
+ // `commentEnd` owns every one of those rules.
352
+ if (fragment.startsWith("<!", index) || isBogusEndTag(fragment, index) || isProcessingInstruction(fragment, index)) {
353
+ const end = commentEnd(fragment, index);
354
+ out.push(fragment.slice(index, end));
355
+ index = end;
356
+ continue;
357
+ }
358
+ if (!opensTag(fragment, index)) {
359
+ out.push(char);
360
+ index += 1;
361
+ continue;
362
+ }
363
+ const end = tagEnd(fragment, index);
364
+ out.push(stripFromTag(fragment.slice(index, end)));
365
+ const name = openingTagName(fragment, index);
366
+ if (RAW_TEXT_ELEMENTS.includes(name)) {
367
+ // Copy the element's text content — and its close tag — verbatim.
368
+ const rawEnd = rawTextEnd(fragment, end, name);
369
+ out.push(fragment.slice(end, rawEnd));
370
+ index = rawEnd;
371
+ continue;
372
+ }
373
+ index = end;
374
+ }
375
+ return out.join("");
376
+ }
377
+ /** Marked extension applying the rule. Register in every marked setup that
378
+ * renders markdown the app did not write. */
379
+ export const rawHtmlPolicyExtension = {
380
+ renderer: {
381
+ html(token) {
382
+ return stripPresentationAttributes(token.text);
383
+ },
384
+ },
385
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/markdown-utils",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "Browser-safe markdown / image rendering utilities shared by the MulmoClaude host and the markdown plugin",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,19 +33,19 @@
33
33
  "license": "MIT",
34
34
  "author": "Receptron Team",
35
35
  "dependencies": {
36
- "@mulmoclaude/common": "^1.2.0",
37
- "dompurify": "^3.4.13",
38
- "js-yaml": "^5.4.1",
39
- "marked": "^18.0.11"
36
+ "@mulmoclaude/common": "^1.3.0",
37
+ "dompurify": "^3.4.15",
38
+ "js-yaml": "^5.4.2",
39
+ "marked": "^18.0.13"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "mathjax-full": "^3.2.2",
43
- "mermaid": "^11.16.1",
43
+ "mermaid": "^12.0.0",
44
44
  "vue": "^3.5.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "mathjax-full": "^3.2.2",
48
- "mermaid": "^11.17.2",
48
+ "mermaid": "^12.0.0",
49
49
  "typescript": "^6.0.3",
50
50
  "vue": "^3.5.42"
51
51
  },