@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.
@@ -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.target;
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+` ensures we only match real attribute boundaries, not
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: leading whitespace
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/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;
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 [full, leading, name, eqWithSpaces, , doubleQuoted, singleQuoted, bare] = captures;
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 container = token;
152
- if (Array.isArray(container.tokens) && container.tokens.length > 0) {
153
- return container.tokens;
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 ?? "").join("");
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 (![alt](url)) is handled by the
203
215
  // image-token branch above; this branch covers the HTML-fallback
204
- // path (#1011 Stage A). Fall back to verbatim raw if `raw` is
205
- // unexpectedly missing — defensive against future marked changes.
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.raw ?? "";
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 (typeof loaded !== "object" || Array.isArray(loaded))
120
+ if (!isRecord(loaded))
120
121
  return null;
121
122
  return loaded;
122
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/markdown-utils",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Browser-safe markdown / image rendering utilities shared by the MulmoClaude host and the markdown plugin",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",