blume 1.0.4 → 1.1.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli/index.js +13255 -10232
  3. package/dist/cli/index.js.map +91 -60
  4. package/dist/types/core/config-input.d.ts +61 -1
  5. package/dist/types/core/data.d.ts +9 -0
  6. package/dist/types/core/deployment-env.d.ts +6 -0
  7. package/dist/types/core/diagnostics.d.ts +23 -0
  8. package/dist/types/core/i18n-ui.d.ts +8 -8
  9. package/dist/types/core/schema.d.ts +131 -22
  10. package/dist/types/core/sources/types.d.ts +3 -1
  11. package/dist/types/core/standard-schema.d.ts +41 -0
  12. package/dist/types/core/types.d.ts +13 -0
  13. package/dist/types/og/card.d.ts +63 -0
  14. package/dist/types/og/dimensions.d.ts +12 -0
  15. package/docs/01-quickstart.mdx +1 -1
  16. package/docs/02-deployment.mdx +9 -1
  17. package/docs/advanced/api-reference.mdx +11 -0
  18. package/docs/advanced/changelog.mdx +1 -1
  19. package/docs/advanced/skills.mdx +1 -1
  20. package/docs/configuration/ai.mdx +1 -1
  21. package/docs/configuration/customization.mdx +1 -1
  22. package/docs/configuration/export.mdx +1 -1
  23. package/docs/configuration/index.mdx +21 -1
  24. package/docs/configuration/search.mdx +28 -1
  25. package/docs/configuration/seo.mdx +21 -2
  26. package/docs/configuration/theming.mdx +1 -1
  27. package/docs/content/components.mdx +14 -0
  28. package/docs/content/index.mdx +1 -1
  29. package/docs/content/meta.mdx +1 -1
  30. package/docs/content/navigation.mdx +1 -1
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/reference/cli.mdx +79 -1
  33. package/docs/reference/frontmatter.mdx +29 -1
  34. package/package.json +3 -3
  35. package/skills/blume-migrate/SKILL.md +1 -1
  36. package/skills/blume-migrate/references/mintlify.md +3 -2
  37. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
  38. package/src/ai/llms.ts +15 -0
  39. package/src/astro/adapter-root.ts +70 -0
  40. package/src/astro/generate.ts +50 -19
  41. package/src/astro/index.ts +1 -0
  42. package/src/astro/pages.ts +18 -3
  43. package/src/astro/templates.ts +65 -22
  44. package/src/audit/agent.ts +114 -0
  45. package/src/audit/catalog.ts +826 -0
  46. package/src/audit/checks/assets.ts +177 -0
  47. package/src/audit/checks/content.ts +231 -0
  48. package/src/audit/checks/duplicates.ts +131 -0
  49. package/src/audit/checks/i18n.ts +246 -0
  50. package/src/audit/checks/indexability.ts +213 -0
  51. package/src/audit/checks/links.ts +223 -0
  52. package/src/audit/checks/llms.ts +135 -0
  53. package/src/audit/checks/network.ts +272 -0
  54. package/src/audit/checks/og-image.ts +113 -0
  55. package/src/audit/checks/redirects.ts +87 -0
  56. package/src/audit/checks/robots.ts +114 -0
  57. package/src/audit/checks/sitemap.ts +229 -0
  58. package/src/audit/checks/social.ts +238 -0
  59. package/src/audit/crawl.ts +259 -0
  60. package/src/audit/graph.ts +74 -0
  61. package/src/audit/html.ts +54 -0
  62. package/src/audit/image-size.ts +63 -0
  63. package/src/audit/locate.ts +33 -0
  64. package/src/audit/redirects.ts +74 -0
  65. package/src/audit/report.ts +278 -0
  66. package/src/audit/run.ts +198 -0
  67. package/src/audit/snapshot.ts +189 -0
  68. package/src/audit/types.ts +214 -0
  69. package/src/audit/url.ts +103 -0
  70. package/src/cli/commands/audit.ts +205 -0
  71. package/src/cli/commands/build.ts +51 -12
  72. package/src/cli/index.ts +2 -0
  73. package/src/components/content/Tabs.astro +98 -15
  74. package/src/components/layout/Breadcrumbs.astro +1 -1
  75. package/src/components/layout/Header.astro +1 -0
  76. package/src/components/layout/PageFeedback.astro +1 -1
  77. package/src/components/layout/PageLayout.astro +5 -1
  78. package/src/components/layout/Pagination.astro +1 -1
  79. package/src/components/layout/RootLayout.astro +5 -3
  80. package/src/components/layout/Search.astro +35 -6
  81. package/src/components/layout/TableOfContents.astro +1 -1
  82. package/src/components/openapi/Authorization.astro +80 -0
  83. package/src/components/openapi/Operation.astro +19 -1
  84. package/src/components/openapi/ParametersTable.astro +1 -1
  85. package/src/components/openapi/security.ts +201 -0
  86. package/src/components/openapi/snippets.ts +42 -13
  87. package/src/core/config-input.ts +66 -1
  88. package/src/core/data.ts +9 -1
  89. package/src/core/deployment-env.ts +9 -0
  90. package/src/core/diagnostics.ts +59 -12
  91. package/src/core/links.ts +2 -91
  92. package/src/core/nav-diagnostics.ts +48 -4
  93. package/src/core/probe.ts +136 -0
  94. package/src/core/project-graph.ts +8 -0
  95. package/src/core/schema.ts +86 -3
  96. package/src/core/sources/normalize.ts +198 -25
  97. package/src/core/sources/types.ts +3 -1
  98. package/src/core/standard-schema.ts +54 -0
  99. package/src/core/types.ts +13 -0
  100. package/src/deploy/adapter-output.ts +27 -15
  101. package/src/deploy/headers.ts +66 -0
  102. package/src/deploy/redirects.ts +49 -9
  103. package/src/og/card.ts +98 -33
  104. package/src/og/index.ts +1 -1
  105. package/src/search/popular.ts +33 -0
  106. package/src/theme/entry.ts +6 -1
@@ -0,0 +1,177 @@
1
+ import { normalizeBasePath } from "../../core/base-path.ts";
2
+ import type { Diagnostic } from "../../core/types.ts";
3
+ import { finding } from "../catalog.ts";
4
+ import type { CheckId } from "../catalog.ts";
5
+ import { pageSite } from "../locate.ts";
6
+ import type {
7
+ AuditContext,
8
+ CheckModule,
9
+ PageSnapshot,
10
+ SnapshotAsset,
11
+ } from "../types.ts";
12
+ import { resolveHref, siteOrigin } from "../url.ts";
13
+
14
+ const formatBytes = (bytes: number): string =>
15
+ bytes < 1024 * 1024
16
+ ? `${Math.round(bytes / 1024)} kB`
17
+ : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
18
+
19
+ /**
20
+ * Resolve a subresource reference to its file in the build, reporting a missing
21
+ * file or an insecure (`http://`) reference. Returns the file's size when it
22
+ * resolves to something real and local.
23
+ */
24
+ const resolveAsset = (
25
+ context: AuditContext,
26
+ page: PageSnapshot,
27
+ asset: SnapshotAsset,
28
+ missingId: CheckId,
29
+ kind: string,
30
+ found: Diagnostic[]
31
+ ): number | null => {
32
+ if (asset.src.startsWith("http://")) {
33
+ found.push(
34
+ finding(
35
+ "BLUME_AUDIT_MIXED_CONTENT",
36
+ pageSite(context, page),
37
+ `Page loads ${kind} over plain HTTP: ${asset.src}`
38
+ )
39
+ );
40
+ return null;
41
+ }
42
+ if (asset.src.startsWith("data:")) {
43
+ return null;
44
+ }
45
+
46
+ const origin = siteOrigin(context.project.config.deployment.site);
47
+ const resolved = resolveHref(
48
+ page.url,
49
+ asset.src,
50
+ origin,
51
+ normalizeBasePath(context.project.config.deployment.base)
52
+ );
53
+ // A subresource on another origin (a CDN, an analytics script) is outside the
54
+ // build; we can't check whether it exists without the network.
55
+ if (resolved.kind === "external" || resolved.kind === "ignored") {
56
+ return null;
57
+ }
58
+
59
+ const bytes = context.files.get(resolved.path);
60
+ if (bytes === undefined) {
61
+ found.push(
62
+ finding(
63
+ missingId,
64
+ pageSite(context, page),
65
+ `Page references ${asset.src}, which is not in the build.`
66
+ )
67
+ );
68
+ return null;
69
+ }
70
+ return bytes;
71
+ };
72
+
73
+ /**
74
+ * Images, scripts, and stylesheets: do they exist, are they secure, are they
75
+ * too heavy, and (for images) are they accessible and layout-stable.
76
+ *
77
+ * Ahrefs devotes ~18 rows to this across its Images, JavaScript, and CSS
78
+ * sections — "JS redirects", "page has redirected CSS", "HTTPS page links to
79
+ * HTTP JavaScript", and both an asset-scoped and a page-scoped copy of each.
80
+ * Vite emits content-hashed, existent, non-redirecting bundles, so nearly all of
81
+ * that is unfireable; what's left is this.
82
+ */
83
+ export const assetChecks: CheckModule = {
84
+ category: "assets",
85
+ run(context) {
86
+ const found: Diagnostic[] = [];
87
+ const { maxAssetBytes } = context.thresholds;
88
+
89
+ const weigh = (
90
+ page: PageSnapshot,
91
+ asset: SnapshotAsset,
92
+ bytes: number | null,
93
+ kind: string
94
+ ): void => {
95
+ if (bytes !== null && bytes > maxAssetBytes) {
96
+ found.push(
97
+ finding(
98
+ "BLUME_AUDIT_ASSET_TOO_LARGE",
99
+ pageSite(context, page),
100
+ `${kind} ${asset.src} is ${formatBytes(bytes)} (over ${formatBytes(maxAssetBytes)}).`
101
+ )
102
+ );
103
+ }
104
+ };
105
+
106
+ for (const page of context.pages) {
107
+ for (const image of page.images) {
108
+ const bytes = resolveAsset(
109
+ context,
110
+ page,
111
+ image,
112
+ "BLUME_AUDIT_IMAGE_BROKEN",
113
+ "an image",
114
+ found
115
+ );
116
+ weigh(page, image, bytes, "Image");
117
+
118
+ // `alt=""` is a deliberate "this image is decorative" and is correct.
119
+ // A missing `alt` attribute is the finding.
120
+ if (image.alt === null) {
121
+ found.push(
122
+ finding(
123
+ "BLUME_AUDIT_IMAGE_ALT_MISSING",
124
+ pageSite(context, page),
125
+ `Image ${image.src} has no alt attribute.`
126
+ )
127
+ );
128
+ }
129
+
130
+ if (!(image.width && image.height)) {
131
+ found.push(
132
+ finding(
133
+ "BLUME_AUDIT_IMAGE_MISSING_DIMENSIONS",
134
+ pageSite(context, page),
135
+ `Image ${image.src} has no width/height, so it will shift the layout as it loads.`
136
+ )
137
+ );
138
+ }
139
+ }
140
+
141
+ for (const script of page.scripts) {
142
+ weigh(
143
+ page,
144
+ script,
145
+ resolveAsset(
146
+ context,
147
+ page,
148
+ script,
149
+ "BLUME_AUDIT_SUBRESOURCE_MISSING",
150
+ "a script",
151
+ found
152
+ ),
153
+ "Script"
154
+ );
155
+ }
156
+
157
+ for (const style of page.styles) {
158
+ weigh(
159
+ page,
160
+ style,
161
+ resolveAsset(
162
+ context,
163
+ page,
164
+ style,
165
+ "BLUME_AUDIT_SUBRESOURCE_MISSING",
166
+ "a stylesheet",
167
+ found
168
+ ),
169
+ "Stylesheet"
170
+ );
171
+ }
172
+ }
173
+
174
+ return found;
175
+ },
176
+ tier: "static",
177
+ };
@@ -0,0 +1,231 @@
1
+ import matter from "../../core/frontmatter.ts";
2
+ import type { Diagnostic } from "../../core/types.ts";
3
+ import { finding } from "../catalog.ts";
4
+ import { pageSite } from "../locate.ts";
5
+ import { ERROR_ROUTES } from "../types.ts";
6
+ import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
7
+
8
+ /**
9
+ * The `date` a source file's front matter declares, when it parses to a real
10
+ * date. YAML hands back a `Date` for an unquoted `2026-01-01` and a string for
11
+ * a quoted one, so both spellings are accepted; malformed front matter or an
12
+ * unparseable value simply yields nothing — `BLUME_FRONTMATTER_INVALID` is the
13
+ * build's finding, not the audit's.
14
+ */
15
+ const frontmatterDate = (source: string): Date | null => {
16
+ try {
17
+ const { date } = matter(source).data as { date?: unknown };
18
+ if (typeof date !== "string" && !(date instanceof Date)) {
19
+ return null;
20
+ }
21
+ const parsed = date instanceof Date ? date : new Date(date);
22
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
23
+ } catch {
24
+ return null;
25
+ }
26
+ };
27
+
28
+ const titleChecks = (
29
+ context: AuditContext,
30
+ page: PageSnapshot
31
+ ): Diagnostic[] => {
32
+ const { titleMin, titleMax } = context.thresholds;
33
+ const [title] = page.titles;
34
+
35
+ if (!title) {
36
+ return [
37
+ finding(
38
+ "BLUME_AUDIT_TITLE_MISSING",
39
+ pageSite(context, page),
40
+ "Page has no <title>."
41
+ ),
42
+ ];
43
+ }
44
+
45
+ const found: Diagnostic[] = [];
46
+ if (page.titles.length > 1) {
47
+ found.push(
48
+ finding(
49
+ "BLUME_AUDIT_TITLE_MULTIPLE",
50
+ pageSite(context, page),
51
+ `Page has ${page.titles.length} <title> tags; search engines use only the first.`
52
+ )
53
+ );
54
+ }
55
+ if (title.length > titleMax || title.length < titleMin) {
56
+ const direction = title.length > titleMax ? "long" : "short";
57
+ found.push(
58
+ finding(
59
+ "BLUME_AUDIT_TITLE_LENGTH",
60
+ pageSite(context, page, ["title"]),
61
+ `Title is ${title.length} characters — too ${direction} (aim for ${titleMin}–${titleMax}).`
62
+ )
63
+ );
64
+ }
65
+ return found;
66
+ };
67
+
68
+ const descriptionChecks = (
69
+ context: AuditContext,
70
+ page: PageSnapshot
71
+ ): Diagnostic[] => {
72
+ // An error route is noindex by design, so its description never renders as
73
+ // a search snippet — grading it would put a guaranteed finding on every
74
+ // site whose 404 inherits the site-default description.
75
+ if (ERROR_ROUTES.has(page.url)) {
76
+ return [];
77
+ }
78
+
79
+ const { descriptionMin, descriptionMax } = context.thresholds;
80
+ const [description] = page.descriptions;
81
+
82
+ if (!description) {
83
+ return [
84
+ finding(
85
+ "BLUME_AUDIT_DESCRIPTION_MISSING",
86
+ pageSite(context, page),
87
+ "Page has no meta description."
88
+ ),
89
+ ];
90
+ }
91
+
92
+ const found: Diagnostic[] = [];
93
+ if (page.descriptions.length > 1) {
94
+ found.push(
95
+ finding(
96
+ "BLUME_AUDIT_DESCRIPTION_MULTIPLE",
97
+ pageSite(context, page),
98
+ `Page has ${page.descriptions.length} meta description tags.`
99
+ )
100
+ );
101
+ }
102
+ if (
103
+ description.length > descriptionMax ||
104
+ description.length < descriptionMin
105
+ ) {
106
+ const direction = description.length > descriptionMax ? "long" : "short";
107
+ found.push(
108
+ finding(
109
+ "BLUME_AUDIT_DESCRIPTION_LENGTH",
110
+ pageSite(context, page, ["description"]),
111
+ `Meta description is ${description.length} characters — too ${direction} (aim for ${descriptionMin}–${descriptionMax}).`
112
+ )
113
+ );
114
+ }
115
+ return found;
116
+ };
117
+
118
+ const headingChecks = (
119
+ context: AuditContext,
120
+ page: PageSnapshot
121
+ ): Diagnostic[] => {
122
+ const h1s = page.headings.filter((heading) => heading.depth === 1);
123
+ if (h1s.length === 0) {
124
+ return [
125
+ finding(
126
+ "BLUME_AUDIT_H1_MISSING",
127
+ pageSite(context, page),
128
+ "Page has no <h1>."
129
+ ),
130
+ ];
131
+ }
132
+ if (h1s.length > 1) {
133
+ return [
134
+ finding(
135
+ "BLUME_AUDIT_H1_MULTIPLE",
136
+ pageSite(context, page),
137
+ `Page has ${h1s.length} <h1> tags: ${h1s.map((h) => `"${h.text}"`).join(", ")}.`
138
+ ),
139
+ ];
140
+ }
141
+
142
+ // A skipped level (h2 -> h4) breaks table-of-contents nesting and screen-
143
+ // reader outlines. The first skip is the finding — one wrong heading early
144
+ // in a page cascades, and listing every knock-on skip buries the fix.
145
+ let previous: number | null = null;
146
+ for (const heading of page.headings) {
147
+ if (previous !== null && heading.depth > previous + 1) {
148
+ return [
149
+ finding(
150
+ "BLUME_AUDIT_HEADING_SKIP",
151
+ pageSite(context, page),
152
+ `Headings jump from h${previous} to h${heading.depth} at "${heading.text}".`
153
+ ),
154
+ ];
155
+ }
156
+ previous = heading.depth;
157
+ }
158
+ return [];
159
+ };
160
+
161
+ /**
162
+ * A page dated in the future usually means scheduled content that leaked into
163
+ * the build early. Only the front matter knows — the rendered page looks
164
+ * perfectly ordinary.
165
+ */
166
+ const futureDateChecks = (
167
+ context: AuditContext,
168
+ page: PageSnapshot
169
+ ): Diagnostic[] => {
170
+ const source = page.source && context.sources.get(page.source);
171
+ if (!source) {
172
+ return [];
173
+ }
174
+ const date = frontmatterDate(source);
175
+ if (date && date.getTime() > Date.now()) {
176
+ return [
177
+ finding(
178
+ "BLUME_AUDIT_FUTURE_DATED_PAGE",
179
+ pageSite(context, page, ["date"]),
180
+ `Page is dated ${date.toISOString().slice(0, 10)}, which is in the future.`
181
+ ),
182
+ ];
183
+ }
184
+ return [];
185
+ };
186
+
187
+ /**
188
+ * Per-page head and body content: title, description, headings, word count,
189
+ * viewport.
190
+ *
191
+ * Ahrefs reports each of these twice — once for indexable pages and once for
192
+ * non-indexable ones. That's a crawler artifact: the finding is the same either
193
+ * way, so it's reported once and the page's indexability rides along on the
194
+ * snapshot.
195
+ */
196
+ export const contentChecks: CheckModule = {
197
+ category: "content",
198
+ run(context) {
199
+ const found: Diagnostic[] = [];
200
+ for (const page of context.pages) {
201
+ found.push(
202
+ ...titleChecks(context, page),
203
+ ...descriptionChecks(context, page),
204
+ ...headingChecks(context, page),
205
+ ...futureDateChecks(context, page)
206
+ );
207
+
208
+ if (page.wordCount < context.thresholds.minWordCount && page.indexable) {
209
+ found.push(
210
+ finding(
211
+ "BLUME_AUDIT_LOW_WORD_COUNT",
212
+ pageSite(context, page),
213
+ `Page has ${page.wordCount} words of prose (excluding code blocks).`
214
+ )
215
+ );
216
+ }
217
+
218
+ if (!page.viewport) {
219
+ found.push(
220
+ finding(
221
+ "BLUME_AUDIT_VIEWPORT_MISSING",
222
+ pageSite(context, page),
223
+ "Page has no viewport <meta> — it will not render correctly on mobile."
224
+ )
225
+ );
226
+ }
227
+ }
228
+ return found;
229
+ },
230
+ tier: "static",
231
+ };
@@ -0,0 +1,131 @@
1
+ import type { Diagnostic } from "../../core/types.ts";
2
+ import { finding } from "../catalog.ts";
3
+ import type { CheckId } from "../catalog.ts";
4
+ import { pageSite } from "../locate.ts";
5
+ import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
6
+
7
+ const isNonCanonical = (page: PageSnapshot): boolean => {
8
+ if (!page.canonical) {
9
+ return false;
10
+ }
11
+ try {
12
+ return (
13
+ new URL(page.canonical).pathname.replace(/\/$/u, "") !==
14
+ page.url.replace(/\/$/u, "")
15
+ );
16
+ } catch {
17
+ return false;
18
+ }
19
+ };
20
+
21
+ /** Pages that can meaningfully be compared against each other for duplication. */
22
+ const comparable = (context: AuditContext): PageSnapshot[] =>
23
+ context.pages.filter(
24
+ (page) =>
25
+ page.indexable &&
26
+ // A fallback page renders the default locale's content at a localized URL.
27
+ // It is *supposed* to be an exact copy, so comparing it would make every
28
+ // i18n site with an untranslated page a wall of false positives.
29
+ !page.route?.fallback &&
30
+ // A page that points its canonical elsewhere has already declared itself a
31
+ // duplicate; that's the mechanism working, not a finding.
32
+ !isNonCanonical(page)
33
+ );
34
+
35
+ /**
36
+ * Group pages by a value and report every group with more than one member.
37
+ * Translations are kept apart by keying on locale as well as the value — two
38
+ * pages titled "Overview" in different languages are not duplicates.
39
+ */
40
+ const reportGroups = (
41
+ context: AuditContext,
42
+ pages: PageSnapshot[],
43
+ id: CheckId,
44
+ key: (page: PageSnapshot) => string | undefined,
45
+ describe: (value: string, others: PageSnapshot[]) => string,
46
+ frontmatterKey?: readonly string[]
47
+ ): Diagnostic[] => {
48
+ const groups = new Map<string, PageSnapshot[]>();
49
+ for (const page of pages) {
50
+ const value = key(page);
51
+ if (!value) {
52
+ continue;
53
+ }
54
+ const groupKey = `${page.route?.locale ?? ""}\u0000${value}`;
55
+ const group = groups.get(groupKey);
56
+ if (group) {
57
+ group.push(page);
58
+ } else {
59
+ groups.set(groupKey, [page]);
60
+ }
61
+ }
62
+
63
+ const found: Diagnostic[] = [];
64
+ for (const group of groups.values()) {
65
+ if (group.length < 2) {
66
+ continue;
67
+ }
68
+ for (const page of group) {
69
+ const others = group.filter((other) => other !== page);
70
+ found.push(
71
+ finding(
72
+ id,
73
+ pageSite(context, page, frontmatterKey),
74
+ describe(key(page) ?? "", others)
75
+ )
76
+ );
77
+ }
78
+ }
79
+ return found;
80
+ };
81
+
82
+ const list = (pages: PageSnapshot[]): string => {
83
+ const shown = pages.slice(0, 3).map((page) => page.url);
84
+ const rest = pages.length - shown.length;
85
+ return rest > 0 ? `${shown.join(", ")} and ${rest} more` : shown.join(", ");
86
+ };
87
+
88
+ /**
89
+ * Pages that collide on title, description, or body text.
90
+ *
91
+ * Ahrefs only reports "duplicate pages without canonical". Duplicate *titles*
92
+ * and *descriptions* are the ones that actually bite a docs site — five pages
93
+ * called "Overview" all compete for the same result headline — so they're
94
+ * reported too.
95
+ */
96
+ export const duplicateChecks: CheckModule = {
97
+ category: "duplicates",
98
+ run(context) {
99
+ const pages = comparable(context);
100
+ return [
101
+ ...reportGroups(
102
+ context,
103
+ pages,
104
+ "BLUME_AUDIT_DUPLICATE_TITLE",
105
+ (page) => page.titles[0],
106
+ (value, others) => `Title "${value}" is also used by ${list(others)}.`,
107
+ ["title"]
108
+ ),
109
+ ...reportGroups(
110
+ context,
111
+ pages,
112
+ "BLUME_AUDIT_DUPLICATE_DESCRIPTION",
113
+ (page) => page.descriptions[0],
114
+ (_value, others) => `Meta description is identical to ${list(others)}.`,
115
+ ["description"]
116
+ ),
117
+ ...reportGroups(
118
+ context,
119
+ // An empty page (no prose at all) hashes the same as every other empty
120
+ // page; that's a content problem LOW_WORD_COUNT already reports, not a
121
+ // duplication one.
122
+ pages.filter((page) => page.wordCount > 0),
123
+ "BLUME_AUDIT_DUPLICATE_CONTENT",
124
+ (page) => page.contentHash,
125
+ (_value, others) =>
126
+ `Page content is byte-identical to ${list(others)}, and none declares a canonical.`
127
+ ),
128
+ ];
129
+ },
130
+ tier: "static",
131
+ };