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.
- package/CHANGELOG.md +65 -0
- package/dist/cli/index.js +13255 -10232
- package/dist/cli/index.js.map +91 -60
- package/dist/types/core/config-input.d.ts +61 -1
- package/dist/types/core/data.d.ts +9 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +8 -8
- package/dist/types/core/schema.d.ts +131 -22
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +13 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +11 -0
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +21 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +14 -0
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +1 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/reference/cli.mdx +79 -1
- package/docs/reference/frontmatter.mdx +29 -1
- package/package.json +3 -3
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/generate.ts +50 -19
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +65 -22
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +135 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +51 -12
- package/src/cli/index.ts +2 -0
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +1 -0
- package/src/components/layout/PageFeedback.astro +1 -1
- package/src/components/layout/PageLayout.astro +5 -1
- package/src/components/layout/Pagination.astro +1 -1
- package/src/components/layout/RootLayout.astro +5 -3
- package/src/components/layout/Search.astro +35 -6
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +66 -1
- package/src/core/data.ts +9 -1
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +59 -12
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +86 -3
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +13 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/og/card.ts +98 -33
- package/src/og/index.ts +1 -1
- package/src/search/popular.ts +33 -0
- 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
|
+
};
|