@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 +3 -0
- package/dist/dom/externalLink.d.ts +2 -0
- package/dist/dom/externalLink.js +61 -0
- package/dist/files/filename.d.ts +7 -0
- package/dist/files/filename.js +33 -0
- package/dist/image/cacheBust.d.ts +2 -0
- package/dist/image/cacheBust.js +13 -0
- package/dist/image/htmlSrcAttrs.d.ts +21 -0
- package/dist/image/htmlSrcAttrs.js +211 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/markdown/extractFirstH1.d.ts +10 -0
- package/dist/markdown/extractFirstH1.js +39 -0
- package/dist/markdown/frontmatter.d.ts +39 -0
- package/dist/markdown/frontmatter.js +113 -0
- package/dist/markdown/marpCustomSize.d.ts +15 -0
- package/dist/markdown/marpCustomSize.js +102 -0
- package/dist/markdown/marpDetect.d.ts +1 -0
- package/dist/markdown/marpDetect.js +16 -0
- package/dist/markdown/marpTheme.d.ts +28 -0
- package/dist/markdown/marpTheme.js +99 -0
- package/dist/markdown/taskList.d.ts +36 -0
- package/dist/markdown/taskList.js +170 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -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,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
|
+
}
|
package/dist/index.d.ts
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";
|
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
|
+
}
|