feat(web): improve web build configuration and accessibility
- Add source maps and web renderer build arguments with defaults - Configure Caddy with CSP headers, cache policies, and service worker handling - Defer loading of import screen for performance optimization - Add semantic labels to icons for accessibility - Update web index.html with Swedish language, meta tags, and description - Add robots.txt and lighthouse configuration - Add new planning documents and archive entries
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
url: '',
|
||||
label: 'baseline',
|
||||
runs: 3,
|
||||
formFactor: 'mobile',
|
||||
outDir: 'flutter/lighthouse/results',
|
||||
};
|
||||
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const current = argv[i];
|
||||
const next = argv[i + 1];
|
||||
if (current === '--url' && next) {
|
||||
args.url = next;
|
||||
i += 1;
|
||||
} else if (current === '--label' && next) {
|
||||
args.label = next;
|
||||
i += 1;
|
||||
} else if (current === '--runs' && next) {
|
||||
args.runs = Number.parseInt(next, 10);
|
||||
i += 1;
|
||||
} else if (current === '--form-factor' && next) {
|
||||
args.formFactor = next;
|
||||
i += 1;
|
||||
} else if (current === '--out-dir' && next) {
|
||||
args.outDir = next;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.url) {
|
||||
throw new Error('Missing --url, example: node flutter/lighthouse/collect.mjs --url http://localhost:5000');
|
||||
}
|
||||
if (!Number.isFinite(args.runs) || args.runs < 1) {
|
||||
throw new Error('--runs must be >= 1');
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
if (values.length === 0) return null;
|
||||
const ordered = [...values].sort((a, b) => a - b);
|
||||
const center = Math.floor(ordered.length / 2);
|
||||
if (ordered.length % 2 === 0) {
|
||||
return (ordered[center - 1] + ordered[center]) / 2;
|
||||
}
|
||||
return ordered[center];
|
||||
}
|
||||
|
||||
function toMs(value) {
|
||||
if (typeof value !== 'number') return null;
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function readMetric(lhr, id) {
|
||||
const audit = lhr.audits[id];
|
||||
return audit && typeof audit.numericValue === 'number' ? audit.numericValue : null;
|
||||
}
|
||||
|
||||
function readResourceSummary(lhr) {
|
||||
const details = lhr.audits['resource-summary']?.details;
|
||||
const items = details?.items;
|
||||
if (!Array.isArray(items)) return { transferKb: null, requests: null };
|
||||
|
||||
const total = items.find((item) => item.resourceType === 'total');
|
||||
if (!total) return { transferKb: null, requests: null };
|
||||
|
||||
return {
|
||||
transferKb: typeof total.transferSize === 'number' ? Math.round(total.transferSize / 1024) : null,
|
||||
requests: typeof total.requestCount === 'number' ? total.requestCount : null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatNullable(value) {
|
||||
return value == null ? 'n/a' : String(value);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const outputRoot = resolve(args.outDir);
|
||||
mkdirSync(outputRoot, { recursive: true });
|
||||
|
||||
const runRows = [];
|
||||
|
||||
for (let runIndex = 1; runIndex <= args.runs; runIndex += 1) {
|
||||
const outputPath = join(outputRoot, `${args.label}-run-${runIndex}.json`);
|
||||
const flags = [
|
||||
'lighthouse',
|
||||
args.url,
|
||||
'--only-categories=performance,accessibility,seo',
|
||||
'--throttling-method=simulate',
|
||||
`--form-factor=${args.formFactor}`,
|
||||
'--screenEmulation.mobile=true',
|
||||
'--output=json',
|
||||
`--output-path=${outputPath}`,
|
||||
'--chrome-flags=--headless=new --no-sandbox',
|
||||
'--quiet',
|
||||
];
|
||||
|
||||
execFileSync('npx', flags, { stdio: 'inherit' });
|
||||
|
||||
const lhr = JSON.parse(readFileSync(outputPath, 'utf8'));
|
||||
const summary = readResourceSummary(lhr);
|
||||
|
||||
runRows.push({
|
||||
run: runIndex,
|
||||
performance: Math.round((lhr.categories.performance.score ?? 0) * 100),
|
||||
accessibility: Math.round((lhr.categories.accessibility.score ?? 0) * 100),
|
||||
seo: Math.round((lhr.categories.seo.score ?? 0) * 100),
|
||||
lcpMs: toMs(readMetric(lhr, 'largest-contentful-paint')),
|
||||
tbtMs: toMs(readMetric(lhr, 'total-blocking-time')),
|
||||
inpMs: toMs(readMetric(lhr, 'interaction-to-next-paint')),
|
||||
transferKb: summary.transferKb,
|
||||
requests: summary.requests,
|
||||
fetchTime: lhr.fetchTime,
|
||||
finalUrl: lhr.finalUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const medians = {
|
||||
performance: median(runRows.map((r) => r.performance)),
|
||||
accessibility: median(runRows.map((r) => r.accessibility)),
|
||||
seo: median(runRows.map((r) => r.seo)),
|
||||
lcpMs: median(runRows.filter((r) => r.lcpMs != null).map((r) => r.lcpMs)),
|
||||
tbtMs: median(runRows.filter((r) => r.tbtMs != null).map((r) => r.tbtMs)),
|
||||
inpMs: median(runRows.filter((r) => r.inpMs != null).map((r) => r.inpMs)),
|
||||
transferKb: median(runRows.filter((r) => r.transferKb != null).map((r) => r.transferKb)),
|
||||
requests: median(runRows.filter((r) => r.requests != null).map((r) => r.requests)),
|
||||
};
|
||||
|
||||
const summaryPath = join(outputRoot, `${args.label}-summary.json`);
|
||||
writeFileSync(
|
||||
summaryPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
url: args.url,
|
||||
label: args.label,
|
||||
runs: args.runs,
|
||||
formFactor: args.formFactor,
|
||||
medians,
|
||||
results: runRows,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const markdownPath = join(outputRoot, `${args.label}-summary.md`);
|
||||
const markdown = [
|
||||
`# Lighthouse summary: ${args.label}`,
|
||||
'',
|
||||
`- URL: ${args.url}`,
|
||||
`- Runs: ${args.runs}`,
|
||||
`- Form factor: ${args.formFactor}`,
|
||||
'',
|
||||
'| Metric | Median |',
|
||||
'| --- | ---: |',
|
||||
`| Performance | ${formatNullable(medians.performance)} |`,
|
||||
`| Accessibility | ${formatNullable(medians.accessibility)} |`,
|
||||
`| SEO | ${formatNullable(medians.seo)} |`,
|
||||
`| LCP (ms) | ${formatNullable(medians.lcpMs)} |`,
|
||||
`| TBT (ms) | ${formatNullable(medians.tbtMs)} |`,
|
||||
`| INP (ms) | ${formatNullable(medians.inpMs)} |`,
|
||||
`| Transfer (KB) | ${formatNullable(medians.transferKb)} |`,
|
||||
`| Requests | ${formatNullable(medians.requests)} |`,
|
||||
'',
|
||||
'## Runs',
|
||||
'',
|
||||
'| Run | Perf | A11y | SEO | LCP | TBT | INP | KB | Req |',
|
||||
'| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
...runRows.map(
|
||||
(row) =>
|
||||
`| ${row.run} | ${row.performance} | ${row.accessibility} | ${row.seo} | ${formatNullable(row.lcpMs)} | ${formatNullable(row.tbtMs)} | ${formatNullable(row.inpMs)} | ${formatNullable(row.transferKb)} | ${formatNullable(row.requests)} |`,
|
||||
),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
writeFileSync(markdownPath, markdown);
|
||||
process.stdout.write(`Wrote ${summaryPath}\n`);
|
||||
process.stdout.write(`Wrote ${markdownPath}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user