@jay-framework/jay-stack-cli 0.24.7 → 0.25.1
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/agent-kit-template/contracts/GUIDE.md +8 -8
- package/agent-kit-template/contracts/linked-contracts.md +1 -1
- package/agent-kit-template/designer/cli-commands.md +7 -0
- package/agent-kit-template/designer/contracts-and-plugins.md +1 -1
- package/agent-kit-template/designer/design-system-guide.md +43 -31
- package/agent-kit-template/designer/jay-html-styling.md +5 -4
- package/agent-kit-template/designer/jay-html-template-syntax.md +1 -1
- package/agent-kit-template/designer/validation-guide.md +59 -2
- package/agent-kit-template/designer/validation-tier-2-guide.md +64 -0
- package/dist/{index-l6QVpvnY.js → index-BQbkAEhi.js} +108 -99
- package/dist/index.js +773 -81
- package/package.json +14 -13
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { checkValidationErrors, JAY_CONTRACT_EXTENSION as JAY_CONTRACT_EXTENSION
|
|
|
12
12
|
import fs$2 from "node:fs/promises";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import path__default from "node:path";
|
|
15
|
-
import { scanPlugins, sortPluginsByDependencies, getServiceRegistry, actionRegistry, resolveServices, ActionRegistry, discoverPluginsWithInit, executePluginServerInits, runInitCallbacks, registerService } from "@jay-framework/stack-server-runtime";
|
|
15
|
+
import { scanPlugins, sortPluginsByDependencies, getServiceRegistry, actionRegistry, resolveServices, headMetaToHeadTags, ActionRegistry, discoverPluginsWithInit, executePluginServerInits, runInitCallbacks, registerService } from "@jay-framework/stack-server-runtime";
|
|
16
16
|
import { createRequire } from "module";
|
|
17
17
|
import jsBeautify from "js-beautify";
|
|
18
18
|
import "node:crypto";
|
|
@@ -21,10 +21,11 @@ import fs__default from "node:fs";
|
|
|
21
21
|
import { createRequire as createRequire$1 } from "node:module";
|
|
22
22
|
import { isJayAction, isJayStreamAction, isJayCliCommand, CONSOLE_CONTEXT } from "@jay-framework/fullstack-component";
|
|
23
23
|
import { parse, NodeType, HTMLElement } from "node-html-parser";
|
|
24
|
-
import { Command } from "commander";
|
|
24
|
+
import { Command, Option } from "commander";
|
|
25
25
|
import chalk from "chalk";
|
|
26
26
|
import { glob } from "glob";
|
|
27
27
|
import postcss from "postcss";
|
|
28
|
+
import { scanRoutes, JayRouteParamType } from "@jay-framework/stack-route-scanner";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
29
30
|
import { select, confirm, input } from "@inquirer/prompts";
|
|
30
31
|
const DEFAULT_CONFIG = {
|
|
@@ -4253,11 +4254,10 @@ function firstColon(decl) {
|
|
|
4253
4254
|
}
|
|
4254
4255
|
function diffBodies(sourceParent, regionParent) {
|
|
4255
4256
|
const out = [];
|
|
4256
|
-
|
|
4257
|
-
diffChildren([], sourceParent, regionParent, ignoreClass, out);
|
|
4257
|
+
diffChildren([], sourceParent, regionParent, out);
|
|
4258
4258
|
return out;
|
|
4259
4259
|
}
|
|
4260
|
-
function diffChildren(parentPath, sParent, rParent,
|
|
4260
|
+
function diffChildren(parentPath, sParent, rParent, out) {
|
|
4261
4261
|
const rSup = isElement(rParent) ? parseOverride(rParent) : NO_SUPPRESSION;
|
|
4262
4262
|
const sSup = isElement(sParent) ? parseContent(sParent) : NO_SUPPRESSION;
|
|
4263
4263
|
const sup = unionSuppression(rSup, sSup);
|
|
@@ -4280,24 +4280,23 @@ function diffChildren(parentPath, sParent, rParent, ignoreClass, out) {
|
|
|
4280
4280
|
if (rc.nodeType !== NodeType.ELEMENT_NODE) continue;
|
|
4281
4281
|
const re = rc;
|
|
4282
4282
|
if (isRegionTag(re)) continue;
|
|
4283
|
-
|
|
4284
|
-
diffElement([...parentPath, i], s2[i], re, childIgnoreClass, out);
|
|
4283
|
+
diffElement([...parentPath, i], s2[i], re, out);
|
|
4285
4284
|
}
|
|
4286
4285
|
}
|
|
4287
|
-
function diffElement(path2, se, re,
|
|
4286
|
+
function diffElement(path2, se, re, out) {
|
|
4288
4287
|
const sup = unionSuppression(parseOverride(re), parseContent(se));
|
|
4289
4288
|
if (sup.all) return;
|
|
4290
|
-
compareAttributes(path2, se, re, sup,
|
|
4289
|
+
compareAttributes(path2, se, re, sup, out);
|
|
4291
4290
|
compareStyle(path2, se, re, sup, out);
|
|
4292
|
-
diffChildren(path2, se, re,
|
|
4291
|
+
diffChildren(path2, se, re, out);
|
|
4293
4292
|
}
|
|
4294
|
-
function compareAttributes(path2, se, re, sup,
|
|
4293
|
+
function compareAttributes(path2, se, re, sup, out) {
|
|
4295
4294
|
const sm = attrMap(se);
|
|
4296
4295
|
const rm = attrMap(re);
|
|
4297
4296
|
const names = [.../* @__PURE__ */ new Set([...sm.keys(), ...rm.keys()])].sort();
|
|
4298
4297
|
for (const name of names) {
|
|
4299
|
-
const sv =
|
|
4300
|
-
const rv =
|
|
4298
|
+
const sv = sm.get(name);
|
|
4299
|
+
const rv = rm.get(name);
|
|
4301
4300
|
const change = scalarChange(sv, rv, (a, b) => normalizeExpr(a) === normalizeExpr(b));
|
|
4302
4301
|
if (!change) continue;
|
|
4303
4302
|
const facet = { kind: "attribute", path: path2, element: tagOf(re), name };
|
|
@@ -4319,11 +4318,6 @@ function compareStyle(path2, se, re, sup, out) {
|
|
|
4319
4318
|
out.push({ facet, change, sourceValue: sv, regionValue: rv });
|
|
4320
4319
|
}
|
|
4321
4320
|
}
|
|
4322
|
-
function stripClassToken(value, token) {
|
|
4323
|
-
if (value === void 0 || !token) return value;
|
|
4324
|
-
const kept = value.split(/\s+/).filter((t) => t && t !== token);
|
|
4325
|
-
return kept.length ? kept.join(" ") : void 0;
|
|
4326
|
-
}
|
|
4327
4321
|
function scalarChange(sv, rv, equal) {
|
|
4328
4322
|
if (sv === void 0 && rv === void 0) return void 0;
|
|
4329
4323
|
if (sv === void 0) return "added";
|
|
@@ -4541,7 +4535,7 @@ function coalesceCss(contributions) {
|
|
|
4541
4535
|
(g) => g.selectors.length > 0 ? scopeWrap(g.css, g.selectors.join(", "), g.to) : g.css
|
|
4542
4536
|
).join("\n\n");
|
|
4543
4537
|
}
|
|
4544
|
-
function fillRegions(container, stack, opts, errors, cssBlocks
|
|
4538
|
+
function fillRegions(container, stack, opts, errors, cssBlocks) {
|
|
4545
4539
|
for (const region of directRegions(container)) {
|
|
4546
4540
|
const name = contractName(region);
|
|
4547
4541
|
const templatePath = opts.resolveTemplate(name);
|
|
@@ -4559,15 +4553,11 @@ function fillRegions(container, stack, opts, errors, cssBlocks, parentScoped = f
|
|
|
4559
4553
|
);
|
|
4560
4554
|
continue;
|
|
4561
4555
|
}
|
|
4562
|
-
const
|
|
4563
|
-
const filled = opts.preserveOverrides ? mergeOverrides(template.body, existing) : template.body;
|
|
4556
|
+
const filled = opts.preserveOverrides ? mergeOverrides(template.body, region.innerHTML) : template.body;
|
|
4564
4557
|
region.set_content(filled);
|
|
4565
|
-
const ref = readAttr(region, "ref");
|
|
4566
|
-
let scoped = false;
|
|
4567
4558
|
if (template.css) {
|
|
4568
4559
|
const selector = (opts.scopeSelector ?? defaultScopeSelector)(region);
|
|
4569
4560
|
if (selector) {
|
|
4570
|
-
scoped = true;
|
|
4571
4561
|
const to = [
|
|
4572
4562
|
...new Set(
|
|
4573
4563
|
directRegions(region).filter((c) => opts.resolveTemplate(contractName(c)) !== null).map((c) => readAttr(c, "ref")).filter((r) => !!r).map((r) => `.${r}`)
|
|
@@ -4576,15 +4566,14 @@ function fillRegions(container, stack, opts, errors, cssBlocks, parentScoped = f
|
|
|
4576
4566
|
cssBlocks.push({
|
|
4577
4567
|
key: `${templatePath}\0${[...to].sort().join(",")}`,
|
|
4578
4568
|
selector,
|
|
4579
|
-
css:
|
|
4569
|
+
css: template.css,
|
|
4580
4570
|
to
|
|
4581
4571
|
});
|
|
4582
4572
|
} else {
|
|
4583
4573
|
cssBlocks.push({ key: templatePath, selector: null, css: template.css });
|
|
4584
4574
|
}
|
|
4585
4575
|
}
|
|
4586
|
-
|
|
4587
|
-
fillRegions(region, [...stack, templatePath], opts, errors, cssBlocks, scoped);
|
|
4576
|
+
fillRegions(region, [...stack, templatePath], opts, errors, cssBlocks);
|
|
4588
4577
|
}
|
|
4589
4578
|
}
|
|
4590
4579
|
function directRegions(container) {
|
|
@@ -4612,39 +4601,6 @@ function scopeWrap(css, selector, to) {
|
|
|
4612
4601
|
${css}
|
|
4613
4602
|
}`;
|
|
4614
4603
|
}
|
|
4615
|
-
function rootClassesOf(templateBody) {
|
|
4616
|
-
const classes = /* @__PURE__ */ new Set();
|
|
4617
|
-
for (const child of parse(templateBody).childNodes) {
|
|
4618
|
-
if (child instanceof HTMLElement) {
|
|
4619
|
-
for (const c of child.classList.values()) classes.add(c);
|
|
4620
|
-
}
|
|
4621
|
-
}
|
|
4622
|
-
return classes;
|
|
4623
|
-
}
|
|
4624
|
-
function scopeReadyCss(templateBody, css) {
|
|
4625
|
-
const rootClasses = rootClassesOf(templateBody);
|
|
4626
|
-
if (rootClasses.size === 0) return css;
|
|
4627
|
-
try {
|
|
4628
|
-
const root = postcss.parse(css);
|
|
4629
|
-
root.walkRules((rule) => {
|
|
4630
|
-
rule.selectors = rule.selectors.map((sel) => rewriteRootSelector(sel, rootClasses));
|
|
4631
|
-
});
|
|
4632
|
-
return root.toString();
|
|
4633
|
-
} catch {
|
|
4634
|
-
return css;
|
|
4635
|
-
}
|
|
4636
|
-
}
|
|
4637
|
-
function rewriteRootSelector(selector, rootClasses) {
|
|
4638
|
-
return selector.replace(
|
|
4639
|
-
/\.([A-Za-z0-9_-]+)/g,
|
|
4640
|
-
(match, name) => rootClasses.has(name) ? ":scope" : match
|
|
4641
|
-
);
|
|
4642
|
-
}
|
|
4643
|
-
function stampScopeAnchor(region, className) {
|
|
4644
|
-
for (const child of region.childNodes) {
|
|
4645
|
-
if (child instanceof HTMLElement) child.classList.add(className);
|
|
4646
|
-
}
|
|
4647
|
-
}
|
|
4648
4604
|
function mergeOverrides(templateBody, existingBody) {
|
|
4649
4605
|
const tRoot = parse(templateBody);
|
|
4650
4606
|
const eRoot = parse(existingBody);
|
|
@@ -4790,6 +4746,428 @@ function scopeInnerBody(block) {
|
|
|
4790
4746
|
function normalizeCssBody(body) {
|
|
4791
4747
|
return body.replace(/\s+/g, " ").replace(/\s*([{}:;,])\s*/g, "$1").replace(/;}/g, "}").trim();
|
|
4792
4748
|
}
|
|
4749
|
+
const SUPPRESS_KEY = "allow-broken-links";
|
|
4750
|
+
const SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true\nin a <script type="application/jay-validations"> block';
|
|
4751
|
+
function normalizePath(p) {
|
|
4752
|
+
let s2 = p.split("#")[0].split("?")[0];
|
|
4753
|
+
s2 = s2.replace(/\/{2,}/g, "/");
|
|
4754
|
+
if (s2.length > 1) s2 = s2.replace(/\/+$/, "");
|
|
4755
|
+
return s2 === "" ? "/" : s2;
|
|
4756
|
+
}
|
|
4757
|
+
function escapeRegExp(s2) {
|
|
4758
|
+
return s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4759
|
+
}
|
|
4760
|
+
function routeToConcreteUrl(route) {
|
|
4761
|
+
const parts = [];
|
|
4762
|
+
for (const seg of route.segments) {
|
|
4763
|
+
if (typeof seg === "string") {
|
|
4764
|
+
parts.push(seg);
|
|
4765
|
+
} else {
|
|
4766
|
+
const v = route.inferredParams?.[seg.name];
|
|
4767
|
+
if (v === void 0) return null;
|
|
4768
|
+
parts.push(v);
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4771
|
+
return normalizePath("/" + parts.join("/"));
|
|
4772
|
+
}
|
|
4773
|
+
function routeToRegex(route) {
|
|
4774
|
+
let pattern = "";
|
|
4775
|
+
for (const seg of route.segments) {
|
|
4776
|
+
if (typeof seg === "string") {
|
|
4777
|
+
pattern += "/" + escapeRegExp(seg);
|
|
4778
|
+
} else if (seg.type === JayRouteParamType.optional) {
|
|
4779
|
+
pattern += "(?:/[^/]+)?";
|
|
4780
|
+
} else if (seg.type === JayRouteParamType.catchAll) {
|
|
4781
|
+
pattern += "(?:/[^/]+)+";
|
|
4782
|
+
} else {
|
|
4783
|
+
pattern += "/[^/]+";
|
|
4784
|
+
}
|
|
4785
|
+
}
|
|
4786
|
+
if (pattern === "") pattern = "/";
|
|
4787
|
+
return new RegExp("^" + pattern + "$");
|
|
4788
|
+
}
|
|
4789
|
+
async function buildRouteOracle(pagesBase) {
|
|
4790
|
+
let routes = [];
|
|
4791
|
+
try {
|
|
4792
|
+
routes = await scanRoutes(pagesBase, {
|
|
4793
|
+
jayHtmlFilename: "page.jay-html",
|
|
4794
|
+
compFilename: "page.ts"
|
|
4795
|
+
});
|
|
4796
|
+
} catch {
|
|
4797
|
+
routes = [];
|
|
4798
|
+
}
|
|
4799
|
+
const staticUrls = /* @__PURE__ */ new Set();
|
|
4800
|
+
const dynamicMatchers = [];
|
|
4801
|
+
const selfUrlByPath = /* @__PURE__ */ new Map();
|
|
4802
|
+
for (const route of routes) {
|
|
4803
|
+
const concrete = routeToConcreteUrl(route);
|
|
4804
|
+
if (concrete !== null) {
|
|
4805
|
+
staticUrls.add(concrete);
|
|
4806
|
+
if (route.jayHtmlPath) selfUrlByPath.set(path$1.resolve(route.jayHtmlPath), concrete);
|
|
4807
|
+
}
|
|
4808
|
+
if (route.segments.some((s2) => typeof s2 !== "string")) {
|
|
4809
|
+
dynamicMatchers.push(routeToRegex(route));
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
return { staticUrls, dynamicMatchers, selfUrlByPath };
|
|
4813
|
+
}
|
|
4814
|
+
async function collectPublicAssets(publicFolder) {
|
|
4815
|
+
const assets = /* @__PURE__ */ new Set();
|
|
4816
|
+
async function walk(dir, prefix) {
|
|
4817
|
+
let entries;
|
|
4818
|
+
try {
|
|
4819
|
+
entries = await promises.readdir(dir, { withFileTypes: true });
|
|
4820
|
+
} catch {
|
|
4821
|
+
return;
|
|
4822
|
+
}
|
|
4823
|
+
for (const entry of entries) {
|
|
4824
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
4825
|
+
if (entry.isDirectory()) {
|
|
4826
|
+
await walk(path$1.join(dir, entry.name), rel);
|
|
4827
|
+
} else {
|
|
4828
|
+
assets.add("/" + rel);
|
|
4829
|
+
}
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4832
|
+
await walk(publicFolder, "");
|
|
4833
|
+
return assets;
|
|
4834
|
+
}
|
|
4835
|
+
function originOf(baseUrl) {
|
|
4836
|
+
try {
|
|
4837
|
+
return new URL(baseUrl).origin;
|
|
4838
|
+
} catch {
|
|
4839
|
+
return void 0;
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
function classifyHref(raw, baseUrl) {
|
|
4843
|
+
if (raw.includes("{")) return { kind: "skip" };
|
|
4844
|
+
const t = raw.trim();
|
|
4845
|
+
if (t === "" || t === "#") return { kind: "degenerate" };
|
|
4846
|
+
if (t.startsWith("#")) return { kind: "skip" };
|
|
4847
|
+
if (/^(mailto:|tel:|javascript:|data:|sms:|ftp:)/i.test(t)) return { kind: "skip" };
|
|
4848
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || t.startsWith("//")) {
|
|
4849
|
+
const sameOrigin = baseUrl ? originOf(baseUrl) : void 0;
|
|
4850
|
+
if (sameOrigin) {
|
|
4851
|
+
try {
|
|
4852
|
+
const u2 = new URL(t.startsWith("//") ? "https:" + t : t);
|
|
4853
|
+
if (u2.origin === sameOrigin)
|
|
4854
|
+
return { kind: "check", targetPath: normalizePath(u2.pathname) };
|
|
4855
|
+
} catch {
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
return { kind: "skip" };
|
|
4859
|
+
}
|
|
4860
|
+
if (t.startsWith("/")) return { kind: "check", targetPath: normalizePath(t) };
|
|
4861
|
+
return { kind: "skip" };
|
|
4862
|
+
}
|
|
4863
|
+
function checkInternalLinks(options) {
|
|
4864
|
+
const { parsedFiles, oracle, assetUrls, baseUrl, projectRoot } = options;
|
|
4865
|
+
const findings = [];
|
|
4866
|
+
for (const { relativePath, parsed } of parsedFiles) {
|
|
4867
|
+
if (parsed.validationOverrides?.["jay-stack"]?.[SUPPRESS_KEY] === true) continue;
|
|
4868
|
+
const ownUrl = oracle.selfUrlByPath.get(path$1.resolve(projectRoot ?? ".", relativePath));
|
|
4869
|
+
for (const anchor of parsed.body.querySelectorAll("a[href]")) {
|
|
4870
|
+
const raw = anchor.getAttribute("href");
|
|
4871
|
+
if (raw === void 0 || raw === null) continue;
|
|
4872
|
+
const verdict = classifyHref(raw, baseUrl);
|
|
4873
|
+
if (verdict.kind === "skip") continue;
|
|
4874
|
+
if (verdict.kind === "degenerate") {
|
|
4875
|
+
findings.push({
|
|
4876
|
+
file: relativePath,
|
|
4877
|
+
message: `Anchor has a placeholder href "${raw}" — it links nowhere and ships as a dead link.`,
|
|
4878
|
+
suggestion: `Point the link at a real page, use <button> for a JS-driven action, or ${SUPPRESS_HINT}.`
|
|
4879
|
+
});
|
|
4880
|
+
continue;
|
|
4881
|
+
}
|
|
4882
|
+
const target = verdict.targetPath;
|
|
4883
|
+
if (ownUrl !== void 0 && target === ownUrl) {
|
|
4884
|
+
findings.push({
|
|
4885
|
+
file: relativePath,
|
|
4886
|
+
message: `Link points to the page's own route "${target}" — a self-link goes nowhere.`,
|
|
4887
|
+
suggestion: `Remove the link, use "#" within-page navigation, or point it at a different page.`
|
|
4888
|
+
});
|
|
4889
|
+
continue;
|
|
4890
|
+
}
|
|
4891
|
+
if (oracle.staticUrls.has(target)) continue;
|
|
4892
|
+
if (assetUrls.has(target)) continue;
|
|
4893
|
+
if (oracle.dynamicMatchers.some((re) => re.test(target))) continue;
|
|
4894
|
+
findings.push({
|
|
4895
|
+
file: relativePath,
|
|
4896
|
+
message: `Broken internal link "${raw}" — no page or public asset produces the URL "${target}".`,
|
|
4897
|
+
suggestion: `Create a placeholder page/route for "${target}", or remove the link. Otherwise ${SUPPRESS_HINT}.`
|
|
4898
|
+
});
|
|
4899
|
+
}
|
|
4900
|
+
}
|
|
4901
|
+
return findings;
|
|
4902
|
+
}
|
|
4903
|
+
const META_SUPPRESS_HINT = 'suppress with jay-stack:\n allow-meta-issues: true\nin the page <script type="application/jay-validations"> block';
|
|
4904
|
+
const LINK_SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true\nin the page <script type="application/jay-validations"> block';
|
|
4905
|
+
const UNRESOLVED_BINDING = /\{[A-Za-z_$][\w$]*(?:\.[\w$]+)*\}/;
|
|
4906
|
+
const TITLE_MAX = 60;
|
|
4907
|
+
const DESCRIPTION_MAX = 160;
|
|
4908
|
+
function buildUrlFromManifest(pattern, params) {
|
|
4909
|
+
return pattern.replace(/\[\[(\w+)\]\]/g, (_, name) => params[name] || "").replace(/\[\.\.\.(\w+)\]/g, (_, name) => params[name] || "").replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
4910
|
+
}
|
|
4911
|
+
function routeInstanceUrl(route, instance) {
|
|
4912
|
+
return normalizePath(buildUrlFromManifest(route.pattern, instance.params));
|
|
4913
|
+
}
|
|
4914
|
+
async function discoverBuildBackendDir(projectRoot, explicit) {
|
|
4915
|
+
if (explicit) {
|
|
4916
|
+
const abs = path$1.resolve(projectRoot, explicit);
|
|
4917
|
+
if (await fileExists(path$1.join(abs, "route-manifest.json"))) return abs;
|
|
4918
|
+
const nested = path$1.join(abs, "backend");
|
|
4919
|
+
if (await fileExists(path$1.join(nested, "route-manifest.json"))) return nested;
|
|
4920
|
+
const fromRoot = await highestVersionBackend(abs);
|
|
4921
|
+
if (fromRoot) return fromRoot;
|
|
4922
|
+
return null;
|
|
4923
|
+
}
|
|
4924
|
+
return highestVersionBackend(path$1.join(projectRoot, "build"));
|
|
4925
|
+
}
|
|
4926
|
+
async function highestVersionBackend(buildRoot) {
|
|
4927
|
+
let entries;
|
|
4928
|
+
try {
|
|
4929
|
+
entries = (await promises.readdir(buildRoot, { withFileTypes: true })).filter((e2) => e2.isDirectory() && /^v/.test(e2.name)).map((e2) => e2.name);
|
|
4930
|
+
} catch {
|
|
4931
|
+
return null;
|
|
4932
|
+
}
|
|
4933
|
+
if (entries.length === 0) return null;
|
|
4934
|
+
entries.sort(compareVersionDesc);
|
|
4935
|
+
for (const name of entries) {
|
|
4936
|
+
const backend = path$1.join(buildRoot, name, "backend");
|
|
4937
|
+
if (await fileExists(path$1.join(backend, "route-manifest.json"))) return backend;
|
|
4938
|
+
}
|
|
4939
|
+
return null;
|
|
4940
|
+
}
|
|
4941
|
+
function compareVersionDesc(a, b) {
|
|
4942
|
+
const pa = a.replace(/^v/, "").split(".").map(Number);
|
|
4943
|
+
const pb = b.replace(/^v/, "").split(".").map(Number);
|
|
4944
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
4945
|
+
const da = pa[i] || 0;
|
|
4946
|
+
const db = pb[i] || 0;
|
|
4947
|
+
if (da !== db) return db - da;
|
|
4948
|
+
}
|
|
4949
|
+
return 0;
|
|
4950
|
+
}
|
|
4951
|
+
async function fileExists(p) {
|
|
4952
|
+
try {
|
|
4953
|
+
await promises.access(p);
|
|
4954
|
+
return true;
|
|
4955
|
+
} catch {
|
|
4956
|
+
return false;
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
async function loadBuildManifest(backendDir) {
|
|
4960
|
+
let manifest;
|
|
4961
|
+
try {
|
|
4962
|
+
manifest = JSON.parse(
|
|
4963
|
+
await promises.readFile(path$1.join(backendDir, "route-manifest.json"), "utf8")
|
|
4964
|
+
);
|
|
4965
|
+
} catch {
|
|
4966
|
+
return null;
|
|
4967
|
+
}
|
|
4968
|
+
let metadata = null;
|
|
4969
|
+
try {
|
|
4970
|
+
metadata = JSON.parse(
|
|
4971
|
+
await promises.readFile(path$1.join(backendDir, "build-metadata.json"), "utf8")
|
|
4972
|
+
);
|
|
4973
|
+
} catch {
|
|
4974
|
+
metadata = null;
|
|
4975
|
+
}
|
|
4976
|
+
return { manifest, metadata };
|
|
4977
|
+
}
|
|
4978
|
+
function buildBuildOracle(manifest, backendDir) {
|
|
4979
|
+
const urls = /* @__PURE__ */ new Set();
|
|
4980
|
+
const instances = [];
|
|
4981
|
+
for (const route of manifest.routes) {
|
|
4982
|
+
const hasDynamic = route.segments.some((s2) => s2.type !== "static");
|
|
4983
|
+
if (route.instances.length === 0) {
|
|
4984
|
+
if (!hasDynamic) urls.add(normalizePath(route.pattern));
|
|
4985
|
+
continue;
|
|
4986
|
+
}
|
|
4987
|
+
for (const instance of route.instances) {
|
|
4988
|
+
const url = routeInstanceUrl(route, instance);
|
|
4989
|
+
urls.add(url);
|
|
4990
|
+
instances.push({
|
|
4991
|
+
url,
|
|
4992
|
+
cacheAbsPath: path$1.resolve(backendDir, instance.cachePath),
|
|
4993
|
+
pattern: route.pattern,
|
|
4994
|
+
headMeta: route.headMeta
|
|
4995
|
+
});
|
|
4996
|
+
}
|
|
4997
|
+
}
|
|
4998
|
+
return { urls, instances };
|
|
4999
|
+
}
|
|
5000
|
+
async function loadSlowViewState(cacheAbsPath) {
|
|
5001
|
+
try {
|
|
5002
|
+
const cache = JSON.parse(await promises.readFile(cacheAbsPath, "utf8"));
|
|
5003
|
+
return cache.slowViewState ?? {};
|
|
5004
|
+
} catch {
|
|
5005
|
+
return {};
|
|
5006
|
+
}
|
|
5007
|
+
}
|
|
5008
|
+
function checkTemplateLinksAgainstBuild(args) {
|
|
5009
|
+
const { parsedFiles, buildUrls, assetUrls, dynamicMatchers, baseUrl } = args;
|
|
5010
|
+
const findings = [];
|
|
5011
|
+
for (const { relativePath, parsed } of parsedFiles) {
|
|
5012
|
+
if (parsed.validationOverrides?.["jay-stack"]?.["allow-broken-links"] === true) continue;
|
|
5013
|
+
for (const anchor of parsed.body.querySelectorAll("a[href]")) {
|
|
5014
|
+
const raw = anchor.getAttribute("href");
|
|
5015
|
+
if (raw === void 0 || raw === null) continue;
|
|
5016
|
+
const verdict = classifyHref(raw, baseUrl);
|
|
5017
|
+
if (verdict.kind !== "check") continue;
|
|
5018
|
+
const target = verdict.targetPath;
|
|
5019
|
+
if (buildUrls.has(target) || assetUrls.has(target)) continue;
|
|
5020
|
+
if (!dynamicMatchers.some((re) => re.test(target))) continue;
|
|
5021
|
+
findings.push({
|
|
5022
|
+
file: relativePath,
|
|
5023
|
+
message: `Broken link "${raw}" — matches a dynamic route but "${target}" is not among the URLs this build produces.`,
|
|
5024
|
+
suggestion: `Create the slug/page, fix the path, or remove the link. Otherwise ${LINK_SUPPRESS_HINT}.`,
|
|
5025
|
+
severity: "error",
|
|
5026
|
+
source: "build-output"
|
|
5027
|
+
});
|
|
5028
|
+
}
|
|
5029
|
+
}
|
|
5030
|
+
return findings;
|
|
5031
|
+
}
|
|
5032
|
+
function resolveContentHref(raw, instanceUrl, baseUrl) {
|
|
5033
|
+
const t = raw.trim();
|
|
5034
|
+
if (t === "" || t === "#" || t.startsWith("#")) return { kind: "skip" };
|
|
5035
|
+
if (t.includes("{")) return { kind: "skip" };
|
|
5036
|
+
if (t.startsWith("/") || t.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(t)) {
|
|
5037
|
+
const v = classifyHref(t, baseUrl);
|
|
5038
|
+
return v.kind === "check" ? v : { kind: "skip" };
|
|
5039
|
+
}
|
|
5040
|
+
try {
|
|
5041
|
+
const u2 = new URL(t, "https://_local_" + (instanceUrl === "/" ? "/" : instanceUrl));
|
|
5042
|
+
return { kind: "check", targetPath: normalizePath(u2.pathname) };
|
|
5043
|
+
} catch {
|
|
5044
|
+
return { kind: "skip" };
|
|
5045
|
+
}
|
|
5046
|
+
}
|
|
5047
|
+
function collectContentHrefs(viewState) {
|
|
5048
|
+
const hrefs = [];
|
|
5049
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5050
|
+
const walk = (node) => {
|
|
5051
|
+
if (node == null) return;
|
|
5052
|
+
if (typeof node === "string") {
|
|
5053
|
+
if (node.includes("<a ")) {
|
|
5054
|
+
const root = parse(node);
|
|
5055
|
+
for (const a of root.querySelectorAll("a[href]")) {
|
|
5056
|
+
const h = a.getAttribute("href");
|
|
5057
|
+
if (h) hrefs.push(h);
|
|
5058
|
+
}
|
|
5059
|
+
}
|
|
5060
|
+
return;
|
|
5061
|
+
}
|
|
5062
|
+
if (typeof node !== "object") return;
|
|
5063
|
+
if (seen.has(node)) return;
|
|
5064
|
+
seen.add(node);
|
|
5065
|
+
if (Array.isArray(node)) {
|
|
5066
|
+
for (const item of node) walk(item);
|
|
5067
|
+
} else {
|
|
5068
|
+
for (const v of Object.values(node)) walk(v);
|
|
5069
|
+
}
|
|
5070
|
+
};
|
|
5071
|
+
walk(viewState);
|
|
5072
|
+
return hrefs;
|
|
5073
|
+
}
|
|
5074
|
+
function checkContentLinks(args) {
|
|
5075
|
+
const { instanceUrl, slowViewState, buildUrls, assetUrls, baseUrl } = args;
|
|
5076
|
+
const findings = [];
|
|
5077
|
+
const reported = /* @__PURE__ */ new Set();
|
|
5078
|
+
for (const raw of collectContentHrefs(slowViewState)) {
|
|
5079
|
+
const verdict = resolveContentHref(raw, instanceUrl, baseUrl);
|
|
5080
|
+
if (verdict.kind !== "check") continue;
|
|
5081
|
+
const target = verdict.targetPath;
|
|
5082
|
+
if (buildUrls.has(target) || assetUrls.has(target)) continue;
|
|
5083
|
+
if (reported.has(target)) continue;
|
|
5084
|
+
reported.add(target);
|
|
5085
|
+
findings.push({
|
|
5086
|
+
file: instanceUrl,
|
|
5087
|
+
message: `Broken link "${raw}" in rendered content of ${instanceUrl} — resolves to "${target}", which this build does not produce.`,
|
|
5088
|
+
// Content links come from authored markdown/CMS data that often doubles as repo docs (where a
|
|
5089
|
+
// repo-relative "../pkg/foo.ts" is correct), so this is a warning, not a build-failing error —
|
|
5090
|
+
// unlike hand-authored template links (Check 1), which are site-only and stay errors.
|
|
5091
|
+
suggestion: `Fix the link in the source content, point it at an absolute URL, or ${LINK_SUPPRESS_HINT}.`,
|
|
5092
|
+
severity: "warning",
|
|
5093
|
+
source: "build-output"
|
|
5094
|
+
});
|
|
5095
|
+
}
|
|
5096
|
+
return findings;
|
|
5097
|
+
}
|
|
5098
|
+
function checkInstanceMeta(args) {
|
|
5099
|
+
const { instanceUrl, headMeta, slowViewState } = args;
|
|
5100
|
+
if (!headMeta) return [];
|
|
5101
|
+
const findings = [];
|
|
5102
|
+
const tags = headMetaToHeadTags(headMeta, slowViewState);
|
|
5103
|
+
const addLeftover = (what, value) => findings.push({
|
|
5104
|
+
file: instanceUrl,
|
|
5105
|
+
message: `<${what}> for ${instanceUrl} resolves to "${value}" — an unresolved binding was not produced by slow render.`,
|
|
5106
|
+
suggestion: `Ensure the field is produced by the slow phase, or make the ${what} static.`,
|
|
5107
|
+
severity: "error",
|
|
5108
|
+
source: "build-output"
|
|
5109
|
+
});
|
|
5110
|
+
const addWarn = (message) => findings.push({
|
|
5111
|
+
file: instanceUrl,
|
|
5112
|
+
message,
|
|
5113
|
+
suggestion: `Fix the content or ${META_SUPPRESS_HINT}.`,
|
|
5114
|
+
severity: "warning",
|
|
5115
|
+
source: "build-output"
|
|
5116
|
+
});
|
|
5117
|
+
for (const tag of tags) {
|
|
5118
|
+
if (tag.tag === "title") {
|
|
5119
|
+
const value = tag.children ?? "";
|
|
5120
|
+
if (UNRESOLVED_BINDING.test(value)) addLeftover("title", value);
|
|
5121
|
+
else if (value.trim() === "") addWarn(`<title> for ${instanceUrl} is empty.`);
|
|
5122
|
+
else if (value.length > TITLE_MAX)
|
|
5123
|
+
addWarn(
|
|
5124
|
+
`<title> for ${instanceUrl} is ${value.length} chars (recommend ≤ ${TITLE_MAX}).`
|
|
5125
|
+
);
|
|
5126
|
+
} else if (tag.tag === "meta" && tag.attrs?.name === "description") {
|
|
5127
|
+
const value = tag.attrs.content ?? "";
|
|
5128
|
+
if (UNRESOLVED_BINDING.test(value)) addLeftover('meta name="description"', value);
|
|
5129
|
+
else if (value.trim() === "")
|
|
5130
|
+
addWarn(`<meta name="description"> for ${instanceUrl} is empty.`);
|
|
5131
|
+
else if (value.length > DESCRIPTION_MAX)
|
|
5132
|
+
addWarn(
|
|
5133
|
+
`<meta name="description"> for ${instanceUrl} is ${value.length} chars (recommend ≤ ${DESCRIPTION_MAX}).`
|
|
5134
|
+
);
|
|
5135
|
+
}
|
|
5136
|
+
}
|
|
5137
|
+
return findings;
|
|
5138
|
+
}
|
|
5139
|
+
function isBuildStale(metadata, newestSourceMtimeMs) {
|
|
5140
|
+
if (!metadata?.buildTimestamp) return false;
|
|
5141
|
+
const built = Date.parse(metadata.buildTimestamp);
|
|
5142
|
+
if (Number.isNaN(built)) return false;
|
|
5143
|
+
return newestSourceMtimeMs > built;
|
|
5144
|
+
}
|
|
5145
|
+
async function newestSourceMtime(dirs) {
|
|
5146
|
+
let newest = 0;
|
|
5147
|
+
const walk = async (dir) => {
|
|
5148
|
+
let entries;
|
|
5149
|
+
try {
|
|
5150
|
+
entries = await promises.readdir(dir, { withFileTypes: true });
|
|
5151
|
+
} catch {
|
|
5152
|
+
return;
|
|
5153
|
+
}
|
|
5154
|
+
for (const entry of entries) {
|
|
5155
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
5156
|
+
const full = path$1.join(dir, entry.name);
|
|
5157
|
+
if (entry.isDirectory()) {
|
|
5158
|
+
await walk(full);
|
|
5159
|
+
} else {
|
|
5160
|
+
try {
|
|
5161
|
+
const st = await promises.stat(full);
|
|
5162
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
5163
|
+
} catch {
|
|
5164
|
+
}
|
|
5165
|
+
}
|
|
5166
|
+
}
|
|
5167
|
+
};
|
|
5168
|
+
for (const dir of dirs) await walk(dir);
|
|
5169
|
+
return newest;
|
|
5170
|
+
}
|
|
4793
5171
|
async function findJayFiles(dir) {
|
|
4794
5172
|
return await glob(`${dir}/**/*${JAY_EXTENSION$1}`);
|
|
4795
5173
|
}
|
|
@@ -5369,8 +5747,7 @@ function checkRegionCssDrift(jayHtml, loadTemplate) {
|
|
|
5369
5747
|
if (content === void 0) {
|
|
5370
5748
|
templateCss = null;
|
|
5371
5749
|
} else {
|
|
5372
|
-
|
|
5373
|
-
templateCss = scopeReadyCss(body ? body.innerHTML : "", extractStyleCss(content));
|
|
5750
|
+
templateCss = extractStyleCss(content);
|
|
5374
5751
|
}
|
|
5375
5752
|
templateCssCache.set(imp.template, templateCss);
|
|
5376
5753
|
}
|
|
@@ -5462,6 +5839,33 @@ function checkNestedRegionRefs(jayHtml, loadTemplate) {
|
|
|
5462
5839
|
}
|
|
5463
5840
|
return warnings;
|
|
5464
5841
|
}
|
|
5842
|
+
function checkRegionCssNoRef(jayHtml, loadTemplate) {
|
|
5843
|
+
const importsWithTemplate = jayHtml.headlessImports.filter((imp) => imp.template);
|
|
5844
|
+
if (importsWithTemplate.length === 0) return [];
|
|
5845
|
+
const findings = [];
|
|
5846
|
+
const shipsCssCache = /* @__PURE__ */ new Map();
|
|
5847
|
+
const templateShipsCss = (rel) => {
|
|
5848
|
+
let v = shipsCssCache.get(rel);
|
|
5849
|
+
if (v === void 0) {
|
|
5850
|
+
const content = loadTemplate(rel);
|
|
5851
|
+
v = content !== void 0 && extractStyleCss(content).trim() !== "";
|
|
5852
|
+
shipsCssCache.set(rel, v);
|
|
5853
|
+
}
|
|
5854
|
+
return v;
|
|
5855
|
+
};
|
|
5856
|
+
for (const region of collectRegionElements(jayHtml.body)) {
|
|
5857
|
+
const contractName2 = (region.rawTagName ?? "").toLowerCase().substring(4);
|
|
5858
|
+
const imp = importsWithTemplate.find((i) => i.contractName === contractName2);
|
|
5859
|
+
if (!imp?.template) continue;
|
|
5860
|
+
if (region.getAttribute("ref")) continue;
|
|
5861
|
+
if (!templateShipsCss(imp.template)) continue;
|
|
5862
|
+
findings.push({
|
|
5863
|
+
message: `<jay:${contractName2}> flattens template="${imp.template}" which ships CSS, but the region has no ref= — so its CSS cannot be scoped and \`jay-stack sync\` will silently drop it.`,
|
|
5864
|
+
suggestion: `Add a ref= to the <jay:${contractName2}> so sync can scope its CSS as an @scope (.<ref>) block and preserve it.`
|
|
5865
|
+
});
|
|
5866
|
+
}
|
|
5867
|
+
return findings;
|
|
5868
|
+
}
|
|
5465
5869
|
const CONTENT_TAGS = /* @__PURE__ */ new Set([
|
|
5466
5870
|
// inline text semantics
|
|
5467
5871
|
"span",
|
|
@@ -5888,6 +6292,9 @@ async function validateJayFiles(options = {}) {
|
|
|
5888
6292
|
const parsedFiles = [];
|
|
5889
6293
|
let regionsSeen = 0;
|
|
5890
6294
|
let templateImportsSeen = 0;
|
|
6295
|
+
const designSystemCoverage = [];
|
|
6296
|
+
const reuseByTemplate = /* @__PURE__ */ new Map();
|
|
6297
|
+
const cataloguedContractFiles = /* @__PURE__ */ new Set();
|
|
5891
6298
|
const pageJayHtmlFiles = await findJayFiles(scanDir);
|
|
5892
6299
|
const componentJayHtmlFiles = await findJayFiles(componentsDir).catch(() => []);
|
|
5893
6300
|
const jayHtmlFiles = [...pageJayHtmlFiles, ...componentJayHtmlFiles];
|
|
@@ -5895,6 +6302,7 @@ async function validateJayFiles(options = {}) {
|
|
|
5895
6302
|
const componentContractFiles = await findContractFiles(componentsDir).catch(
|
|
5896
6303
|
() => []
|
|
5897
6304
|
);
|
|
6305
|
+
for (const cf of componentContractFiles) cataloguedContractFiles.add(cf);
|
|
5898
6306
|
const contractFiles = [...await findContractFiles(scanDir), ...componentContractFiles];
|
|
5899
6307
|
const contractNameByFile = /* @__PURE__ */ new Map();
|
|
5900
6308
|
if (options.verbose) {
|
|
@@ -6044,10 +6452,45 @@ async function validateJayFiles(options = {}) {
|
|
|
6044
6452
|
suggestion: finding.suggestion
|
|
6045
6453
|
});
|
|
6046
6454
|
}
|
|
6455
|
+
const cssNoRefErrors = checkRegionCssNoRef(parsedFile.val, readTemplateRel);
|
|
6456
|
+
for (const finding of cssNoRefErrors) {
|
|
6457
|
+
errors.push({
|
|
6458
|
+
file: relativePath,
|
|
6459
|
+
message: finding.message,
|
|
6460
|
+
stage: "generate",
|
|
6461
|
+
suggestion: finding.suggestion
|
|
6462
|
+
});
|
|
6463
|
+
}
|
|
6047
6464
|
regionsSeen += collectRegionElements(parsedFile.val.body).length;
|
|
6048
6465
|
templateImportsSeen += parsedFile.val.headlessImports.filter(
|
|
6049
6466
|
(imp) => imp.template
|
|
6050
6467
|
).length;
|
|
6468
|
+
if (!componentJayHtmlFileSet.has(jayFile)) {
|
|
6469
|
+
const body = parsedFile.val.body;
|
|
6470
|
+
const total = body.querySelectorAll("*").length;
|
|
6471
|
+
const coveredNodes = /* @__PURE__ */ new Set();
|
|
6472
|
+
for (const region of directChildRegions(body)) {
|
|
6473
|
+
const contractName2 = regionContractName(region);
|
|
6474
|
+
const imp = parsedFile.val.headlessImports.find(
|
|
6475
|
+
(i) => i.contractName === contractName2
|
|
6476
|
+
);
|
|
6477
|
+
if (!imp?.template) continue;
|
|
6478
|
+
coveredNodes.add(region);
|
|
6479
|
+
for (const descendant of region.querySelectorAll("*")) {
|
|
6480
|
+
coveredNodes.add(descendant);
|
|
6481
|
+
}
|
|
6482
|
+
const templateAbs = path$1.resolve(dirname, imp.template);
|
|
6483
|
+
reuseByTemplate.set(templateAbs, (reuseByTemplate.get(templateAbs) ?? 0) + 1);
|
|
6484
|
+
if (imp.contractPath) cataloguedContractFiles.add(imp.contractPath);
|
|
6485
|
+
}
|
|
6486
|
+
const covered = coveredNodes.size;
|
|
6487
|
+
designSystemCoverage.push({
|
|
6488
|
+
file: relativePath,
|
|
6489
|
+
coveragePct: total ? covered / total : 0,
|
|
6490
|
+
covered,
|
|
6491
|
+
total
|
|
6492
|
+
});
|
|
6493
|
+
}
|
|
6051
6494
|
const notLinkedWarnings = componentJayHtmlFileSet.has(jayFile) ? [] : checkRegionNotLinked(
|
|
6052
6495
|
parsedFile.val,
|
|
6053
6496
|
(imp) => listTemplatesForContractFile(imp.contractPath).map((t) => ({
|
|
@@ -6198,6 +6641,55 @@ async function validateJayFiles(options = {}) {
|
|
|
6198
6641
|
suggestion: noDesignSystem.suggestion
|
|
6199
6642
|
});
|
|
6200
6643
|
}
|
|
6644
|
+
const cataloguedTemplatePaths = /* @__PURE__ */ new Set();
|
|
6645
|
+
for (const contractFile of cataloguedContractFiles) {
|
|
6646
|
+
for (const t of listTemplatesForContractFile(contractFile)) {
|
|
6647
|
+
cataloguedTemplatePaths.add(path$1.resolve(t.path));
|
|
6648
|
+
}
|
|
6649
|
+
}
|
|
6650
|
+
const rel = (p) => "./" + path$1.relative(projectRoot, p).replace(/\\/g, "/");
|
|
6651
|
+
const perTemplate = {};
|
|
6652
|
+
for (const [abs, count] of reuseByTemplate) perTemplate[rel(abs)] = count;
|
|
6653
|
+
const designSystemReuse = {
|
|
6654
|
+
perTemplate,
|
|
6655
|
+
catalogued: cataloguedTemplatePaths.size,
|
|
6656
|
+
reusedMoreThanOnce: [...reuseByTemplate.values()].filter((n) => n >= 2).length,
|
|
6657
|
+
reusedOfCatalogued: [...cataloguedTemplatePaths].filter(
|
|
6658
|
+
(p) => (reuseByTemplate.get(p) ?? 0) >= 2
|
|
6659
|
+
).length
|
|
6660
|
+
};
|
|
6661
|
+
const linkOracle = await buildRouteOracle(scanDir);
|
|
6662
|
+
const assetUrls = await collectPublicAssets(
|
|
6663
|
+
path$1.resolve(projectRoot, resolvedConfig.devServer.publicFolder)
|
|
6664
|
+
);
|
|
6665
|
+
const linkFindings = checkInternalLinks({
|
|
6666
|
+
parsedFiles,
|
|
6667
|
+
oracle: linkOracle,
|
|
6668
|
+
assetUrls,
|
|
6669
|
+
baseUrl: config.site?.baseUrl,
|
|
6670
|
+
projectRoot
|
|
6671
|
+
});
|
|
6672
|
+
for (const finding of linkFindings) {
|
|
6673
|
+
errors.push({
|
|
6674
|
+
file: finding.file,
|
|
6675
|
+
message: finding.message,
|
|
6676
|
+
stage: "generate",
|
|
6677
|
+
source: "internal-links",
|
|
6678
|
+
suggestion: finding.suggestion
|
|
6679
|
+
});
|
|
6680
|
+
}
|
|
6681
|
+
if (options.tier2) {
|
|
6682
|
+
await runBuildOutputValidation({
|
|
6683
|
+
projectRoot,
|
|
6684
|
+
buildDir: options.buildDir,
|
|
6685
|
+
parsedFiles,
|
|
6686
|
+
linkOracle,
|
|
6687
|
+
assetUrls,
|
|
6688
|
+
baseUrl: config.site?.baseUrl,
|
|
6689
|
+
errors,
|
|
6690
|
+
warnings
|
|
6691
|
+
});
|
|
6692
|
+
}
|
|
6201
6693
|
const pluginValidators = await runPluginValidators(projectRoot, parsedFiles, errors, warnings);
|
|
6202
6694
|
return {
|
|
6203
6695
|
valid: errors.length === 0,
|
|
@@ -6206,9 +6698,94 @@ async function validateJayFiles(options = {}) {
|
|
|
6206
6698
|
errors,
|
|
6207
6699
|
warnings,
|
|
6208
6700
|
coverage,
|
|
6209
|
-
pluginValidators
|
|
6701
|
+
pluginValidators,
|
|
6702
|
+
designSystemCoverage,
|
|
6703
|
+
designSystemReuse
|
|
6210
6704
|
};
|
|
6211
6705
|
}
|
|
6706
|
+
async function runBuildOutputValidation(args) {
|
|
6707
|
+
const { projectRoot, buildDir, parsedFiles, linkOracle, assetUrls, baseUrl, errors, warnings } = args;
|
|
6708
|
+
const backendDir = await discoverBuildBackendDir(projectRoot, buildDir);
|
|
6709
|
+
if (!backendDir) {
|
|
6710
|
+
errors.push({
|
|
6711
|
+
file: "build",
|
|
6712
|
+
message: "Tier 2: no build found. Run `jay-stack build` first, or point --build-dir at a build.",
|
|
6713
|
+
stage: "generate",
|
|
6714
|
+
source: "build-output"
|
|
6715
|
+
});
|
|
6716
|
+
return;
|
|
6717
|
+
}
|
|
6718
|
+
const loaded = await loadBuildManifest(backendDir);
|
|
6719
|
+
if (!loaded) {
|
|
6720
|
+
errors.push({
|
|
6721
|
+
file: backendDir,
|
|
6722
|
+
message: `Tier 2: could not read route-manifest.json in ${backendDir}.`,
|
|
6723
|
+
stage: "generate",
|
|
6724
|
+
source: "build-output"
|
|
6725
|
+
});
|
|
6726
|
+
return;
|
|
6727
|
+
}
|
|
6728
|
+
const newest = await newestSourceMtime([
|
|
6729
|
+
path$1.join(projectRoot, "src"),
|
|
6730
|
+
path$1.join(projectRoot, "content")
|
|
6731
|
+
]);
|
|
6732
|
+
if (isBuildStale(loaded.metadata, newest)) {
|
|
6733
|
+
warnings.push({
|
|
6734
|
+
file: "build",
|
|
6735
|
+
message: `Tier 2: validated against a build from ${loaded.metadata?.buildTimestamp}; source has changed since.`,
|
|
6736
|
+
source: "build-output",
|
|
6737
|
+
suggestion: "Re-run `jay-stack build` for accurate results."
|
|
6738
|
+
});
|
|
6739
|
+
}
|
|
6740
|
+
const oracle = buildBuildOracle(loaded.manifest, backendDir);
|
|
6741
|
+
const findings = [];
|
|
6742
|
+
findings.push(
|
|
6743
|
+
...checkTemplateLinksAgainstBuild({
|
|
6744
|
+
parsedFiles,
|
|
6745
|
+
buildUrls: oracle.urls,
|
|
6746
|
+
assetUrls,
|
|
6747
|
+
dynamicMatchers: linkOracle.dynamicMatchers,
|
|
6748
|
+
baseUrl
|
|
6749
|
+
})
|
|
6750
|
+
);
|
|
6751
|
+
for (const instance of oracle.instances) {
|
|
6752
|
+
const slowViewState = await loadSlowViewState(instance.cacheAbsPath);
|
|
6753
|
+
findings.push(
|
|
6754
|
+
...checkContentLinks({
|
|
6755
|
+
instanceUrl: instance.url,
|
|
6756
|
+
slowViewState,
|
|
6757
|
+
buildUrls: oracle.urls,
|
|
6758
|
+
assetUrls,
|
|
6759
|
+
baseUrl
|
|
6760
|
+
})
|
|
6761
|
+
);
|
|
6762
|
+
findings.push(
|
|
6763
|
+
...checkInstanceMeta({
|
|
6764
|
+
instanceUrl: instance.url,
|
|
6765
|
+
headMeta: instance.headMeta,
|
|
6766
|
+
slowViewState
|
|
6767
|
+
})
|
|
6768
|
+
);
|
|
6769
|
+
}
|
|
6770
|
+
for (const finding of findings) {
|
|
6771
|
+
if (finding.severity === "error") {
|
|
6772
|
+
errors.push({
|
|
6773
|
+
file: finding.file,
|
|
6774
|
+
message: finding.message,
|
|
6775
|
+
stage: "generate",
|
|
6776
|
+
source: "build-output",
|
|
6777
|
+
suggestion: finding.suggestion
|
|
6778
|
+
});
|
|
6779
|
+
} else {
|
|
6780
|
+
warnings.push({
|
|
6781
|
+
file: finding.file,
|
|
6782
|
+
message: finding.message,
|
|
6783
|
+
source: "build-output",
|
|
6784
|
+
suggestion: finding.suggestion
|
|
6785
|
+
});
|
|
6786
|
+
}
|
|
6787
|
+
}
|
|
6788
|
+
}
|
|
6212
6789
|
function printJayValidationResult(result, options) {
|
|
6213
6790
|
const logger = getLogger();
|
|
6214
6791
|
if (options.json) {
|
|
@@ -6216,9 +6793,11 @@ function printJayValidationResult(result, options) {
|
|
|
6216
6793
|
return;
|
|
6217
6794
|
}
|
|
6218
6795
|
logger.important("");
|
|
6219
|
-
const coreErrors = result.errors.filter(
|
|
6796
|
+
const coreErrors = result.errors.filter(
|
|
6797
|
+
(e2) => e2.stage !== "plugin" && e2.source !== "build-output"
|
|
6798
|
+
);
|
|
6220
6799
|
const coreWarnings = result.warnings.filter((w) => !w.source);
|
|
6221
|
-
logger.important(chalk.bold("📦 jay-stack (core)"));
|
|
6800
|
+
logger.important(chalk.bold("📦 Tier 1 — jay-stack (core)"));
|
|
6222
6801
|
if (coreErrors.length === 0) {
|
|
6223
6802
|
logger.important(
|
|
6224
6803
|
chalk.green(
|
|
@@ -6241,6 +6820,40 @@ function printJayValidationResult(result, options) {
|
|
|
6241
6820
|
logger.important(chalk.blue(` Suggestion: ${warning.suggestion}`));
|
|
6242
6821
|
}
|
|
6243
6822
|
}
|
|
6823
|
+
const buildErrors = result.errors.filter((e2) => e2.source === "build-output");
|
|
6824
|
+
const buildWarnings = result.warnings.filter((w) => w.source === "build-output");
|
|
6825
|
+
if (buildErrors.length > 0 || buildWarnings.length > 0) {
|
|
6826
|
+
logger.important("");
|
|
6827
|
+
logger.important(chalk.bold("📦 Tier 2 — build-output validation (--tier-2)"));
|
|
6828
|
+
for (const error of buildErrors) {
|
|
6829
|
+
logger.important(chalk.red(` ❌ ${error.file}`));
|
|
6830
|
+
logger.important(chalk.gray(` ${error.message}`));
|
|
6831
|
+
if (error.suggestion) {
|
|
6832
|
+
logger.important(chalk.blue(` Suggestion: ${error.suggestion}`));
|
|
6833
|
+
}
|
|
6834
|
+
}
|
|
6835
|
+
const buildWarnGroups = /* @__PURE__ */ new Map();
|
|
6836
|
+
for (const warning of buildWarnings) {
|
|
6837
|
+
const group = buildWarnGroups.get(warning.file) || [];
|
|
6838
|
+
group.push(warning);
|
|
6839
|
+
buildWarnGroups.set(warning.file, group);
|
|
6840
|
+
}
|
|
6841
|
+
for (const [file, groupWarns] of buildWarnGroups) {
|
|
6842
|
+
logger.important(chalk.yellow(` ⚠ ${file}`));
|
|
6843
|
+
for (const warning of groupWarns) {
|
|
6844
|
+
if (warning.message) {
|
|
6845
|
+
logger.important(chalk.gray(` ${warning.message}`));
|
|
6846
|
+
}
|
|
6847
|
+
}
|
|
6848
|
+
const suggestions = [...new Set(groupWarns.map((w) => w.suggestion).filter(Boolean))];
|
|
6849
|
+
if (suggestions.length > 0) {
|
|
6850
|
+
logger.important(chalk.blue(` Suggestions:`));
|
|
6851
|
+
for (const s2 of suggestions) {
|
|
6852
|
+
logger.important(chalk.blue(` ${s2}`));
|
|
6853
|
+
}
|
|
6854
|
+
}
|
|
6855
|
+
}
|
|
6856
|
+
}
|
|
6244
6857
|
const pluginErrors = result.errors.filter((e2) => e2.stage === "plugin");
|
|
6245
6858
|
const pluginWarnings = result.warnings.filter((w) => !!w.source);
|
|
6246
6859
|
for (const validatorName of result.pluginValidators) {
|
|
@@ -6280,23 +6893,84 @@ function printJayValidationResult(result, options) {
|
|
|
6280
6893
|
}
|
|
6281
6894
|
}
|
|
6282
6895
|
}
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6896
|
+
const { designSystemCoverage, designSystemReuse } = result;
|
|
6897
|
+
const reuseTemplates = Object.keys(designSystemReuse.perTemplate);
|
|
6898
|
+
const hasTagCoverage = result.coverage.length > 0;
|
|
6899
|
+
const hasScorecard = designSystemCoverage.length > 0 || reuseTemplates.length > 0;
|
|
6900
|
+
let totalUsedTags = 0;
|
|
6901
|
+
let totalTags = 0;
|
|
6902
|
+
for (const fileCov of result.coverage) {
|
|
6903
|
+
for (const contract of fileCov.contracts) {
|
|
6904
|
+
totalUsedTags += contract.usedTags;
|
|
6905
|
+
totalTags += contract.totalTags;
|
|
6906
|
+
}
|
|
6907
|
+
}
|
|
6908
|
+
let totalCovered = 0;
|
|
6909
|
+
let totalElements = 0;
|
|
6910
|
+
for (const cov of designSystemCoverage) {
|
|
6911
|
+
totalCovered += cov.covered;
|
|
6912
|
+
totalElements += cov.total;
|
|
6913
|
+
}
|
|
6914
|
+
if (options.verbose) {
|
|
6915
|
+
if (hasTagCoverage) {
|
|
6916
|
+
logger.important("");
|
|
6917
|
+
logger.important(chalk.bold("📦 Tag Coverage"));
|
|
6918
|
+
for (const fileCov of result.coverage) {
|
|
6919
|
+
logger.important(` ${fileCov.file}`);
|
|
6920
|
+
for (const contract of fileCov.contracts) {
|
|
6921
|
+
const label = contract.key ? `${contract.key} (${contract.contractName})` : contract.contractName;
|
|
6294
6922
|
logger.important(
|
|
6295
|
-
|
|
6923
|
+
` ${label}: ${contract.usedTags}/${contract.totalTags} tags used`
|
|
6296
6924
|
);
|
|
6925
|
+
if (contract.unusedTags.length > 0) {
|
|
6926
|
+
logger.important(
|
|
6927
|
+
chalk.gray(` Unused: ${contract.unusedTags.join(", ")}`)
|
|
6928
|
+
);
|
|
6929
|
+
}
|
|
6297
6930
|
}
|
|
6298
6931
|
}
|
|
6299
6932
|
}
|
|
6933
|
+
if (hasScorecard) {
|
|
6934
|
+
logger.important("");
|
|
6935
|
+
logger.important(chalk.bold("📊 Design-system scorecard"));
|
|
6936
|
+
for (const cov of designSystemCoverage) {
|
|
6937
|
+
const pct = Math.round(cov.coveragePct * 100);
|
|
6938
|
+
const lowFlag = pct < 50 ? chalk.yellow(" ⚠ low") : "";
|
|
6939
|
+
logger.important(
|
|
6940
|
+
chalk.gray(
|
|
6941
|
+
` Coverage: ${cov.file} ${pct}% (${cov.covered}/${cov.total} elements)`
|
|
6942
|
+
) + lowFlag
|
|
6943
|
+
);
|
|
6944
|
+
}
|
|
6945
|
+
if (reuseTemplates.length > 0) {
|
|
6946
|
+
const parts = reuseTemplates.map((t) => {
|
|
6947
|
+
const n = designSystemReuse.perTemplate[t];
|
|
6948
|
+
return n < 2 ? `${t} ×${n}${chalk.yellow(" ⚠ single-use")}` : `${t} ×${n}`;
|
|
6949
|
+
});
|
|
6950
|
+
logger.important(chalk.gray(` Reuse: ${parts.join(" ")}`));
|
|
6951
|
+
}
|
|
6952
|
+
logger.important(
|
|
6953
|
+
chalk.gray(
|
|
6954
|
+
` ${designSystemReuse.reusedOfCatalogued} of ${designSystemReuse.catalogued} catalogued templates reused > 1`
|
|
6955
|
+
)
|
|
6956
|
+
);
|
|
6957
|
+
}
|
|
6958
|
+
} else if (hasTagCoverage || hasScorecard) {
|
|
6959
|
+
logger.important("");
|
|
6960
|
+
if (hasTagCoverage) {
|
|
6961
|
+
const pct = totalTags ? Math.round(totalUsedTags / totalTags * 100) : 0;
|
|
6962
|
+
logger.important(
|
|
6963
|
+
chalk.bold("📦 Tag coverage: ") + `${pct}% (${totalUsedTags}/${totalTags} tags used across ${result.coverage.length} page(s))`
|
|
6964
|
+
);
|
|
6965
|
+
}
|
|
6966
|
+
if (hasScorecard) {
|
|
6967
|
+
const pct = totalElements ? Math.round(totalCovered / totalElements * 100) : 0;
|
|
6968
|
+
const lowFlag = pct < 50 ? chalk.yellow(" ⚠ low") : "";
|
|
6969
|
+
logger.important(
|
|
6970
|
+
chalk.bold("📊 Design-system scorecard: ") + `${pct}% element coverage (${totalCovered}/${totalElements}), ${designSystemReuse.reusedOfCatalogued} of ${designSystemReuse.catalogued} catalogued templates reused > 1` + lowFlag
|
|
6971
|
+
);
|
|
6972
|
+
}
|
|
6973
|
+
logger.important(chalk.gray(" Run validate -v for per-page details."));
|
|
6300
6974
|
}
|
|
6301
6975
|
logger.important("");
|
|
6302
6976
|
if (result.valid && result.warnings.length === 0) {
|
|
@@ -6322,12 +6996,22 @@ function printJayValidationResult(result, options) {
|
|
|
6322
6996
|
)
|
|
6323
6997
|
);
|
|
6324
6998
|
}
|
|
6999
|
+
if (!options.tier2) {
|
|
7000
|
+
logger.important("");
|
|
7001
|
+
logger.important(
|
|
7002
|
+
chalk.gray(
|
|
7003
|
+
"ℹ This was Tier 1 (template) validation. Tier 2 runs deeper checks against an existing build\n (dynamic-slug links, rendered-content links, per-instance meta):\n jay-stack validate --tier-2\n See: agent-kit/designer/validation-tier-2-guide.md"
|
|
7004
|
+
)
|
|
7005
|
+
);
|
|
7006
|
+
}
|
|
6325
7007
|
}
|
|
6326
7008
|
async function runValidate(scanPath, options) {
|
|
6327
7009
|
const result = await validateJayFiles({
|
|
6328
7010
|
path: scanPath,
|
|
6329
7011
|
verbose: options.verbose,
|
|
6330
|
-
json: options.json
|
|
7012
|
+
json: options.json,
|
|
7013
|
+
tier2: options.tier2,
|
|
7014
|
+
buildDir: options.buildDir
|
|
6331
7015
|
});
|
|
6332
7016
|
printJayValidationResult(result, options);
|
|
6333
7017
|
if (!result.valid) {
|
|
@@ -6474,7 +7158,7 @@ async function runBuild(projectPath, options) {
|
|
|
6474
7158
|
}
|
|
6475
7159
|
const ctx = await resolveProductionContext(projectPath, options.version);
|
|
6476
7160
|
await generateDefinitionFiles(ctx);
|
|
6477
|
-
const { buildVersion } = await import("./index-
|
|
7161
|
+
const { buildVersion } = await import("./index-BQbkAEhi.js");
|
|
6478
7162
|
await buildVersion({
|
|
6479
7163
|
version: ctx.version,
|
|
6480
7164
|
projectRoot: ctx.resolvedPath,
|
|
@@ -7594,9 +8278,17 @@ program.command("cleanup").description("Delete orphaned files from previous rebu
|
|
|
7594
8278
|
process.exit(1);
|
|
7595
8279
|
}
|
|
7596
8280
|
});
|
|
7597
|
-
program.command("validate").description("Validate all .jay-html and .jay-contract files").option("-p, --path <path>", "Scan root (default: cwd)").option("-v, --verbose", "Show per-file validation status").option("--json", "Output results as JSON").
|
|
8281
|
+
program.command("validate").description("Validate all .jay-html and .jay-contract files").option("-p, --path <path>", "Scan root (default: cwd)").option("-v, --verbose", "Show per-file validation status").option("--json", "Output results as JSON").addOption(
|
|
8282
|
+
new Option(
|
|
8283
|
+
"--tier-2",
|
|
8284
|
+
"Run Tier 2: deep validation against an existing build (dynamic-slug links, rendered content, per-instance meta)"
|
|
8285
|
+
)
|
|
8286
|
+
).addOption(new Option("--t2", "Alias for --tier-2").hideHelp()).option(
|
|
8287
|
+
"--build-dir <dir>",
|
|
8288
|
+
"Build backend dir or build root for Tier 2 (default: highest build/v*)"
|
|
8289
|
+
).action(async (options) => {
|
|
7598
8290
|
try {
|
|
7599
|
-
await runValidate(options.path, options);
|
|
8291
|
+
await runValidate(options.path, { ...options, tier2: options.tier2 || options.t2 });
|
|
7600
8292
|
} catch (error) {
|
|
7601
8293
|
if (options.json) {
|
|
7602
8294
|
getLogger().important(
|