@askrjs/cli 0.0.17 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyze.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/{guardrails-Dq4-Rglh.js → guardrails-L60OAzIp.js} +1 -1
- package/dist/{runner-SJnfoNvK.js → runner-CYvonJ0C.js} +378 -72
- package/dist/{runner-H-TW4lHZ.js → runner-DzWc9R4G.js} +1 -1
- package/dist/ssg-config.d.ts +68 -1
- package/dist/ssg.js +312 -7
- package/dist/templates/ssg/ssg.config.ts +3 -1
- package/dist/templates/ssg/tests/ssg-config.test.ts +1 -0
- package/dist/templates/ssr/src/pages/example.tsx +1 -1
- package/dist/templates/ssr/src/styles.css +7 -0
- package/dist/templates/startkit/src/components/app-header.tsx +1 -1
- package/dist/templates/startkit/src/components/page-header.tsx +1 -7
- package/dist/templates/startkit/src/pages/workspace/accounts/index.tsx +1 -1
- package/dist/templates/startkit/src/pages/workspace/settings.tsx +1 -1
- package/dist/templates/startkit/src/styles/components.css +10 -0
- package/package.json +7 -4
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-
|
|
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-
|
|
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-
|
|
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: "
|
|
834
|
+
description: "Static route paths must satisfy the runtime authoring contract.",
|
|
748
835
|
analyze(context) {
|
|
749
836
|
const diagnostics = [];
|
|
750
|
-
const
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
diagnostics.push(diagnostic(context,
|
|
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:
|
|
764
|
-
end:
|
|
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 || !
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if (!
|
|
1429
|
-
diagnostics.push(diagnostic(context, node.expression, this, `${name}()
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
|
1982
|
+
description: "Runtime UI literals should use semantic theme styling.",
|
|
1683
1983
|
analyze(context) {
|
|
1684
|
-
|
|
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
|
-
|
|
1689
|
-
|
|
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
|
-
|
|
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-
|
|
3
|
+
import { analysisHasBlockingFindings, runAnalysis } from "./runner-CYvonJ0C.js";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { spawn } from "node:child_process";
|
package/dist/ssg-config.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ interface SitemapRouteContext {
|
|
|
15
15
|
path: string;
|
|
16
16
|
filePath: string;
|
|
17
17
|
status: string;
|
|
18
|
+
/** Resolved rendered canonical URL, when the document declares one. */
|
|
19
|
+
canonical?: string;
|
|
18
20
|
}
|
|
19
21
|
interface SitemapConfig {
|
|
20
22
|
/** Site-wide values inherited by included routes. */
|
|
@@ -38,4 +40,69 @@ interface SitemapConfig {
|
|
|
38
40
|
resolverConcurrency?: number;
|
|
39
41
|
}
|
|
40
42
|
//#endregion
|
|
41
|
-
|
|
43
|
+
//#region src/ssg/output-report.d.ts
|
|
44
|
+
interface SsgByteBudget {
|
|
45
|
+
raw?: number;
|
|
46
|
+
gzip?: number;
|
|
47
|
+
}
|
|
48
|
+
interface SsgOutputBudgets {
|
|
49
|
+
/** Default raw/gzip HTML limits for every route. */
|
|
50
|
+
routes?: SsgByteBudget;
|
|
51
|
+
/** Exact route overrides merged with defaults; false exempts a route. */
|
|
52
|
+
routeOverrides?: Readonly<Record<string, SsgByteBudget | false>>;
|
|
53
|
+
hydration?: {
|
|
54
|
+
/** Maximum hydration bytes as a 0..1 share of raw HTML. */
|
|
55
|
+
share?: number;
|
|
56
|
+
/** Exact share overrides; false exempts a route. */
|
|
57
|
+
routes?: Readonly<Record<string, number | false>>;
|
|
58
|
+
};
|
|
59
|
+
/** Exact emitted asset raw/gzip limits; false exempts an asset. */
|
|
60
|
+
assets?: Readonly<Record<string, SsgByteBudget | false>>;
|
|
61
|
+
aggregate?: {
|
|
62
|
+
javascript?: SsgByteBudget;
|
|
63
|
+
css?: SsgByteBudget;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
interface SsgOutputReportConfig {
|
|
67
|
+
budgets?: SsgOutputBudgets;
|
|
68
|
+
/** Number of largest pages retained in the summary. Defaults to 20. */
|
|
69
|
+
largestPages?: number;
|
|
70
|
+
/** Number of largest assets retained in the summary. Defaults to 20. */
|
|
71
|
+
largestAssets?: number;
|
|
72
|
+
}
|
|
73
|
+
interface SsgOutputSize {
|
|
74
|
+
raw: number;
|
|
75
|
+
gzip: number;
|
|
76
|
+
}
|
|
77
|
+
interface SsgOutputAsset extends SsgOutputSize {
|
|
78
|
+
path: string;
|
|
79
|
+
type: "javascript" | "css" | "other";
|
|
80
|
+
}
|
|
81
|
+
interface SsgOutputRoute {
|
|
82
|
+
route: string;
|
|
83
|
+
filePath: string;
|
|
84
|
+
html: SsgOutputSize;
|
|
85
|
+
hydration: {
|
|
86
|
+
raw: number;
|
|
87
|
+
share: number;
|
|
88
|
+
};
|
|
89
|
+
initial: {
|
|
90
|
+
javascript: SsgOutputAsset[];
|
|
91
|
+
css: SsgOutputAsset[];
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
interface SsgOutputReport {
|
|
95
|
+
version: 1;
|
|
96
|
+
routes: SsgOutputRoute[];
|
|
97
|
+
assets: SsgOutputAsset[];
|
|
98
|
+
aggregate: {
|
|
99
|
+
javascript: SsgOutputSize;
|
|
100
|
+
css: SsgOutputSize;
|
|
101
|
+
};
|
|
102
|
+
largest: {
|
|
103
|
+
pages: SsgOutputRoute[];
|
|
104
|
+
assets: SsgOutputAsset[];
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export type { SitemapChangeFrequency, SitemapConfig, SitemapRouteConfig, SitemapRouteContext, SsgByteBudget, SsgOutputAsset, SsgOutputBudgets, SsgOutputReport, SsgOutputReportConfig, SsgOutputRoute, SsgOutputSize };
|
package/dist/ssg.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import { t as isDirectExecution } from "./is-direct-execution-Cdlr-ZUl.js";
|
|
3
3
|
import { n as publishStagedDirectory, t as createSiblingStage } from "./directory-swap-DWoHtx7C.js";
|
|
4
4
|
import * as fs$1 from "node:fs/promises";
|
|
5
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import fs, { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import * as path$1 from "node:path";
|
|
7
7
|
import path, { dirname, resolve } from "node:path";
|
|
8
8
|
import { constants, existsSync } from "node:fs";
|
|
9
9
|
import { pathToFileURL } from "node:url";
|
|
10
10
|
import { register } from "tsx/esm/api";
|
|
11
|
+
import { gzipSync } from "node:zlib";
|
|
12
|
+
import { parse } from "parse5";
|
|
11
13
|
//#region src/ssg/sitemap.ts
|
|
12
14
|
const datePattern = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2})))?$/;
|
|
13
15
|
const languagePattern = /^(?:x-default|[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*)$/;
|
|
@@ -58,6 +60,17 @@ function resolveLocation(value, siteUrl) {
|
|
|
58
60
|
if (resolved.hash) throw new Error(`Sitemap URLs must not contain fragments: ${value}`);
|
|
59
61
|
return resolved.href;
|
|
60
62
|
}
|
|
63
|
+
function resolveDocumentCanonical(value, siteUrl, label = "rendered canonical") {
|
|
64
|
+
let resolved;
|
|
65
|
+
try {
|
|
66
|
+
resolved = new URL(value, siteUrl);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(`Invalid ${label} URL: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
if (resolved.protocol !== "http:" && resolved.protocol !== "https:") throw new Error(`${label} URLs must use HTTP or HTTPS: ${value}`);
|
|
71
|
+
if (resolved.hash) throw new Error(`${label} URLs must not contain fragments: ${value}`);
|
|
72
|
+
return resolved.href;
|
|
73
|
+
}
|
|
61
74
|
function formatLastModified(value) {
|
|
62
75
|
if (value instanceof Date) {
|
|
63
76
|
if (Number.isNaN(value.getTime())) throw new Error("Invalid sitemap lastModified Date");
|
|
@@ -209,13 +222,21 @@ async function generateSitemap(outputDir, siteUrl, routes, config = {}) {
|
|
|
209
222
|
const generated = (await mapConcurrent(routes.filter((route) => (route.status === "success" || route.status === "skipped") && !route.path.includes("*")), resolverConcurrency, async (route) => {
|
|
210
223
|
const exact = config.routes?.[route.path];
|
|
211
224
|
if (exact === false) return void 0;
|
|
212
|
-
const
|
|
225
|
+
const canonical = route.canonical ? resolveDocumentCanonical(route.canonical, baseUrl) : void 0;
|
|
226
|
+
const resolved = await config.resolve?.({
|
|
227
|
+
...route,
|
|
228
|
+
...canonical ? { canonical } : {}
|
|
229
|
+
});
|
|
213
230
|
if (resolved === false) return void 0;
|
|
214
231
|
const merged = {
|
|
215
232
|
...config.defaults,
|
|
216
233
|
...exact,
|
|
217
234
|
...resolved
|
|
218
235
|
};
|
|
236
|
+
for (const [source, value] of [["sitemap.routes", exact?.url], ["sitemap.resolve", resolved?.url]]) {
|
|
237
|
+
const explicitUrl = value ? resolveDocumentCanonical(value, baseUrl, `${source} override`) : void 0;
|
|
238
|
+
if (canonical && explicitUrl && explicitUrl !== canonical) throw new Error(`Sitemap URL mismatch for ${route.path}: rendered canonical ${canonical} disagrees with ${source} URL ${explicitUrl}`);
|
|
239
|
+
}
|
|
219
240
|
const alternates = [];
|
|
220
241
|
for (const [language, location] of Object.entries(merged.alternates ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
221
242
|
if (!languagePattern.test(language)) throw new Error(`Invalid sitemap hreflang value: ${language}`);
|
|
@@ -225,7 +246,7 @@ async function generateSitemap(outputDir, siteUrl, routes, config = {}) {
|
|
|
225
246
|
});
|
|
226
247
|
}
|
|
227
248
|
return {
|
|
228
|
-
location: resolveLocation(merged.url ?? route.path, baseUrl),
|
|
249
|
+
location: canonical ?? resolveLocation(merged.url ?? route.path, baseUrl),
|
|
229
250
|
...merged.lastModified !== void 0 ? { lastModified: formatLastModified(merged.lastModified) } : {},
|
|
230
251
|
...merged.changeFrequency ? { changeFrequency: validateChangeFrequency(merged.changeFrequency) } : {},
|
|
231
252
|
...merged.priority !== void 0 ? { priority: validatePriority(merged.priority) } : {},
|
|
@@ -267,6 +288,256 @@ 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
|
+
".askr/ssg-manifest.json",
|
|
377
|
+
REPORT_PATH
|
|
378
|
+
]);
|
|
379
|
+
function outputType(filePath) {
|
|
380
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
381
|
+
if (extension === ".js" || extension === ".mjs" || extension === ".cjs") return "javascript";
|
|
382
|
+
return extension === ".css" ? "css" : "other";
|
|
383
|
+
}
|
|
384
|
+
function size(value) {
|
|
385
|
+
const buffer = typeof value === "string" ? Buffer.from(value) : value;
|
|
386
|
+
return {
|
|
387
|
+
raw: buffer.byteLength,
|
|
388
|
+
gzip: gzipSync(buffer, { level: 9 }).byteLength
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
async function emittedFiles(directory, prefix = "") {
|
|
392
|
+
const entries = await fs.readdir(path.join(directory, prefix), { withFileTypes: true });
|
|
393
|
+
const files = [];
|
|
394
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
395
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
396
|
+
if (entry.isDirectory()) files.push(...await emittedFiles(directory, relative));
|
|
397
|
+
else if (entry.isFile()) files.push(relative);
|
|
398
|
+
}
|
|
399
|
+
return files;
|
|
400
|
+
}
|
|
401
|
+
function isReportableAsset(filePath) {
|
|
402
|
+
return !filePath.toLowerCase().endsWith(".html") && !INTERNAL_OUTPUTS.has(filePath);
|
|
403
|
+
}
|
|
404
|
+
async function removeSsgOutputReport(outputDir) {
|
|
405
|
+
await fs.rm(path.join(outputDir, REPORT_PATH), { force: true });
|
|
406
|
+
}
|
|
407
|
+
function localReference(reference, documentPath) {
|
|
408
|
+
let resolved;
|
|
409
|
+
try {
|
|
410
|
+
const portableDocumentPath = documentPath.replaceAll("\\", "/");
|
|
411
|
+
const directory = path.posix.dirname(`/${portableDocumentPath}`);
|
|
412
|
+
const base = new URL(directory.endsWith("/") ? directory : `${directory}/`, "https://askr.invalid");
|
|
413
|
+
resolved = new URL(reference, base);
|
|
414
|
+
} catch {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (resolved.origin !== "https://askr.invalid") return void 0;
|
|
418
|
+
try {
|
|
419
|
+
return decodeURIComponent(resolved.pathname).replace(/^\/+/, "");
|
|
420
|
+
} catch {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function positiveCount(value, fallback, label) {
|
|
425
|
+
const resolved = value ?? fallback;
|
|
426
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1) throw new Error(`${label} must be a positive integer`);
|
|
427
|
+
return resolved;
|
|
428
|
+
}
|
|
429
|
+
function validateByteBudget(budget, label) {
|
|
430
|
+
for (const [measurement, limit] of Object.entries(budget ?? {})) {
|
|
431
|
+
if (measurement !== "raw" && measurement !== "gzip") throw new Error(`${label}.${measurement} is not supported; use raw or gzip`);
|
|
432
|
+
if (!Number.isSafeInteger(limit) || Number(limit) < 0) throw new Error(`${label}.${measurement} must be a non-negative integer byte limit`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function validateConfig(config) {
|
|
436
|
+
const budgets = config.budgets;
|
|
437
|
+
validateByteBudget(budgets?.routes, "outputReport.budgets.routes");
|
|
438
|
+
for (const [route, budget] of Object.entries(budgets?.routeOverrides ?? {})) if (budget !== false) validateByteBudget(budget, `route override ${route}`);
|
|
439
|
+
for (const [asset, budget] of Object.entries(budgets?.assets ?? {})) if (budget !== false) validateByteBudget(budget, `asset budget ${asset}`);
|
|
440
|
+
validateByteBudget(budgets?.aggregate?.javascript, "aggregate javascript budget");
|
|
441
|
+
validateByteBudget(budgets?.aggregate?.css, "aggregate css budget");
|
|
442
|
+
const hydrationShares = [["*", budgets?.hydration?.share], ...Object.entries(budgets?.hydration?.routes ?? {})];
|
|
443
|
+
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
|
+
positiveCount(config.largestPages, 20, "outputReport.largestPages");
|
|
445
|
+
positiveCount(config.largestAssets, 20, "outputReport.largestAssets");
|
|
446
|
+
}
|
|
447
|
+
function budgetViolations(report, budgets = {}) {
|
|
448
|
+
const violations = [];
|
|
449
|
+
const check = (subject, measured, budget, remediation) => {
|
|
450
|
+
for (const measurement of ["raw", "gzip"]) {
|
|
451
|
+
const limit = budget?.[measurement];
|
|
452
|
+
if (limit !== void 0 && measured[measurement] > limit) violations.push(`${subject} ${measurement}: ${measured[measurement]} B > ${limit} B. ${remediation}`);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
for (const route of report.routes) {
|
|
456
|
+
const override = budgets.routeOverrides?.[route.route];
|
|
457
|
+
if (override !== false) check(`route ${route.route} HTML`, route.html, {
|
|
458
|
+
...budgets.routes,
|
|
459
|
+
...override
|
|
460
|
+
}, "Reduce rendered markup/data or raise the exact route budget.");
|
|
461
|
+
const shareOverride = budgets.hydration?.routes?.[route.route];
|
|
462
|
+
const shareLimit = shareOverride === false ? void 0 : shareOverride ?? budgets.hydration?.share;
|
|
463
|
+
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.`);
|
|
464
|
+
}
|
|
465
|
+
const assets = new Map(report.assets.map((asset) => [asset.path, asset]));
|
|
466
|
+
for (const [assetPath, budget] of Object.entries(budgets.assets ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
467
|
+
if (budget === false) continue;
|
|
468
|
+
const asset = assets.get(assetPath);
|
|
469
|
+
if (!asset) continue;
|
|
470
|
+
check(`asset ${assetPath}`, asset, budget, "Split, compress, or lazy-load the asset, or raise its exact budget.");
|
|
471
|
+
}
|
|
472
|
+
check("aggregate JavaScript", report.aggregate.javascript, budgets.aggregate?.javascript, "Code-split initial JavaScript or raise the aggregate budget.");
|
|
473
|
+
check("aggregate CSS", report.aggregate.css, budgets.aggregate?.css, "Remove or split unused CSS, or raise the aggregate budget.");
|
|
474
|
+
return violations;
|
|
475
|
+
}
|
|
476
|
+
async function writeSsgOutputReport(outputDir, routes, inspections, config = {}) {
|
|
477
|
+
validateConfig(config);
|
|
478
|
+
const assets = [];
|
|
479
|
+
for (const filePath of (await emittedFiles(outputDir)).filter(isReportableAsset)) {
|
|
480
|
+
const measured = size(await fs.readFile(path.join(outputDir, filePath)));
|
|
481
|
+
assets.push({
|
|
482
|
+
path: filePath,
|
|
483
|
+
type: outputType(filePath),
|
|
484
|
+
...measured
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
assets.sort((left, right) => left.path.localeCompare(right.path));
|
|
488
|
+
const assetMap = new Map(assets.map((asset) => [asset.path, asset]));
|
|
489
|
+
const outputRoutes = [];
|
|
490
|
+
for (const route of [...routes].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
491
|
+
const inspection = inspections.get(route.path);
|
|
492
|
+
if (!inspection) continue;
|
|
493
|
+
const html = inspection.html;
|
|
494
|
+
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));
|
|
495
|
+
outputRoutes.push({
|
|
496
|
+
route: route.path,
|
|
497
|
+
filePath: inspection.filePath,
|
|
498
|
+
html,
|
|
499
|
+
hydration: {
|
|
500
|
+
raw: inspection.hydrationBytes,
|
|
501
|
+
share: html.raw === 0 ? 0 : Number((inspection.hydrationBytes / html.raw).toFixed(6))
|
|
502
|
+
},
|
|
503
|
+
initial: {
|
|
504
|
+
javascript: referenced(inspection.javascript, "javascript"),
|
|
505
|
+
css: referenced(inspection.css, "css")
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
const total = (type) => assets.filter((asset) => asset.type === type).reduce((sum, asset) => ({
|
|
510
|
+
raw: sum.raw + asset.raw,
|
|
511
|
+
gzip: sum.gzip + asset.gzip
|
|
512
|
+
}), {
|
|
513
|
+
raw: 0,
|
|
514
|
+
gzip: 0
|
|
515
|
+
});
|
|
516
|
+
const byLargest = (values, count, name) => [...values].sort((left, right) => right.raw - left.raw || name(left).localeCompare(name(right))).slice(0, count);
|
|
517
|
+
const report = {
|
|
518
|
+
version: 1,
|
|
519
|
+
routes: outputRoutes,
|
|
520
|
+
assets,
|
|
521
|
+
aggregate: {
|
|
522
|
+
javascript: total("javascript"),
|
|
523
|
+
css: total("css")
|
|
524
|
+
},
|
|
525
|
+
largest: {
|
|
526
|
+
pages: byLargest(outputRoutes.map((route) => ({
|
|
527
|
+
...route,
|
|
528
|
+
raw: route.html.raw
|
|
529
|
+
})), positiveCount(config.largestPages, 20, "outputReport.largestPages"), (route) => route.route).map(({ raw: _raw, ...route }) => route),
|
|
530
|
+
assets: byLargest(assets, positiveCount(config.largestAssets, 20, "outputReport.largestAssets"), (asset) => asset.path)
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
const violations = budgetViolations(report, config.budgets);
|
|
534
|
+
if (violations.length > 0) throw new Error(`SSG output budgets exceeded:\n${violations.map((item) => `- ${item}`).join("\n")}`);
|
|
535
|
+
const destination = path.join(outputDir, REPORT_PATH);
|
|
536
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
537
|
+
await fs.writeFile(destination, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
538
|
+
return destination;
|
|
539
|
+
}
|
|
540
|
+
//#endregion
|
|
270
541
|
//#region src/bin/ssg.ts
|
|
271
542
|
const helpText = `
|
|
272
543
|
askr ssg - Static Site Generation for Askr
|
|
@@ -319,6 +590,31 @@ async function loadCreateStaticGen() {
|
|
|
319
590
|
if (typeof mod.createStaticGen !== "function") throw new Error("Failed to load createStaticGen from @askrjs/askr/ssg");
|
|
320
591
|
return mod.createStaticGen;
|
|
321
592
|
}
|
|
593
|
+
async function loadRouteAdapter() {
|
|
594
|
+
const mod = await import("@askrjs/askr/router");
|
|
595
|
+
if (typeof mod.createRouteRegistry !== "function" || typeof mod.route !== "function") throw new Error("Failed to load route registry APIs from @askrjs/askr/router");
|
|
596
|
+
return mod;
|
|
597
|
+
}
|
|
598
|
+
function registryFromLegacyRoutes(routes, adapter) {
|
|
599
|
+
return adapter.createRouteRegistry(() => {
|
|
600
|
+
for (const [index, value] of routes.entries()) {
|
|
601
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`SSG route at index ${index} must be an object`);
|
|
602
|
+
const { path: routePath, handler, component, props, params, ...options } = value;
|
|
603
|
+
const implementation = handler ?? component;
|
|
604
|
+
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`);
|
|
605
|
+
const implementationFunction = implementation;
|
|
606
|
+
const routeComponent = props && typeof props === "object" && !Array.isArray(props) ? (routeParams, context) => implementationFunction({
|
|
607
|
+
...props,
|
|
608
|
+
...routeParams
|
|
609
|
+
}, context) : implementationFunction;
|
|
610
|
+
const routeOptions = {
|
|
611
|
+
...options,
|
|
612
|
+
...params !== void 0 && options.entries === void 0 ? { entries: () => [params] } : {}
|
|
613
|
+
};
|
|
614
|
+
adapter.route(routePath, routeComponent, routeOptions);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
322
618
|
function parseCliArgs(args) {
|
|
323
619
|
const parsed = {
|
|
324
620
|
configPath: "",
|
|
@@ -388,7 +684,7 @@ function toGenerateOptions(args) {
|
|
|
388
684
|
forceFull: args.forceFull
|
|
389
685
|
};
|
|
390
686
|
}
|
|
391
|
-
function printSummary(io, outputDir, durationSeconds, result, sitemapPath) {
|
|
687
|
+
function printSummary(io, outputDir, durationSeconds, result, sitemapPath, reportPath) {
|
|
392
688
|
io.log("");
|
|
393
689
|
io.log(`Generation complete in ${durationSeconds}s`);
|
|
394
690
|
io.log(` Mode: ${result.mode}`);
|
|
@@ -401,6 +697,7 @@ function printSummary(io, outputDir, durationSeconds, result, sitemapPath) {
|
|
|
401
697
|
io.log(` Output: ${outputDir}`);
|
|
402
698
|
io.log(` Metadata: ${outputDir}/metadata.json`);
|
|
403
699
|
if (sitemapPath) io.log(` Sitemap: ${sitemapPath}`);
|
|
700
|
+
if (reportPath) io.log(` Report: ${reportPath}`);
|
|
404
701
|
io.log("");
|
|
405
702
|
}
|
|
406
703
|
async function runSsgCli(args = process.argv.slice(2), deps = {}, io = console) {
|
|
@@ -470,6 +767,7 @@ async function runSsgCli(args = process.argv.slice(2), deps = {}, io = console)
|
|
|
470
767
|
}
|
|
471
768
|
io.log(hasRoutes ? `Generating ${config.routes?.length ?? 0} routes...` : "Generating registered routes...");
|
|
472
769
|
const createStaticGen = typeof resolvedDeps.createStaticGen === "function" ? resolvedDeps.createStaticGen : await loadCreateStaticGen();
|
|
770
|
+
const routeSource = hasRoutes && typeof resolvedDeps.createStaticGen !== "function" ? { registry: registryFromLegacyRoutes(config.routes ?? [], await loadRouteAdapter()) } : hasRoutes ? { routes: config.routes } : { registry: config.registry };
|
|
473
771
|
cliStagingDir = await createSiblingStage(resolvedOutputDir, "askr-ssg");
|
|
474
772
|
if (parsed.incremental && !parsed.forceFull && await pathExists(resolvedOutputDir)) await fs$1.cp(resolvedOutputDir, cliStagingDir, {
|
|
475
773
|
recursive: true,
|
|
@@ -477,7 +775,7 @@ async function runSsgCli(args = process.argv.slice(2), deps = {}, io = console)
|
|
|
477
775
|
});
|
|
478
776
|
const generationOutputDir = cliStagingDir;
|
|
479
777
|
const ssg = createStaticGen({
|
|
480
|
-
...
|
|
778
|
+
...routeSource,
|
|
481
779
|
outputDir: generationOutputDir,
|
|
482
780
|
seed: config.seed,
|
|
483
781
|
dataOverrides: config.dataOverrides,
|
|
@@ -488,13 +786,20 @@ async function runSsgCli(args = process.argv.slice(2), deps = {}, io = console)
|
|
|
488
786
|
});
|
|
489
787
|
const startTime = resolvedDeps.now();
|
|
490
788
|
const result = await ssg.generate(toGenerateOptions(parsed));
|
|
491
|
-
const
|
|
789
|
+
const inspections = result.failed === 0 ? await inspectSsgDocuments(generationOutputDir, result.routes) : /* @__PURE__ */ new Map();
|
|
790
|
+
const inspectedRoutes = result.routes.map((route) => ({
|
|
791
|
+
...route,
|
|
792
|
+
...inspections.get(route.path)?.canonical ? { canonical: inspections.get(route.path)?.canonical } : {}
|
|
793
|
+
}));
|
|
794
|
+
const sitemapPath = result.failed === 0 && config.sitemap !== false && config.siteUrl ? await generateSitemap(generationOutputDir, config.siteUrl, inspectedRoutes, config.sitemap) : void 0;
|
|
492
795
|
if (result.failed === 0 && config.sitemap === false) await removeGeneratedSitemap(generationOutputDir);
|
|
796
|
+
const reportPath = result.failed === 0 && config.outputReport !== false ? await writeSsgOutputReport(generationOutputDir, result.routes, inspections, config.outputReport) : void 0;
|
|
797
|
+
if (result.failed === 0 && config.outputReport === false) await removeSsgOutputReport(generationOutputDir);
|
|
493
798
|
if (result.failed === 0 && cliStagingDir) {
|
|
494
799
|
await publishStagedDirectory(cliStagingDir, resolvedOutputDir);
|
|
495
800
|
cliStagingDir = void 0;
|
|
496
801
|
}
|
|
497
|
-
printSummary(io, resolvedOutputDir, ((resolvedDeps.now() - startTime) / 1e3).toFixed(2), result, sitemapPath ? path$1.join(resolvedOutputDir, path$1.relative(generationOutputDir, sitemapPath)) : void 0);
|
|
802
|
+
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
803
|
if (result.failed > 0) {
|
|
499
804
|
io.log("Errors encountered:");
|
|
500
805
|
for (const route of result.routes) if (route.status === "error") io.log(` ${route.path}: ${route.error}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import type { DocumentRenderArgs } from '@askrjs/askr/ssg';
|
|
4
|
+
import { withThemeStyles } from '@askrjs/themes/ssr';
|
|
4
5
|
import { pageRegistry } from './src/routes';
|
|
5
6
|
|
|
6
7
|
export const outputDir = './dist';
|
|
@@ -31,7 +32,8 @@ export const staticConfig = {
|
|
|
31
32
|
'/preview': false,
|
|
32
33
|
},
|
|
33
34
|
},
|
|
34
|
-
document: renderDocument,
|
|
35
|
+
document: withThemeStyles(renderDocument),
|
|
36
|
+
styleRegistrationValidation: 'error' as const,
|
|
35
37
|
assets: [
|
|
36
38
|
{
|
|
37
39
|
from: resolve(process.cwd(), '.askr/client/assets'),
|
|
@@ -9,5 +9,6 @@ describe('SSG config', () => {
|
|
|
9
9
|
expect(staticConfig.registry.routes).toHaveLength(4);
|
|
10
10
|
expect(staticConfig.siteUrl).toBe('https://example.com');
|
|
11
11
|
expect(staticConfig.sitemap.routes['/preview']).toBe(false);
|
|
12
|
+
expect(staticConfig.styleRegistrationValidation).toBe('error');
|
|
12
13
|
});
|
|
13
14
|
});
|
|
@@ -92,7 +92,7 @@ export default function Example() {
|
|
|
92
92
|
<p class="text-muted">
|
|
93
93
|
Reactive state driving UI updates in real time.
|
|
94
94
|
</p>
|
|
95
|
-
<div
|
|
95
|
+
<div class="showcase-controls">
|
|
96
96
|
<Toggle pressed={bold()} onPress={() => setBold((b) => !b)}>
|
|
97
97
|
Bold
|
|
98
98
|
</Toggle>
|
|
@@ -127,6 +127,13 @@ code {
|
|
|
127
127
|
margin-bottom: var(--ak-space-md);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
.showcase-controls {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: var(--ak-space-md);
|
|
134
|
+
margin-bottom: var(--ak-space-md);
|
|
135
|
+
}
|
|
136
|
+
|
|
130
137
|
/* Hero buttons */
|
|
131
138
|
.hero-actions {
|
|
132
139
|
display: flex;
|
|
@@ -6,13 +6,7 @@ export default function PageHeader(props: {
|
|
|
6
6
|
actions?: unknown;
|
|
7
7
|
}) {
|
|
8
8
|
return (
|
|
9
|
-
<Inline
|
|
10
|
-
class="page-header"
|
|
11
|
-
align="center"
|
|
12
|
-
justify="between"
|
|
13
|
-
gap="var(--ak-space-lg)"
|
|
14
|
-
wrap="wrap"
|
|
15
|
-
>
|
|
9
|
+
<Inline class="page-header" align="center" justify="between" wrap="wrap">
|
|
16
10
|
<div class="page-header-copy">
|
|
17
11
|
<h1>{props.title}</h1>
|
|
18
12
|
<p>{props.description}</p>
|
|
@@ -147,7 +147,7 @@ export default function AccountsPage() {
|
|
|
147
147
|
errorText={accountsResource.error?.message ?? null}
|
|
148
148
|
/>
|
|
149
149
|
|
|
150
|
-
<Inline
|
|
150
|
+
<Inline class="account-bulk-actions" align="center" wrap="wrap">
|
|
151
151
|
<span class="muted">{selectedIdsState().length} selected</span>
|
|
152
152
|
|
|
153
153
|
<AlertDialog>
|
|
@@ -172,7 +172,7 @@ export default function SettingsPage() {
|
|
|
172
172
|
</Select>
|
|
173
173
|
</Field>
|
|
174
174
|
|
|
175
|
-
<Inline
|
|
175
|
+
<Inline class="settings-example-actions" align="center" wrap="wrap">
|
|
176
176
|
<Button class="button-secondary" disabled>
|
|
177
177
|
Disabled action example
|
|
178
178
|
</Button>
|
|
@@ -124,6 +124,16 @@
|
|
|
124
124
|
max-inline-size: var(--starter-content-max-ch);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
.page-header,
|
|
128
|
+
.app-header-content,
|
|
129
|
+
.account-bulk-actions {
|
|
130
|
+
gap: var(--ak-space-lg);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.settings-example-actions {
|
|
134
|
+
gap: var(--ak-space-sm);
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
.stat-card {
|
|
128
138
|
display: grid;
|
|
129
139
|
gap: var(--ak-space-sm);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askrjs/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
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": "
|
|
79
|
+
"vite-plus": "0.2.5",
|
|
77
80
|
"vitest": "^4.1.10"
|
|
78
81
|
},
|
|
79
82
|
"peerDependencies": {
|