@mulmoclaude/markdown-utils 1.3.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dom/externalLink.js
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
// invoke the wrapper from their own `@click` handler and check the
|
|
9
9
|
// return value to decide whether to fall through to plugin-specific
|
|
10
10
|
// navigation.
|
|
11
|
+
// `nodeType`, not `instanceof Element`: this package is also consumed outside a
|
|
12
|
+
// browser (the Node + jsdom test harness installs `window`/`document` but not
|
|
13
|
+
// the DOM constructors), where the bare `Element` global is a ReferenceError.
|
|
14
|
+
// nodeType 1 is the DOM standard's own element discriminant, and it stops at
|
|
15
|
+
// Element rather than HTMLElement because an inline <svg> inside a link is a
|
|
16
|
+
// legitimate click target that must still resolve to its anchor.
|
|
17
|
+
const ELEMENT_NODE = 1;
|
|
18
|
+
const isElementNode = (target) => target !== null && "nodeType" in target && target.nodeType === ELEMENT_NODE;
|
|
11
19
|
// Pure predicate: is `href` an absolute http(s) URL pointing at an
|
|
12
20
|
// origin different from `currentOrigin`? Used by
|
|
13
21
|
// `handleExternalLinkClick` below, and directly by tests.
|
|
@@ -42,8 +50,8 @@ export function handleExternalLinkClick(event) {
|
|
|
42
50
|
return false;
|
|
43
51
|
if (event.ctrlKey || event.metaKey || event.shiftKey)
|
|
44
52
|
return false;
|
|
45
|
-
const target = event
|
|
46
|
-
if (!target)
|
|
53
|
+
const { target } = event;
|
|
54
|
+
if (!isElementNode(target))
|
|
47
55
|
return false;
|
|
48
56
|
const anchor = target.closest("a");
|
|
49
57
|
if (!anchor)
|
|
@@ -141,10 +141,24 @@ const RESOLVABLE_TAG_OUTER_RE = /<(?:img|source|video|audio)\b(?:[^>"']|"[^"]*"|
|
|
|
141
141
|
// look like a tag.
|
|
142
142
|
const TAG_NAME_RE = /^<([a-z]+)/i;
|
|
143
143
|
// Attribute iterator: walks each `name=value` pair inside a tag. The
|
|
144
|
-
// leading `\s
|
|
144
|
+
// leading `\s` ensures we only match real attribute boundaries, not
|
|
145
145
|
// `src=` text embedded inside another attribute's quoted value.
|
|
146
|
+
//
|
|
147
|
+
// That leading class is a single `\s`, NOT `\s+`, and the difference is
|
|
148
|
+
// quadratic runtime rather than style. With `\s+`, a run of N spaces that
|
|
149
|
+
// is not followed by an attribute name makes the engine match all N, fail
|
|
150
|
+
// `[A-Za-z]`, then backtrack through every shorter length — repeated from
|
|
151
|
+
// every start position inside the run, so `<img` + N spaces + `!>` costs
|
|
152
|
+
// O(N²) (measured 4x per doubling; ~1.7s at N=32k). Matching one
|
|
153
|
+
// whitespace char cannot backtrack, so the same input is linear.
|
|
154
|
+
//
|
|
155
|
+
// Output is unaffected: under the `g` flag the scan advances one char at a
|
|
156
|
+
// time, so the match still lands on the whitespace immediately before the
|
|
157
|
+
// name. Any earlier whitespace simply falls outside the match and is
|
|
158
|
+
// copied through verbatim by `replace`.
|
|
159
|
+
//
|
|
146
160
|
// Capture groups:
|
|
147
|
-
// 1:
|
|
161
|
+
// 1: the single whitespace char before the attribute name
|
|
148
162
|
// 2: attribute name
|
|
149
163
|
// 3: `=` with surrounding spaces (only when value present)
|
|
150
164
|
// 4: full quoted/unquoted value (unused but captured for clarity)
|
|
@@ -155,8 +169,8 @@ const TAG_NAME_RE = /^<([a-z]+)/i;
|
|
|
155
169
|
// quote as the value
|
|
156
170
|
//
|
|
157
171
|
// All quantifiers bounded — verified ReDoS-safe in test_htmlSrcAttrs.ts.
|
|
158
|
-
// eslint-disable-next-line sonarjs/
|
|
159
|
-
const ATTR_ITER_RE = /(\s
|
|
172
|
+
// eslint-disable-next-line sonarjs/regex-complexity, security/detect-unsafe-regex -- bounded quantifiers, ReDoS-safe (test in test_htmlSrcAttrs.ts)
|
|
173
|
+
const ATTR_ITER_RE = /(\s)([A-Za-z][\w:-]*)(?:(\s*=\s*)("([^"]*)"|'([^']*)'|([^\s>"'][^\s>]*)))?/g;
|
|
160
174
|
/** Transform every URL-bearing attribute on a recognised tag.
|
|
161
175
|
*
|
|
162
176
|
* `transform` is invoked once per matching attribute value. Return:
|
|
@@ -189,8 +203,17 @@ export function transformResolvableUrlsInHtml(html, transform) {
|
|
|
189
203
|
return tag.replace(ATTR_ITER_RE, (...captures) => replaceAttrIfResolvable(captures, resolvableAttrs ?? [], srcsetAttrs ?? [], transform));
|
|
190
204
|
});
|
|
191
205
|
}
|
|
206
|
+
// Named view of `ATTR_ITER_RE`'s capture list (group 4, the full quoted
|
|
207
|
+
// value, is captured for clarity and skipped here). Groups that never
|
|
208
|
+
// participate come back `undefined`; the three the regex always fills fall
|
|
209
|
+
// back to `""` so a non-string can only ever blank the attribute, never
|
|
210
|
+
// reach the rewriter as if it were text.
|
|
211
|
+
function readAttrCaptures(captures) {
|
|
212
|
+
const [full, leading, name, eqWithSpaces, , doubleQuoted, singleQuoted, bare] = captures.map((value) => (typeof value === "string" ? value : undefined));
|
|
213
|
+
return { full: full ?? "", leading: leading ?? "", name: name ?? "", eqWithSpaces, doubleQuoted, singleQuoted, bare };
|
|
214
|
+
}
|
|
192
215
|
function replaceAttrIfResolvable(captures, resolvableAttrs, srcsetAttrs, transform) {
|
|
193
|
-
const
|
|
216
|
+
const { full, leading, name, eqWithSpaces, doubleQuoted, singleQuoted, bare } = readAttrCaptures(captures);
|
|
194
217
|
if (!eqWithSpaces)
|
|
195
218
|
return full;
|
|
196
219
|
const lowerName = name.toLowerCase();
|
|
@@ -89,6 +89,9 @@ function extractBracketedAlt(raw) {
|
|
|
89
89
|
}
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
|
+
// Declares the four fields it reads rather than the whole `Tokens.Image`:
|
|
93
|
+
// `type === "image"` leaves marked's open `Tokens.Generic` member in the
|
|
94
|
+
// union, and every read below already tolerates a missing value.
|
|
92
95
|
function rewriteImageToken(token, basePath) {
|
|
93
96
|
const href = (token.href ?? "").trim();
|
|
94
97
|
if (href === "" || shouldSkip(href))
|
|
@@ -147,15 +150,24 @@ export function rewriteImgSrcAttrsInHtml(html, basePath) {
|
|
|
147
150
|
function isSkippable(token) {
|
|
148
151
|
return token.type === "code" || token.type === "codespan";
|
|
149
152
|
}
|
|
153
|
+
// `Tokens.Generic` — the open member of marked's `Token` union — requires
|
|
154
|
+
// only a string `type` and a string `raw`, so proving those two is enough
|
|
155
|
+
// to call a value a `Token`.
|
|
156
|
+
function isToken(value) {
|
|
157
|
+
if (typeof value !== "object" || value === null)
|
|
158
|
+
return false;
|
|
159
|
+
return "type" in value && typeof value.type === "string" && "raw" in value && typeof value.raw === "string";
|
|
160
|
+
}
|
|
161
|
+
function toTokenArray(value) {
|
|
162
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
163
|
+
return null;
|
|
164
|
+
const candidates = value;
|
|
165
|
+
return candidates.every(isToken) ? candidates : null;
|
|
166
|
+
}
|
|
150
167
|
function getContainerChildren(token) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
if (Array.isArray(container.items) && container.items.length > 0) {
|
|
156
|
-
return container.items;
|
|
157
|
-
}
|
|
158
|
-
return null;
|
|
168
|
+
const tokens = "tokens" in token ? token.tokens : undefined;
|
|
169
|
+
const items = "items" in token ? token.items : undefined;
|
|
170
|
+
return toTokenArray(tokens) ?? toTokenArray(items);
|
|
159
171
|
}
|
|
160
172
|
// Render a container's children back into the output, preserving any
|
|
161
173
|
// structural glue the parent carries outside the children's combined
|
|
@@ -163,7 +175,7 @@ function getContainerChildren(token) {
|
|
|
163
175
|
// Returns true if the container was rendered via its children, false
|
|
164
176
|
// if the caller should fall back to emitting the parent's raw.
|
|
165
177
|
function renderContainerChildren(raw, children, basePath, out) {
|
|
166
|
-
const joined = children.map((token) => token.raw
|
|
178
|
+
const joined = children.map((token) => token.raw).join("");
|
|
167
179
|
if (joined === "")
|
|
168
180
|
return false;
|
|
169
181
|
const idx = raw.indexOf(joined);
|
|
@@ -201,13 +213,11 @@ function renderToken(token, basePath, out) {
|
|
|
201
213
|
// Block / inline HTML — rewrite raw <img> tags inside before
|
|
202
214
|
// emitting. Markdown image syntax () is handled by the
|
|
203
215
|
// image-token branch above; this branch covers the HTML-fallback
|
|
204
|
-
// path (#1011 Stage A).
|
|
205
|
-
|
|
206
|
-
const raw = token.raw ?? "";
|
|
207
|
-
out.push(rewriteImgSrcAttrsInHtml(raw, basePath));
|
|
216
|
+
// path (#1011 Stage A).
|
|
217
|
+
out.push(rewriteImgSrcAttrsInHtml(token.raw, basePath));
|
|
208
218
|
return;
|
|
209
219
|
}
|
|
210
|
-
const raw = token
|
|
220
|
+
const { raw } = token;
|
|
211
221
|
const children = getContainerChildren(token);
|
|
212
222
|
if (children && renderContainerChildren(raw, children, basePath, out)) {
|
|
213
223
|
return;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// lists, multi-line strings, escaping) instead of the regex
|
|
11
11
|
// approximation in the legacy `src/utils/format/frontmatter.ts`.
|
|
12
12
|
import { FAILSAFE_SCHEMA, dump as yamlDump, load as yamlLoad } from "js-yaml";
|
|
13
|
+
import { isRecord } from "@mulmoclaude/common";
|
|
13
14
|
const FRONTMATTER_OPEN = /^---\r?\n/;
|
|
14
15
|
// `(?:^|\r?\n)` lets the closing fence sit at the very start of
|
|
15
16
|
// `afterOpen` — needed for the empty-envelope case `---\n---\n`
|
|
@@ -116,7 +117,7 @@ function safeYamlLoad(text) {
|
|
|
116
117
|
// plain objects — anything else is a malformed header.
|
|
117
118
|
if (loaded === null || loaded === undefined)
|
|
118
119
|
return {};
|
|
119
|
-
if (
|
|
120
|
+
if (!isRecord(loaded))
|
|
120
121
|
return null;
|
|
121
122
|
return loaded;
|
|
122
123
|
}
|
package/package.json
CHANGED