@askrjs/cli 0.0.17 → 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 CHANGED
@@ -71,7 +71,7 @@ async function runAnalyzeCli(args = process.argv.slice(2), io = console, runtime
71
71
  io.log(helpText.trimEnd());
72
72
  return 0;
73
73
  }
74
- const report = await (runtime.analyze ?? (await import("./runner-SJnfoNvK.js")).runAnalysis)({
74
+ const report = await (runtime.analyze ?? (await import("./runner-CYvonJ0C.js")).runAnalysis)({
75
75
  cwd: parsed.cwd,
76
76
  workspacePatterns: parsed.workspacePatterns,
77
77
  check: parsed.check
package/dist/cli.js CHANGED
@@ -96,7 +96,7 @@ async function runCli(args = process.argv.slice(2), io = console) {
96
96
  return runAnalyzeCli(args.slice(1), io);
97
97
  }
98
98
  if (command === "check" || command === "doctor" || command === "repair") {
99
- const { runGuardrailCli } = await import("./guardrails-Dq4-Rglh.js");
99
+ const { runGuardrailCli } = await import("./guardrails-L60OAzIp.js");
100
100
  return runGuardrailCli(command, args.slice(1), io);
101
101
  }
102
102
  if (command === "generate") {
@@ -98,7 +98,7 @@ async function runGuardrailCli(command, args = process.argv.slice(2), io = conso
98
98
  cwd: parsed.cwd,
99
99
  workspacePatterns: parsed.workspacePatterns
100
100
  };
101
- const { runCheck, runDoctor, runRepair } = await import("./runner-H-TW4lHZ.js");
101
+ const { runCheck, runDoctor, runRepair } = await import("./runner-DzWc9R4G.js");
102
102
  const report = command === "doctor" ? await runDoctor(options, runtime) : command === "repair" ? await runRepair(options) : await runCheck(options, runtime);
103
103
  if (parsed.json) io.log(JSON.stringify(report));
104
104
  else if (report.command === "doctor") printDoctor(report, io);
@@ -336,6 +336,10 @@ function visit(sourceFile, callback) {
336
336
  };
337
337
  walk(sourceFile);
338
338
  }
339
+ function runtimeLiteralText(node) {
340
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) return node.text;
341
+ return null;
342
+ }
339
343
  function containingFunction(node) {
340
344
  for (let current = node.parent; current; current = current.parent) if (ts.isFunctionLike(current)) return current;
341
345
  return null;
@@ -651,6 +655,89 @@ function resolvedFunction(expression, context) {
651
655
  if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer;
652
656
  return null;
653
657
  }
658
+ function routeDefinitionCallback(call, name, context) {
659
+ return resolvedFunction(name === "group" ? call.arguments[1] : call.arguments.at(-1), context);
660
+ }
661
+ function normalizeRoutePrefix(pathname) {
662
+ if (!pathname.startsWith("/")) return null;
663
+ if (pathname === "/") return pathname;
664
+ return pathname.replace(/\/+$/, "") || "/";
665
+ }
666
+ function analyzedPagePrefix(pathname) {
667
+ if (!pathname) return null;
668
+ return normalizeRoutePrefix(pathname.startsWith("/") ? pathname : `/${pathname}`);
669
+ }
670
+ function joinAnalyzedRoutePath(prefix, pathname) {
671
+ const child = pathname.replace(/^\/+|\/+$/g, "");
672
+ if (!child) return prefix;
673
+ return prefix === "/" ? `/${child}` : `${prefix}/${child}`;
674
+ }
675
+ function analyzedRoutePath(call, name, scope) {
676
+ if (name !== "route" && name !== "page") return null;
677
+ const literal = call.arguments[0];
678
+ if (!literal || !ts.isStringLiteral(literal)) return null;
679
+ if (name === "page") return scope.page ? null : analyzedPagePrefix(literal.text);
680
+ if (literal.text.startsWith("/")) return scope.page ? null : normalizeRoutePrefix(literal.text);
681
+ return scope.page?.pathPrefix ? joinAnalyzedRoutePath(scope.page.pathPrefix, literal.text) : null;
682
+ }
683
+ const ROUTE_DEFINITION_CALLS = /* @__PURE__ */ new Set([
684
+ "route",
685
+ "page",
686
+ "index",
687
+ "group",
688
+ "fallback"
689
+ ]);
690
+ function walkRouteDefinition(context, definition, scope, visitor, active = /* @__PURE__ */ new Set()) {
691
+ if (active.has(definition)) return;
692
+ active.add(definition);
693
+ const bindings = sourceBindings(definition.getSourceFile());
694
+ const body = ts.isArrowFunction(definition) || ts.isFunctionExpression(definition) || ts.isFunctionDeclaration(definition) ? definition.body : void 0;
695
+ if (body) {
696
+ const walkNode = (node) => {
697
+ if (node !== body && ts.isFunctionLike(node)) return;
698
+ if (ts.isCallExpression(node)) {
699
+ const name = canonicalCallName(node.expression, bindings);
700
+ if (name && ROUTE_DEFINITION_CALLS.has(name)) {
701
+ visitor(node, name, scope);
702
+ if (name === "group") {
703
+ const callback = routeDefinitionCallback(node, "group", context);
704
+ if (callback) walkRouteDefinition(context, callback, scope, visitor, active);
705
+ } else if (name === "page" && !scope.page) {
706
+ const callback = routeDefinitionCallback(node, "page", context);
707
+ const pathname = node.arguments[0];
708
+ const pathPrefix = pathname && ts.isStringLiteral(pathname) ? analyzedPagePrefix(pathname.text) : null;
709
+ if (callback) walkRouteDefinition(context, callback, { page: {
710
+ pathPrefix,
711
+ indexCount: 0
712
+ } }, visitor, active);
713
+ }
714
+ return;
715
+ }
716
+ const called = resolvedFunction(node.expression, context);
717
+ if (called) {
718
+ walkRouteDefinition(context, called, scope, visitor, active);
719
+ return;
720
+ }
721
+ }
722
+ ts.forEachChild(node, walkNode);
723
+ };
724
+ walkNode(body);
725
+ }
726
+ active.delete(definition);
727
+ }
728
+ function walkRouteDefinitions(context, visitor) {
729
+ const entrypoints = [];
730
+ for (const sourceFile of context.sourceFiles) {
731
+ const bindings = sourceBindings(sourceFile);
732
+ visit(sourceFile, (node) => {
733
+ if (ts.isCallExpression(node) && canonicalCallName(node.expression, bindings) === "createRouteRegistry") {
734
+ const definition = resolvedFunction(node.arguments[0], context);
735
+ if (definition) entrypoints.push(definition);
736
+ }
737
+ });
738
+ }
739
+ for (const definition of entrypoints) walkRouteDefinition(context, definition, { page: null }, visitor);
740
+ }
654
741
  const asyncComponentRule = {
655
742
  id: "askr/no-async-component",
656
743
  category: "correctness",
@@ -744,27 +831,99 @@ const routePathRule = {
744
831
  id: "askr/route-path-syntax",
745
832
  category: "correctness",
746
833
  severity: "error",
747
- description: "Askr route parameters use {name} segments.",
834
+ description: "Static route paths must satisfy the runtime authoring contract.",
748
835
  analyze(context) {
749
836
  const diagnostics = [];
750
- const pathCalls = /* @__PURE__ */ new Set(["route", "page"]);
751
- for (const sourceFile of context.sourceFiles) {
752
- const bindings = sourceBindings(sourceFile);
753
- visit(sourceFile, (node) => {
754
- if (!ts.isCallExpression(node)) return;
755
- const name = canonicalCallName(node.expression, bindings);
756
- const first = node.arguments[0];
757
- if (!name || !pathCalls.has(name) || !first || !ts.isStringLiteral(first)) return;
758
- if (!/:([^/{}]+)/.test(first.text)) return;
759
- const replacement = first.text.replace(/:([^/{}]+)/g, "{$1}");
760
- diagnostics.push(diagnostic(context, first, this, `Route path '${first.text}' uses colon parameters instead of {name} segments.`, `Use '${replacement}'.`, {
837
+ const seen = /* @__PURE__ */ new Set();
838
+ const inspect = (call, name, scope) => {
839
+ if (name !== "route" && name !== "page") return;
840
+ const literal = call.arguments[0];
841
+ if (!literal || !ts.isStringLiteral(literal)) return;
842
+ const sourceFile = literal.getSourceFile();
843
+ const key = `${sourceFile.fileName}:${literal.getStart(sourceFile)}`;
844
+ if (seen.has(key)) return;
845
+ seen.add(key);
846
+ if (name === "page" && literal.text.length === 0) {
847
+ diagnostics.push(diagnostic(context, literal, this, "page() requires a non-empty path.", "Pass a non-empty path to page()."));
848
+ return;
849
+ }
850
+ if (scope.page && name === "page") return;
851
+ if (scope.page && literal.text.startsWith("/")) return;
852
+ let pathname = literal.text;
853
+ if (!pathname.startsWith("/")) {
854
+ if (name === "route" && !scope.page) {
855
+ const replacement = `/${pathname}`;
856
+ diagnostics.push(diagnostic(context, literal, this, `Route path '${pathname}' must begin with "/" outside a page scope.`, `Use '${replacement}'.`, {
857
+ description: "Add the required leading slash to the root route path",
858
+ filePath: sourceFile.fileName,
859
+ start: literal.getStart(sourceFile),
860
+ end: literal.getEnd(),
861
+ replacement: JSON.stringify(replacement)
862
+ }));
863
+ return;
864
+ }
865
+ pathname = scope.page?.pathPrefix ? joinAnalyzedRoutePath(scope.page.pathPrefix, pathname) : `/${pathname.replace(/^\/+/, "")}`;
866
+ }
867
+ if (/\/{2,}/.test(pathname)) {
868
+ const replacement = literal.text.replace(/\/{2,}/g, "/");
869
+ diagnostics.push(diagnostic(context, literal, this, `Route path '${literal.text}' contains consecutive slashes.`, `Use '${replacement}'.`, {
870
+ description: "Collapse consecutive slashes in the route path",
871
+ filePath: sourceFile.fileName,
872
+ start: literal.getStart(sourceFile),
873
+ end: literal.getEnd(),
874
+ replacement: JSON.stringify(replacement)
875
+ }));
876
+ return;
877
+ }
878
+ if (/:([^/{}]+)/.test(pathname)) {
879
+ const replacement = literal.text.replace(/:([^/{}]+)/g, "{$1}");
880
+ diagnostics.push(diagnostic(context, literal, this, `Route path '${literal.text}' uses colon parameters instead of {name} segments.`, `Use '${replacement}'.`, {
761
881
  description: "Convert colon route parameters to Askr {name} segments",
762
882
  filePath: sourceFile.fileName,
763
- start: first.getStart(sourceFile),
764
- end: first.getEnd(),
883
+ start: literal.getStart(sourceFile),
884
+ end: literal.getEnd(),
765
885
  replacement: JSON.stringify(replacement)
766
886
  }));
767
- });
887
+ return;
888
+ }
889
+ const segments = pathname.split("/").filter(Boolean);
890
+ const seenParameters = /* @__PURE__ */ new Set();
891
+ for (let index = 0; index < segments.length; index += 1) {
892
+ const segment = segments[index];
893
+ if (segment === "*") continue;
894
+ const hasOpenBrace = segment.includes("{");
895
+ const hasCloseBrace = segment.includes("}");
896
+ if (!hasOpenBrace && !hasCloseBrace) continue;
897
+ if (!(segment.startsWith("{") && segment.endsWith("}"))) {
898
+ diagnostics.push(diagnostic(context, literal, this, "Route parameter segments must use complete {name} interpolation.", "Make the entire path segment a {name} or {*name} interpolation."));
899
+ return;
900
+ }
901
+ const rawParameter = segment.slice(1, -1).trim();
902
+ const splat = rawParameter.startsWith("*");
903
+ const parameter = (splat ? rawParameter.slice(1) : rawParameter).trim();
904
+ if (!parameter) {
905
+ diagnostics.push(diagnostic(context, literal, this, splat ? "Route splat parameter name cannot be empty." : "Route parameter name cannot be empty.", "Give every route parameter a non-empty name."));
906
+ return;
907
+ }
908
+ if (splat && parameter === "*") {
909
+ diagnostics.push(diagnostic(context, literal, this, "Route named splat parameter name cannot be \"*\".", "Use a descriptive named splat such as {*path}."));
910
+ return;
911
+ }
912
+ if (splat && index !== segments.length - 1) {
913
+ diagnostics.push(diagnostic(context, literal, this, "Route named splat parameters must be the final segment.", "Move the named splat to the end of the route path."));
914
+ return;
915
+ }
916
+ if (seenParameters.has(parameter)) {
917
+ diagnostics.push(diagnostic(context, literal, this, `Route path cannot reuse duplicate parameter name "${parameter}".`, "Use a unique name for each route parameter."));
918
+ return;
919
+ }
920
+ seenParameters.add(parameter);
921
+ }
922
+ };
923
+ walkRouteDefinitions(context, inspect);
924
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
925
+ if (name !== "route" && name !== "page") continue;
926
+ inspect(node, name, { page: null });
768
927
  }
769
928
  return diagnostics;
770
929
  }
@@ -1400,6 +1559,45 @@ const forRowClosureCaptureRule = {
1400
1559
  return diagnostics;
1401
1560
  }
1402
1561
  };
1562
+ const RENDER_REQUIRED_CONCEPTS = /* @__PURE__ */ new Set([
1563
+ "readScope",
1564
+ "getSignal",
1565
+ "routeData",
1566
+ "ErrorBoundary"
1567
+ ]);
1568
+ const NON_RENDER_SCHEDULERS = /* @__PURE__ */ new Set([
1569
+ "setTimeout",
1570
+ "setInterval",
1571
+ "queueMicrotask",
1572
+ "requestAnimationFrame"
1573
+ ]);
1574
+ const PROMISE_CONTINUATIONS = /* @__PURE__ */ new Set([
1575
+ "then",
1576
+ "catch",
1577
+ "finally"
1578
+ ]);
1579
+ function nonRenderCallbackOwner(callback, bindings) {
1580
+ const parent = callback.parent;
1581
+ if (ts.isJsxExpression(parent) && ts.isJsxAttribute(parent.parent)) {
1582
+ const attribute = parent.parent;
1583
+ return ts.isIdentifier(attribute.name) && /^on[A-Z]/.test(attribute.name.text);
1584
+ }
1585
+ if (ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === callback)) {
1586
+ if (canonicalCallName(parent.expression, bindings) === "task") return true;
1587
+ if (ts.isIdentifier(parent.expression)) return NON_RENDER_SCHEDULERS.has(parent.expression.text);
1588
+ if (ts.isPropertyAccessExpression(parent.expression)) return PROMISE_CONTINUATIONS.has(parent.expression.name.text);
1589
+ }
1590
+ if (ts.isPropertyAssignment(parent) && parent.name.getText() === "body") {
1591
+ const options = parent.parent;
1592
+ const call = options.parent;
1593
+ return ts.isObjectLiteralExpression(options) && ts.isCallExpression(call) && call.arguments.some((argument) => argument === options) && canonicalCallName(call.expression, bindings) === "task";
1594
+ }
1595
+ return false;
1596
+ }
1597
+ function isResourceLoader(callback, bindings) {
1598
+ const parent = callback.parent;
1599
+ return ts.isCallExpression(parent) && parent.arguments[0] === callback && canonicalCallName(parent.expression, bindings) === "resource";
1600
+ }
1403
1601
  const renderScopeRequiredRule = {
1404
1602
  id: "askr/render-scope-required",
1405
1603
  category: "correctness",
@@ -1407,28 +1605,28 @@ const renderScopeRequiredRule = {
1407
1605
  description: "Render-scoped APIs cannot be created in callbacks that execute outside rendering.",
1408
1606
  analyze(context) {
1409
1607
  const diagnostics = [];
1410
- const callbackCalls = /* @__PURE__ */ new Set([
1411
- "setTimeout",
1412
- "setInterval",
1413
- "queueMicrotask",
1414
- "then",
1415
- "catch",
1416
- "finally"
1417
- ]);
1418
1608
  for (const sourceFile of context.sourceFiles) {
1419
1609
  const bindings = sourceBindings(sourceFile);
1420
- if (!sourceFacts(sourceFile).calls.some((fact) => RENDER_SCOPED_CONCEPTS.has(fact.name))) continue;
1610
+ if (!sourceFacts(sourceFile).calls.some((fact) => RENDER_REQUIRED_CONCEPTS.has(fact.name) || RENDER_SCOPED_CONCEPTS.has(fact.name))) continue;
1421
1611
  visit(sourceFile, (node) => {
1422
1612
  if (!ts.isCallExpression(node)) return;
1423
1613
  const name = canonicalCallName(node.expression, bindings);
1424
- if (!name || !RENDER_SCOPED_CONCEPTS.has(name) || name === "readScope") return;
1425
- for (let current = node.parent; current; current = current.parent) {
1426
- if (!ts.isFunctionLike(current)) continue;
1427
- const parent = current.parent;
1428
- if (!(ts.isCallExpression(parent) && (ts.isIdentifier(parent.expression) && callbackCalls.has(parent.expression.text) || ts.isPropertyAccessExpression(parent.expression) && callbackCalls.has(parent.expression.name.text)) || ts.isJsxExpression(parent) && ts.isJsxAttribute(parent.parent) || ts.isPropertyAssignment(parent) && parent.name.getText() === "body")) continue;
1429
- diagnostics.push(diagnostic(context, node.expression, this, `${name}() is created in a callback that is statically outside component rendering.`, `Create ${name}() at component render scope and use its value from the callback.`));
1614
+ if (!name || !RENDER_REQUIRED_CONCEPTS.has(name) && !RENDER_SCOPED_CONCEPTS.has(name)) return;
1615
+ const required = RENDER_REQUIRED_CONCEPTS.has(name);
1616
+ const owner = containingFunction(node);
1617
+ if (!owner) {
1618
+ if (!required) return;
1619
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() requires an active render scope and cannot be called at module scope.`, `Move ${name}() into component rendering.`));
1430
1620
  return;
1431
1621
  }
1622
+ for (let current = owner; current; current = current.parent) {
1623
+ if (!ts.isFunctionLike(current)) continue;
1624
+ if (nonRenderCallbackOwner(current, bindings)) {
1625
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() is called in a callback that is statically outside component rendering.`, `Read ${name}() during component render and use its value from the callback.`));
1626
+ return;
1627
+ }
1628
+ if (name === "readScope" && isResourceLoader(current, bindings)) return;
1629
+ }
1432
1630
  });
1433
1631
  }
1434
1632
  return diagnostics;
@@ -1498,44 +1696,50 @@ const routeScopeStructureRule = {
1498
1696
  description: "Nested page route scopes must have one index and relative child routes.",
1499
1697
  analyze(context) {
1500
1698
  const diagnostics = [];
1501
- for (const sourceFile of context.sourceFiles) {
1502
- const bindings = sourceBindings(sourceFile);
1503
- if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "page")) continue;
1504
- const inspectBody = (body) => {
1505
- let indexes = 0;
1506
- const walk = (node) => {
1507
- if (node !== body && ts.isFunctionLike(node)) return;
1508
- if (!ts.isCallExpression(node)) {
1509
- ts.forEachChild(node, walk);
1510
- return;
1511
- }
1512
- const name = canonicalCallName(node.expression, bindings);
1513
- if (name === "index") {
1514
- indexes += 1;
1515
- if (indexes > 1) diagnostics.push(diagnostic(context, node.expression, this, "A page scope declares more than one index route.", "Keep exactly one index() declaration in a page scope."));
1516
- }
1517
- if (name === "route") {
1518
- const routePath = node.arguments[0];
1519
- if (routePath && ts.isStringLiteral(routePath) && routePath.text.startsWith("/") && routePath.text.length > 1) {
1520
- const replacement = routePath.text.replace(/^\/+/, "");
1521
- diagnostics.push(diagnostic(context, routePath, this, `Child route '${routePath.text}' is absolute inside a page scope.`, `Use the relative child path '${replacement}'.`, {
1522
- description: "Strip the leading slash from a proven child route",
1523
- filePath: sourceFile.fileName,
1524
- start: routePath.getStart(sourceFile),
1525
- end: routePath.getEnd(),
1526
- replacement: JSON.stringify(replacement)
1527
- }));
1528
- }
1529
- }
1530
- ts.forEachChild(node, walk);
1531
- };
1532
- walk(body);
1533
- };
1534
- for (const { node, name } of sourceFacts(sourceFile).calls) {
1535
- if (name !== "page") continue;
1536
- const callback = node.arguments.find((argument) => ts.isArrowFunction(argument) || ts.isFunctionExpression(argument));
1537
- if (callback) inspectBody(callback.body);
1699
+ const seen = /* @__PURE__ */ new Set();
1700
+ const visitedCalls = /* @__PURE__ */ new Set();
1701
+ const reportOnce = (node, kind, create) => {
1702
+ const sourceFile = node.getSourceFile();
1703
+ const key = `${sourceFile.fileName}:${node.getStart(sourceFile)}:${kind}`;
1704
+ if (seen.has(key)) return;
1705
+ seen.add(key);
1706
+ diagnostics.push(create());
1707
+ };
1708
+ const inspect = (call, name, scope) => {
1709
+ visitedCalls.add(call);
1710
+ if (!scope.page) return;
1711
+ if (name === "page") {
1712
+ reportOnce(call, "nested-page", () => diagnostic(context, call.expression, this, "page() cannot be nested inside another page().", "Use route() for child leaves or group() for inherited behavior in the page scope."));
1713
+ return;
1538
1714
  }
1715
+ if (name === "index") {
1716
+ scope.page.indexCount += 1;
1717
+ if (scope.page.indexCount > 1) reportOnce(call, "multiple-index", () => diagnostic(context, call.expression, this, "A page scope declares more than one index route.", "Keep exactly one index() declaration in a page scope."));
1718
+ return;
1719
+ }
1720
+ if (name !== "route") return;
1721
+ const routePath = call.arguments[0];
1722
+ if (!routePath || !ts.isStringLiteral(routePath) || !routePath.text.startsWith("/") || routePath.text.length <= 1) return;
1723
+ const sourceFile = routePath.getSourceFile();
1724
+ const replacement = routePath.text.replace(/^\/+/, "");
1725
+ reportOnce(routePath, "absolute-child", () => diagnostic(context, routePath, this, `Child route '${routePath.text}' is absolute inside a page scope.`, `Use the relative child path '${replacement}'.`, {
1726
+ description: "Strip the leading slash from a proven child route",
1727
+ filePath: sourceFile.fileName,
1728
+ start: routePath.getStart(sourceFile),
1729
+ end: routePath.getEnd(),
1730
+ replacement: JSON.stringify(replacement)
1731
+ }));
1732
+ };
1733
+ walkRouteDefinitions(context, inspect);
1734
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
1735
+ if (name !== "page" || visitedCalls.has(node)) continue;
1736
+ const callback = routeDefinitionCallback(node, "page", context);
1737
+ if (!callback) continue;
1738
+ const literal = node.arguments[0];
1739
+ walkRouteDefinition(context, callback, { page: {
1740
+ pathPrefix: literal && ts.isStringLiteral(literal) ? analyzedPagePrefix(literal.text) : null,
1741
+ indexCount: 0
1742
+ } }, inspect);
1539
1743
  }
1540
1744
  return diagnostics;
1541
1745
  }
@@ -1612,6 +1816,71 @@ function literalJsxString(attribute) {
1612
1816
  const expression = jsxExpression(attribute);
1613
1817
  return expression && ts.isStringLiteralLike(expression) ? expression.text : null;
1614
1818
  }
1819
+ function unwrapStaticExpression(expression) {
1820
+ let current = expression;
1821
+ while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current) || ts.isSatisfiesExpression(current)) current = current.expression;
1822
+ return current;
1823
+ }
1824
+ function routeReferencePath(expression, context, routePaths) {
1825
+ const candidate = unwrapStaticExpression(expression);
1826
+ if (ts.isCallExpression(candidate)) return routePaths.get(candidate) ?? null;
1827
+ if (!ts.isIdentifier(candidate) && !ts.isPropertyAccessExpression(candidate)) return null;
1828
+ const symbolNode = ts.isPropertyAccessExpression(candidate) ? candidate.name : candidate;
1829
+ const localSymbol = context.checker.getSymbolAtLocation(symbolNode);
1830
+ let symbol = localSymbol;
1831
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = context.checker.getAliasedSymbol(symbol);
1832
+ const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
1833
+ if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer) {
1834
+ const initializer = unwrapStaticExpression(declaration.initializer);
1835
+ if (ts.isCallExpression(initializer)) return routePaths.get(initializer) ?? null;
1836
+ }
1837
+ if (!ts.isIdentifier(candidate)) return null;
1838
+ const importSpecifier = localSymbol?.declarations?.find(ts.isImportSpecifier);
1839
+ if (!importSpecifier) return null;
1840
+ let importDeclaration = importSpecifier;
1841
+ while (importDeclaration.parent && !ts.isImportDeclaration(importDeclaration)) importDeclaration = importDeclaration.parent;
1842
+ if (!ts.isImportDeclaration(importDeclaration) || !ts.isStringLiteral(importDeclaration.moduleSpecifier) || !importDeclaration.moduleSpecifier.text.startsWith(".")) return null;
1843
+ const modulePath = path.resolve(path.dirname(importDeclaration.getSourceFile().fileName), importDeclaration.moduleSpecifier.text);
1844
+ const sourceStem = (filePath) => {
1845
+ const stem = filePath.replace(/\\/g, "/").replace(/\.[cm]?[jt]sx?$/, "");
1846
+ return process.platform === "win32" ? stem.toLowerCase() : stem;
1847
+ };
1848
+ const target = context.sourceFiles.find((sourceFile) => {
1849
+ const stem = sourceStem(sourceFile.fileName);
1850
+ const expected = sourceStem(modulePath);
1851
+ return stem === expected || stem === `${expected}/index`;
1852
+ });
1853
+ if (!target) return null;
1854
+ const importedName = importSpecifier.propertyName?.text ?? importSpecifier.name.text;
1855
+ let pathname = null;
1856
+ visit(target, (node) => {
1857
+ if (pathname !== null || !ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || node.name.text !== importedName || !node.initializer || containingFunction(node)) return;
1858
+ const initializer = unwrapStaticExpression(node.initializer);
1859
+ if (ts.isCallExpression(initializer)) pathname = routePaths.get(initializer) ?? null;
1860
+ });
1861
+ return pathname;
1862
+ }
1863
+ function staticObjectKeys(object) {
1864
+ const keys = /* @__PURE__ */ new Set();
1865
+ for (const property of object.properties) {
1866
+ if (ts.isSpreadAssignment(property)) return null;
1867
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property) && !ts.isMethodDeclaration(property)) return null;
1868
+ if (ts.isComputedPropertyName(property.name)) return null;
1869
+ if (!ts.isIdentifier(property.name) && !ts.isStringLiteralLike(property.name) && !ts.isNumericLiteral(property.name)) return null;
1870
+ keys.add(property.name.text);
1871
+ }
1872
+ return keys;
1873
+ }
1874
+ function routeParameterNames(pathname) {
1875
+ const names = /* @__PURE__ */ new Set();
1876
+ for (const segment of pathname.split("/")) {
1877
+ if (!segment.startsWith("{") || !segment.endsWith("}")) continue;
1878
+ const raw = segment.slice(1, -1).trim();
1879
+ const name = (raw.startsWith("*") ? raw.slice(1) : raw).trim();
1880
+ if (name) names.add(name);
1881
+ }
1882
+ return [...names];
1883
+ }
1615
1884
  const linkContractRule = {
1616
1885
  id: "askr/link-contract",
1617
1886
  category: "correctness",
@@ -1620,6 +1889,18 @@ const linkContractRule = {
1620
1889
  analyze(context) {
1621
1890
  const diagnostics = [];
1622
1891
  const unsafe = /^(?:javascript|data|vbscript):/i;
1892
+ const routePaths = /* @__PURE__ */ new Map();
1893
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
1894
+ if (name !== "route" && name !== "page") continue;
1895
+ const literal = node.arguments[0];
1896
+ if (!literal || !ts.isStringLiteral(literal)) continue;
1897
+ const pathname = literal.text.startsWith("/") ? normalizeRoutePrefix(literal.text) : name === "page" ? analyzedPagePrefix(literal.text) : null;
1898
+ if (pathname) routePaths.set(node, pathname);
1899
+ }
1900
+ walkRouteDefinitions(context, (call, name, scope) => {
1901
+ const pathname = analyzedRoutePath(call, name, scope);
1902
+ if (pathname) routePaths.set(call, pathname);
1903
+ });
1623
1904
  for (const sourceFile of context.sourceFiles) {
1624
1905
  const bindings = sourceBindings(sourceFile);
1625
1906
  if (!sourceFacts(sourceFile).jsx.some((fact) => fact.name === "Link")) continue;
@@ -1634,6 +1915,25 @@ const linkContractRule = {
1634
1915
  else if (to && href) diagnostics.push(diagnostic(context, node.tagName, this, "<Link> cannot specify both to and href."));
1635
1916
  const destination = literalJsxString(href ?? to);
1636
1917
  if (destination && unsafe.test(destination.trim())) diagnostics.push(diagnostic(context, href ?? to ?? node.tagName, this, `<Link> uses the unsafe '${destination.split(":")[0]}:' URL scheme.`, "Use http, https, mailto, tel, sms, a relative URL, or a route reference."));
1918
+ const rawToExpression = jsxExpression(to);
1919
+ if (!rawToExpression) return;
1920
+ const toExpression = unwrapStaticExpression(rawToExpression);
1921
+ if (!ts.isCallExpression(toExpression)) return;
1922
+ if (canonicalCallName(toExpression.expression, bindings) !== "to") return;
1923
+ const rawParams = toExpression.arguments[1];
1924
+ if (!rawParams) return;
1925
+ const params = unwrapStaticExpression(rawParams);
1926
+ if (!ts.isObjectLiteralExpression(params)) return;
1927
+ const keys = staticObjectKeys(params);
1928
+ if (!keys) return;
1929
+ const routeExpression = toExpression.arguments[0];
1930
+ if (!routeExpression) return;
1931
+ const pathname = routeReferencePath(routeExpression, context, routePaths);
1932
+ if (!pathname) return;
1933
+ for (const parameter of routeParameterNames(pathname)) {
1934
+ if (keys.has(parameter)) continue;
1935
+ diagnostics.push(diagnostic(context, params, this, `Link destination is missing route parameter "${parameter}".`, `Pass '${parameter}' in the static to() parameter object.`));
1936
+ }
1637
1937
  });
1638
1938
  }
1639
1939
  return diagnostics;
@@ -1679,16 +1979,22 @@ const ANALYZE_RULES = [
1679
1979
  id: "askr/no-hardcoded-theme-token",
1680
1980
  category: "correctness",
1681
1981
  severity: "warning",
1682
- description: "Runtime UI literals should use semantic theme tokens.",
1982
+ description: "Runtime UI literals should use semantic theme styling.",
1683
1983
  analyze(context) {
1684
- if (["@askrjs/askr", "@askrjs/themes"].includes(packageName(context.workspace.manifest))) return [];
1984
+ const workspacePackage = packageName(context.workspace.manifest);
1985
+ const ownsThemeTokens = workspacePackage === "@askrjs/themes";
1986
+ const mayHardcodeColors = ["@askrjs/askr", "@askrjs/themes"].includes(workspacePackage);
1685
1987
  const diagnostics = [];
1686
1988
  const color = /(?:#[0-9a-f]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\()/i;
1687
1989
  for (const sourceFile of context.sourceFiles) {
1688
- if (/(?:^|[./_-])(?:test|spec)\.[cm]?[jt]sx?$/.test(sourceFile.fileName)) continue;
1689
- if (!color.test(sourceFile.text)) continue;
1990
+ const checkColors = !mayHardcodeColors && !/(?:^|[./_-])(?:test|spec)\.[cm]?[jt]sx?$/.test(sourceFile.fileName) && color.test(sourceFile.text);
1991
+ const checkTokens = !ownsThemeTokens && sourceFile.text.includes("--ak-");
1992
+ if (!checkColors && !checkTokens) continue;
1690
1993
  visit(sourceFile, (node) => {
1691
- if ((ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) && color.test(node.text)) diagnostics.push(diagnostic(context, node, this, `Runtime UI literal '${node.text}' hardcodes a color instead of a theme token.`, "Use a semantic prop, theme variable, or design-system class."));
1994
+ const text = runtimeLiteralText(node);
1995
+ if (text === null) return;
1996
+ if (checkTokens && text.includes("--ak-")) diagnostics.push(diagnostic(context, node, this, "Runtime code names an Askr theme token directly; use a semantic class or data-* attribute instead.", "Move the token mapping to theme CSS and select it through a semantic class or data-* attribute."));
1997
+ else if (checkColors && color.test(text)) diagnostics.push(diagnostic(context, node, this, `Runtime UI literal '${text}' hardcodes a color instead of a theme token.`, "Use a semantic prop, theme variable, or design-system class."));
1692
1998
  });
1693
1999
  }
1694
2000
  return diagnostics;
@@ -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-SJnfoNvK.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.17",
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
58
  "js-yaml": "^5.2.2",
57
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": {