@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.
- package/CHANGELOG.md +22 -0
- package/bin/zudo-doc.mjs +130 -14
- package/dist/catalog.js +1 -1
- package/dist/compiled.css +12 -1
- package/dist/content.css +31 -1
- package/dist/home-intro/index.d.ts +9 -0
- package/dist/home-intro/index.js +7 -0
- package/dist/home-page/index.js +41 -38
- package/dist/plugins/internal/claude-resources/generate.js +21 -22
- package/dist/plugins/internal/img-src-check/index.d.ts +5 -3
- package/dist/plugins/internal/img-src-check/index.js +234 -14
- package/dist/plugins/internal/resource-docs-shared/mdx.js +2 -1
- package/dist/safelist.css +1 -1
- package/dist/theme-packs/academia/meta.json +1 -1
- package/dist/theme-packs/academia/pack.css +12 -0
- package/package.json +3 -2
package/dist/home-page/index.js
CHANGED
|
@@ -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:
|
|
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__ */
|
|
165
|
-
/* @__PURE__ */ jsx(
|
|
166
|
-
/* @__PURE__ */ jsx(
|
|
167
|
-
|
|
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: "
|
|
200
|
-
children:
|
|
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:
|
|
85
|
-
description:
|
|
79
|
+
title: ${formatFrontmatterString(item.displayPath)}
|
|
80
|
+
description: ${formatFrontmatterString(`CLAUDE.md at ${item.displayPath}`)}
|
|
86
81
|
sidebar_position: ${index + 1}
|
|
87
|
-
sidebar_label:
|
|
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:
|
|
131
|
-
description:
|
|
132
|
-
sidebar_label:
|
|
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:
|
|
193
|
-
description:
|
|
194
|
-
sidebar_label:
|
|
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: ${
|
|
263
|
-
description: ${
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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("
|
|
248
|
+
if (!value || value.startsWith("#") || value.startsWith("//") || SCHEME_RE.test(value)) {
|
|
44
249
|
return { kind: "skip" };
|
|
45
250
|
}
|
|
46
|
-
|
|
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(
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
}
|