@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.
@@ -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-42nqhW9u.js";
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";
@@ -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
- export type { SitemapChangeFrequency, SitemapConfig, SitemapRouteConfig, SitemapRouteContext };
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 resolved = await config.resolve?.(route);
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
- ...hasRoutes ? { routes: config.routes } : { registry: config.registry },
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 sitemapPath = result.failed === 0 && config.sitemap !== false && config.siteUrl ? await generateSitemap(generationOutputDir, config.siteUrl, result.routes, config.sitemap) : void 0;
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 style="display: flex; align-items: center; gap: var(--ak-space-md); margin-bottom: var(--ak-space-md);">
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;
@@ -24,9 +24,9 @@ export default function AppHeader() {
24
24
  return (
25
25
  <header class="app-header">
26
26
  <Inline
27
+ class="app-header-content"
27
28
  align="center"
28
29
  justify="between"
29
- gap="var(--ak-space-lg)"
30
30
  wrap="wrap"
31
31
  >
32
32
  <div class="breadcrumbs">
@@ -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 align="center" gap="var(--ak-space-lg)" wrap="wrap">
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 align="center" gap="var(--ak-space-sm)" wrap="wrap">
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.16",
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.1",
57
- "minimatch": "^10.2.5",
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": "^0.2.4",
79
+ "vite-plus": "0.2.5",
77
80
  "vitest": "^4.1.10"
78
81
  },
79
82
  "peerDependencies": {