@askrjs/cli 0.0.22 → 0.0.24

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/add.js CHANGED
@@ -41,10 +41,12 @@ function helpText() {
41
41
  "Usage:",
42
42
  " askr add page <name> [--branch app|public] [--cwd <dir>] [--title <title>] [--route <path>] [--force]",
43
43
  " askr add action <name> --route <path> [--cwd <dir>] [--force]",
44
+ " askr add database postgres|sqlite [--cwd <dir>]",
44
45
  "",
45
46
  "Commands:",
46
47
  " page Scaffold a route page and register it in a route-first SPA branch",
47
48
  " action Scaffold a browser descriptor, server handler, registration, authorization, and test",
49
+ " database Scaffold a dialect-specific Askr ORM database definition",
48
50
  "",
49
51
  "Options:",
50
52
  " --branch <name> Route branch to target (default: app)",
@@ -59,6 +61,7 @@ function helpText() {
59
61
  " askr add page ops/audit-log --branch public",
60
62
  " askr add page approvals --title \"Human approvals\" --route /app/approvals",
61
63
  " askr add action approve-request --route /requests/{id}",
64
+ " askr add database sqlite",
62
65
  "",
63
66
  "Notes:",
64
67
  " Page generation supports route-first SPA projects created from `askr create spa`.",
@@ -478,6 +481,95 @@ async function addAction(parsed, io, writeChanges) {
478
481
  io.log(` Test: ${path.relative(projectRoot, testFile).replace(/\\/g, "/")}`);
479
482
  return 0;
480
483
  }
484
+ async function addDatabase(parsed, io, writeChanges) {
485
+ if (parsed.name !== "postgres" && parsed.name !== "sqlite") {
486
+ io.error("Database dialect must be `postgres` or `sqlite`.");
487
+ return 1;
488
+ }
489
+ const projectRoot = path.resolve(parsed.cwd);
490
+ const manifestFile = path.join(projectRoot, "package.json");
491
+ const definitionFile = path.join(projectRoot, "src", "database", "index.ts");
492
+ const generatedFile = path.join(projectRoot, "src", "database", "generated.ts");
493
+ const migrationKeep = path.join(projectRoot, "src", "database", "migrations", ".gitkeep");
494
+ const environmentFile = path.join(projectRoot, ".env.example");
495
+ if (!await pathExists(manifestFile)) {
496
+ io.error("Database generation requires a project package.json.");
497
+ return 1;
498
+ }
499
+ if (!parsed.force && (await pathExists(definitionFile) || await pathExists(generatedFile))) {
500
+ io.error("Database files already exist. Pass --force to replace generated scaffolding.");
501
+ return 1;
502
+ }
503
+ try {
504
+ const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8"));
505
+ manifest.dependencies = {
506
+ ...manifest.dependencies,
507
+ "@askrjs/orm": "0.0.0",
508
+ ...parsed.name === "postgres" ? {
509
+ pg: "^8.16.0",
510
+ "pg-query-stream": "^4.10.0"
511
+ } : {}
512
+ };
513
+ manifest.dependencies = Object.fromEntries(Object.entries(manifest.dependencies).sort(([left], [right]) => left.localeCompare(right)));
514
+ const currentEnvironment = await fs.readFile(environmentFile, "utf8").catch(() => "");
515
+ const additions = (parsed.name === "postgres" ? ["DATABASE_URL=postgres://postgres:postgres@localhost:5432/app", "DATABASE_SHADOW_URL=postgres://postgres:postgres@localhost:5432/app_shadow"] : ["DATABASE_PATH=./data/app.sqlite"]).filter((line) => !currentEnvironment.includes(`${line.split("=")[0]}=`));
516
+ const environment = `${currentEnvironment}${currentEnvironment && !currentEnvironment.endsWith("\n") ? "\n" : ""}${additions.join("\n")}${additions.length ? "\n" : ""}`;
517
+ const driverImport = parsed.name;
518
+ const definition = [
519
+ "import { defineDatabase } from '@askrjs/orm';",
520
+ `import { ${driverImport} } from '@askrjs/orm/${driverImport}';`,
521
+ "import { generated } from './generated.js';",
522
+ "",
523
+ "export const database = defineDatabase({",
524
+ ` driver: ${driverImport}(),`,
525
+ " tables: {},",
526
+ " queries: {},",
527
+ " generated,",
528
+ "});",
529
+ ""
530
+ ].join("\n");
531
+ const generated = [
532
+ "import type { GeneratedDatabaseArtifact } from '@askrjs/orm';",
533
+ "",
534
+ "// Generated by `askr database generate`; commit this file.",
535
+ "export const generated = {",
536
+ " manifest: { migrations: [] },",
537
+ " queries: {},",
538
+ "} as const satisfies GeneratedDatabaseArtifact;",
539
+ ""
540
+ ].join("\n");
541
+ await writeChanges([
542
+ {
543
+ filePath: definitionFile,
544
+ content: definition
545
+ },
546
+ {
547
+ filePath: generatedFile,
548
+ content: generated
549
+ },
550
+ {
551
+ filePath: migrationKeep,
552
+ content: ""
553
+ },
554
+ {
555
+ filePath: environmentFile,
556
+ content: environment
557
+ },
558
+ {
559
+ filePath: manifestFile,
560
+ content: `${JSON.stringify(manifest, null, 2)}\n`
561
+ }
562
+ ]);
563
+ } catch (error) {
564
+ io.error("Failed to write generated database artifacts.");
565
+ io.error(error instanceof Error ? error.message : String(error));
566
+ return 1;
567
+ }
568
+ io.log(`Added ${parsed.name} database support.`);
569
+ io.log(" Definition: src/database/index.ts");
570
+ io.log(" Migrations: src/database/migrations");
571
+ return 0;
572
+ }
481
573
  async function runAddCli(args = process.argv.slice(2), io = console, writeChanges = writeFileChanges) {
482
574
  const parsed = parseArgs(args);
483
575
  if (parsed.errors.length > 0) {
@@ -494,6 +586,7 @@ async function runAddCli(args = process.argv.slice(2), io = console, writeChange
494
586
  }
495
587
  if (parsed.command === "page") return addPage(parsed, io, writeChanges);
496
588
  if (parsed.command === "action") return addAction(parsed, io, writeChanges);
589
+ if (parsed.command === "database") return addDatabase(parsed, io, writeChanges);
497
590
  io.error(`Unknown add command: ${parsed.command}`);
498
591
  io.error("Run `askr add --help` to see available commands.");
499
592
  return 1;
package/dist/cli.js CHANGED
@@ -37,6 +37,7 @@ function printHelp(io = console) {
37
37
  io.log(" check Run the complete project validation path");
38
38
  io.log(" create Create a new Askr app from a template or product prompt");
39
39
  io.log(" database Generate, validate, and migrate project databases");
40
+ io.log(" docs Check or snapshot consumer-visible API documentation");
40
41
  io.log(" doctor Diagnose environment and Askr project health");
41
42
  io.log(" generate Generate an @askrjs/fetch client from OpenAPI");
42
43
  io.log(" openapi Generate or check an OpenAPI YAML artifact");
@@ -91,6 +92,10 @@ async function runCli(args = process.argv.slice(2), io = console) {
91
92
  const { runDatabaseCommand } = await import("./database.js");
92
93
  return runDatabaseCommand(args.slice(1), io);
93
94
  }
95
+ if (command === "docs") {
96
+ const { runDocsCli } = await import("./docs-BSPiXiAW.js");
97
+ return runDocsCli(args.slice(1), io);
98
+ }
94
99
  if (command === "analyze") {
95
100
  const { runAnalyzeCli } = await import("./analyze.js");
96
101
  return runAnalyzeCli(args.slice(1), io);
package/dist/database.js CHANGED
@@ -14,7 +14,7 @@ async function loadOrmTooling(cwd) {
14
14
  if (!relative) throw new Error("Missing import export for @askrjs/orm/tooling.");
15
15
  entry = path.resolve(path.dirname(manifestPath), relative);
16
16
  } catch (error) {
17
- throw new Error("This project does not have @askrjs/orm installed. Install it before running `askr database`.", { cause: error });
17
+ throw new Error("This project does not have @askrjs/orm installed. Run `npm install @askrjs/orm` before `askr database`.", { cause: error });
18
18
  }
19
19
  const tooling = await import(pathToFileURL(entry).href);
20
20
  if (typeof tooling.runDatabaseCli !== "function") throw new Error("The installed @askrjs/orm package does not expose compatible database tooling.");
@@ -0,0 +1,224 @@
1
+ import { t as isDirectExecution } from "./is-direct-execution-Cdlr-ZUl.js";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import ts from "typescript";
5
+ //#region src/docs.ts
6
+ const text = (parts) => ts.displayPartsToString([...parts]).trim();
7
+ const commentText = (comment) => typeof comment === "string" ? comment.trim() : comment ? String(comment).trim() : "";
8
+ function tagsFor(symbol, checker) {
9
+ const result = {};
10
+ for (const tag of symbol.getJsDocTags(checker)) {
11
+ const value = typeof tag.text === "string" ? tag.text : ts.displayPartsToString([...tag.text ?? []]);
12
+ (result[tag.name] ??= []).push(value.trim());
13
+ }
14
+ return result;
15
+ }
16
+ function declarationFor(symbol) {
17
+ return symbol.declarations?.find((declaration) => /\.d\.(?:ts|mts|cts)$/.test(declaration.getSourceFile().fileName));
18
+ }
19
+ function signatureFor(symbol, checker) {
20
+ const declaration = declarationFor(symbol);
21
+ if (!declaration) return symbol.name;
22
+ if (ts.isTypeAliasDeclaration(declaration)) return `${symbol.name}: ${declaration.type.getText(declaration.getSourceFile())}`;
23
+ if (ts.isInterfaceDeclaration(declaration) || ts.isClassDeclaration(declaration) || ts.isEnumDeclaration(declaration)) return declaration.getText(declaration.getSourceFile());
24
+ const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
25
+ return `${symbol.name}: ${checker.typeToString(type, declaration, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope)}`;
26
+ }
27
+ function memberDocs(symbol, checker) {
28
+ const declaration = declarationFor(symbol);
29
+ if (!declaration) return [];
30
+ if (ts.isVariableDeclaration(declaration)) return checker.getTypeOfSymbolAtLocation(symbol, declaration).getProperties().flatMap((property) => {
31
+ const propertyDeclaration = declarationFor(property);
32
+ if (!propertyDeclaration) return [];
33
+ const summary = text(property.getDocumentationComment(checker));
34
+ return [{
35
+ name: property.name,
36
+ summary,
37
+ tags: tagsFor(property, checker),
38
+ signature: checker.typeToString(checker.getTypeOfSymbolAtLocation(property, propertyDeclaration), propertyDeclaration)
39
+ }];
40
+ });
41
+ if (!(ts.isInterfaceDeclaration(declaration) || ts.isClassDeclaration(declaration) || ts.isEnumDeclaration(declaration) || ts.isTypeLiteralNode(declaration))) return [];
42
+ return (ts.isEnumDeclaration(declaration) ? declaration.members : ts.isTypeLiteralNode(declaration) ? declaration.members : declaration.members).flatMap((member) => {
43
+ const name = member.name && ts.isIdentifier(member.name) ? member.name.text : void 0;
44
+ if (!name) return [];
45
+ const docs = ts.getJSDocCommentsAndTags(member);
46
+ const summary = docs.filter(ts.isJSDoc).map((doc) => commentText(doc.comment)).filter(Boolean).join("\n");
47
+ const tags = {};
48
+ for (const tag of docs) {
49
+ if (!("tagName" in tag)) continue;
50
+ const name = tag.tagName.text;
51
+ (tags[name] ??= []).push(tag.comment ? String(tag.comment) : "");
52
+ }
53
+ return [{
54
+ name,
55
+ summary,
56
+ tags,
57
+ signature: member.getText(member.getSourceFile())
58
+ }];
59
+ });
60
+ }
61
+ function resolveTypes(value, out) {
62
+ if (typeof value === "string") out.push(value);
63
+ else if (value && typeof value === "object") {
64
+ for (const [key, child] of Object.entries(value)) if (key === "types") resolveTypes(child, out);
65
+ else if (key !== "default" && key !== "import" && key !== "require" && key !== "node" && key !== "browser") resolveTypes(child, out);
66
+ }
67
+ }
68
+ async function walk(directory) {
69
+ const entries = await fs.readdir(directory, { withFileTypes: true });
70
+ const files = [];
71
+ for (const entry of entries) {
72
+ const current = path.join(directory, entry.name);
73
+ if (entry.isDirectory()) files.push(...await walk(current));
74
+ else files.push(current);
75
+ }
76
+ return files;
77
+ }
78
+ async function declarationEntrypoints(root, pkg) {
79
+ const targets = [];
80
+ resolveTypes(pkg.exports, targets);
81
+ if (targets.length === 0) targets.push("./dist/index.d.ts", "./dist/index.d.mts", "./dist/index.d.cts");
82
+ const files = await walk(root);
83
+ const result = [];
84
+ for (const target of new Set(targets)) {
85
+ if (!target.endsWith(".d.ts") && !target.endsWith(".d.mts") && !target.endsWith(".d.cts")) continue;
86
+ if (target.includes("*")) {
87
+ const prefix = target.slice(0, target.indexOf("*"));
88
+ const suffix = target.slice(target.indexOf("*") + 1);
89
+ for (const file of files) if (file.startsWith(path.resolve(root, prefix)) && file.endsWith(suffix)) result.push({
90
+ name: target,
91
+ file
92
+ });
93
+ } else {
94
+ const file = path.resolve(root, target);
95
+ if (files.includes(file)) result.push({
96
+ name: target,
97
+ file
98
+ });
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+ async function inspectDocs(root = process.cwd()) {
104
+ const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
105
+ const entries = await declarationEntrypoints(root, pkg);
106
+ const program = ts.createProgram(entries.map((entry) => entry.file), {
107
+ allowJs: false,
108
+ skipLibCheck: true,
109
+ moduleResolution: ts.ModuleResolutionKind.Bundler
110
+ });
111
+ const checker = program.getTypeChecker();
112
+ const symbols = [];
113
+ const diagnostics = [];
114
+ for (const entry of entries) {
115
+ const source = program.getSourceFile(entry.file);
116
+ const module = source && checker.getSymbolAtLocation(source);
117
+ if (!module) continue;
118
+ for (const exported of checker.getExportsOfModule(module)) {
119
+ const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
120
+ const declaration = declarationFor(symbol) ?? declarationFor(exported);
121
+ if (!declaration) continue;
122
+ const summary = text(symbol.getDocumentationComment(checker));
123
+ const tags = tagsFor(symbol, checker);
124
+ const signature = signatureFor(symbol, checker);
125
+ const members = memberDocs(symbol, checker);
126
+ const declarationPath = path.relative(root, declaration.getSourceFile().fileName);
127
+ const base = {
128
+ package: pkg.name ?? path.basename(root),
129
+ entrypoint: entry.name,
130
+ symbol: exported.name,
131
+ declaration: declarationPath
132
+ };
133
+ if (!summary || summary === exported.name) diagnostics.push({
134
+ ...base,
135
+ missing: "summary"
136
+ });
137
+ const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
138
+ for (const signatureInfo of type.getCallSignatures()) {
139
+ for (const parameter of signatureInfo.parameters) if (!tags.param?.some((value) => {
140
+ return (value.trim().split(/\s+/, 1)[0] ?? "").replace(/^\{[^}]+\}\s*/, "") === parameter.name;
141
+ })) diagnostics.push({
142
+ ...base,
143
+ missing: `@param ${parameter.name}`
144
+ });
145
+ if (signatureInfo.getReturnType().flags !== ts.TypeFlags.Void && !tags.returns) diagnostics.push({
146
+ ...base,
147
+ missing: "@returns"
148
+ });
149
+ }
150
+ for (const member of members) if (!member.summary || member.summary === member.name) diagnostics.push({
151
+ ...base,
152
+ symbol: `${exported.name}.${member.name}`,
153
+ missing: "member summary"
154
+ });
155
+ symbols.push({
156
+ name: exported.name,
157
+ entrypoint: entry.name,
158
+ declaration: declarationPath,
159
+ summary,
160
+ ...tags.remarks ? { remarks: tags.remarks.join("\n") } : {},
161
+ tags,
162
+ signature,
163
+ members
164
+ });
165
+ }
166
+ }
167
+ symbols.sort((a, b) => `${a.entrypoint}:${a.name}`.localeCompare(`${b.entrypoint}:${b.name}`));
168
+ diagnostics.sort((a, b) => `${a.entrypoint}:${a.symbol}:${a.missing}`.localeCompare(`${b.entrypoint}:${b.symbol}:${b.missing}`));
169
+ return {
170
+ snapshot: {
171
+ package: pkg.name ?? path.basename(root),
172
+ version: pkg.version,
173
+ generatedBy: "askr docs snapshot",
174
+ symbols
175
+ },
176
+ diagnostics
177
+ };
178
+ }
179
+ //#endregion
180
+ //#region src/bin/docs.ts
181
+ async function runDocsCli(args = process.argv.slice(2), io = console) {
182
+ const command = args[0] ?? "check";
183
+ const json = args.includes("--json");
184
+ const rootIndex = args.findIndex((arg) => arg === "--root" || arg.startsWith("--root="));
185
+ const rootArg = rootIndex >= 0 ? args[rootIndex].includes("=") ? args[rootIndex].slice(7) : args[rootIndex + 1] : void 0;
186
+ const root = path.resolve(rootArg ?? process.cwd());
187
+ if (command === "--help" || command === "-h") {
188
+ io.log("askr docs check|snapshot [--json] [--root <path>] [--output <path>]");
189
+ return 0;
190
+ }
191
+ if (command !== "check" && command !== "snapshot") {
192
+ io.error(`Unknown docs command: ${command}`);
193
+ return 1;
194
+ }
195
+ try {
196
+ const result = await inspectDocs(root);
197
+ if (command === "snapshot") {
198
+ const outputIndex = args.findIndex((arg) => arg === "--output" || arg.startsWith("--output="));
199
+ const outputArg = outputIndex >= 0 ? args[outputIndex].includes("=") ? args[outputIndex].slice(9) : args[outputIndex + 1] : void 0;
200
+ const output = outputArg ? path.resolve(root, outputArg) : path.join(root, "docs", "api-snapshot.json");
201
+ await fs.mkdir(path.dirname(output), { recursive: true });
202
+ await fs.writeFile(output, `${JSON.stringify(result.snapshot, null, 2)}\n`, "utf8");
203
+ if (json) io.log(JSON.stringify({
204
+ output,
205
+ symbols: result.snapshot.symbols.length,
206
+ diagnostics: result.diagnostics.length
207
+ }));
208
+ else io.log(`Wrote ${path.relative(root, output)} (${result.snapshot.symbols.length} symbols)`);
209
+ return result.diagnostics.length === 0 ? 0 : 1;
210
+ }
211
+ if (json) io.log(JSON.stringify(result.diagnostics));
212
+ else if (result.diagnostics.length) for (const diagnostic of result.diagnostics) io.error(`${diagnostic.package} ${diagnostic.entrypoint} ${diagnostic.symbol} (${diagnostic.declaration}): missing ${diagnostic.missing}`);
213
+ else io.log(`Documentation check passed (${result.snapshot.symbols.length} symbols)`);
214
+ return result.diagnostics.length === 0 ? 0 : 1;
215
+ } catch (error) {
216
+ const message = error instanceof Error ? error.message : String(error);
217
+ if (json) io.log(JSON.stringify({ error: message }));
218
+ else io.error(`Documentation check failed: ${message}`);
219
+ return 1;
220
+ }
221
+ }
222
+ if (isDirectExecution(import.meta.url)) runDocsCli();
223
+ //#endregion
224
+ export { runDocsCli };
package/dist/generate.js CHANGED
@@ -588,11 +588,13 @@ async function writeGenerated(directory, files, check) {
588
588
  force: true
589
589
  });
590
590
  } catch (error) {
591
- await rm(output, {
592
- recursive: true,
593
- force: true
594
- });
595
- if (moved) await rename(backup, output);
591
+ if (moved) {
592
+ await rm(output, {
593
+ recursive: true,
594
+ force: true
595
+ });
596
+ await rename(backup, output);
597
+ }
596
598
  throw error;
597
599
  } finally {
598
600
  await rm(stage, {
@@ -1,23 +1,31 @@
1
1
  //#region src/ssg/sitemap.d.ts
2
+ /** Allowed XML sitemap change-frequency values. */
2
3
  type SitemapChangeFrequency = "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
4
+ /** Metadata emitted for one generated sitemap URL. */
3
5
  interface SitemapRouteConfig {
4
6
  /** Canonical URL. Relative values resolve against siteUrl. */
5
7
  url?: string;
6
8
  /** W3C date/datetime or Date describing the last meaningful page update. */
7
9
  lastModified?: string | Date;
10
+ /** How often the resource is expected to change. */
8
11
  changeFrequency?: SitemapChangeFrequency;
9
12
  /** Relative crawl priority from 0 through 1. */
10
13
  priority?: number;
11
14
  /** hreflang to canonical URL mapping, including optional x-default. */
12
15
  alternates?: Readonly<Record<string, string>>;
13
16
  }
17
+ /** Route information passed to a sitemap metadata resolver. */
14
18
  interface SitemapRouteContext {
19
+ /** Published route pathname. */
15
20
  path: string;
21
+ /** Generated file path for the route. */
16
22
  filePath: string;
23
+ /** Render status reported by the SSG pipeline. */
17
24
  status: string;
18
25
  /** Resolved rendered canonical URL, when the document declares one. */
19
26
  canonical?: string;
20
27
  }
28
+ /** Configuration for sitemap generation during static-site generation. */
21
29
  interface SitemapConfig {
22
30
  /** Site-wide values inherited by included routes. */
23
31
  defaults?: Omit<SitemapRouteConfig, "url" | "alternates">;
@@ -28,77 +36,108 @@ interface SitemapConfig {
28
36
  /** Output path relative to the SSG output directory. */
29
37
  output?: string;
30
38
  /** Maintain a Sitemap directive in robots.txt. Defaults to true. */
39
+ /** Maintain a Sitemap directive in robots.txt; an object selects its output path. */
31
40
  robots?: boolean | {
32
41
  output?: string;
33
42
  };
34
43
  /** Optional lower limits for deterministic partitioning and tests. */
35
44
  limits?: {
36
- urlsPerFile?: number;
37
- bytesPerFile?: number;
45
+ /** Maximum URLs per generated file. */ urlsPerFile?: number;
46
+ /** Maximum bytes per generated file. */ bytesPerFile?: number;
38
47
  };
39
48
  /** Maximum concurrent async route metadata resolvers. Defaults to 16. */
40
49
  resolverConcurrency?: number;
41
50
  }
42
51
  //#endregion
43
52
  //#region src/ssg/output-report.d.ts
53
+ /** Raw and gzip byte limits for one emitted artifact. */
44
54
  interface SsgByteBudget {
55
+ /** Maximum uncompressed byte count. */
45
56
  raw?: number;
57
+ /** Maximum gzip-compressed byte count. */
46
58
  gzip?: number;
47
59
  }
60
+ /** Optional limits applied while validating an SSG output report. */
48
61
  interface SsgOutputBudgets {
49
62
  /** Default raw/gzip HTML limits for every route. */
50
63
  routes?: SsgByteBudget;
51
64
  /** Exact route overrides merged with defaults; false exempts a route. */
52
65
  routeOverrides?: Readonly<Record<string, SsgByteBudget | false>>;
66
+ /** Hydration-size limits, expressed as a share of raw HTML. */
53
67
  hydration?: {
54
68
  /** Maximum hydration bytes as a 0..1 share of raw HTML. */
69
+ /** Default maximum hydration share from 0 through 1. */
55
70
  share?: number;
56
71
  /** Exact share overrides; false exempts a route. */
72
+ /** Exact route shares; false exempts a route. */
57
73
  routes?: Readonly<Record<string, number | false>>;
58
74
  };
59
75
  /** Exact emitted asset raw/gzip limits; false exempts an asset. */
60
76
  assets?: Readonly<Record<string, SsgByteBudget | false>>;
77
+ /** Aggregate JavaScript and CSS limits. */
61
78
  aggregate?: {
62
79
  javascript?: SsgByteBudget;
63
80
  css?: SsgByteBudget;
64
81
  };
65
82
  }
83
+ /** Configuration for generating and validating an SSG output report. */
66
84
  interface SsgOutputReportConfig {
85
+ /** Deployment pathname prefix stripped from root-absolute asset references. */
86
+ basePath?: string;
87
+ /** Byte and hydration limits to enforce. */
67
88
  budgets?: SsgOutputBudgets;
68
89
  /** Number of largest pages retained in the summary. Defaults to 20. */
69
90
  largestPages?: number;
70
91
  /** Number of largest assets retained in the summary. Defaults to 20. */
71
92
  largestAssets?: number;
72
93
  }
94
+ /** Raw and gzip sizes measured for an emitted resource. */
73
95
  interface SsgOutputSize {
96
+ /** Uncompressed byte count. */
74
97
  raw: number;
98
+ /** Gzip-compressed byte count. */
75
99
  gzip: number;
76
100
  }
101
+ /** An emitted JavaScript, CSS, or other static asset. */
77
102
  interface SsgOutputAsset extends SsgOutputSize {
103
+ /** Output-relative asset path. */
78
104
  path: string;
105
+ /** Classified asset type. */
79
106
  type: "javascript" | "css" | "other";
80
107
  }
108
+ /** Size and initial-asset information for one generated route. */
81
109
  interface SsgOutputRoute {
110
+ /** Published route pathname. */
82
111
  route: string;
112
+ /** Output-relative HTML file path. */
83
113
  filePath: string;
114
+ /** Rendered HTML sizes. */
84
115
  html: SsgOutputSize;
116
+ /** Hydration payload size and share of HTML. */
85
117
  hydration: {
86
118
  raw: number;
87
119
  share: number;
88
120
  };
121
+ /** Initial JavaScript and CSS assets referenced by the route. */
89
122
  initial: {
90
123
  javascript: SsgOutputAsset[];
91
124
  css: SsgOutputAsset[];
92
125
  };
93
126
  }
127
+ /** Complete size report produced for one SSG output directory. */
94
128
  interface SsgOutputReport {
129
+ /** Schema version of this report. */
95
130
  version: 1;
131
+ /** All generated route measurements. */
96
132
  routes: SsgOutputRoute[];
133
+ /** All emitted non-HTML assets. */
97
134
  assets: SsgOutputAsset[];
135
+ /** Aggregate JavaScript and CSS sizes. */
98
136
  aggregate: {
99
137
  javascript: SsgOutputSize;
100
138
  css: SsgOutputSize;
101
139
  };
140
+ /** Largest pages and assets retained for diagnostics. */
102
141
  largest: {
103
142
  pages: SsgOutputRoute[];
104
143
  assets: SsgOutputAsset[];
package/dist/ssg.js CHANGED
@@ -404,7 +404,7 @@ function isReportableAsset(filePath) {
404
404
  async function removeSsgOutputReport(outputDir) {
405
405
  await fs.rm(path.join(outputDir, REPORT_PATH), { force: true });
406
406
  }
407
- function localReference(reference, documentPath) {
407
+ function localReference(reference, documentPath, basePath) {
408
408
  let resolved;
409
409
  try {
410
410
  const portableDocumentPath = documentPath.replaceAll("\\", "/");
@@ -416,11 +416,25 @@ function localReference(reference, documentPath) {
416
416
  }
417
417
  if (resolved.origin !== "https://askr.invalid") return void 0;
418
418
  try {
419
- return decodeURIComponent(resolved.pathname).replace(/^\/+/, "");
419
+ let pathname = decodeURIComponent(resolved.pathname);
420
+ if (basePath && reference.startsWith("/") && (pathname === basePath || pathname.startsWith(`${basePath}/`))) pathname = pathname.slice(basePath.length);
421
+ return pathname.replace(/^\/+/, "");
420
422
  } catch {
421
423
  return;
422
424
  }
423
425
  }
426
+ function deploymentBasePath(value) {
427
+ if (value === void 0 || value === "" || value === "/") return "";
428
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.includes("?") || value.includes("#") || value.includes("\\")) throw new Error("outputReport.basePath must be an absolute URL pathname");
429
+ let decoded;
430
+ try {
431
+ decoded = decodeURIComponent(value);
432
+ } catch {
433
+ throw new Error("outputReport.basePath must use valid URL encoding");
434
+ }
435
+ if (decoded.split("/").some((segment) => segment === "." || segment === "..")) throw new Error("outputReport.basePath must not contain dot-segment traversal");
436
+ return decoded.replace(/\/+$/, "");
437
+ }
424
438
  function positiveCount(value, fallback, label) {
425
439
  const resolved = value ?? fallback;
426
440
  if (!Number.isSafeInteger(resolved) || resolved < 1) throw new Error(`${label} must be a positive integer`);
@@ -433,6 +447,7 @@ function validateByteBudget(budget, label) {
433
447
  }
434
448
  }
435
449
  function validateConfig(config) {
450
+ const basePath = deploymentBasePath(config.basePath);
436
451
  const budgets = config.budgets;
437
452
  validateByteBudget(budgets?.routes, "outputReport.budgets.routes");
438
453
  for (const [route, budget] of Object.entries(budgets?.routeOverrides ?? {})) if (budget !== false) validateByteBudget(budget, `route override ${route}`);
@@ -443,6 +458,7 @@ function validateConfig(config) {
443
458
  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`);
444
459
  positiveCount(config.largestPages, 20, "outputReport.largestPages");
445
460
  positiveCount(config.largestAssets, 20, "outputReport.largestAssets");
461
+ return basePath;
446
462
  }
447
463
  function budgetViolations(report, budgets = {}) {
448
464
  const violations = [];
@@ -474,7 +490,7 @@ function budgetViolations(report, budgets = {}) {
474
490
  return violations;
475
491
  }
476
492
  async function writeSsgOutputReport(outputDir, routes, inspections, config = {}) {
477
- validateConfig(config);
493
+ const basePath = validateConfig(config);
478
494
  const assets = [];
479
495
  for (const filePath of (await emittedFiles(outputDir)).filter(isReportableAsset)) {
480
496
  const measured = size(await fs.readFile(path.join(outputDir, filePath)));
@@ -495,7 +511,7 @@ async function writeSsgOutputReport(outputDir, routes, inspections, config = {})
495
511
  const referenced = (references, type) => {
496
512
  const resolved = [];
497
513
  for (const reference of references) {
498
- const localPath = localReference(reference, inspection.filePath);
514
+ const localPath = localReference(reference, inspection.filePath, basePath);
499
515
  if (!localPath) continue;
500
516
  const asset = assetMap.get(localPath);
501
517
  if (!asset) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askrjs/cli",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "Unified CLI for the Askr platform",
5
5
  "homepage": "https://github.com/askrjs/askr-cli#readme",
6
6
  "bugs": {
@@ -43,13 +43,16 @@
43
43
  "typecheck": "tsc -p tsconfig.json --noEmit",
44
44
  "test:changelog": "vp test run -c vitest.config.ts tests/changelog.test.ts",
45
45
  "test:peer-floor": "vp test run -c vitest.integration.config.ts tests/integration/peer-floor.test.ts",
46
+ "test:registry-database": "vp test run -c vitest.integration.config.ts tests/integration/registry-database.test.ts",
46
47
  "test:publint": "publint",
47
48
  "pack:check": "npm pack --ignore-scripts --dry-run --json",
48
49
  "test:templates": "vp test run -c vitest.integration.config.ts tests/integration/packed-templates.test.ts",
49
50
  "bench": "npm run build --silent && npm run bench:analyze && node --import tsx benchmarks/cli.mjs --gate",
50
51
  "bench:analyze": "vp test bench --run -c vitest.bench.config.ts",
51
52
  "bench:json": "npm run build --silent && node --import tsx benchmarks/cli.mjs --gate --json",
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",
53
+ "docs:check": "npm run build && node ./dist/cli.js docs check",
54
+ "docs:snapshot": "npm run build && node ./dist/cli.js docs snapshot",
55
+ "check": "npm run lint && npm run typecheck && npm run test:coverage && npm run test:changelog && npm run build && npm run docs:check && npm run test:publint && npm run pack:check",
53
56
  "prepack": "npm run build",
54
57
  "prepublishOnly": "npm run check && npm run test:templates && npm run test:peer-floor"
55
58
  },