@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 +3 -0
- package/dist/index.js +3 -0
- package/dist/markdown/codeCopyClipboard.d.ts +13 -0
- package/dist/markdown/codeCopyClipboard.js +172 -0
- package/dist/markdown/codeCopyExtension.d.ts +77 -0
- package/dist/markdown/codeCopyExtension.js +187 -0
- package/dist/markdown/mermaidRender.js +5 -1
- package/dist/markdown/rawHtmlPolicy.d.ts +14 -0
- package/dist/markdown/rawHtmlPolicy.js +385 -0
- package/package.json +7 -7
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 `"` into `&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
|
-
|
|
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": "
|
|
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.
|
|
37
|
-
"dompurify": "^3.4.
|
|
38
|
-
"js-yaml": "^5.4.
|
|
39
|
-
"marked": "^18.0.
|
|
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": "^
|
|
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": "^
|
|
48
|
+
"mermaid": "^12.0.0",
|
|
49
49
|
"typescript": "^6.0.3",
|
|
50
50
|
"vue": "^3.5.42"
|
|
51
51
|
},
|