@takazudo/zudo-doc 5.22.1 → 5.24.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.
@@ -3,7 +3,7 @@ import { Fragment as Fragment2 } from "preact";
3
3
  import { Island } from "@takazudo/zfb";
4
4
  import { AutoLogo } from "../auto-logo/index.js";
5
5
  import { DocLayoutWithDefaults } from "../doclayout/index.js";
6
- import { CompactProse, resolveHomeIntro } from "../home-intro/index.js";
6
+ import { CompactProse, HOME_SECTION_HEADING_CLASS, resolveHomeIntro } from "../home-intro/index.js";
7
7
  import { SiteTreeNav } from "../site-tree-nav-island/index.js";
8
8
  import { TagNav } from "../nav-indexing/tag-nav.js";
9
9
  import { CategoryLinkIcon } from "../tree-nav-shared/index.js";
@@ -14,7 +14,7 @@ import { derivePrimaryChromeSlots } from "../chrome/primary-slots.js";
14
14
  import { assertChromeContext } from "../chrome/assert-chrome-context.js";
15
15
  import { prepareHomeData } from "./prepare-home-data.js";
16
16
  function HomeSectionHeading({ children }) {
17
- return /* @__PURE__ */ jsx("h2", { class: "text-title font-bold mb-vsp-md", children });
17
+ return /* @__PURE__ */ jsx("h2", { class: `${HOME_SECTION_HEADING_CLASS} mb-vsp-md`, children });
18
18
  }
19
19
  function cssUrl(url) {
20
20
  return `url("${url.replace(/[\\"]/g, "\\$&")}")`;
@@ -161,47 +161,50 @@ function createHomePageView(ctx) {
161
161
  )
162
162
  })
163
163
  ] }),
164
- settings.docTags && tagCount > 0 && /* @__PURE__ */ jsx("section", { class: "mt-vsp-xl", children: tags && tags.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
165
- /* @__PURE__ */ jsx(HomeSectionHeading, { children: t("doc.tags", locale) }),
166
- /* @__PURE__ */ jsx(
167
- TagNav,
168
- {
169
- variant: "all",
170
- tags: tags.slice(0, tagLimit),
171
- labels: {
172
- tags: t("doc.tags", locale),
173
- taggedWith: t("doc.taggedWith", locale)
174
- }
175
- }
176
- ),
177
- tags.length > tagLimit && /* @__PURE__ */ jsx("div", { class: "mt-vsp-sm text-title text-muted", "aria-hidden": "true", children: "\u2026" }),
178
- /* @__PURE__ */ jsx("div", { class: "mt-vsp-md", children: /* @__PURE__ */ jsxs(
179
- "a",
180
- {
181
- href: withBase(`${prefix}/docs/tags`),
182
- class: "group inline-flex items-center gap-hsp-xs text-accent hover:underline",
183
- children: [
184
- /* @__PURE__ */ jsx(CategoryLinkIcon, { className: "w-icon-sm text-accent" }),
185
- /* @__PURE__ */ jsx("span", { children: t("doc.seeAllTags", locale) })
186
- ]
187
- }
188
- ) })
189
- ] }) : (
190
- // Legacy fallback: caller passed only `tagCount` (no tag list) —
191
- // reproduce the pre-#3027 single "All Tags" link so existing
192
- // `@takazudo/zudo-doc/home-page` consumers are unaffected.
193
- /* @__PURE__ */ jsxs(Fragment, { children: [
194
- /* @__PURE__ */ jsx(HomeSectionHeading, { children: t("doc.allTags", locale) }),
164
+ settings.docTags && tagCount > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
165
+ /* @__PURE__ */ jsx("hr", { class: "zd-home-rule", "data-home-rule": "tags" }),
166
+ /* @__PURE__ */ jsx("section", { class: "zd-home-tags", children: tags && tags.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
167
+ /* @__PURE__ */ jsx(HomeSectionHeading, { children: t("doc.tags", locale) }),
195
168
  /* @__PURE__ */ jsx(
169
+ TagNav,
170
+ {
171
+ variant: "all",
172
+ tags: tags.slice(0, tagLimit),
173
+ labels: {
174
+ tags: t("doc.tags", locale),
175
+ taggedWith: t("doc.taggedWith", locale)
176
+ }
177
+ }
178
+ ),
179
+ tags.length > tagLimit && /* @__PURE__ */ jsx("div", { class: "mt-vsp-sm text-title text-muted", "aria-hidden": "true", children: "\u2026" }),
180
+ /* @__PURE__ */ jsx("div", { class: "mt-vsp-md", children: /* @__PURE__ */ jsxs(
196
181
  "a",
197
182
  {
198
183
  href: withBase(`${prefix}/docs/tags`),
199
- class: "text-accent underline hover:text-accent-hover",
200
- children: t("doc.allTags", locale)
184
+ class: "group inline-flex items-center gap-hsp-xs text-accent hover:underline",
185
+ children: [
186
+ /* @__PURE__ */ jsx(CategoryLinkIcon, { className: "w-icon-sm text-accent" }),
187
+ /* @__PURE__ */ jsx("span", { children: t("doc.seeAllTags", locale) })
188
+ ]
201
189
  }
202
- )
203
- ] })
204
- ) })
190
+ ) })
191
+ ] }) : (
192
+ // Legacy fallback: caller passed only `tagCount` (no tag list) —
193
+ // reproduce the pre-#3027 single "All Tags" link so existing
194
+ // `@takazudo/zudo-doc/home-page` consumers are unaffected.
195
+ /* @__PURE__ */ jsxs(Fragment, { children: [
196
+ /* @__PURE__ */ jsx(HomeSectionHeading, { children: t("doc.allTags", locale) }),
197
+ /* @__PURE__ */ jsx(
198
+ "a",
199
+ {
200
+ href: withBase(`${prefix}/docs/tags`),
201
+ class: "text-accent underline hover:text-accent-hover",
202
+ children: t("doc.allTags", locale)
203
+ }
204
+ )
205
+ ] })
206
+ ) })
207
+ ] })
205
208
  ]
206
209
  }
207
210
  );
@@ -6,9 +6,8 @@ import {
6
6
  downgradeRepoRelativeLinks,
7
7
  ensureDir,
8
8
  escapeForMdx,
9
- escapeTitle,
10
- findNamedFiles,
11
9
  formatFrontmatterString,
10
+ findNamedFiles,
12
11
  generateSkillsCategory,
13
12
  parseFrontmatter,
14
13
  removeGeneratedIndex,
@@ -36,10 +35,6 @@ function defaultResourceLabel(config, key, fallbackLiteral) {
36
35
  fallbackLiteral
37
36
  );
38
37
  }
39
- function formatClaudeFrontmatterString(value) {
40
- const formatted = formatFrontmatterString(value);
41
- return formatted === value ? JSON.stringify(value) : formatted;
42
- }
43
38
  function generateClaudemdDocs(config) {
44
39
  const projectRoot = config.projectRoot ?? config.claudeDir;
45
40
  const scanRoot = config.scanRoot ?? projectRoot;
@@ -81,10 +76,10 @@ function generateClaudemdDocs(config) {
81
76
  emittedSlugs.set(item.slug, item.relPath);
82
77
  const content = fs.readFileSync(item.absPath, "utf8");
83
78
  const mdx = `---
84
- title: "${escapeTitle(item.displayPath)}"
85
- description: "CLAUDE.md at ${escapeTitle(item.displayPath)}"
79
+ title: ${formatFrontmatterString(item.displayPath)}
80
+ description: ${formatFrontmatterString(`CLAUDE.md at ${item.displayPath}`)}
86
81
  sidebar_position: ${index + 1}
87
- sidebar_label: "${escapeTitle(item.relPath)}"
82
+ sidebar_label: ${formatFrontmatterString(item.relPath)}
88
83
  generated: true
89
84
  ---
90
85
 
@@ -102,7 +97,8 @@ ${escapeForMdx(downgradeRepoRelativeLinks(content.trim()))}
102
97
  config,
103
98
  "resource.claudeMd.description",
104
99
  "Project-specific instructions"
105
- )
100
+ ),
101
+ formatFrontmatterString
106
102
  );
107
103
  return items;
108
104
  }
@@ -127,9 +123,9 @@ function generateCommandsDocs(config) {
127
123
  const description = parsed.data.description || "";
128
124
  items.push({ name, description });
129
125
  const mdx = `---
130
- title: "${escapeTitle(name)}"
131
- description: "${escapeTitle(description)}"
132
- sidebar_label: "${escapeTitle(name)}"
126
+ title: ${formatFrontmatterString(name)}
127
+ description: ${formatFrontmatterString(description)}
128
+ sidebar_label: ${formatFrontmatterString(name)}
133
129
  generated: true
134
130
  ---
135
131
 
@@ -146,7 +142,8 @@ ${escapeForMdx(downgradeRepoRelativeLinks(parsed.content.trim()))}
146
142
  config,
147
143
  "resource.claudeCommands.description",
148
144
  "Custom slash commands"
149
- )
145
+ ),
146
+ formatFrontmatterString
150
147
  );
151
148
  return items;
152
149
  }
@@ -161,7 +158,8 @@ function generateSkillsDocs(config) {
161
158
  "resource.claudeSkills.description",
162
159
  "Skill packages"
163
160
  ),
164
- sourceLabel: ".claude/skills"
161
+ sourceLabel: ".claude/skills",
162
+ renderFrontmatterString: formatFrontmatterString
165
163
  });
166
164
  }
167
165
  function generateAgentsDocs(config) {
@@ -189,9 +187,9 @@ function generateAgentsDocs(config) {
189
187
  const modelBadge = model ? `**Model:** \`${model}\`
190
188
  ` : "";
191
189
  const mdx = `---
192
- title: "${escapeTitle(name)}"
193
- description: "${escapeTitle(description)}"
194
- sidebar_label: "${escapeTitle(name)}"
190
+ title: ${formatFrontmatterString(name)}
191
+ description: ${formatFrontmatterString(description)}
192
+ sidebar_label: ${formatFrontmatterString(name)}
195
193
  generated: true
196
194
  ---
197
195
 
@@ -209,7 +207,8 @@ ${escapeForMdx(downgradeRepoRelativeLinks(parsed.content.trim()))}
209
207
  config,
210
208
  "resource.claudeAgents.description",
211
209
  "Custom subagents"
212
- )
210
+ ),
211
+ formatFrontmatterString
213
212
  );
214
213
  return items;
215
214
  }
@@ -259,8 +258,8 @@ function generateOverviewIndex(config, {
259
258
  }
260
259
  function renderOverviewIndex(config, locale, categorySlugs) {
261
260
  return `---
262
- title: ${formatClaudeFrontmatterString(resourceLabel(config, locale, "resource.claude.title", "Claude"))}
263
- description: ${formatClaudeFrontmatterString(resourceLabel(
261
+ title: ${formatFrontmatterString(resourceLabel(config, locale, "resource.claude.title", "Claude"))}
262
+ description: ${formatFrontmatterString(resourceLabel(
264
263
  config,
265
264
  locale,
266
265
  "resource.claude.description",
@@ -341,7 +340,7 @@ function writeLocaleCategoryIndex(config, locale, localeDir, categoryDir, presen
341
340
  resourceLabel(config, locale, labelKey, fallbackLabel),
342
341
  position,
343
342
  resourceLabel(config, locale, descriptionKey, fallbackDescription),
344
- formatClaudeFrontmatterString,
343
+ formatFrontmatterString,
345
344
  (absolutePath, content) => writeGeneratedIndex(absolutePath, content, `/docs/${categoryDir}/`)
346
345
  );
347
346
  }
@@ -8,8 +8,10 @@ export interface ImgSrcCheckLogger {
8
8
  export interface BrokenImgSrc {
9
9
  /** HTML page path relative to the build output directory. */
10
10
  pagePath: string;
11
- /** The decoded attribute value as authored in the rendered HTML. */
11
+ /** The individual URL value as authored in the rendered HTML. */
12
12
  src: string;
13
+ /** The element and attribute that supplied `src` (for example `img[src]`). */
14
+ element?: string;
13
15
  /** Why the reference was considered broken. */
14
16
  reason: string;
15
17
  }
@@ -17,7 +19,7 @@ export interface BrokenImgSrc {
17
19
  export interface ImgSrcCheckResult {
18
20
  /** Number of HTML files visited under `outDir`. */
19
21
  htmlFileCount: number;
20
- /** Number of site-absolute `src` attributes inspected. */
22
+ /** Number of local media/asset references inspected. */
21
23
  imageCount: number;
22
24
  /** Every broken occurrence, including duplicate references. */
23
25
  broken: BrokenImgSrc[];
@@ -46,7 +48,7 @@ export declare function extractImgSrcs(html: string): string[];
46
48
  /** Normalize a zfb base to a slash-delimited URL prefix. */
47
49
  export declare function normalizeImgSrcBase(base: string | undefined): string;
48
50
  /**
49
- * Walk every built HTML file and validate its site-absolute image sources.
51
+ * Walk every built HTML file and validate its local media/asset references.
50
52
  *
51
53
  * This is intentionally synchronous: zfb's postBuild hook is async-compatible
52
54
  * but the operation is a deterministic local filesystem walk, and a sync
@@ -2,7 +2,9 @@ import { realpathSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { relative, resolve, sep } from "node:path";
3
3
  import { parse } from "parse5";
4
4
  const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
5
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
5
6
  const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/u;
7
+ const SCANNER_ORIGIN = "https://zudo-doc-img-src-check.invalid";
6
8
  function extractImgSrcs(html) {
7
9
  const document = parse(html);
8
10
  const srcs = [];
@@ -32,27 +34,241 @@ function isWithin(root, candidate) {
32
34
  const rel = relative(root, candidate);
33
35
  return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep);
34
36
  }
35
- function stripQueryAndFragment(src) {
36
- const query = src.indexOf("?");
37
- const fragment = src.indexOf("#");
38
- const end = [query, fragment].filter((index) => index >= 0).sort((a, b) => a - b)[0];
39
- return end === void 0 ? src : src.slice(0, end);
37
+ function attributeValue(element, name) {
38
+ return element.attrs.find((attribute) => {
39
+ const qualifiedName = attribute.prefix ? `${attribute.prefix}:${attribute.name}` : attribute.name;
40
+ return qualifiedName.toLowerCase() === name;
41
+ })?.value;
40
42
  }
41
- function resolveImgSrc(src, outDir, base, canonicalOutDir) {
43
+ function addAssetReference(references, element, attribute, value) {
44
+ if (value !== void 0) references.push({ src: value, element: `${element}[${attribute}]` });
45
+ }
46
+ function isHtmlElement(element, tagName) {
47
+ return element.namespaceURI === HTML_NAMESPACE && element.tagName.toLowerCase() === tagName;
48
+ }
49
+ function isSvgElement(element, tagName) {
50
+ return element.namespaceURI === SVG_NAMESPACE && element.tagName.toLowerCase() === tagName;
51
+ }
52
+ function parseSrcsetCandidates(value) {
53
+ const candidates = [];
54
+ let position = 0;
55
+ const isAsciiWhitespace = (character) => character === " " || character === " " || character === "\n" || character === "\f" || character === "\r";
56
+ const skipSplittingWhitespaceAndCommas = () => {
57
+ while (position < value.length) {
58
+ const character = value[position];
59
+ if (!isAsciiWhitespace(character) && character !== ",") break;
60
+ position += 1;
61
+ }
62
+ };
63
+ const parseDescriptors = (descriptors) => {
64
+ let width;
65
+ let density;
66
+ let futureCompatH = false;
67
+ for (const descriptor of descriptors) {
68
+ if (/^\d+w$/u.test(descriptor)) {
69
+ if (width !== void 0 || density !== void 0) return false;
70
+ const parsed = Number.parseInt(descriptor.slice(0, -1), 10);
71
+ if (!Number.isFinite(parsed) || parsed === 0) return false;
72
+ width = parsed;
73
+ continue;
74
+ }
75
+ if (/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:[Ee][+-]?\d+)?x$/u.test(descriptor)) {
76
+ if (width !== void 0 || density !== void 0 || futureCompatH) return false;
77
+ const parsed = Number.parseFloat(descriptor.slice(0, -1));
78
+ if (!Number.isFinite(parsed) || parsed < 0) return false;
79
+ density = parsed;
80
+ continue;
81
+ }
82
+ if (/^\d+h$/u.test(descriptor)) {
83
+ if (futureCompatH || density !== void 0) return false;
84
+ const parsed = Number.parseInt(descriptor.slice(0, -1), 10);
85
+ if (!Number.isFinite(parsed) || parsed === 0) return false;
86
+ futureCompatH = true;
87
+ continue;
88
+ }
89
+ return false;
90
+ }
91
+ return !futureCompatH || width !== void 0;
92
+ };
93
+ while (position < value.length) {
94
+ skipSplittingWhitespaceAndCommas();
95
+ if (position >= value.length) break;
96
+ const urlStart = position;
97
+ while (position < value.length && !isAsciiWhitespace(value[position])) position += 1;
98
+ let url = value.slice(urlStart, position);
99
+ const hadTrailingComma = url.endsWith(",");
100
+ if (hadTrailingComma) url = url.replace(/,+$/u, "");
101
+ if (url.length === 0) {
102
+ continue;
103
+ }
104
+ const descriptors = [];
105
+ if (!hadTrailingComma) {
106
+ let current = "";
107
+ let inParens = false;
108
+ let descriptorDone = false;
109
+ while (!descriptorDone) {
110
+ const character = value[position];
111
+ if (inParens) {
112
+ if (character === void 0) {
113
+ if (current) descriptors.push(current);
114
+ descriptorDone = true;
115
+ } else {
116
+ current += character;
117
+ position += 1;
118
+ if (character === ")") inParens = false;
119
+ }
120
+ continue;
121
+ }
122
+ if (isAsciiWhitespace(character)) {
123
+ if (current) {
124
+ descriptors.push(current);
125
+ current = "";
126
+ }
127
+ while (isAsciiWhitespace(value[position])) position += 1;
128
+ if (value[position] === "," || position >= value.length) {
129
+ descriptorDone = true;
130
+ }
131
+ continue;
132
+ }
133
+ if (character === ",") {
134
+ position += 1;
135
+ if (current) descriptors.push(current);
136
+ descriptorDone = true;
137
+ continue;
138
+ }
139
+ if (character === void 0) {
140
+ if (current) descriptors.push(current);
141
+ descriptorDone = true;
142
+ continue;
143
+ }
144
+ current += character;
145
+ position += 1;
146
+ if (character === "(") inParens = true;
147
+ }
148
+ }
149
+ if (parseDescriptors(descriptors)) candidates.push(url);
150
+ }
151
+ return candidates;
152
+ }
153
+ function extractAssetReferences(document) {
154
+ const references = [];
155
+ const baseHrefs = [];
156
+ const visit = (node, insideTemplate) => {
157
+ if (!("tagName" in node)) return;
158
+ const element = node;
159
+ if (!insideTemplate && isHtmlElement(element, "base")) {
160
+ const href = attributeValue(element, "href");
161
+ if (href !== void 0) baseHrefs.push(href);
162
+ }
163
+ if (isHtmlElement(element, "img")) {
164
+ addAssetReference(references, "img", "src", attributeValue(element, "src"));
165
+ const srcset = attributeValue(element, "srcset");
166
+ if (srcset !== void 0) {
167
+ for (const candidate of parseSrcsetCandidates(srcset)) {
168
+ addAssetReference(references, "img", "srcset", candidate);
169
+ }
170
+ }
171
+ } else if (isHtmlElement(element, "source")) {
172
+ addAssetReference(references, "source", "src", attributeValue(element, "src"));
173
+ const srcset = attributeValue(element, "srcset");
174
+ if (srcset !== void 0) {
175
+ for (const candidate of parseSrcsetCandidates(srcset)) {
176
+ addAssetReference(references, "source", "srcset", candidate);
177
+ }
178
+ }
179
+ } else if (isHtmlElement(element, "video")) {
180
+ addAssetReference(references, "video", "src", attributeValue(element, "src"));
181
+ addAssetReference(references, "video", "poster", attributeValue(element, "poster"));
182
+ } else if (isHtmlElement(element, "audio")) {
183
+ addAssetReference(references, "audio", "src", attributeValue(element, "src"));
184
+ } else if (isHtmlElement(element, "track")) {
185
+ addAssetReference(references, "track", "src", attributeValue(element, "src"));
186
+ } else if (isHtmlElement(element, "input")) {
187
+ const type = attributeValue(element, "type");
188
+ if (type?.trim().toLowerCase() === "image") {
189
+ addAssetReference(references, "input", "src", attributeValue(element, "src"));
190
+ }
191
+ } else if (isSvgElement(element, "image")) {
192
+ addAssetReference(references, "image", "href", attributeValue(element, "href"));
193
+ addAssetReference(references, "image", "xlink:href", attributeValue(element, "xlink:href"));
194
+ }
195
+ for (const child of element.childNodes) visit(child, insideTemplate);
196
+ if (element.nodeName === "template") {
197
+ const template = element;
198
+ for (const child of template.content.childNodes) visit(child, true);
199
+ }
200
+ };
201
+ for (const child of document.childNodes) visit(child, false);
202
+ return { references, baseHrefs };
203
+ }
204
+ function makePageUrl(pagePath, base) {
205
+ const pageName = /(?:^|\/)index\.html$/iu.test(pagePath) ? pagePath.slice(0, -"index.html".length) : pagePath;
206
+ const pathname = `${base}${pageName}`;
207
+ const pageUrl = new URL(SCANNER_ORIGIN);
208
+ const filePathname = (pathname.startsWith("/") ? pathname : `/${pathname}`).replaceAll(
209
+ "%",
210
+ "%25"
211
+ );
212
+ pageUrl.pathname = filePathname;
213
+ return pageUrl;
214
+ }
215
+ function effectiveDocumentBase(pageUrl, hrefs) {
216
+ for (const href of hrefs) {
217
+ const value = href.trim();
218
+ try {
219
+ return new URL(value, pageUrl);
220
+ } catch {
221
+ }
222
+ }
223
+ return pageUrl;
224
+ }
225
+ function hasAbsolutePathTraversal(value) {
226
+ if (!value.startsWith("/") || value.startsWith("//")) return false;
227
+ const pathPart = value.split(/[?#]/u, 1)[0] ?? value;
228
+ let decodedPath;
229
+ try {
230
+ decodedPath = decodeURIComponent(pathPart);
231
+ } catch {
232
+ return false;
233
+ }
234
+ const stack = [];
235
+ for (const segment of decodedPath.replaceAll("\\", "/").split("/")) {
236
+ if (!segment || segment === ".") continue;
237
+ if (segment === "..") {
238
+ if (stack.length === 0) return true;
239
+ stack.pop();
240
+ } else {
241
+ stack.push(segment);
242
+ }
243
+ }
244
+ return false;
245
+ }
246
+ function resolveImgSrc(src, outDir, base, canonicalOutDir, documentBase) {
42
247
  const value = src.trim();
43
- if (!value.startsWith("/") || value.startsWith("//") || SCHEME_RE.test(value)) {
248
+ if (!value || value.startsWith("#") || value.startsWith("//") || SCHEME_RE.test(value)) {
44
249
  return { kind: "skip" };
45
250
  }
46
- const pathPart = stripQueryAndFragment(value);
251
+ if (documentBase.origin !== SCANNER_ORIGIN) return { kind: "skip" };
252
+ let resolvedUrl;
253
+ try {
254
+ resolvedUrl = new URL(value, documentBase);
255
+ } catch {
256
+ return { kind: "broken", reason: "invalid URL" };
257
+ }
258
+ if (resolvedUrl.origin !== SCANNER_ORIGIN) return { kind: "skip" };
259
+ if (hasAbsolutePathTraversal(value)) {
260
+ return { kind: "broken", reason: "resolves outside the build output directory" };
261
+ }
47
262
  let decodedPath;
48
263
  try {
49
- decodedPath = decodeURIComponent(pathPart);
264
+ decodedPath = decodeURIComponent(resolvedUrl.pathname);
50
265
  } catch {
51
266
  return { kind: "broken", reason: "malformed percent escape" };
52
267
  }
53
268
  if (decodedPath.includes("\0")) {
54
269
  return { kind: "broken", reason: "invalid path" };
55
270
  }
271
+ decodedPath = decodedPath.replaceAll("\\", "/");
56
272
  let relativeUrlPath;
57
273
  if (base === "/") {
58
274
  relativeUrlPath = decodedPath.slice(1).replace(/^\/+/, "");
@@ -129,20 +345,24 @@ function scanImgSrcs(options) {
129
345
  for (const htmlFile of htmlFiles) {
130
346
  const pagePath = relative(outDir, htmlFile).split(sep).join("/");
131
347
  const html = readFileSync(htmlFile, "utf8");
132
- if (!/<img\b/iu.test(html)) continue;
133
- for (const src of extractImgSrcs(html)) {
134
- const resolved = resolveImgSrc(src, outDir, base, canonicalOutDir);
348
+ if (!/<(?:img|source|video|audio|track|input|image)\b/iu.test(html)) continue;
349
+ const document = parse(html);
350
+ const parsed = extractAssetReferences(document);
351
+ const documentBase = effectiveDocumentBase(makePageUrl(pagePath, base), parsed.baseHrefs);
352
+ for (const reference of parsed.references) {
353
+ const resolved = resolveImgSrc(reference.src, outDir, base, canonicalOutDir, documentBase);
135
354
  if (resolved.kind === "skip") continue;
136
355
  imageCount += 1;
137
356
  if (resolved.kind === "broken") {
138
- broken.push({ pagePath, src, reason: resolved.reason });
357
+ broken.push({ pagePath, src: reference.src, element: reference.element, reason: resolved.reason });
139
358
  }
140
359
  }
141
360
  }
142
361
  return { htmlFileCount: htmlFiles.length, imageCount, broken };
143
362
  }
144
363
  function formatBrokenImgSrc(reference) {
145
- return `[img-src-check] Broken image source in ${reference.pagePath}: ${reference.src} (${reference.reason})`;
364
+ const element = reference.element ? `${reference.element} ` : "";
365
+ return `[img-src-check] Broken image source in ${reference.pagePath}: ${element}${reference.src} (${reference.reason})`;
146
366
  }
147
367
  function checkImgSrcs(options) {
148
368
  const severity = options.onBroken ?? "warn";
@@ -12,13 +12,14 @@ function escapeTitle(s) {
12
12
  return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
13
13
  }
14
14
  function formatFrontmatterString(value) {
15
+ const formatterRequiresQuotes = /["']/.test(value) || /^[?:-]/.test(value) || /^(?:yes|no|on|off)$/iu.test(value) || /^\+?(?:nan|inf(?:inity)?)$/iu.test(value);
15
16
  if (!/[\r\n]/.test(value)) {
16
17
  try {
17
18
  const parsed = matter(`---
18
19
  value: ${value}
19
20
  ---
20
21
  `).data.value;
21
- if (parsed === value) return value;
22
+ if (parsed === value && !formatterRequiresQuotes) return value;
22
23
  } catch {
23
24
  }
24
25
  }