@askrjs/cli 0.0.16 → 0.0.18
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/dist/analyze.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/{guardrails-XypvEZOT.js → guardrails-L60OAzIp.js} +1 -1
- package/dist/{runner-42nqhW9u.js → runner-CYvonJ0C.js} +845 -77
- package/dist/{runner-CEt07ieG.js → runner-DzWc9R4G.js} +1 -1
- package/dist/ssg-config.d.ts +68 -1
- package/dist/ssg.js +310 -7
- package/dist/templates/ssr/src/pages/example.tsx +1 -1
- package/dist/templates/ssr/src/styles.css +7 -0
- package/dist/templates/startkit/src/components/app-header.tsx +1 -1
- package/dist/templates/startkit/src/components/page-header.tsx +1 -7
- package/dist/templates/startkit/src/pages/workspace/accounts/index.tsx +1 -1
- package/dist/templates/startkit/src/pages/workspace/settings.tsx +1 -1
- package/dist/templates/startkit/src/styles/components.css +10 -0
- package/package.json +9 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as inspectBundledSkills } from "./skills-CSGdAZHN.js";
|
|
2
2
|
import { discoverWorkspaceProject } from "./discovery-DUDrZCIC.js";
|
|
3
|
-
import { analysisHasBlockingFindings, runAnalysis } from "./runner-
|
|
3
|
+
import { analysisHasBlockingFindings, runAnalysis } from "./runner-CYvonJ0C.js";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { spawn } from "node:child_process";
|
package/dist/ssg-config.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ interface SitemapRouteContext {
|
|
|
15
15
|
path: string;
|
|
16
16
|
filePath: string;
|
|
17
17
|
status: string;
|
|
18
|
+
/** Resolved rendered canonical URL, when the document declares one. */
|
|
19
|
+
canonical?: string;
|
|
18
20
|
}
|
|
19
21
|
interface SitemapConfig {
|
|
20
22
|
/** Site-wide values inherited by included routes. */
|
|
@@ -38,4 +40,69 @@ interface SitemapConfig {
|
|
|
38
40
|
resolverConcurrency?: number;
|
|
39
41
|
}
|
|
40
42
|
//#endregion
|
|
41
|
-
|
|
43
|
+
//#region src/ssg/output-report.d.ts
|
|
44
|
+
interface SsgByteBudget {
|
|
45
|
+
raw?: number;
|
|
46
|
+
gzip?: number;
|
|
47
|
+
}
|
|
48
|
+
interface SsgOutputBudgets {
|
|
49
|
+
/** Default raw/gzip HTML limits for every route. */
|
|
50
|
+
routes?: SsgByteBudget;
|
|
51
|
+
/** Exact route overrides merged with defaults; false exempts a route. */
|
|
52
|
+
routeOverrides?: Readonly<Record<string, SsgByteBudget | false>>;
|
|
53
|
+
hydration?: {
|
|
54
|
+
/** Maximum hydration bytes as a 0..1 share of raw HTML. */
|
|
55
|
+
share?: number;
|
|
56
|
+
/** Exact share overrides; false exempts a route. */
|
|
57
|
+
routes?: Readonly<Record<string, number | false>>;
|
|
58
|
+
};
|
|
59
|
+
/** Exact emitted asset raw/gzip limits; false exempts an asset. */
|
|
60
|
+
assets?: Readonly<Record<string, SsgByteBudget | false>>;
|
|
61
|
+
aggregate?: {
|
|
62
|
+
javascript?: SsgByteBudget;
|
|
63
|
+
css?: SsgByteBudget;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
interface SsgOutputReportConfig {
|
|
67
|
+
budgets?: SsgOutputBudgets;
|
|
68
|
+
/** Number of largest pages retained in the summary. Defaults to 20. */
|
|
69
|
+
largestPages?: number;
|
|
70
|
+
/** Number of largest assets retained in the summary. Defaults to 20. */
|
|
71
|
+
largestAssets?: number;
|
|
72
|
+
}
|
|
73
|
+
interface SsgOutputSize {
|
|
74
|
+
raw: number;
|
|
75
|
+
gzip: number;
|
|
76
|
+
}
|
|
77
|
+
interface SsgOutputAsset extends SsgOutputSize {
|
|
78
|
+
path: string;
|
|
79
|
+
type: "javascript" | "css" | "other";
|
|
80
|
+
}
|
|
81
|
+
interface SsgOutputRoute {
|
|
82
|
+
route: string;
|
|
83
|
+
filePath: string;
|
|
84
|
+
html: SsgOutputSize;
|
|
85
|
+
hydration: {
|
|
86
|
+
raw: number;
|
|
87
|
+
share: number;
|
|
88
|
+
};
|
|
89
|
+
initial: {
|
|
90
|
+
javascript: SsgOutputAsset[];
|
|
91
|
+
css: SsgOutputAsset[];
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
interface SsgOutputReport {
|
|
95
|
+
version: 1;
|
|
96
|
+
routes: SsgOutputRoute[];
|
|
97
|
+
assets: SsgOutputAsset[];
|
|
98
|
+
aggregate: {
|
|
99
|
+
javascript: SsgOutputSize;
|
|
100
|
+
css: SsgOutputSize;
|
|
101
|
+
};
|
|
102
|
+
largest: {
|
|
103
|
+
pages: SsgOutputRoute[];
|
|
104
|
+
assets: SsgOutputAsset[];
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export type { SitemapChangeFrequency, SitemapConfig, SitemapRouteConfig, SitemapRouteContext, SsgByteBudget, SsgOutputAsset, SsgOutputBudgets, SsgOutputReport, SsgOutputReportConfig, SsgOutputRoute, SsgOutputSize };
|
package/dist/ssg.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import { t as isDirectExecution } from "./is-direct-execution-Cdlr-ZUl.js";
|
|
3
3
|
import { n as publishStagedDirectory, t as createSiblingStage } from "./directory-swap-DWoHtx7C.js";
|
|
4
4
|
import * as fs$1 from "node:fs/promises";
|
|
5
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import fs, { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import * as path$1 from "node:path";
|
|
7
7
|
import path, { dirname, resolve } from "node:path";
|
|
8
8
|
import { constants, existsSync } from "node:fs";
|
|
9
9
|
import { pathToFileURL } from "node:url";
|
|
10
10
|
import { register } from "tsx/esm/api";
|
|
11
|
+
import { gzipSync } from "node:zlib";
|
|
12
|
+
import { parse } from "parse5";
|
|
11
13
|
//#region src/ssg/sitemap.ts
|
|
12
14
|
const datePattern = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2})))?$/;
|
|
13
15
|
const languagePattern = /^(?:x-default|[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*)$/;
|
|
@@ -58,6 +60,17 @@ function resolveLocation(value, siteUrl) {
|
|
|
58
60
|
if (resolved.hash) throw new Error(`Sitemap URLs must not contain fragments: ${value}`);
|
|
59
61
|
return resolved.href;
|
|
60
62
|
}
|
|
63
|
+
function resolveDocumentCanonical(value, siteUrl, label = "rendered canonical") {
|
|
64
|
+
let resolved;
|
|
65
|
+
try {
|
|
66
|
+
resolved = new URL(value, siteUrl);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(`Invalid ${label} URL: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
if (resolved.protocol !== "http:" && resolved.protocol !== "https:") throw new Error(`${label} URLs must use HTTP or HTTPS: ${value}`);
|
|
71
|
+
if (resolved.hash) throw new Error(`${label} URLs must not contain fragments: ${value}`);
|
|
72
|
+
return resolved.href;
|
|
73
|
+
}
|
|
61
74
|
function formatLastModified(value) {
|
|
62
75
|
if (value instanceof Date) {
|
|
63
76
|
if (Number.isNaN(value.getTime())) throw new Error("Invalid sitemap lastModified Date");
|
|
@@ -209,13 +222,21 @@ async function generateSitemap(outputDir, siteUrl, routes, config = {}) {
|
|
|
209
222
|
const generated = (await mapConcurrent(routes.filter((route) => (route.status === "success" || route.status === "skipped") && !route.path.includes("*")), resolverConcurrency, async (route) => {
|
|
210
223
|
const exact = config.routes?.[route.path];
|
|
211
224
|
if (exact === false) return void 0;
|
|
212
|
-
const
|
|
225
|
+
const canonical = route.canonical ? resolveDocumentCanonical(route.canonical, baseUrl) : void 0;
|
|
226
|
+
const resolved = await config.resolve?.({
|
|
227
|
+
...route,
|
|
228
|
+
...canonical ? { canonical } : {}
|
|
229
|
+
});
|
|
213
230
|
if (resolved === false) return void 0;
|
|
214
231
|
const merged = {
|
|
215
232
|
...config.defaults,
|
|
216
233
|
...exact,
|
|
217
234
|
...resolved
|
|
218
235
|
};
|
|
236
|
+
for (const [source, value] of [["sitemap.routes", exact?.url], ["sitemap.resolve", resolved?.url]]) {
|
|
237
|
+
const explicitUrl = value ? resolveDocumentCanonical(value, baseUrl, `${source} override`) : void 0;
|
|
238
|
+
if (canonical && explicitUrl && explicitUrl !== canonical) throw new Error(`Sitemap URL mismatch for ${route.path}: rendered canonical ${canonical} disagrees with ${source} URL ${explicitUrl}`);
|
|
239
|
+
}
|
|
219
240
|
const alternates = [];
|
|
220
241
|
for (const [language, location] of Object.entries(merged.alternates ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
221
242
|
if (!languagePattern.test(language)) throw new Error(`Invalid sitemap hreflang value: ${language}`);
|
|
@@ -225,7 +246,7 @@ async function generateSitemap(outputDir, siteUrl, routes, config = {}) {
|
|
|
225
246
|
});
|
|
226
247
|
}
|
|
227
248
|
return {
|
|
228
|
-
location: resolveLocation(merged.url ?? route.path, baseUrl),
|
|
249
|
+
location: canonical ?? resolveLocation(merged.url ?? route.path, baseUrl),
|
|
229
250
|
...merged.lastModified !== void 0 ? { lastModified: formatLastModified(merged.lastModified) } : {},
|
|
230
251
|
...merged.changeFrequency ? { changeFrequency: validateChangeFrequency(merged.changeFrequency) } : {},
|
|
231
252
|
...merged.priority !== void 0 ? { priority: validatePriority(merged.priority) } : {},
|
|
@@ -267,6 +288,254 @@ async function generateSitemap(outputDir, siteUrl, routes, config = {}) {
|
|
|
267
288
|
return destination;
|
|
268
289
|
}
|
|
269
290
|
//#endregion
|
|
291
|
+
//#region src/ssg/documents.ts
|
|
292
|
+
function attribute(element, name) {
|
|
293
|
+
return element.attrs.find((candidate) => candidate.name.toLowerCase() === name)?.value;
|
|
294
|
+
}
|
|
295
|
+
function relTokens(element) {
|
|
296
|
+
return new Set((attribute(element, "rel") ?? "").toLowerCase().split(/\s+/).filter(Boolean));
|
|
297
|
+
}
|
|
298
|
+
function textContent(node) {
|
|
299
|
+
if ("value" in node) return node.value;
|
|
300
|
+
if (!("childNodes" in node)) return "";
|
|
301
|
+
return node.childNodes.map(textContent).join("");
|
|
302
|
+
}
|
|
303
|
+
function walk(node, visit) {
|
|
304
|
+
if ("tagName" in node) visit(node);
|
|
305
|
+
if ("childNodes" in node) for (const child of node.childNodes) walk(child, visit);
|
|
306
|
+
if ("content" in node) walk(node.content, visit);
|
|
307
|
+
}
|
|
308
|
+
function inspectHtml(route, html) {
|
|
309
|
+
const canonicals = [];
|
|
310
|
+
const javascript = /* @__PURE__ */ new Set();
|
|
311
|
+
const css = /* @__PURE__ */ new Set();
|
|
312
|
+
let hydrationBytes = 0;
|
|
313
|
+
walk(parse(html), (element) => {
|
|
314
|
+
if (element.tagName === "link") {
|
|
315
|
+
const rel = relTokens(element);
|
|
316
|
+
const href = attribute(element, "href");
|
|
317
|
+
if (rel.has("canonical")) {
|
|
318
|
+
if (!href?.trim()) throw new Error(`Generated route ${route.path} contains a canonical link without href`);
|
|
319
|
+
canonicals.push(href.trim());
|
|
320
|
+
}
|
|
321
|
+
if (href && rel.has("stylesheet")) css.add(href);
|
|
322
|
+
if (href && rel.has("modulepreload")) javascript.add(href);
|
|
323
|
+
if (href && rel.has("preload")) {
|
|
324
|
+
const as = attribute(element, "as")?.toLowerCase();
|
|
325
|
+
if (as === "script") javascript.add(href);
|
|
326
|
+
if (as === "style") css.add(href);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (element.tagName === "script") {
|
|
330
|
+
const src = attribute(element, "src");
|
|
331
|
+
if (src) javascript.add(src);
|
|
332
|
+
if (attribute(element, "data-askr-render-data") === "true") hydrationBytes += Buffer.byteLength(textContent(element));
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
if (canonicals.length > 1) throw new Error(`Generated route ${route.path} contains multiple canonical links: ${canonicals.join(", ")}`);
|
|
336
|
+
return {
|
|
337
|
+
route: route.path,
|
|
338
|
+
filePath: route.filePath,
|
|
339
|
+
html: {
|
|
340
|
+
raw: Buffer.byteLength(html),
|
|
341
|
+
gzip: gzipSync(html, { level: 9 }).byteLength
|
|
342
|
+
},
|
|
343
|
+
...canonicals[0] ? { canonical: canonicals[0] } : {},
|
|
344
|
+
hydrationBytes,
|
|
345
|
+
javascript: [...javascript].sort(),
|
|
346
|
+
css: [...css].sort()
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function documentPath(outputDir, route) {
|
|
350
|
+
const root = path.resolve(outputDir);
|
|
351
|
+
const resolved = path.resolve(root, route.filePath);
|
|
352
|
+
const relative = path.relative(root, resolved);
|
|
353
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Generated route ${route.path} has an invalid output path: ${route.filePath}`);
|
|
354
|
+
return resolved;
|
|
355
|
+
}
|
|
356
|
+
async function inspectSsgDocuments(outputDir, routes) {
|
|
357
|
+
const inspections = /* @__PURE__ */ new Map();
|
|
358
|
+
const included = routes.filter((route) => (route.status === "success" || route.status === "skipped") && !route.path.includes("*")).sort((left, right) => left.path.localeCompare(right.path));
|
|
359
|
+
for (const route of included) {
|
|
360
|
+
let html;
|
|
361
|
+
try {
|
|
362
|
+
html = await fs.readFile(documentPath(outputDir, route), "utf8");
|
|
363
|
+
} catch (error) {
|
|
364
|
+
throw new Error(`Unable to inspect generated document for ${route.path}: ${route.filePath}`, { cause: error });
|
|
365
|
+
}
|
|
366
|
+
inspections.set(route.path, inspectHtml(route, html));
|
|
367
|
+
}
|
|
368
|
+
return inspections;
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/ssg/output-report.ts
|
|
372
|
+
const REPORT_PATH = ".askr/ssg-output.json";
|
|
373
|
+
const INTERNAL_OUTPUTS = /* @__PURE__ */ new Set([
|
|
374
|
+
"metadata.json",
|
|
375
|
+
".askr/sitemap-manifest.json",
|
|
376
|
+
REPORT_PATH
|
|
377
|
+
]);
|
|
378
|
+
function outputType(filePath) {
|
|
379
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
380
|
+
if (extension === ".js" || extension === ".mjs" || extension === ".cjs") return "javascript";
|
|
381
|
+
return extension === ".css" ? "css" : "other";
|
|
382
|
+
}
|
|
383
|
+
function size(value) {
|
|
384
|
+
const buffer = typeof value === "string" ? Buffer.from(value) : value;
|
|
385
|
+
return {
|
|
386
|
+
raw: buffer.byteLength,
|
|
387
|
+
gzip: gzipSync(buffer, { level: 9 }).byteLength
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async function emittedFiles(directory, prefix = "") {
|
|
391
|
+
const entries = await fs.readdir(path.join(directory, prefix), { withFileTypes: true });
|
|
392
|
+
const files = [];
|
|
393
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
394
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
395
|
+
if (entry.isDirectory()) files.push(...await emittedFiles(directory, relative));
|
|
396
|
+
else if (entry.isFile()) files.push(relative);
|
|
397
|
+
}
|
|
398
|
+
return files;
|
|
399
|
+
}
|
|
400
|
+
function isReportableAsset(filePath) {
|
|
401
|
+
return !filePath.toLowerCase().endsWith(".html") && !INTERNAL_OUTPUTS.has(filePath);
|
|
402
|
+
}
|
|
403
|
+
async function removeSsgOutputReport(outputDir) {
|
|
404
|
+
await fs.rm(path.join(outputDir, REPORT_PATH), { force: true });
|
|
405
|
+
}
|
|
406
|
+
function localReference(reference, documentPath) {
|
|
407
|
+
let resolved;
|
|
408
|
+
try {
|
|
409
|
+
const portableDocumentPath = documentPath.replaceAll("\\", "/");
|
|
410
|
+
const base = new URL(path.posix.dirname(`/${portableDocumentPath}`) + "/", "https://askr.invalid");
|
|
411
|
+
resolved = new URL(reference, base);
|
|
412
|
+
} catch {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (resolved.origin !== "https://askr.invalid") return void 0;
|
|
416
|
+
try {
|
|
417
|
+
return decodeURIComponent(resolved.pathname).replace(/^\/+/, "");
|
|
418
|
+
} catch {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function positiveCount(value, fallback, label) {
|
|
423
|
+
const resolved = value ?? fallback;
|
|
424
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1) throw new Error(`${label} must be a positive integer`);
|
|
425
|
+
return resolved;
|
|
426
|
+
}
|
|
427
|
+
function validateByteBudget(budget, label) {
|
|
428
|
+
for (const [measurement, limit] of Object.entries(budget ?? {})) {
|
|
429
|
+
if (measurement !== "raw" && measurement !== "gzip") throw new Error(`${label}.${measurement} is not supported; use raw or gzip`);
|
|
430
|
+
if (!Number.isSafeInteger(limit) || Number(limit) < 0) throw new Error(`${label}.${measurement} must be a non-negative integer byte limit`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function validateConfig(config) {
|
|
434
|
+
const budgets = config.budgets;
|
|
435
|
+
validateByteBudget(budgets?.routes, "outputReport.budgets.routes");
|
|
436
|
+
for (const [route, budget] of Object.entries(budgets?.routeOverrides ?? {})) if (budget !== false) validateByteBudget(budget, `route override ${route}`);
|
|
437
|
+
for (const [asset, budget] of Object.entries(budgets?.assets ?? {})) if (budget !== false) validateByteBudget(budget, `asset budget ${asset}`);
|
|
438
|
+
validateByteBudget(budgets?.aggregate?.javascript, "aggregate javascript budget");
|
|
439
|
+
validateByteBudget(budgets?.aggregate?.css, "aggregate css budget");
|
|
440
|
+
const hydrationShares = [["*", budgets?.hydration?.share], ...Object.entries(budgets?.hydration?.routes ?? {})];
|
|
441
|
+
for (const [route, share] of hydrationShares) if (share !== void 0 && share !== false && (!Number.isFinite(share) || share < 0 || share > 1)) throw new Error(`hydration share for ${route} must be from 0 through 1`);
|
|
442
|
+
positiveCount(config.largestPages, 20, "outputReport.largestPages");
|
|
443
|
+
positiveCount(config.largestAssets, 20, "outputReport.largestAssets");
|
|
444
|
+
}
|
|
445
|
+
function budgetViolations(report, budgets = {}) {
|
|
446
|
+
const violations = [];
|
|
447
|
+
const check = (subject, measured, budget, remediation) => {
|
|
448
|
+
for (const measurement of ["raw", "gzip"]) {
|
|
449
|
+
const limit = budget?.[measurement];
|
|
450
|
+
if (limit !== void 0 && measured[measurement] > limit) violations.push(`${subject} ${measurement}: ${measured[measurement]} B > ${limit} B. ${remediation}`);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
for (const route of report.routes) {
|
|
454
|
+
const override = budgets.routeOverrides?.[route.route];
|
|
455
|
+
if (override !== false) check(`route ${route.route} HTML`, route.html, {
|
|
456
|
+
...budgets.routes,
|
|
457
|
+
...override
|
|
458
|
+
}, "Reduce rendered markup/data or raise the exact route budget.");
|
|
459
|
+
const shareOverride = budgets.hydration?.routes?.[route.route];
|
|
460
|
+
const shareLimit = shareOverride === false ? void 0 : shareOverride ?? budgets.hydration?.share;
|
|
461
|
+
if (shareLimit !== void 0 && route.hydration.share > shareLimit) violations.push(`route ${route.route} hydration share: ${route.hydration.share} > ${shareLimit}. Use route dehydrate() to omit server-only data or raise the exact route limit.`);
|
|
462
|
+
}
|
|
463
|
+
const assets = new Map(report.assets.map((asset) => [asset.path, asset]));
|
|
464
|
+
for (const [assetPath, budget] of Object.entries(budgets.assets ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
465
|
+
if (budget === false) continue;
|
|
466
|
+
const asset = assets.get(assetPath);
|
|
467
|
+
if (!asset) continue;
|
|
468
|
+
check(`asset ${assetPath}`, asset, budget, "Split, compress, or lazy-load the asset, or raise its exact budget.");
|
|
469
|
+
}
|
|
470
|
+
check("aggregate JavaScript", report.aggregate.javascript, budgets.aggregate?.javascript, "Code-split initial JavaScript or raise the aggregate budget.");
|
|
471
|
+
check("aggregate CSS", report.aggregate.css, budgets.aggregate?.css, "Remove or split unused CSS, or raise the aggregate budget.");
|
|
472
|
+
return violations;
|
|
473
|
+
}
|
|
474
|
+
async function writeSsgOutputReport(outputDir, routes, inspections, config = {}) {
|
|
475
|
+
validateConfig(config);
|
|
476
|
+
const assets = [];
|
|
477
|
+
for (const filePath of (await emittedFiles(outputDir)).filter(isReportableAsset)) {
|
|
478
|
+
const measured = size(await fs.readFile(path.join(outputDir, filePath)));
|
|
479
|
+
assets.push({
|
|
480
|
+
path: filePath,
|
|
481
|
+
type: outputType(filePath),
|
|
482
|
+
...measured
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
assets.sort((left, right) => left.path.localeCompare(right.path));
|
|
486
|
+
const assetMap = new Map(assets.map((asset) => [asset.path, asset]));
|
|
487
|
+
const outputRoutes = [];
|
|
488
|
+
for (const route of [...routes].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
489
|
+
const inspection = inspections.get(route.path);
|
|
490
|
+
if (!inspection) continue;
|
|
491
|
+
const html = inspection.html;
|
|
492
|
+
const referenced = (references, type) => references.map((reference) => localReference(reference, inspection.filePath)).filter((reference) => Boolean(reference)).map((reference) => assetMap.get(reference)).filter((asset) => asset?.type === type).sort((left, right) => left.path.localeCompare(right.path));
|
|
493
|
+
outputRoutes.push({
|
|
494
|
+
route: route.path,
|
|
495
|
+
filePath: inspection.filePath,
|
|
496
|
+
html,
|
|
497
|
+
hydration: {
|
|
498
|
+
raw: inspection.hydrationBytes,
|
|
499
|
+
share: html.raw === 0 ? 0 : Number((inspection.hydrationBytes / html.raw).toFixed(6))
|
|
500
|
+
},
|
|
501
|
+
initial: {
|
|
502
|
+
javascript: referenced(inspection.javascript, "javascript"),
|
|
503
|
+
css: referenced(inspection.css, "css")
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const total = (type) => assets.filter((asset) => asset.type === type).reduce((sum, asset) => ({
|
|
508
|
+
raw: sum.raw + asset.raw,
|
|
509
|
+
gzip: sum.gzip + asset.gzip
|
|
510
|
+
}), {
|
|
511
|
+
raw: 0,
|
|
512
|
+
gzip: 0
|
|
513
|
+
});
|
|
514
|
+
const byLargest = (values, count, name) => [...values].sort((left, right) => right.raw - left.raw || name(left).localeCompare(name(right))).slice(0, count);
|
|
515
|
+
const report = {
|
|
516
|
+
version: 1,
|
|
517
|
+
routes: outputRoutes,
|
|
518
|
+
assets,
|
|
519
|
+
aggregate: {
|
|
520
|
+
javascript: total("javascript"),
|
|
521
|
+
css: total("css")
|
|
522
|
+
},
|
|
523
|
+
largest: {
|
|
524
|
+
pages: byLargest(outputRoutes.map((route) => ({
|
|
525
|
+
...route,
|
|
526
|
+
raw: route.html.raw
|
|
527
|
+
})), positiveCount(config.largestPages, 20, "outputReport.largestPages"), (route) => route.route).map(({ raw: _raw, ...route }) => route),
|
|
528
|
+
assets: byLargest(assets, positiveCount(config.largestAssets, 20, "outputReport.largestAssets"), (asset) => asset.path)
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const violations = budgetViolations(report, config.budgets);
|
|
532
|
+
if (violations.length > 0) throw new Error(`SSG output budgets exceeded:\n${violations.map((item) => `- ${item}`).join("\n")}`);
|
|
533
|
+
const destination = path.join(outputDir, REPORT_PATH);
|
|
534
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
535
|
+
await fs.writeFile(destination, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
536
|
+
return destination;
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
270
539
|
//#region src/bin/ssg.ts
|
|
271
540
|
const helpText = `
|
|
272
541
|
askr ssg - Static Site Generation for Askr
|
|
@@ -319,6 +588,31 @@ async function loadCreateStaticGen() {
|
|
|
319
588
|
if (typeof mod.createStaticGen !== "function") throw new Error("Failed to load createStaticGen from @askrjs/askr/ssg");
|
|
320
589
|
return mod.createStaticGen;
|
|
321
590
|
}
|
|
591
|
+
async function loadRouteAdapter() {
|
|
592
|
+
const mod = await import("@askrjs/askr/router");
|
|
593
|
+
if (typeof mod.createRouteRegistry !== "function" || typeof mod.route !== "function") throw new Error("Failed to load route registry APIs from @askrjs/askr/router");
|
|
594
|
+
return mod;
|
|
595
|
+
}
|
|
596
|
+
function registryFromLegacyRoutes(routes, adapter) {
|
|
597
|
+
return adapter.createRouteRegistry(() => {
|
|
598
|
+
for (const [index, value] of routes.entries()) {
|
|
599
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`SSG route at index ${index} must be an object`);
|
|
600
|
+
const { path: routePath, handler, component, props, params, ...options } = value;
|
|
601
|
+
const implementation = handler ?? component;
|
|
602
|
+
if (typeof routePath !== "string" || typeof implementation !== "function") throw new TypeError(`SSG route at index ${index} must provide a string path and a handler or component function`);
|
|
603
|
+
const implementationFunction = implementation;
|
|
604
|
+
const routeComponent = props && typeof props === "object" && !Array.isArray(props) ? (routeParams, context) => implementationFunction({
|
|
605
|
+
...props,
|
|
606
|
+
...routeParams
|
|
607
|
+
}, context) : implementationFunction;
|
|
608
|
+
const routeOptions = {
|
|
609
|
+
...options,
|
|
610
|
+
...params !== void 0 && options.entries === void 0 ? { entries: () => [params] } : {}
|
|
611
|
+
};
|
|
612
|
+
adapter.route(routePath, routeComponent, routeOptions);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
}
|
|
322
616
|
function parseCliArgs(args) {
|
|
323
617
|
const parsed = {
|
|
324
618
|
configPath: "",
|
|
@@ -388,7 +682,7 @@ function toGenerateOptions(args) {
|
|
|
388
682
|
forceFull: args.forceFull
|
|
389
683
|
};
|
|
390
684
|
}
|
|
391
|
-
function printSummary(io, outputDir, durationSeconds, result, sitemapPath) {
|
|
685
|
+
function printSummary(io, outputDir, durationSeconds, result, sitemapPath, reportPath) {
|
|
392
686
|
io.log("");
|
|
393
687
|
io.log(`Generation complete in ${durationSeconds}s`);
|
|
394
688
|
io.log(` Mode: ${result.mode}`);
|
|
@@ -401,6 +695,7 @@ function printSummary(io, outputDir, durationSeconds, result, sitemapPath) {
|
|
|
401
695
|
io.log(` Output: ${outputDir}`);
|
|
402
696
|
io.log(` Metadata: ${outputDir}/metadata.json`);
|
|
403
697
|
if (sitemapPath) io.log(` Sitemap: ${sitemapPath}`);
|
|
698
|
+
if (reportPath) io.log(` Report: ${reportPath}`);
|
|
404
699
|
io.log("");
|
|
405
700
|
}
|
|
406
701
|
async function runSsgCli(args = process.argv.slice(2), deps = {}, io = console) {
|
|
@@ -470,6 +765,7 @@ async function runSsgCli(args = process.argv.slice(2), deps = {}, io = console)
|
|
|
470
765
|
}
|
|
471
766
|
io.log(hasRoutes ? `Generating ${config.routes?.length ?? 0} routes...` : "Generating registered routes...");
|
|
472
767
|
const createStaticGen = typeof resolvedDeps.createStaticGen === "function" ? resolvedDeps.createStaticGen : await loadCreateStaticGen();
|
|
768
|
+
const routeSource = hasRoutes && typeof resolvedDeps.createStaticGen !== "function" ? { registry: registryFromLegacyRoutes(config.routes ?? [], await loadRouteAdapter()) } : hasRoutes ? { routes: config.routes } : { registry: config.registry };
|
|
473
769
|
cliStagingDir = await createSiblingStage(resolvedOutputDir, "askr-ssg");
|
|
474
770
|
if (parsed.incremental && !parsed.forceFull && await pathExists(resolvedOutputDir)) await fs$1.cp(resolvedOutputDir, cliStagingDir, {
|
|
475
771
|
recursive: true,
|
|
@@ -477,7 +773,7 @@ async function runSsgCli(args = process.argv.slice(2), deps = {}, io = console)
|
|
|
477
773
|
});
|
|
478
774
|
const generationOutputDir = cliStagingDir;
|
|
479
775
|
const ssg = createStaticGen({
|
|
480
|
-
...
|
|
776
|
+
...routeSource,
|
|
481
777
|
outputDir: generationOutputDir,
|
|
482
778
|
seed: config.seed,
|
|
483
779
|
dataOverrides: config.dataOverrides,
|
|
@@ -488,13 +784,20 @@ async function runSsgCli(args = process.argv.slice(2), deps = {}, io = console)
|
|
|
488
784
|
});
|
|
489
785
|
const startTime = resolvedDeps.now();
|
|
490
786
|
const result = await ssg.generate(toGenerateOptions(parsed));
|
|
491
|
-
const
|
|
787
|
+
const inspections = result.failed === 0 ? await inspectSsgDocuments(generationOutputDir, result.routes) : /* @__PURE__ */ new Map();
|
|
788
|
+
const inspectedRoutes = result.routes.map((route) => ({
|
|
789
|
+
...route,
|
|
790
|
+
...inspections.get(route.path)?.canonical ? { canonical: inspections.get(route.path)?.canonical } : {}
|
|
791
|
+
}));
|
|
792
|
+
const sitemapPath = result.failed === 0 && config.sitemap !== false && config.siteUrl ? await generateSitemap(generationOutputDir, config.siteUrl, inspectedRoutes, config.sitemap) : void 0;
|
|
492
793
|
if (result.failed === 0 && config.sitemap === false) await removeGeneratedSitemap(generationOutputDir);
|
|
794
|
+
const reportPath = result.failed === 0 && config.outputReport !== false ? await writeSsgOutputReport(generationOutputDir, result.routes, inspections, config.outputReport) : void 0;
|
|
795
|
+
if (result.failed === 0 && config.outputReport === false) await removeSsgOutputReport(generationOutputDir);
|
|
493
796
|
if (result.failed === 0 && cliStagingDir) {
|
|
494
797
|
await publishStagedDirectory(cliStagingDir, resolvedOutputDir);
|
|
495
798
|
cliStagingDir = void 0;
|
|
496
799
|
}
|
|
497
|
-
printSummary(io, resolvedOutputDir, ((resolvedDeps.now() - startTime) / 1e3).toFixed(2), result, sitemapPath ? path$1.join(resolvedOutputDir, path$1.relative(generationOutputDir, sitemapPath)) : void 0);
|
|
800
|
+
printSummary(io, resolvedOutputDir, ((resolvedDeps.now() - startTime) / 1e3).toFixed(2), result, sitemapPath ? path$1.join(resolvedOutputDir, path$1.relative(generationOutputDir, sitemapPath)) : void 0, reportPath ? path$1.join(resolvedOutputDir, path$1.relative(generationOutputDir, reportPath)) : void 0);
|
|
498
801
|
if (result.failed > 0) {
|
|
499
802
|
io.log("Errors encountered:");
|
|
500
803
|
for (const route of result.routes) if (route.status === "error") io.log(` ${route.path}: ${route.error}`);
|
|
@@ -92,7 +92,7 @@ export default function Example() {
|
|
|
92
92
|
<p class="text-muted">
|
|
93
93
|
Reactive state driving UI updates in real time.
|
|
94
94
|
</p>
|
|
95
|
-
<div
|
|
95
|
+
<div class="showcase-controls">
|
|
96
96
|
<Toggle pressed={bold()} onPress={() => setBold((b) => !b)}>
|
|
97
97
|
Bold
|
|
98
98
|
</Toggle>
|
|
@@ -127,6 +127,13 @@ code {
|
|
|
127
127
|
margin-bottom: var(--ak-space-md);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
.showcase-controls {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: var(--ak-space-md);
|
|
134
|
+
margin-bottom: var(--ak-space-md);
|
|
135
|
+
}
|
|
136
|
+
|
|
130
137
|
/* Hero buttons */
|
|
131
138
|
.hero-actions {
|
|
132
139
|
display: flex;
|
|
@@ -6,13 +6,7 @@ export default function PageHeader(props: {
|
|
|
6
6
|
actions?: unknown;
|
|
7
7
|
}) {
|
|
8
8
|
return (
|
|
9
|
-
<Inline
|
|
10
|
-
class="page-header"
|
|
11
|
-
align="center"
|
|
12
|
-
justify="between"
|
|
13
|
-
gap="var(--ak-space-lg)"
|
|
14
|
-
wrap="wrap"
|
|
15
|
-
>
|
|
9
|
+
<Inline class="page-header" align="center" justify="between" wrap="wrap">
|
|
16
10
|
<div class="page-header-copy">
|
|
17
11
|
<h1>{props.title}</h1>
|
|
18
12
|
<p>{props.description}</p>
|
|
@@ -147,7 +147,7 @@ export default function AccountsPage() {
|
|
|
147
147
|
errorText={accountsResource.error?.message ?? null}
|
|
148
148
|
/>
|
|
149
149
|
|
|
150
|
-
<Inline
|
|
150
|
+
<Inline class="account-bulk-actions" align="center" wrap="wrap">
|
|
151
151
|
<span class="muted">{selectedIdsState().length} selected</span>
|
|
152
152
|
|
|
153
153
|
<AlertDialog>
|
|
@@ -172,7 +172,7 @@ export default function SettingsPage() {
|
|
|
172
172
|
</Select>
|
|
173
173
|
</Field>
|
|
174
174
|
|
|
175
|
-
<Inline
|
|
175
|
+
<Inline class="settings-example-actions" align="center" wrap="wrap">
|
|
176
176
|
<Button class="button-secondary" disabled>
|
|
177
177
|
Disabled action example
|
|
178
178
|
</Button>
|
|
@@ -124,6 +124,16 @@
|
|
|
124
124
|
max-inline-size: var(--starter-content-max-ch);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
.page-header,
|
|
128
|
+
.app-header-content,
|
|
129
|
+
.account-bulk-actions {
|
|
130
|
+
gap: var(--ak-space-lg);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.settings-example-actions {
|
|
134
|
+
gap: var(--ak-space-sm);
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
.stat-card {
|
|
128
138
|
display: grid;
|
|
129
139
|
gap: var(--ak-space-sm);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askrjs/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"description": "Unified CLI for the Askr platform",
|
|
5
5
|
"homepage": "https://github.com/askrjs/askr-cli#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -41,21 +41,24 @@
|
|
|
41
41
|
"fmt": "vp fmt .",
|
|
42
42
|
"lint": "vp lint src tests benchmarks vite.config.ts vitest.config.ts vitest.bench.config.ts",
|
|
43
43
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
44
|
+
"test:changelog": "node scripts/verify-changelog.mjs",
|
|
45
|
+
"test:peer-floor": "node scripts/verify-peer-floor.mjs",
|
|
44
46
|
"test:publint": "publint",
|
|
45
47
|
"pack:check": "npm pack --ignore-scripts --dry-run --json",
|
|
46
48
|
"test:templates": "node scripts/verify-packed-templates.mjs",
|
|
47
49
|
"bench": "npm run build --silent && npm run bench:analyze && node --import tsx benchmarks/cli.mjs --gate",
|
|
48
50
|
"bench:analyze": "vp test bench --run -c vitest.bench.config.ts",
|
|
49
51
|
"bench:json": "npm run build --silent && node --import tsx benchmarks/cli.mjs --gate --json",
|
|
50
|
-
"check": "npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run test:publint && npm run pack:check",
|
|
52
|
+
"check": "npm run lint && npm run typecheck && npm run test:coverage && npm run test:changelog && npm run build && npm run test:publint && npm run pack:check",
|
|
51
53
|
"prepack": "npm run build",
|
|
52
|
-
"prepublishOnly": "npm run check && npm run test:templates"
|
|
54
|
+
"prepublishOnly": "npm run check && npm run test:templates && npm run test:peer-floor"
|
|
53
55
|
},
|
|
54
56
|
"dependencies": {
|
|
55
57
|
"@npmcli/config": "^10.12.0",
|
|
56
|
-
"js-yaml": "^5.2.
|
|
57
|
-
"minimatch": "^10.2.
|
|
58
|
+
"js-yaml": "^5.2.2",
|
|
59
|
+
"minimatch": "^10.2.6",
|
|
58
60
|
"npm-registry-fetch": "^19.1.1",
|
|
61
|
+
"parse5": "^8.0.1",
|
|
59
62
|
"semver": "^7.8.5",
|
|
60
63
|
"tsx": "^4.23.1",
|
|
61
64
|
"typescript": "^6.0.3"
|
|
@@ -73,7 +76,7 @@
|
|
|
73
76
|
"@types/semver": "^7.7.1",
|
|
74
77
|
"@vitest/coverage-v8": "^4.1.10",
|
|
75
78
|
"publint": "^0.3.21",
|
|
76
|
-
"vite-plus": "
|
|
79
|
+
"vite-plus": "0.2.5",
|
|
77
80
|
"vitest": "^4.1.10"
|
|
78
81
|
},
|
|
79
82
|
"peerDependencies": {
|