simple-site-generator 0.1.3 → 0.2.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 (57) hide show
  1. package/dist/cli.js +165 -2
  2. package/dist/commands/add-page.js +50 -0
  3. package/dist/commands/build.js +20 -0
  4. package/dist/commands/check.js +53 -0
  5. package/dist/commands/init.js +229 -0
  6. package/dist/commands/verify.js +150 -0
  7. package/dist/pages/page-schema.js +161 -0
  8. package/dist/pages/render-page.js +46 -0
  9. package/dist/paths.js +27 -0
  10. package/dist/types/cli.d.ts +1 -0
  11. package/dist/types/commands/add-page.d.ts +26 -0
  12. package/dist/types/commands/build.d.ts +18 -0
  13. package/dist/types/commands/check.d.ts +20 -0
  14. package/dist/types/commands/init.d.ts +40 -0
  15. package/dist/types/commands/verify.d.ts +47 -0
  16. package/dist/types/pages/page-schema.d.ts +81 -0
  17. package/dist/types/pages/render-page.d.ts +26 -0
  18. package/dist/types/paths.d.ts +16 -0
  19. package/dist/types/verify/budgets.d.ts +114 -0
  20. package/dist/types/verify/class-coverage.d.ts +65 -0
  21. package/dist/types/verify/ignore.d.ts +8 -0
  22. package/dist/types/verify/lighthouse.d.ts +27 -0
  23. package/dist/types/verify/seo-surface.d.ts +23 -0
  24. package/dist/types/verify/serve.d.ts +6 -0
  25. package/dist/verify/budgets.js +180 -0
  26. package/dist/verify/class-coverage.js +114 -0
  27. package/dist/verify/ignore.js +11 -0
  28. package/dist/verify/lighthouse.js +98 -0
  29. package/dist/verify/seo-surface.js +97 -0
  30. package/dist/verify/serve.js +123 -0
  31. package/package.json +14 -8
  32. package/templates/app/.github/workflows/deploy-rsync.yaml.hbs +58 -0
  33. package/templates/app/.oxfmtrc.json +32 -0
  34. package/templates/app/.oxlintrc.json +10 -0
  35. package/templates/app/.vscode/extensions.json +3 -0
  36. package/templates/app/build/Caddyfile.hbs +24 -0
  37. package/templates/app/build/Caddyfile.preview +34 -0
  38. package/templates/app/index.html +35 -0
  39. package/templates/app/logo.svg +143 -0
  40. package/templates/app/pnpm-workspace.yaml +10 -0
  41. package/templates/app/public/favicon-128.png +0 -0
  42. package/templates/app/public/favicon-48.png +0 -0
  43. package/templates/app/public/favicon-96.png +0 -0
  44. package/templates/app/public/favicon.ico +0 -0
  45. package/templates/app/public/images/landing/landing.png +0 -0
  46. package/templates/app/public/robots.txt +5 -0
  47. package/templates/app/src/App.vue +22 -0
  48. package/templates/app/src/pages/Contact.md +25 -0
  49. package/templates/app/src/pages/Error404Page.vue +31 -0
  50. package/templates/app/src/pages/GettingStarted/GettingStarted.md +52 -0
  51. package/templates/app/src/pages/Index.md.hbs +46 -0
  52. package/templates/app/src/router/index.ts +21 -0
  53. package/templates/app/src/router/routes.ts +218 -0
  54. package/templates/app/src/setup.ts +8 -0
  55. package/templates/app/src/shims-virtual.d.ts +67 -0
  56. package/templates/app/src/shims-vue.d.ts +6 -0
  57. package/templates/app/vitrify.config.ts.hbs +249 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Markup ↔ stylesheet coverage.
3
+ *
4
+ * The kit's design system is UnoCSS configuration: `ssg-*` classes in markup
5
+ * resolve to shortcuts and preflights that are generated at build time. Nothing
6
+ * connects the two, and every failure in that gap is **silent** — which is the
7
+ * class of defect this repository keeps meeting (see `docs/adr/0001` and
8
+ * `docs/adr/0004`).
9
+ *
10
+ * The concrete case that motivated this: a shortcut array in `design.ts` lost
11
+ * its separating comma, which parses as a computed member access. The entry
12
+ * *after* the comma survived, the entry before it vanished, and
13
+ * `tsc`, `oxlint` and the whole test suite stayed green while the class it named
14
+ * rendered unstyled. It happened twice in one afternoon — `ssg-business__band-demo`
15
+ * and then `ssg-proof-section__heading` — and was caught both times only by
16
+ * looking at the page.
17
+ *
18
+ * This is the check that would have caught it mechanically. It compares what the
19
+ * built HTML *asks for* against what the built CSS *provides*, which is a set
20
+ * difference over two files the build has already written.
21
+ */
22
+ /**
23
+ * `ssg-*` classes that carry no styling by design.
24
+ *
25
+ * These are hooks: targets for tests and for `data-ssg`-style selection, or
26
+ * structural markers a component uses to find its own nodes. They must be listed
27
+ * rather than merely tolerated, because "this class has no rule" should be a
28
+ * decision someone made, not an accident — which is the whole point of the check.
29
+ *
30
+ * Only `ssg-*` classes are checked at all. Third-party classes are out of scope
31
+ * on purpose: Quasar adds state classes at runtime (`q-btn--active`, `body--dark`)
32
+ * that are legitimately absent from the prerendered sheet.
33
+ */
34
+ export declare const UNSTYLED_HOOKS: readonly string[];
35
+ /** Class tokens a document asks for. */
36
+ export declare function collectMarkupClasses(html: string): Set<string>;
37
+ /**
38
+ * Class names a stylesheet mentions.
39
+ *
40
+ * The whole sheet is scanned rather than just its selector text. An earlier
41
+ * version stripped declaration blocks first to be precise about it, and was
42
+ * wrong: stripping innermost blocks turns `@media(...){.a{color:red}}` into
43
+ * `@media(...){.a}`, and the next pass then strips `{.a}` — deleting the very
44
+ * selector it was looking for. It reported a styled class as unstyled, which is
45
+ * the one direction that would get a check like this switched off.
46
+ *
47
+ * Scanning everything is safe here because the check only asks about `ssg-*`
48
+ * names, and generated CSS contains those in selectors and nowhere else. A
49
+ * declaration value cannot produce one, and UnoCSS emits no comments.
50
+ */
51
+ export declare function collectCssClasses(css: string): Set<string>;
52
+ export interface ClassCoverageIssue {
53
+ page: string;
54
+ className: string;
55
+ }
56
+ /**
57
+ * `ssg-*` classes that a page asks for and the stylesheet does not provide.
58
+ *
59
+ * Reported per page rather than as a flat list: which page uses an unstyled class
60
+ * is the first thing needed to find it.
61
+ */
62
+ export declare function checkClassCoverage(pages: {
63
+ page: string;
64
+ html: string;
65
+ }[], css: string, hooks?: readonly string[]): ClassCoverageIssue[];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * True when a built page (its path relative to the output root) matches one of
3
+ * the `--ignore` globs.
4
+ *
5
+ * Kept in its own module — rather than inline in `verify.ts` — so it can be
6
+ * unit-tested without importing the Lighthouse half of the verifier.
7
+ */
8
+ export declare function isIgnored(page: string, patterns: readonly string[]): boolean;
@@ -0,0 +1,27 @@
1
+ import type { ResourceEntry } from './budgets.js';
2
+ /**
3
+ * Run Lighthouse against a URL and return the parts `ssg verify` acts on.
4
+ *
5
+ * Lighthouse is used as a **library** rather than the CLI so the verification
6
+ * runs in-process, reports its own scores, and does not require the caller to
7
+ * have the `lighthouse` binary on `PATH`.
8
+ */
9
+ export interface LighthouseVerification {
10
+ scores: Record<string, number>;
11
+ resources: ResourceEntry[];
12
+ /**
13
+ * Failing audit ids per category. `verify` reports these next to a score so a
14
+ * failed accessibility/SEO score names the audit that failed instead of only
15
+ * the number — a bare "seo score is 82" is not actionable.
16
+ */
17
+ failures: Record<string, string[]>;
18
+ /** Failing-audit id to the selectors of the nodes that failed it. */
19
+ details: Record<string, string[]>;
20
+ }
21
+ export interface LighthouseOptions {
22
+ /** Chromium/Chrome binary. Falls back to `CHROME_PATH`, then auto-detection. */
23
+ chromePath?: string;
24
+ /** `mobile` (default) or `desktop` — the two form factors `verify` reports. */
25
+ formFactor?: 'mobile' | 'desktop';
26
+ }
27
+ export declare function runLighthouse(url: string, options?: LighthouseOptions): Promise<LighthouseVerification>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Verify the SEO surface of built HTML.
3
+ *
4
+ * These are the checks that would have caught the original template's SEO
5
+ * defect: it did produce meta tags in some configurations, but never asserted
6
+ * anything about them, so a page could ship with no description, two titles, or
7
+ * unparseable structured data and nothing noticed. Each check is a property a
8
+ * crawler depends on.
9
+ */
10
+ export interface SeoSurfaceIssue {
11
+ /** Output file the problem was found in, e.g. `index.html`. */
12
+ page: string;
13
+ message: string;
14
+ }
15
+ export interface SeoSurfaceOptions {
16
+ /** Site origin, used to assert the canonical is absolute. */
17
+ siteUrl?: string;
18
+ }
19
+ /**
20
+ * Check one built page. Returns every problem found, so a broken build reports
21
+ * all of its SEO faults at once rather than one per run.
22
+ */
23
+ export declare function checkSeoSurface(html: string, page: string, options?: SeoSurfaceOptions): SeoSurfaceIssue[];
@@ -0,0 +1,6 @@
1
+ export interface StaticServer {
2
+ url: string;
3
+ port: number;
4
+ close: () => Promise<void>;
5
+ }
6
+ export declare function startStaticServer(root: string): Promise<StaticServer>;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Transfer-size budgets.
3
+ *
4
+ * The tiered policy from the plan: byte budgets are **hard** failures because
5
+ * they are deterministic and directly measurable, while Lighthouse performance
6
+ * is a **warn** because it varies with machine and network simulation. A gate
7
+ * that fails randomly gets disabled, so only the deterministic half blocks.
8
+ */
9
+ import { promises as fs } from 'node:fs';
10
+ import { join, relative } from 'node:path';
11
+ import { gzipSync } from 'node:zlib';
12
+ export const DEFAULT_BUDGETS = {
13
+ jsBytes: 250 * 1024,
14
+ cssBytes: 60 * 1024
15
+ };
16
+ function kindOf(resourceType) {
17
+ if (resourceType === 'script')
18
+ return 'js';
19
+ if (resourceType === 'stylesheet')
20
+ return 'css';
21
+ return 'other';
22
+ }
23
+ export function formatBytes(bytes) {
24
+ return `${(bytes / 1024).toFixed(1)} KB`;
25
+ }
26
+ /**
27
+ * Evaluate transferred bytes against the budgets.
28
+ *
29
+ * `transferSize` (compressed, over the wire) is used rather than decoded size,
30
+ * because that is what the visitor actually pays for.
31
+ */
32
+ export function evaluateBudgets(resources, budgets = DEFAULT_BUDGETS) {
33
+ let js = 0;
34
+ let css = 0;
35
+ for (const resource of resources) {
36
+ const size = resource.transferSize ?? 0;
37
+ if (kindOf(resource.resourceType) === 'js')
38
+ js += size;
39
+ else if (kindOf(resource.resourceType) === 'css')
40
+ css += size;
41
+ }
42
+ const issues = [];
43
+ if (js > budgets.jsBytes) {
44
+ issues.push(`JavaScript budget exceeded: ${formatBytes(js)} transferred, budget is ${formatBytes(budgets.jsBytes)}`);
45
+ }
46
+ if (css > budgets.cssBytes) {
47
+ issues.push(`CSS budget exceeded: ${formatBytes(css)} transferred, budget is ${formatBytes(budgets.cssBytes)}`);
48
+ }
49
+ return { ok: issues.length === 0, totals: { js, css }, issues };
50
+ }
51
+ /** Scores below these are a warning, not a failure (see the tiering note above). */
52
+ /**
53
+ * Warn when a measured total is within this fraction of its ceiling.
54
+ *
55
+ * A budget a site is always near is a cliff: everything passes until the day a
56
+ * dependency bump pushes it over, and then the failure looks sudden. A warning at
57
+ * 10% remaining turns "no headroom" into something visible on every run.
58
+ *
59
+ * Deliberately not a failure. Being inside the budget is not a defect, and the
60
+ * deterministic tier is reserved for things that are.
61
+ */
62
+ export const HEADROOM_WARNING_FRACTION = 0.1;
63
+ export function budgetHeadroomWarnings(totals, budgets = DEFAULT_BUDGETS, fraction = HEADROOM_WARNING_FRACTION) {
64
+ const warnings = [];
65
+ const check = (label, actual, limit) => {
66
+ // A zero total is a failed measurement, which `evaluateTotals` already
67
+ // reports as a failure; warning about its headroom would be noise on top.
68
+ if (actual <= 0 || limit <= 0)
69
+ return;
70
+ const remaining = (limit - actual) / limit;
71
+ if (remaining >= 0 && remaining <= fraction) {
72
+ warnings.push(`${label} is within ${Math.round(fraction * 100)}% of its budget: ${formatBytes(actual)} of ${formatBytes(limit)}, ${formatBytes(limit - actual)} left`);
73
+ }
74
+ };
75
+ check('JavaScript', totals.js, budgets.jsBytes);
76
+ check('CSS', totals.css, budgets.cssBytes);
77
+ return warnings;
78
+ }
79
+ export const DEFAULT_SCORE_TARGETS = {
80
+ /** Must equal `required` exactly — anything lower is a defect, not a trade-off. */
81
+ required: { accessibility: 100, 'best-practices': 100, seo: 100 },
82
+ /** A warning threshold: performance varies with the measuring machine. */
83
+ warnBelow: { performance: 90 }
84
+ };
85
+ export function evaluateScores(scores, targets = DEFAULT_SCORE_TARGETS) {
86
+ const issues = [];
87
+ const warnings = [];
88
+ for (const [category, minimum] of Object.entries(targets.required)) {
89
+ const actual = scores[category];
90
+ if (actual === undefined)
91
+ continue;
92
+ if (actual < minimum) {
93
+ issues.push(`${category} score is ${actual}, expected ${minimum}`);
94
+ }
95
+ }
96
+ for (const [category, minimum] of Object.entries(targets.warnBelow)) {
97
+ const actual = scores[category];
98
+ if (actual === undefined)
99
+ continue;
100
+ if (actual < minimum) {
101
+ warnings.push(`${category} score is ${actual}, below the ${minimum} target`);
102
+ }
103
+ }
104
+ return { ok: issues.length === 0, issues, warnings };
105
+ }
106
+ /**
107
+ * Measure the transferred JavaScript and CSS of a built site **from disk**.
108
+ *
109
+ * This is deliberately not read from Lighthouse. Its `resource-summary` audit
110
+ * reports `transferSize: 0` when the trace captures no network activity
111
+ * (reproduced in a sandboxed Chromium), and a budget that measures nothing
112
+ * silently reports "OK" — a gate that cannot fail is worse than no gate.
113
+ * Reading the built files is deterministic, needs no browser, and lets the byte
114
+ * half of `ssg verify` run with `--no-lighthouse`.
115
+ *
116
+ * Sizes are gzipped, because that is what actually crosses the network.
117
+ */
118
+ export async function measureBuildAssets(root) {
119
+ const files = [];
120
+ async function walk(dir) {
121
+ let entries;
122
+ try {
123
+ entries = await fs.readdir(dir, { withFileTypes: true });
124
+ }
125
+ catch {
126
+ return;
127
+ }
128
+ for (const entry of entries) {
129
+ const full = join(dir, entry.name);
130
+ if (entry.isDirectory()) {
131
+ await walk(full);
132
+ continue;
133
+ }
134
+ const type = /\.(mjs|js)$/.test(entry.name)
135
+ ? 'js'
136
+ : entry.name.endsWith('.css')
137
+ ? 'css'
138
+ : null;
139
+ if (!type)
140
+ continue;
141
+ const compressed = gzipSync(await fs.readFile(full)).length;
142
+ files.push({ file: relative(root, full), bytes: compressed, type });
143
+ }
144
+ }
145
+ await walk(root);
146
+ return {
147
+ js: files
148
+ .filter((f) => f.type === 'js')
149
+ .reduce((total, f) => total + f.bytes, 0),
150
+ css: files
151
+ .filter((f) => f.type === 'css')
152
+ .reduce((total, f) => total + f.bytes, 0),
153
+ files: files.sort((a, b) => b.bytes - a.bytes)
154
+ };
155
+ }
156
+ /**
157
+ * Evaluate measured byte totals against the budgets.
158
+ *
159
+ * An all-zero measurement is **no measurement**, not a pass: a built site always
160
+ * ships some JavaScript, so zero means the measurement failed.
161
+ */
162
+ export function evaluateTotals(totals, budgets = DEFAULT_BUDGETS) {
163
+ if (totals.js + totals.css === 0) {
164
+ return {
165
+ ok: false,
166
+ totals,
167
+ issues: [
168
+ 'Byte measurement returned 0 bytes for both JavaScript and CSS — the budget gate cannot be trusted. Does the build output exist?'
169
+ ]
170
+ };
171
+ }
172
+ const issues = [];
173
+ if (totals.js > budgets.jsBytes) {
174
+ issues.push(`JavaScript budget exceeded: ${formatBytes(totals.js)} transferred, budget is ${formatBytes(budgets.jsBytes)}`);
175
+ }
176
+ if (totals.css > budgets.cssBytes) {
177
+ issues.push(`CSS budget exceeded: ${formatBytes(totals.css)} transferred, budget is ${formatBytes(budgets.cssBytes)}`);
178
+ }
179
+ return { ok: issues.length === 0, totals, issues };
180
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Markup ↔ stylesheet coverage.
3
+ *
4
+ * The kit's design system is UnoCSS configuration: `ssg-*` classes in markup
5
+ * resolve to shortcuts and preflights that are generated at build time. Nothing
6
+ * connects the two, and every failure in that gap is **silent** — which is the
7
+ * class of defect this repository keeps meeting (see `docs/adr/0001` and
8
+ * `docs/adr/0004`).
9
+ *
10
+ * The concrete case that motivated this: a shortcut array in `design.ts` lost
11
+ * its separating comma, which parses as a computed member access. The entry
12
+ * *after* the comma survived, the entry before it vanished, and
13
+ * `tsc`, `oxlint` and the whole test suite stayed green while the class it named
14
+ * rendered unstyled. It happened twice in one afternoon — `ssg-business__band-demo`
15
+ * and then `ssg-proof-section__heading` — and was caught both times only by
16
+ * looking at the page.
17
+ *
18
+ * This is the check that would have caught it mechanically. It compares what the
19
+ * built HTML *asks for* against what the built CSS *provides*, which is a set
20
+ * difference over two files the build has already written.
21
+ */
22
+ /**
23
+ * `ssg-*` classes that carry no styling by design.
24
+ *
25
+ * These are hooks: targets for tests and for `data-ssg`-style selection, or
26
+ * structural markers a component uses to find its own nodes. They must be listed
27
+ * rather than merely tolerated, because "this class has no rule" should be a
28
+ * decision someone made, not an accident — which is the whole point of the check.
29
+ *
30
+ * Only `ssg-*` classes are checked at all. Third-party classes are out of scope
31
+ * on purpose: Quasar adds state classes at runtime (`q-btn--active`, `body--dark`)
32
+ * that are legitimately absent from the prerendered sheet.
33
+ */
34
+ export const UNSTYLED_HOOKS = [
35
+ 'ssg-breadcrumbs__sep',
36
+ 'ssg-content',
37
+ 'ssg-content__carousel',
38
+ 'ssg-content__markdown',
39
+ 'ssg-cta',
40
+ // The demo's grid and per-stop group carry SVG presentation attributes (`fill`,
41
+ // `mask`) rather than CSS, and the group is only a wrapper: its children are
42
+ // what is styled. Both are selection hooks for the component's own tests.
43
+ 'ssg-demo__grid',
44
+ 'ssg-demo__stop',
45
+ 'ssg-header__language',
46
+ 'ssg-header__menu-toggle',
47
+ 'ssg-landing',
48
+ 'ssg-primary-navigation',
49
+ 'ssg-proof-section__facts',
50
+ 'ssg-proof-section__reviews',
51
+ 'ssg-proof__text'
52
+ ];
53
+ /** Class tokens a document asks for. */
54
+ export function collectMarkupClasses(html) {
55
+ // Comments and scripts first. The SEO surface check learned this the hard way:
56
+ // it counted tags inside an HTML comment, so a page whose head was entirely
57
+ // commented out passed. The same trap applies here, and a JSON-LD block is a
58
+ // script whose content is not markup either.
59
+ const markup = html
60
+ .replace(/<!--[\s\S]*?-->/g, '')
61
+ .replace(/<script\b[\s\S]*?<\/script>/gi, '');
62
+ const classes = new Set();
63
+ for (const match of markup.matchAll(/\bclass\s*=\s*"([^"]*)"/gi)) {
64
+ for (const token of match[1].split(/\s+/)) {
65
+ if (token)
66
+ classes.add(token);
67
+ }
68
+ }
69
+ return classes;
70
+ }
71
+ /**
72
+ * Class names a stylesheet mentions.
73
+ *
74
+ * The whole sheet is scanned rather than just its selector text. An earlier
75
+ * version stripped declaration blocks first to be precise about it, and was
76
+ * wrong: stripping innermost blocks turns `@media(...){.a{color:red}}` into
77
+ * `@media(...){.a}`, and the next pass then strips `{.a}` — deleting the very
78
+ * selector it was looking for. It reported a styled class as unstyled, which is
79
+ * the one direction that would get a check like this switched off.
80
+ *
81
+ * Scanning everything is safe here because the check only asks about `ssg-*`
82
+ * names, and generated CSS contains those in selectors and nowhere else. A
83
+ * declaration value cannot produce one, and UnoCSS emits no comments.
84
+ */
85
+ export function collectCssClasses(css) {
86
+ const classes = new Set();
87
+ for (const match of css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)) {
88
+ classes.add(match[1]);
89
+ }
90
+ return classes;
91
+ }
92
+ /**
93
+ * `ssg-*` classes that a page asks for and the stylesheet does not provide.
94
+ *
95
+ * Reported per page rather than as a flat list: which page uses an unstyled class
96
+ * is the first thing needed to find it.
97
+ */
98
+ export function checkClassCoverage(pages, css, hooks = UNSTYLED_HOOKS) {
99
+ const provided = collectCssClasses(css);
100
+ const exempt = new Set(hooks);
101
+ const issues = [];
102
+ for (const { page, html } of pages) {
103
+ for (const className of collectMarkupClasses(html)) {
104
+ if (!className.startsWith('ssg-'))
105
+ continue;
106
+ if (exempt.has(className))
107
+ continue;
108
+ if (provided.has(className))
109
+ continue;
110
+ issues.push({ page, className });
111
+ }
112
+ }
113
+ return issues;
114
+ }
@@ -0,0 +1,11 @@
1
+ import { matchesGlob } from 'node:path';
2
+ /**
3
+ * True when a built page (its path relative to the output root) matches one of
4
+ * the `--ignore` globs.
5
+ *
6
+ * Kept in its own module — rather than inline in `verify.ts` — so it can be
7
+ * unit-tested without importing the Lighthouse half of the verifier.
8
+ */
9
+ export function isIgnored(page, patterns) {
10
+ return patterns.some((pattern) => matchesGlob(page, pattern));
11
+ }
@@ -0,0 +1,98 @@
1
+ import { launch } from 'chrome-launcher';
2
+ import lighthouse from 'lighthouse';
3
+ /**
4
+ * Categories that must be measured. Performance is included because the plan
5
+ * warns on it; `verify` decides what is fatal.
6
+ */
7
+ const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'];
8
+ export async function runLighthouse(url, options = {}) {
9
+ const chromePath = options.chromePath ?? process.env.CHROME_PATH;
10
+ if (!chromePath) {
11
+ throw new Error('No Chromium binary found. Set CHROME_PATH to a Chrome/Chromium executable (for example CHROME_PATH=/usr/bin/chromium ssg verify).');
12
+ }
13
+ const chrome = await launch({
14
+ chromePath,
15
+ chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu']
16
+ });
17
+ try {
18
+ const result = await lighthouse(url, {
19
+ port: chrome.port,
20
+ output: 'json',
21
+ logLevel: 'error',
22
+ onlyCategories: CATEGORIES
23
+ },
24
+ // Lighthouse 12+ expects the form factor in the config's settings.
25
+ options.formFactor === 'desktop'
26
+ ? {
27
+ extends: 'lighthouse:default',
28
+ settings: {
29
+ formFactor: 'desktop',
30
+ screenEmulation: {
31
+ mobile: false,
32
+ width: 1350,
33
+ height: 940,
34
+ deviceScaleFactor: 1,
35
+ disabled: false
36
+ }
37
+ }
38
+ }
39
+ : { extends: 'lighthouse:default' });
40
+ if (!result) {
41
+ throw new Error('Lighthouse returned no result');
42
+ }
43
+ const { lhr } = result;
44
+ const scores = {};
45
+ const failures = {};
46
+ for (const [key, category] of Object.entries(lhr.categories)) {
47
+ const refs = category.auditRefs ?? [];
48
+ const failing = refs
49
+ .map((ref) => lhr.audits[ref.id])
50
+ .filter((audit) => audit &&
51
+ audit.score !== null &&
52
+ audit.score !== undefined &&
53
+ audit.score < 1)
54
+ .map((audit) => audit.id);
55
+ if (failing.length)
56
+ failures[key] = failing;
57
+ if (category.score === null || category.score === undefined)
58
+ continue;
59
+ // Lighthouse reports 0–1; a 0–100 integer is what people compare against.
60
+ scores[key] = Math.round(category.score * 100);
61
+ }
62
+ // The selector of the nodes behind each failing audit, so a failed
63
+ // accessibility or SEO score points at an element instead of only an id.
64
+ const details = {};
65
+ for (const audit of Object.values(lhr.audits)) {
66
+ if (!audit ||
67
+ audit.score === null ||
68
+ audit.score === undefined ||
69
+ audit.score >= 1) {
70
+ continue;
71
+ }
72
+ const items = audit.details?.items;
73
+ const selectors = (Array.isArray(items) ? items : [])
74
+ .map((item) => item.node?.selector)
75
+ .filter((selector) => Boolean(selector))
76
+ .slice(0, 3);
77
+ if (selectors.length)
78
+ details[audit.id] = selectors;
79
+ }
80
+ // Measured from `resource-summary`, which reports one row per resource TYPE
81
+ // with a total `transferSize`. `network-requests` is not used: it only lists
82
+ // requests Lighthouse observed in its own trace, so a warm cache makes it
83
+ // under-report and the budget gate would pass vacuously.
84
+ const summaryAudit = lhr.audits['resource-summary'];
85
+ const resources = (summaryAudit?.details?.items ?? [])
86
+ .filter((item) => typeof item === 'object' &&
87
+ item !== null &&
88
+ typeof item.resourceType === 'string')
89
+ .map((item) => ({
90
+ resourceType: item.resourceType,
91
+ transferSize: Number(item.transferSize) || 0
92
+ }));
93
+ return { scores, resources, failures, details };
94
+ }
95
+ finally {
96
+ await chrome.kill();
97
+ }
98
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Verify the SEO surface of built HTML.
3
+ *
4
+ * These are the checks that would have caught the original template's SEO
5
+ * defect: it did produce meta tags in some configurations, but never asserted
6
+ * anything about them, so a page could ship with no description, two titles, or
7
+ * unparseable structured data and nothing noticed. Each check is a property a
8
+ * crawler depends on.
9
+ */
10
+ function countMatches(html, pattern) {
11
+ return [...html.matchAll(pattern)].length;
12
+ }
13
+ /**
14
+ * Check one built page. Returns every problem found, so a broken build reports
15
+ * all of its SEO faults at once rather than one per run.
16
+ */
17
+ export function checkSeoSurface(html, page, options = {}) {
18
+ const issues = [];
19
+ const add = (message) => issues.push({ page, message });
20
+ // Comments are meta-text, not page content. The app template's own comments
21
+ // mention `<h1>`, and counting those made a page that renders exactly one
22
+ // heading report "3 <h1> tags". Every check below therefore runs on the
23
+ // comment-free document.
24
+ html = html.replace(/<!--[\s\S]*?-->/g, '');
25
+ // A page the site has deliberately marked `noindex` (the 404, for example) is
26
+ // excluded from the index, so the tags that exist to describe an indexed page
27
+ // — canonical, description, structured data — are not required of it. A title,
28
+ // an h1 and a language still are: the page is rendered and read either way.
29
+ const noindex = /<meta\b[^>]*\bname=["']?robots["']?[^>]*\bcontent=["'][^"']*noindex/i.test(html);
30
+ // Exactly one <title>: two of them means a crawler picks one arbitrarily.
31
+ const titleCount = countMatches(html, /<title\b[^>]*>[\s\S]*?<\/title>/gi);
32
+ if (titleCount === 0)
33
+ add('no <title>');
34
+ if (titleCount > 1)
35
+ add(`${titleCount} <title> tags — expected exactly one`);
36
+ const title = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
37
+ if (titleCount === 1 && !title)
38
+ add('<title> is empty');
39
+ // Exactly one canonical, and it must be absolute.
40
+ const canonical = [
41
+ ...html.matchAll(/<link\b[^>]*\brel=["']?canonical["']?[^>]*>/gi)
42
+ ];
43
+ if (!noindex && canonical.length === 0)
44
+ add('no canonical link');
45
+ if (canonical.length > 1) {
46
+ add(`${canonical.length} canonical links — expected exactly one`);
47
+ }
48
+ const canonicalHref = canonical[0]?.[0].match(/\bhref=["']([^"']+)["']/i)?.[1];
49
+ if (canonical.length === 1 && !canonicalHref) {
50
+ add('canonical link has no href');
51
+ }
52
+ else if (canonicalHref && !/^https?:\/\//i.test(canonicalHref)) {
53
+ add(`canonical "${canonicalHref}" is not absolute`);
54
+ }
55
+ else if (canonicalHref && options.siteUrl) {
56
+ const origin = options.siteUrl.replace(/\/+$/, '');
57
+ if (!canonicalHref.startsWith(origin)) {
58
+ add(`canonical "${canonicalHref}" is not on the configured site origin`);
59
+ }
60
+ }
61
+ // A meta description is required on every indexable page.
62
+ const description = html.match(/<meta\b[^>]*\bname=["']?description["']?[^>]*>/i)?.[0];
63
+ const descriptionContent = description?.match(/\bcontent=["']([^"']*)["']/i)?.[1];
64
+ if (!description) {
65
+ if (!noindex)
66
+ add('no meta description');
67
+ }
68
+ else if (!descriptionContent?.trim())
69
+ add('meta description is empty');
70
+ // Structured data must exist and must be valid JSON — a syntax error there is
71
+ // silently ignored by crawlers, so it has to be asserted.
72
+ const jsonLdBlocks = [
73
+ ...html.matchAll(/<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)
74
+ ];
75
+ if (jsonLdBlocks.length === 0 && !noindex) {
76
+ add('no application/ld+json block');
77
+ }
78
+ for (const [index, block] of jsonLdBlocks.entries()) {
79
+ try {
80
+ JSON.parse(block[1]);
81
+ }
82
+ catch (error) {
83
+ add(`JSON-LD block ${index + 1} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
84
+ }
85
+ }
86
+ // Exactly one <h1>: the page template owns it, and content must not add one.
87
+ const h1Count = countMatches(html, /<h1\b/gi);
88
+ if (h1Count === 0)
89
+ add('no <h1>');
90
+ if (h1Count > 1)
91
+ add(`${h1Count} <h1> tags — expected exactly one`);
92
+ // <html> must declare a language for both crawlers and screen readers.
93
+ const lang = html.match(/<html\b[^>]*\blang=["']([^"']*)["']/i)?.[1];
94
+ if (!lang?.trim())
95
+ add('<html> has no lang attribute');
96
+ return issues;
97
+ }