@jay-framework/jay-stack-cli 0.25.0 → 0.25.2

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/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 = {
@@ -49,7 +50,8 @@ function loadConfig(projectRoot) {
49
50
  ...DEFAULT_CONFIG.devServer,
50
51
  ...userConfig.devServer
51
52
  },
52
- site: userConfig.site
53
+ site: userConfig.site,
54
+ validations: userConfig.validations
53
55
  };
54
56
  } catch (error) {
55
57
  getLogger().warn(`Failed to parse .jay YAML config file, using defaults: ${error}`);
@@ -198,6 +200,13 @@ const generateContractDefinitionFiles$1 = /* @__PURE__ */ Object.freeze(/* @__PU
198
200
  __proto__: null,
199
201
  generateContractDefinitionFiles
200
202
  }, Symbol.toStringTag, { value: "Module" }));
203
+ function setWellKnownHeaders(res, filePath) {
204
+ if (filePath.includes(`${path$1.sep}.well-known${path$1.sep}`)) {
205
+ res.setHeader("Access-Control-Allow-Origin", "*");
206
+ if (filePath.endsWith(".md")) res.setHeader("Content-Type", "text/markdown; charset=utf-8");
207
+ if (filePath.endsWith(".gz")) res.setHeader("Content-Type", "application/gzip");
208
+ }
209
+ }
201
210
  async function startDevServer(options = {}) {
202
211
  const projectPath = options.projectPath || process.cwd();
203
212
  if (projectPath !== process.cwd()) {
@@ -213,7 +222,7 @@ async function startDevServer(options = {}) {
213
222
  const httpServer = http.createServer(app);
214
223
  const devServerPort = await getPort({ port: resolvedConfig.devServer.portRange });
215
224
  const log = getLogger();
216
- const { server, viteServer, routes, service } = await mkDevServer({
225
+ const { server, viteServer, routes, service, errorPageHandler } = await mkDevServer({
217
226
  pagesRootFolder: path$1.resolve(resolvedConfig.devServer.pagesBase),
218
227
  projectRootFolder: process.cwd(),
219
228
  publicBaseUrlPath: "/",
@@ -231,18 +240,20 @@ async function startDevServer(options = {}) {
231
240
  });
232
241
  }
233
242
  if (fs$1.existsSync(publicPath)) {
234
- app.use(express.static(publicPath));
243
+ app.use(express.static(publicPath, { dotfiles: "allow", setHeaders: setWellKnownHeaders }));
235
244
  } else {
236
245
  log.important(`⚠️ Public folder not found: ${resolvedConfig.devServer.publicFolder}`);
237
246
  }
247
+ const pageRouter = express.Router();
238
248
  routes.forEach((route) => {
239
- app.get(route.path, route.handler);
249
+ pageRouter.get(route.path, route.handler);
240
250
  });
241
251
  service.attachRouteRegistrar((added) => {
242
252
  added.forEach((route) => {
243
- app.get(route.path, route.handler);
253
+ pageRouter.get(route.path, route.handler);
244
254
  });
245
255
  });
256
+ app.use(pageRouter);
246
257
  void (async () => {
247
258
  await generatePageDefinitionFiles(
248
259
  routes.map((route) => route.fsRoute.jayHtmlPath),
@@ -297,6 +308,7 @@ async function startDevServer(options = {}) {
297
308
  }, options.timeout * 1e3);
298
309
  }
299
310
  }
311
+ app.use(errorPageHandler(404));
300
312
  process.on("SIGTERM", shutdown);
301
313
  process.on("SIGINT", shutdown);
302
314
  }
@@ -3003,6 +3015,61 @@ async function validateAiditorSettings(context, result) {
3003
3015
  });
3004
3016
  }
3005
3017
  }
3018
+ async function compileValidateRouteJayHtml(absoluteJayHtmlPath, pluginRoot) {
3019
+ const content = fs$1.readFileSync(absoluteJayHtmlPath, "utf-8");
3020
+ const dirname = path$1.dirname(absoluteJayHtmlPath);
3021
+ const filename = path$1.basename(absoluteJayHtmlPath, JAY_EXTENSION$1);
3022
+ const errors = [];
3023
+ try {
3024
+ const parsedFile = await parseJayFile(
3025
+ content,
3026
+ filename,
3027
+ dirname,
3028
+ {},
3029
+ JAY_IMPORT_RESOLVER,
3030
+ pluginRoot
3031
+ );
3032
+ for (const validation of parsedFile.validations) {
3033
+ errors.push(validation);
3034
+ }
3035
+ if (!parsedFile.val || parsedFile.validations.length > 0) {
3036
+ return errors;
3037
+ }
3038
+ const generatedFile = generateElementFile(parsedFile.val, RuntimeMode$1.MainTrusted);
3039
+ for (const validation of generatedFile.validations) {
3040
+ errors.push(validation);
3041
+ }
3042
+ const serverElementFile = generateServerElementFile(parsedFile.val);
3043
+ for (const validation of serverElementFile.validations) {
3044
+ errors.push(`[SSR] ${validation}`);
3045
+ }
3046
+ } catch (error) {
3047
+ const message = error instanceof Error ? error.message : String(error);
3048
+ errors.push(message);
3049
+ }
3050
+ return errors;
3051
+ }
3052
+ async function validateRouteJayHtmlTemplates(context, result) {
3053
+ const routes = context.manifest.routes;
3054
+ if (!routes || !Array.isArray(routes)) return;
3055
+ for (const route of routes) {
3056
+ if (!route.jayHtml) continue;
3057
+ const absolutePath = path$1.resolve(context.pluginPath, route.jayHtml);
3058
+ if (!fs$1.existsSync(absolutePath)) {
3059
+ continue;
3060
+ }
3061
+ const compileErrors = await compileValidateRouteJayHtml(absolutePath, context.pluginPath);
3062
+ const relPath = path$1.relative(context.pluginPath, absolutePath);
3063
+ for (const message of compileErrors) {
3064
+ result.errors.push({
3065
+ type: "jay-html-compile",
3066
+ message: `Route "${route.path}" jayHtml failed to compile: ${message}`,
3067
+ location: relPath,
3068
+ suggestion: "Fix the template syntax or run `jay-stack validate -p <dir>` on the source page during development. See agent-kit/designer/jay-html-template-syntax.md"
3069
+ });
3070
+ }
3071
+ }
3072
+ }
3006
3073
  async function validatePlugin(options = {}) {
3007
3074
  const pluginPath = options.pluginPath || process.cwd();
3008
3075
  if (options.local) {
@@ -3072,6 +3139,7 @@ async function validatePluginPackage(pluginPath, options) {
3072
3139
  await validateAddMenuCatalog(context, result);
3073
3140
  await validateAiditorSettings(context, result);
3074
3141
  validateNoCompilerLeak(context, result);
3142
+ await validateRouteJayHtmlTemplates(context, result);
3075
3143
  result.valid = result.errors.length === 0;
3076
3144
  return result;
3077
3145
  }
@@ -3926,6 +3994,20 @@ async function validatePackageJson(context, result) {
3926
3994
  suggestion: 'Add "./plugin.yaml": "./plugin.yaml" to exports field'
3927
3995
  });
3928
3996
  }
3997
+ if (context.manifest.commands) {
3998
+ for (const cmd of context.manifest.commands) {
3999
+ if (!cmd.command) continue;
4000
+ const exportKey = `./${cmd.command}`;
4001
+ if (!packageJson.exports || !packageJson.exports[exportKey]) {
4002
+ result.errors.push({
4003
+ type: "export-mismatch",
4004
+ message: `command metadata file "${cmd.command}" not exported in package.json (required for plugin resolution)`,
4005
+ location: packageJsonPath,
4006
+ suggestion: `Add "${exportKey}": "${exportKey}" to exports field`
4007
+ });
4008
+ }
4009
+ }
4010
+ }
3929
4011
  const agentKitDir = path$1.join(context.pluginPath, "agent-kit");
3930
4012
  if (fs$1.existsSync(agentKitDir) && fs$1.statSync(agentKitDir).isDirectory()) {
3931
4013
  const filesArray = packageJson.files;
@@ -4745,6 +4827,627 @@ function scopeInnerBody(block) {
4745
4827
  function normalizeCssBody(body) {
4746
4828
  return body.replace(/\s+/g, " ").replace(/\s*([{}:;,])\s*/g, "$1").replace(/;}/g, "}").trim();
4747
4829
  }
4830
+ const SUPPRESS_KEY = "allow-broken-links";
4831
+ const SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true\nin a <script type="application/jay-validations"> block';
4832
+ function normalizePath(p) {
4833
+ let s2 = p.split("#")[0].split("?")[0];
4834
+ s2 = s2.replace(/\/{2,}/g, "/");
4835
+ if (s2.length > 1) s2 = s2.replace(/\/+$/, "");
4836
+ return s2 === "" ? "/" : s2;
4837
+ }
4838
+ function escapeRegExp(s2) {
4839
+ return s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4840
+ }
4841
+ function routeToConcreteUrl(route) {
4842
+ const parts = [];
4843
+ for (const seg of route.segments) {
4844
+ if (typeof seg === "string") {
4845
+ parts.push(seg);
4846
+ } else {
4847
+ const v = route.inferredParams?.[seg.name];
4848
+ if (v === void 0) return null;
4849
+ parts.push(v);
4850
+ }
4851
+ }
4852
+ return normalizePath("/" + parts.join("/"));
4853
+ }
4854
+ function routeToRegex(route) {
4855
+ let pattern = "";
4856
+ for (const seg of route.segments) {
4857
+ if (typeof seg === "string") {
4858
+ pattern += "/" + escapeRegExp(seg);
4859
+ } else if (seg.type === JayRouteParamType.optional) {
4860
+ pattern += "(?:/[^/]+)?";
4861
+ } else if (seg.type === JayRouteParamType.catchAll) {
4862
+ pattern += "(?:/[^/]+)+";
4863
+ } else {
4864
+ pattern += "/[^/]+";
4865
+ }
4866
+ }
4867
+ if (pattern === "") pattern = "/";
4868
+ return new RegExp("^" + pattern + "$");
4869
+ }
4870
+ async function buildRouteOracle(pagesBase) {
4871
+ let routes = [];
4872
+ try {
4873
+ ({ routes } = await scanRoutes(pagesBase, {
4874
+ jayHtmlFilename: "page.jay-html",
4875
+ compFilename: "page.ts"
4876
+ }));
4877
+ } catch {
4878
+ routes = [];
4879
+ }
4880
+ const staticUrls = /* @__PURE__ */ new Set();
4881
+ const dynamicMatchers = [];
4882
+ const selfUrlByPath = /* @__PURE__ */ new Map();
4883
+ for (const route of routes) {
4884
+ const concrete = routeToConcreteUrl(route);
4885
+ if (concrete !== null) {
4886
+ staticUrls.add(concrete);
4887
+ if (route.jayHtmlPath) selfUrlByPath.set(path$1.resolve(route.jayHtmlPath), concrete);
4888
+ }
4889
+ if (route.segments.some((s2) => typeof s2 !== "string")) {
4890
+ dynamicMatchers.push(routeToRegex(route));
4891
+ }
4892
+ }
4893
+ return { staticUrls, dynamicMatchers, selfUrlByPath };
4894
+ }
4895
+ async function collectPublicAssets(publicFolder) {
4896
+ const assets = /* @__PURE__ */ new Set();
4897
+ async function walk(dir, prefix) {
4898
+ let entries;
4899
+ try {
4900
+ entries = await promises.readdir(dir, { withFileTypes: true });
4901
+ } catch {
4902
+ return;
4903
+ }
4904
+ for (const entry of entries) {
4905
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
4906
+ if (entry.isDirectory()) {
4907
+ await walk(path$1.join(dir, entry.name), rel);
4908
+ } else {
4909
+ assets.add("/" + rel);
4910
+ }
4911
+ }
4912
+ }
4913
+ await walk(publicFolder, "");
4914
+ return assets;
4915
+ }
4916
+ function originOf(baseUrl) {
4917
+ try {
4918
+ return new URL(baseUrl).origin;
4919
+ } catch {
4920
+ return void 0;
4921
+ }
4922
+ }
4923
+ function classifyHref(raw, baseUrl) {
4924
+ if (raw.includes("{")) return { kind: "skip" };
4925
+ const t = raw.trim();
4926
+ if (t === "" || t === "#") return { kind: "degenerate" };
4927
+ if (t.startsWith("#")) return { kind: "skip" };
4928
+ if (/^(mailto:|tel:|javascript:|data:|sms:|ftp:)/i.test(t)) return { kind: "skip" };
4929
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || t.startsWith("//")) {
4930
+ const sameOrigin = baseUrl ? originOf(baseUrl) : void 0;
4931
+ if (sameOrigin) {
4932
+ try {
4933
+ const u2 = new URL(t.startsWith("//") ? "https:" + t : t);
4934
+ if (u2.origin === sameOrigin)
4935
+ return { kind: "check", targetPath: normalizePath(u2.pathname) };
4936
+ } catch {
4937
+ }
4938
+ }
4939
+ return { kind: "skip" };
4940
+ }
4941
+ if (t.startsWith("/")) return { kind: "check", targetPath: normalizePath(t) };
4942
+ return { kind: "skip" };
4943
+ }
4944
+ function checkInternalLinks(options) {
4945
+ const { parsedFiles, oracle, assetUrls, baseUrl, projectRoot } = options;
4946
+ const findings = [];
4947
+ for (const { relativePath, parsed } of parsedFiles) {
4948
+ if (parsed.validationOverrides?.["jay-stack"]?.[SUPPRESS_KEY] === true) continue;
4949
+ const ownUrl = oracle.selfUrlByPath.get(path$1.resolve(projectRoot ?? ".", relativePath));
4950
+ for (const anchor of parsed.body.querySelectorAll("a[href]")) {
4951
+ const raw = anchor.getAttribute("href");
4952
+ if (raw === void 0 || raw === null) continue;
4953
+ const verdict = classifyHref(raw, baseUrl);
4954
+ if (verdict.kind === "skip") continue;
4955
+ if (verdict.kind === "degenerate") {
4956
+ findings.push({
4957
+ file: relativePath,
4958
+ message: `Anchor has a placeholder href "${raw}" — it links nowhere and ships as a dead link.`,
4959
+ suggestion: `Point the link at a real page, use <button> for a JS-driven action, or ${SUPPRESS_HINT}.`
4960
+ });
4961
+ continue;
4962
+ }
4963
+ const target = verdict.targetPath;
4964
+ if (ownUrl !== void 0 && target === ownUrl) {
4965
+ findings.push({
4966
+ file: relativePath,
4967
+ message: `Link points to the page's own route "${target}" — a self-link goes nowhere.`,
4968
+ suggestion: `Remove the link, use "#" within-page navigation, or point it at a different page.`
4969
+ });
4970
+ continue;
4971
+ }
4972
+ if (oracle.staticUrls.has(target)) continue;
4973
+ if (assetUrls.has(target)) continue;
4974
+ if (oracle.dynamicMatchers.some((re) => re.test(target))) continue;
4975
+ findings.push({
4976
+ file: relativePath,
4977
+ message: `Broken internal link "${raw}" — no page or public asset produces the URL "${target}".`,
4978
+ suggestion: `Create a placeholder page/route for "${target}", or remove the link. Otherwise ${SUPPRESS_HINT}.`
4979
+ });
4980
+ }
4981
+ }
4982
+ return findings;
4983
+ }
4984
+ const META_SUPPRESS_HINT = 'suppress with jay-stack:\n allow-meta-issues: true\nin the page <script type="application/jay-validations"> block';
4985
+ const LINK_SUPPRESS_HINT = 'suppress with jay-stack:\n allow-broken-links: true\nin the page <script type="application/jay-validations"> block';
4986
+ const UNRESOLVED_BINDING = /\{[A-Za-z_$][\w$]*(?:\.[\w$]+)*\}/;
4987
+ const TITLE_MAX = 60;
4988
+ const DESCRIPTION_MAX = 160;
4989
+ function buildUrlFromManifest(pattern, params) {
4990
+ return pattern.replace(/\[\[(\w+)\]\]/g, (_, name) => params[name] || "").replace(/\[\.\.\.(\w+)\]/g, (_, name) => params[name] || "").replace(/\[(\w+)\]/g, (_, name) => params[name] || "").replace(/\/\/+/g, "/").replace(/\/$/, "") || "/";
4991
+ }
4992
+ function routeInstanceUrl(route, instance) {
4993
+ return normalizePath(buildUrlFromManifest(route.pattern, instance.params));
4994
+ }
4995
+ async function discoverBuildBackendDir(projectRoot, explicit) {
4996
+ if (explicit) {
4997
+ const abs = path$1.resolve(projectRoot, explicit);
4998
+ if (await fileExists(path$1.join(abs, "route-manifest.json"))) return abs;
4999
+ const nested = path$1.join(abs, "backend");
5000
+ if (await fileExists(path$1.join(nested, "route-manifest.json"))) return nested;
5001
+ const fromRoot = await highestVersionBackend(abs);
5002
+ if (fromRoot) return fromRoot;
5003
+ return null;
5004
+ }
5005
+ return highestVersionBackend(path$1.join(projectRoot, "build"));
5006
+ }
5007
+ async function highestVersionBackend(buildRoot) {
5008
+ let entries;
5009
+ try {
5010
+ entries = (await promises.readdir(buildRoot, { withFileTypes: true })).filter((e2) => e2.isDirectory() && /^v/.test(e2.name)).map((e2) => e2.name);
5011
+ } catch {
5012
+ return null;
5013
+ }
5014
+ if (entries.length === 0) return null;
5015
+ entries.sort(compareVersionDesc);
5016
+ for (const name of entries) {
5017
+ const backend = path$1.join(buildRoot, name, "backend");
5018
+ if (await fileExists(path$1.join(backend, "route-manifest.json"))) return backend;
5019
+ }
5020
+ return null;
5021
+ }
5022
+ function compareVersionDesc(a, b) {
5023
+ const pa = a.replace(/^v/, "").split(".").map(Number);
5024
+ const pb = b.replace(/^v/, "").split(".").map(Number);
5025
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
5026
+ const da = pa[i] || 0;
5027
+ const db = pb[i] || 0;
5028
+ if (da !== db) return db - da;
5029
+ }
5030
+ return 0;
5031
+ }
5032
+ async function fileExists(p) {
5033
+ try {
5034
+ await promises.access(p);
5035
+ return true;
5036
+ } catch {
5037
+ return false;
5038
+ }
5039
+ }
5040
+ async function loadBuildManifest(backendDir) {
5041
+ let manifest;
5042
+ try {
5043
+ manifest = JSON.parse(
5044
+ await promises.readFile(path$1.join(backendDir, "route-manifest.json"), "utf8")
5045
+ );
5046
+ } catch {
5047
+ return null;
5048
+ }
5049
+ let metadata = null;
5050
+ try {
5051
+ metadata = JSON.parse(
5052
+ await promises.readFile(path$1.join(backendDir, "build-metadata.json"), "utf8")
5053
+ );
5054
+ } catch {
5055
+ metadata = null;
5056
+ }
5057
+ return { manifest, metadata };
5058
+ }
5059
+ function buildBuildOracle(manifest, backendDir) {
5060
+ const urls = /* @__PURE__ */ new Set();
5061
+ const instances = [];
5062
+ for (const route of manifest.routes) {
5063
+ const hasDynamic = route.segments.some((s2) => s2.type !== "static");
5064
+ if (route.instances.length === 0) {
5065
+ if (!hasDynamic) urls.add(normalizePath(route.pattern));
5066
+ continue;
5067
+ }
5068
+ for (const instance of route.instances) {
5069
+ const url = routeInstanceUrl(route, instance);
5070
+ urls.add(url);
5071
+ instances.push({
5072
+ url,
5073
+ cacheAbsPath: path$1.resolve(backendDir, instance.cachePath),
5074
+ pattern: route.pattern,
5075
+ headMeta: route.headMeta
5076
+ });
5077
+ }
5078
+ }
5079
+ return { urls, instances };
5080
+ }
5081
+ async function loadSlowViewState(cacheAbsPath) {
5082
+ try {
5083
+ const cache = JSON.parse(await promises.readFile(cacheAbsPath, "utf8"));
5084
+ return cache.slowViewState ?? {};
5085
+ } catch {
5086
+ return {};
5087
+ }
5088
+ }
5089
+ async function loadCarryForward(cacheAbsPath) {
5090
+ try {
5091
+ const cache = JSON.parse(await promises.readFile(cacheAbsPath, "utf8"));
5092
+ return cache.carryForward ?? {};
5093
+ } catch {
5094
+ return {};
5095
+ }
5096
+ }
5097
+ function jsonLdBodyError(body) {
5098
+ const text = body?.trim();
5099
+ if (!text) return "Empty JSON-LD block — add structured data or remove the tag.";
5100
+ let parsed;
5101
+ try {
5102
+ parsed = JSON.parse(text);
5103
+ } catch (e2) {
5104
+ return `Invalid JSON: ${e2.message}`;
5105
+ }
5106
+ const objs = Array.isArray(parsed) ? parsed : [parsed];
5107
+ for (const obj of objs) {
5108
+ if (!obj || typeof obj !== "object") {
5109
+ return "JSON-LD must be an object (or array of objects) with @context and @type.";
5110
+ }
5111
+ if (!("@context" in obj) || !("@type" in obj)) {
5112
+ return "JSON-LD is missing @context or @type.";
5113
+ }
5114
+ }
5115
+ return null;
5116
+ }
5117
+ function isLdJsonHeadTag(tag) {
5118
+ return tag?.tag?.toLowerCase?.() === "script" && tag?.attrs?.type === "application/ld+json";
5119
+ }
5120
+ function checkBuildJsonLd(manifest) {
5121
+ const findings = [];
5122
+ for (const tag of manifest.siteHeadTags ?? []) {
5123
+ if (!isLdJsonHeadTag(tag)) continue;
5124
+ const err = jsonLdBodyError(tag.children);
5125
+ if (err) {
5126
+ findings.push({
5127
+ file: ".jay (site.*)",
5128
+ message: `Site JSON-LD: ${err}`,
5129
+ severity: "error",
5130
+ source: "build-output"
5131
+ });
5132
+ }
5133
+ }
5134
+ for (const route of manifest.routes) {
5135
+ for (const script of route.scripts ?? []) {
5136
+ if (script.attributes?.type !== "application/ld+json") continue;
5137
+ const err = jsonLdBodyError(script.inline);
5138
+ if (err) {
5139
+ findings.push({
5140
+ file: route.pattern,
5141
+ message: `Template JSON-LD: ${err}`,
5142
+ severity: "error",
5143
+ source: "build-output"
5144
+ });
5145
+ }
5146
+ }
5147
+ }
5148
+ return findings;
5149
+ }
5150
+ function checkInstanceJsonLd(args) {
5151
+ const { instanceUrl, carryForward } = args;
5152
+ const findings = [];
5153
+ const slowHeadTags = carryForward?.__slowHeadTags;
5154
+ if (!Array.isArray(slowHeadTags)) return findings;
5155
+ for (const source of slowHeadTags) {
5156
+ if (!Array.isArray(source)) continue;
5157
+ for (const tag of source) {
5158
+ if (!isLdJsonHeadTag(tag)) continue;
5159
+ const err = jsonLdBodyError(tag?.children);
5160
+ if (err) {
5161
+ findings.push({
5162
+ file: instanceUrl,
5163
+ message: `Component JSON-LD: ${err}`,
5164
+ severity: "error",
5165
+ source: "build-output"
5166
+ });
5167
+ }
5168
+ }
5169
+ }
5170
+ return findings;
5171
+ }
5172
+ function checkTemplateLinksAgainstBuild(args) {
5173
+ const { parsedFiles, buildUrls, assetUrls, dynamicMatchers, baseUrl } = args;
5174
+ const findings = [];
5175
+ for (const { relativePath, parsed } of parsedFiles) {
5176
+ if (parsed.validationOverrides?.["jay-stack"]?.["allow-broken-links"] === true) continue;
5177
+ for (const anchor of parsed.body.querySelectorAll("a[href]")) {
5178
+ const raw = anchor.getAttribute("href");
5179
+ if (raw === void 0 || raw === null) continue;
5180
+ const verdict = classifyHref(raw, baseUrl);
5181
+ if (verdict.kind !== "check") continue;
5182
+ const target = verdict.targetPath;
5183
+ if (buildUrls.has(target) || assetUrls.has(target)) continue;
5184
+ if (!dynamicMatchers.some((re) => re.test(target))) continue;
5185
+ findings.push({
5186
+ file: relativePath,
5187
+ message: `Broken link "${raw}" — matches a dynamic route but "${target}" is not among the URLs this build produces.`,
5188
+ suggestion: `Create the slug/page, fix the path, or remove the link. Otherwise ${LINK_SUPPRESS_HINT}.`,
5189
+ severity: "error",
5190
+ source: "build-output"
5191
+ });
5192
+ }
5193
+ }
5194
+ return findings;
5195
+ }
5196
+ function resolveContentHref(raw, instanceUrl, baseUrl) {
5197
+ const t = raw.trim();
5198
+ if (t === "" || t === "#" || t.startsWith("#")) return { kind: "skip" };
5199
+ if (t.includes("{")) return { kind: "skip" };
5200
+ if (t.startsWith("/") || t.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(t)) {
5201
+ const v = classifyHref(t, baseUrl);
5202
+ return v.kind === "check" ? v : { kind: "skip" };
5203
+ }
5204
+ try {
5205
+ const u2 = new URL(t, "https://_local_" + (instanceUrl === "/" ? "/" : instanceUrl));
5206
+ return { kind: "check", targetPath: normalizePath(u2.pathname) };
5207
+ } catch {
5208
+ return { kind: "skip" };
5209
+ }
5210
+ }
5211
+ function collectContentHrefs(viewState) {
5212
+ const hrefs = [];
5213
+ const seen = /* @__PURE__ */ new Set();
5214
+ const walk = (node) => {
5215
+ if (node == null) return;
5216
+ if (typeof node === "string") {
5217
+ if (node.includes("<a ")) {
5218
+ const root = parse(node);
5219
+ for (const a of root.querySelectorAll("a[href]")) {
5220
+ const h = a.getAttribute("href");
5221
+ if (h) hrefs.push(h);
5222
+ }
5223
+ }
5224
+ return;
5225
+ }
5226
+ if (typeof node !== "object") return;
5227
+ if (seen.has(node)) return;
5228
+ seen.add(node);
5229
+ if (Array.isArray(node)) {
5230
+ for (const item of node) walk(item);
5231
+ } else {
5232
+ for (const v of Object.values(node)) walk(v);
5233
+ }
5234
+ };
5235
+ walk(viewState);
5236
+ return hrefs;
5237
+ }
5238
+ function checkContentLinks(args) {
5239
+ const { instanceUrl, slowViewState, buildUrls, assetUrls, baseUrl } = args;
5240
+ const findings = [];
5241
+ const reported = /* @__PURE__ */ new Set();
5242
+ for (const raw of collectContentHrefs(slowViewState)) {
5243
+ const verdict = resolveContentHref(raw, instanceUrl, baseUrl);
5244
+ if (verdict.kind !== "check") continue;
5245
+ const target = verdict.targetPath;
5246
+ if (buildUrls.has(target) || assetUrls.has(target)) continue;
5247
+ if (reported.has(target)) continue;
5248
+ reported.add(target);
5249
+ findings.push({
5250
+ file: instanceUrl,
5251
+ message: `Broken link "${raw}" in rendered content of ${instanceUrl} — resolves to "${target}", which this build does not produce.`,
5252
+ // Content links come from authored markdown/CMS data that often doubles as repo docs (where a
5253
+ // repo-relative "../pkg/foo.ts" is correct), so this is a warning, not a build-failing error —
5254
+ // unlike hand-authored template links (Check 1), which are site-only and stay errors.
5255
+ suggestion: `Fix the link in the source content, point it at an absolute URL, or ${LINK_SUPPRESS_HINT}.`,
5256
+ severity: "warning",
5257
+ source: "build-output"
5258
+ });
5259
+ }
5260
+ return findings;
5261
+ }
5262
+ function checkInstanceMeta(args) {
5263
+ const { instanceUrl, headMeta, slowViewState } = args;
5264
+ if (!headMeta) return [];
5265
+ const findings = [];
5266
+ const tags = headMetaToHeadTags(headMeta, slowViewState);
5267
+ const addLeftover = (what, value) => findings.push({
5268
+ file: instanceUrl,
5269
+ message: `<${what}> for ${instanceUrl} resolves to "${value}" — an unresolved binding was not produced by slow render.`,
5270
+ suggestion: `Ensure the field is produced by the slow phase, or make the ${what} static.`,
5271
+ severity: "error",
5272
+ source: "build-output"
5273
+ });
5274
+ const addWarn = (message) => findings.push({
5275
+ file: instanceUrl,
5276
+ message,
5277
+ suggestion: `Fix the content or ${META_SUPPRESS_HINT}.`,
5278
+ severity: "warning",
5279
+ source: "build-output"
5280
+ });
5281
+ for (const tag of tags) {
5282
+ if (tag.tag === "title") {
5283
+ const value = tag.children ?? "";
5284
+ if (UNRESOLVED_BINDING.test(value)) addLeftover("title", value);
5285
+ else if (value.trim() === "") addWarn(`<title> for ${instanceUrl} is empty.`);
5286
+ else if (value.length > TITLE_MAX)
5287
+ addWarn(
5288
+ `<title> for ${instanceUrl} is ${value.length} chars (recommend ≤ ${TITLE_MAX}).`
5289
+ );
5290
+ } else if (tag.tag === "meta" && tag.attrs?.name === "description") {
5291
+ const value = tag.attrs.content ?? "";
5292
+ if (UNRESOLVED_BINDING.test(value)) addLeftover('meta name="description"', value);
5293
+ else if (value.trim() === "")
5294
+ addWarn(`<meta name="description"> for ${instanceUrl} is empty.`);
5295
+ else if (value.length > DESCRIPTION_MAX)
5296
+ addWarn(
5297
+ `<meta name="description"> for ${instanceUrl} is ${value.length} chars (recommend ≤ ${DESCRIPTION_MAX}).`
5298
+ );
5299
+ }
5300
+ }
5301
+ return findings;
5302
+ }
5303
+ function isBuildStale(metadata, newestSourceMtimeMs) {
5304
+ if (!metadata?.buildTimestamp) return false;
5305
+ const built = Date.parse(metadata.buildTimestamp);
5306
+ if (Number.isNaN(built)) return false;
5307
+ return newestSourceMtimeMs > built;
5308
+ }
5309
+ async function newestSourceMtime(dirs) {
5310
+ let newest = 0;
5311
+ const walk = async (dir) => {
5312
+ let entries;
5313
+ try {
5314
+ entries = await promises.readdir(dir, { withFileTypes: true });
5315
+ } catch {
5316
+ return;
5317
+ }
5318
+ for (const entry of entries) {
5319
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
5320
+ const full = path$1.join(dir, entry.name);
5321
+ if (entry.isDirectory()) {
5322
+ await walk(full);
5323
+ } else {
5324
+ try {
5325
+ const st = await promises.stat(full);
5326
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
5327
+ } catch {
5328
+ }
5329
+ }
5330
+ }
5331
+ };
5332
+ for (const dir of dirs) await walk(dir);
5333
+ return newest;
5334
+ }
5335
+ const URN_PATTERN = /^urn:air:[^:\s]+:[^:\s]+:[^:\s]+$/;
5336
+ const MEDIA_TYPE_PATTERN = /^[a-z]+\/[A-Za-z0-9.+-]+$/;
5337
+ const ARD_SUGGEST = "See developer/seo-guide.md (ARD v0.91) for the schema and a worked example.";
5338
+ function validateArdDocument(doc) {
5339
+ const findings = [];
5340
+ if (!doc || typeof doc !== "object") {
5341
+ return [
5342
+ {
5343
+ message: "ard.json must be a JSON object with an `entries` array.",
5344
+ severity: "error",
5345
+ suggestion: ARD_SUGGEST
5346
+ }
5347
+ ];
5348
+ }
5349
+ const entries = doc.entries;
5350
+ if (!Array.isArray(entries)) {
5351
+ return [
5352
+ {
5353
+ message: "ard.json is missing the required `entries` array.",
5354
+ severity: "error",
5355
+ suggestion: ARD_SUGGEST
5356
+ }
5357
+ ];
5358
+ }
5359
+ entries.forEach((entry, i) => {
5360
+ const at = `ard.json entries[${i}]`;
5361
+ if (!entry || typeof entry !== "object") {
5362
+ findings.push({ message: `${at} must be an object.`, severity: "error" });
5363
+ return;
5364
+ }
5365
+ if (typeof entry.identifier !== "string" || !URN_PATTERN.test(entry.identifier)) {
5366
+ findings.push({
5367
+ message: `${at}.identifier must match urn:air:<publisher>:<namespace>:<name>.`,
5368
+ severity: "error",
5369
+ suggestion: ARD_SUGGEST
5370
+ });
5371
+ }
5372
+ if (typeof entry.displayName !== "string" || entry.displayName.trim() === "") {
5373
+ findings.push({
5374
+ message: `${at}.displayName is required.`,
5375
+ severity: "error"
5376
+ });
5377
+ }
5378
+ if (typeof entry.type !== "string" || !MEDIA_TYPE_PATTERN.test(entry.type)) {
5379
+ findings.push({
5380
+ message: `${at}.type must be an IANA media type (e.g. application/mcp-server-card+json).`,
5381
+ severity: "error"
5382
+ });
5383
+ }
5384
+ const hasUrl = entry.url !== void 0;
5385
+ const hasData = entry.data !== void 0;
5386
+ if (hasUrl === hasData) {
5387
+ findings.push({
5388
+ message: `${at} must have exactly one of \`url\` or \`data\`.`,
5389
+ severity: "error"
5390
+ });
5391
+ }
5392
+ const rq = entry.representativeQueries;
5393
+ if (rq === void 0) {
5394
+ findings.push({
5395
+ message: `${at} is missing representativeQueries (2–5 recommended) — agents may not surface this resource.`,
5396
+ severity: "warning",
5397
+ suggestion: ARD_SUGGEST
5398
+ });
5399
+ } else if (!Array.isArray(rq) || rq.length < 2 || rq.length > 5) {
5400
+ findings.push({
5401
+ message: `${at}.representativeQueries should list 2–5 example queries.`,
5402
+ severity: "warning"
5403
+ });
5404
+ }
5405
+ });
5406
+ return findings;
5407
+ }
5408
+ async function checkArdFiles(projectRoot) {
5409
+ const wellKnown = path$1.resolve(projectRoot, "public/.well-known");
5410
+ const ardPath = path$1.join(wellKnown, "ard.json");
5411
+ const aliasPath = path$1.join(wellKnown, "ai-catalog.json");
5412
+ const ardRaw = await readIfExists(ardPath);
5413
+ const aliasRaw = await readIfExists(aliasPath);
5414
+ if (ardRaw === null && aliasRaw === null) {
5415
+ return { findings: [], absent: true };
5416
+ }
5417
+ const findings = [];
5418
+ if (ardRaw === null && aliasRaw !== null) {
5419
+ findings.push({
5420
+ message: "Only public/.well-known/ai-catalog.json found — ARD renamed this to ard.json. Add ard.json (keep ai-catalog.json as a copy for older clients).",
5421
+ severity: "warning",
5422
+ suggestion: ARD_SUGGEST
5423
+ });
5424
+ } else if (ardRaw !== null && aliasRaw !== null && ardRaw.trim() !== aliasRaw.trim()) {
5425
+ findings.push({
5426
+ message: "public/.well-known/ard.json and ai-catalog.json differ — the alias should be a copy of ard.json.",
5427
+ severity: "warning"
5428
+ });
5429
+ }
5430
+ const canonicalRaw = ardRaw ?? aliasRaw;
5431
+ let parsed;
5432
+ try {
5433
+ parsed = JSON.parse(canonicalRaw);
5434
+ } catch (e2) {
5435
+ findings.push({
5436
+ message: `${ardRaw !== null ? "ard.json" : "ai-catalog.json"} is not valid JSON: ${e2.message}`,
5437
+ severity: "error"
5438
+ });
5439
+ return { findings, absent: false };
5440
+ }
5441
+ findings.push(...validateArdDocument(parsed));
5442
+ return { findings, absent: false };
5443
+ }
5444
+ async function readIfExists(p) {
5445
+ try {
5446
+ return await promises.readFile(p, "utf8");
5447
+ } catch {
5448
+ return null;
5449
+ }
5450
+ }
4748
5451
  async function findJayFiles(dir) {
4749
5452
  return await glob(`${dir}/**/*${JAY_EXTENSION$1}`);
4750
5453
  }
@@ -4946,6 +5649,17 @@ function resolveContractTag(contract, tagPath) {
4946
5649
  }
4947
5650
  return void 0;
4948
5651
  }
5652
+ function checkJsonLdScripts(jayHtml) {
5653
+ const errors = [];
5654
+ for (const script of jayHtml.scripts ?? []) {
5655
+ if (script.attributes?.type !== "application/ld+json") continue;
5656
+ const err = jsonLdBodyError(script.inline);
5657
+ if (err) {
5658
+ errors.push(`<script type="application/ld+json">: ${err} See designer/seo-guide.md.`);
5659
+ }
5660
+ }
5661
+ return errors;
5662
+ }
4949
5663
  function checkRefElementTypes(jayHtml, file) {
4950
5664
  const imports = jayHtml.headlessImports;
4951
5665
  const keyMap = /* @__PURE__ */ new Map();
@@ -5873,14 +6587,12 @@ async function validateJayFiles(options = {}) {
5873
6587
  const reuseByTemplate = /* @__PURE__ */ new Map();
5874
6588
  const cataloguedContractFiles = /* @__PURE__ */ new Set();
5875
6589
  const pageJayHtmlFiles = await findJayFiles(scanDir);
5876
- const componentJayHtmlFiles = await findJayFiles(componentsDir).catch(() => []);
6590
+ const componentJayHtmlFiles = options.compileOnly ? [] : await findJayFiles(componentsDir).catch(() => []);
5877
6591
  const jayHtmlFiles = [...pageJayHtmlFiles, ...componentJayHtmlFiles];
5878
6592
  const componentJayHtmlFileSet = new Set(componentJayHtmlFiles);
5879
- const componentContractFiles = await findContractFiles(componentsDir).catch(
5880
- () => []
5881
- );
6593
+ const componentContractFiles = options.compileOnly ? [] : await findContractFiles(componentsDir).catch(() => []);
5882
6594
  for (const cf of componentContractFiles) cataloguedContractFiles.add(cf);
5883
- const contractFiles = [...await findContractFiles(scanDir), ...componentContractFiles];
6595
+ const contractFiles = options.compileOnly ? [] : [...await findContractFiles(scanDir), ...componentContractFiles];
5884
6596
  const contractNameByFile = /* @__PURE__ */ new Map();
5885
6597
  if (options.verbose) {
5886
6598
  getLogger().info(chalk.gray(`Scanning directory: ${scanDir}`));
@@ -5947,6 +6659,35 @@ async function validateJayFiles(options = {}) {
5947
6659
  continue;
5948
6660
  }
5949
6661
  parsedFiles.push({ relativePath, parsed: parsedFile.val });
6662
+ if (options.compileOnly) {
6663
+ const generatedFile2 = generateElementFile(
6664
+ parsedFile.val,
6665
+ RuntimeMode$1.MainTrusted,
6666
+ GenerateTarget.jay
6667
+ );
6668
+ for (const validation of generatedFile2.validations) {
6669
+ errors.push({
6670
+ file: relativePath,
6671
+ message: validation,
6672
+ stage: "generate"
6673
+ });
6674
+ }
6675
+ const serverElementFile2 = generateServerElementFile(parsedFile.val);
6676
+ for (const validation of serverElementFile2.validations) {
6677
+ errors.push({
6678
+ file: relativePath,
6679
+ message: `[SSR] ${validation}`,
6680
+ stage: "generate"
6681
+ });
6682
+ }
6683
+ if (options.verbose) {
6684
+ const failed = generatedFile2.validations.length > 0 || serverElementFile2.validations.length > 0;
6685
+ getLogger().info(
6686
+ failed ? chalk.red(`❌ ${relativePath}`) : chalk.green(`✓ ${relativePath}`)
6687
+ );
6688
+ }
6689
+ continue;
6690
+ }
5950
6691
  if (content.includes("application/jay-params")) {
5951
6692
  errors.push({
5952
6693
  file: relativePath,
@@ -6038,6 +6779,9 @@ async function validateJayFiles(options = {}) {
6038
6779
  suggestion: finding.suggestion
6039
6780
  });
6040
6781
  }
6782
+ for (const message of checkJsonLdScripts(parsedFile.val)) {
6783
+ errors.push({ file: relativePath, message, stage: "generate" });
6784
+ }
6041
6785
  regionsSeen += collectRegionElements(parsedFile.val.body).length;
6042
6786
  templateImportsSeen += parsedFile.val.headlessImports.filter(
6043
6787
  (imp) => imp.template
@@ -6165,6 +6909,24 @@ async function validateJayFiles(options = {}) {
6165
6909
  }
6166
6910
  }
6167
6911
  }
6912
+ if (options.compileOnly) {
6913
+ return {
6914
+ valid: errors.length === 0,
6915
+ jayHtmlFilesScanned: jayHtmlFiles.length,
6916
+ contractFilesScanned: contractFiles.length,
6917
+ errors,
6918
+ warnings,
6919
+ coverage,
6920
+ pluginValidators: [],
6921
+ designSystemCoverage,
6922
+ designSystemReuse: {
6923
+ perTemplate: {},
6924
+ catalogued: 0,
6925
+ reusedMoreThanOnce: 0,
6926
+ reusedOfCatalogued: 0
6927
+ }
6928
+ };
6929
+ }
6168
6930
  const robotsTxtPath = path$1.resolve(projectRoot, "public/robots.txt");
6169
6931
  if (!fs$1.existsSync(robotsTxtPath)) {
6170
6932
  warnings.push({
@@ -6180,6 +6942,60 @@ async function validateJayFiles(options = {}) {
6180
6942
  suggestion: "Add to .jay config:\n site:\n baseUrl: https://your-domain.com"
6181
6943
  });
6182
6944
  }
6945
+ if (!config.site?.name) {
6946
+ warnings.push({
6947
+ file: ".jay",
6948
+ message: "site.name not configured — Organization/WebSite JSON-LD and llms.txt will not be emitted, reducing how well search engines and agents understand the site.",
6949
+ suggestion: "Add to .jay config:\n site:\n name: Your Site Name\n logo: /logo.png\n description: What your site is about"
6950
+ });
6951
+ }
6952
+ const allowNoArd = config.validations?.["allow-no-ard"] === true;
6953
+ const ardResult = await checkArdFiles(projectRoot);
6954
+ if (ardResult.absent) {
6955
+ if (!allowNoArd) {
6956
+ warnings.push({
6957
+ file: "public/.well-known/ard.json",
6958
+ message: "public/.well-known/ard.json not found — agents cannot discover this site’s resources/APIs via Agentic Resource Discovery.",
6959
+ suggestion: "Author public/.well-known/ard.json (ARD v0.91), or suppress with `validations: { allow-no-ard: true }` in .jay if the site has no agent resources to advertise. See developer/seo-guide.md."
6960
+ });
6961
+ }
6962
+ } else {
6963
+ for (const finding of ardResult.findings) {
6964
+ if (finding.severity === "error") {
6965
+ errors.push({
6966
+ file: "public/.well-known/ard.json",
6967
+ message: finding.message,
6968
+ stage: "generate",
6969
+ suggestion: finding.suggestion
6970
+ });
6971
+ } else {
6972
+ warnings.push({
6973
+ file: "public/.well-known/ard.json",
6974
+ message: finding.message,
6975
+ suggestion: finding.suggestion
6976
+ });
6977
+ }
6978
+ }
6979
+ }
6980
+ const allowNoErrorPages = config.validations?.["allow-no-error-pages"] === true;
6981
+ if (!allowNoErrorPages) {
6982
+ const scanResult = await scanRoutes(scanDir, {
6983
+ jayHtmlFilename: "page.jay-html",
6984
+ compFilename: "page.ts"
6985
+ });
6986
+ const errorPages = scanResult.errorPages ?? [];
6987
+ const tokens = new Set(errorPages.map((ep) => ep.token));
6988
+ const missing = [];
6989
+ if (!tokens.has("404") && !tokens.has("4xx")) missing.push("404");
6990
+ if (!tokens.has("5xx")) missing.push("5xx");
6991
+ if (missing.length > 0) {
6992
+ warnings.push({
6993
+ file: "src/pages",
6994
+ message: `No branded error page for ${missing.join(" and ")} — these responses fall back to plain text instead of your site's layout.`,
6995
+ suggestion: "Add pages/404/page.jay-html and pages/5xx/page.jay-html importing the `error` headless contract, or suppress with `validations: { allow-no-error-pages: true }` in .jay. See designer/error-pages-guide.md."
6996
+ });
6997
+ }
6998
+ }
6183
6999
  const allowNoTemplate = /* @__PURE__ */ new Set();
6184
7000
  let allowNoDesignSystem = false;
6185
7001
  for (const { parsed } of parsedFiles) {
@@ -6235,6 +7051,38 @@ async function validateJayFiles(options = {}) {
6235
7051
  (p) => (reuseByTemplate.get(p) ?? 0) >= 2
6236
7052
  ).length
6237
7053
  };
7054
+ const linkOracle = await buildRouteOracle(scanDir);
7055
+ const assetUrls = await collectPublicAssets(
7056
+ path$1.resolve(projectRoot, resolvedConfig.devServer.publicFolder)
7057
+ );
7058
+ const linkFindings = checkInternalLinks({
7059
+ parsedFiles,
7060
+ oracle: linkOracle,
7061
+ assetUrls,
7062
+ baseUrl: config.site?.baseUrl,
7063
+ projectRoot
7064
+ });
7065
+ for (const finding of linkFindings) {
7066
+ errors.push({
7067
+ file: finding.file,
7068
+ message: finding.message,
7069
+ stage: "generate",
7070
+ source: "internal-links",
7071
+ suggestion: finding.suggestion
7072
+ });
7073
+ }
7074
+ if (options.tier2) {
7075
+ await runBuildOutputValidation({
7076
+ projectRoot,
7077
+ buildDir: options.buildDir,
7078
+ parsedFiles,
7079
+ linkOracle,
7080
+ assetUrls,
7081
+ baseUrl: config.site?.baseUrl,
7082
+ errors,
7083
+ warnings
7084
+ });
7085
+ }
6238
7086
  const pluginValidators = await runPluginValidators(projectRoot, parsedFiles, errors, warnings);
6239
7087
  return {
6240
7088
  valid: errors.length === 0,
@@ -6248,6 +7096,92 @@ async function validateJayFiles(options = {}) {
6248
7096
  designSystemReuse
6249
7097
  };
6250
7098
  }
7099
+ async function runBuildOutputValidation(args) {
7100
+ const { projectRoot, buildDir, parsedFiles, linkOracle, assetUrls, baseUrl, errors, warnings } = args;
7101
+ const backendDir = await discoverBuildBackendDir(projectRoot, buildDir);
7102
+ if (!backendDir) {
7103
+ errors.push({
7104
+ file: "build",
7105
+ message: "Tier 2: no build found. Run `jay-stack build` first, or point --build-dir at a build.",
7106
+ stage: "generate",
7107
+ source: "build-output"
7108
+ });
7109
+ return;
7110
+ }
7111
+ const loaded = await loadBuildManifest(backendDir);
7112
+ if (!loaded) {
7113
+ errors.push({
7114
+ file: backendDir,
7115
+ message: `Tier 2: could not read route-manifest.json in ${backendDir}.`,
7116
+ stage: "generate",
7117
+ source: "build-output"
7118
+ });
7119
+ return;
7120
+ }
7121
+ const newest = await newestSourceMtime([
7122
+ path$1.join(projectRoot, "src"),
7123
+ path$1.join(projectRoot, "content")
7124
+ ]);
7125
+ if (isBuildStale(loaded.metadata, newest)) {
7126
+ warnings.push({
7127
+ file: "build",
7128
+ message: `Tier 2: validated against a build from ${loaded.metadata?.buildTimestamp}; source has changed since.`,
7129
+ source: "build-output",
7130
+ suggestion: "Re-run `jay-stack build` for accurate results."
7131
+ });
7132
+ }
7133
+ const oracle = buildBuildOracle(loaded.manifest, backendDir);
7134
+ const findings = [];
7135
+ findings.push(
7136
+ ...checkTemplateLinksAgainstBuild({
7137
+ parsedFiles,
7138
+ buildUrls: oracle.urls,
7139
+ assetUrls,
7140
+ dynamicMatchers: linkOracle.dynamicMatchers,
7141
+ baseUrl
7142
+ })
7143
+ );
7144
+ findings.push(...checkBuildJsonLd(loaded.manifest));
7145
+ for (const instance of oracle.instances) {
7146
+ const slowViewState = await loadSlowViewState(instance.cacheAbsPath);
7147
+ findings.push(
7148
+ ...checkContentLinks({
7149
+ instanceUrl: instance.url,
7150
+ slowViewState,
7151
+ buildUrls: oracle.urls,
7152
+ assetUrls,
7153
+ baseUrl
7154
+ })
7155
+ );
7156
+ findings.push(
7157
+ ...checkInstanceMeta({
7158
+ instanceUrl: instance.url,
7159
+ headMeta: instance.headMeta,
7160
+ slowViewState
7161
+ })
7162
+ );
7163
+ const carryForward = await loadCarryForward(instance.cacheAbsPath);
7164
+ findings.push(...checkInstanceJsonLd({ instanceUrl: instance.url, carryForward }));
7165
+ }
7166
+ for (const finding of findings) {
7167
+ if (finding.severity === "error") {
7168
+ errors.push({
7169
+ file: finding.file,
7170
+ message: finding.message,
7171
+ stage: "generate",
7172
+ source: "build-output",
7173
+ suggestion: finding.suggestion
7174
+ });
7175
+ } else {
7176
+ warnings.push({
7177
+ file: finding.file,
7178
+ message: finding.message,
7179
+ source: "build-output",
7180
+ suggestion: finding.suggestion
7181
+ });
7182
+ }
7183
+ }
7184
+ }
6251
7185
  function printJayValidationResult(result, options) {
6252
7186
  const logger = getLogger();
6253
7187
  if (options.json) {
@@ -6255,9 +7189,11 @@ function printJayValidationResult(result, options) {
6255
7189
  return;
6256
7190
  }
6257
7191
  logger.important("");
6258
- const coreErrors = result.errors.filter((e2) => e2.stage !== "plugin");
7192
+ const coreErrors = result.errors.filter(
7193
+ (e2) => e2.stage !== "plugin" && e2.source !== "build-output"
7194
+ );
6259
7195
  const coreWarnings = result.warnings.filter((w) => !w.source);
6260
- logger.important(chalk.bold("📦 jay-stack (core)"));
7196
+ logger.important(chalk.bold("📦 Tier 1 — jay-stack (core)"));
6261
7197
  if (coreErrors.length === 0) {
6262
7198
  logger.important(
6263
7199
  chalk.green(
@@ -6280,6 +7216,40 @@ function printJayValidationResult(result, options) {
6280
7216
  logger.important(chalk.blue(` Suggestion: ${warning.suggestion}`));
6281
7217
  }
6282
7218
  }
7219
+ const buildErrors = result.errors.filter((e2) => e2.source === "build-output");
7220
+ const buildWarnings = result.warnings.filter((w) => w.source === "build-output");
7221
+ if (buildErrors.length > 0 || buildWarnings.length > 0) {
7222
+ logger.important("");
7223
+ logger.important(chalk.bold("📦 Tier 2 — build-output validation (--tier-2)"));
7224
+ for (const error of buildErrors) {
7225
+ logger.important(chalk.red(` ❌ ${error.file}`));
7226
+ logger.important(chalk.gray(` ${error.message}`));
7227
+ if (error.suggestion) {
7228
+ logger.important(chalk.blue(` Suggestion: ${error.suggestion}`));
7229
+ }
7230
+ }
7231
+ const buildWarnGroups = /* @__PURE__ */ new Map();
7232
+ for (const warning of buildWarnings) {
7233
+ const group = buildWarnGroups.get(warning.file) || [];
7234
+ group.push(warning);
7235
+ buildWarnGroups.set(warning.file, group);
7236
+ }
7237
+ for (const [file, groupWarns] of buildWarnGroups) {
7238
+ logger.important(chalk.yellow(` ⚠ ${file}`));
7239
+ for (const warning of groupWarns) {
7240
+ if (warning.message) {
7241
+ logger.important(chalk.gray(` ${warning.message}`));
7242
+ }
7243
+ }
7244
+ const suggestions = [...new Set(groupWarns.map((w) => w.suggestion).filter(Boolean))];
7245
+ if (suggestions.length > 0) {
7246
+ logger.important(chalk.blue(` Suggestions:`));
7247
+ for (const s2 of suggestions) {
7248
+ logger.important(chalk.blue(` ${s2}`));
7249
+ }
7250
+ }
7251
+ }
7252
+ }
6283
7253
  const pluginErrors = result.errors.filter((e2) => e2.stage === "plugin");
6284
7254
  const pluginWarnings = result.warnings.filter((w) => !!w.source);
6285
7255
  for (const validatorName of result.pluginValidators) {
@@ -6422,12 +7392,23 @@ function printJayValidationResult(result, options) {
6422
7392
  )
6423
7393
  );
6424
7394
  }
7395
+ if (!options.tier2) {
7396
+ logger.important("");
7397
+ logger.important(
7398
+ chalk.gray(
7399
+ "ℹ 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"
7400
+ )
7401
+ );
7402
+ }
6425
7403
  }
6426
7404
  async function runValidate(scanPath, options) {
6427
7405
  const result = await validateJayFiles({
6428
7406
  path: scanPath,
6429
7407
  verbose: options.verbose,
6430
- json: options.json
7408
+ json: options.json,
7409
+ tier2: options.tier2,
7410
+ buildDir: options.buildDir,
7411
+ compileOnly: options.compileOnly
6431
7412
  });
6432
7413
  printJayValidationResult(result, options);
6433
7414
  if (!result.valid) {
@@ -6533,10 +7514,12 @@ async function resolveProductionContext(projectPath, versionOverride) {
6533
7514
  const jayConfigPath = path__default.join(resolvedPath, ".jay");
6534
7515
  let pagesBase = "./src/pages";
6535
7516
  let siteBaseUrl;
7517
+ let site;
6536
7518
  try {
6537
7519
  const jayConfig = YAML.parse(await fs$2.readFile(jayConfigPath, "utf-8"));
6538
7520
  pagesBase = jayConfig?.devServer?.pagesBase || pagesBase;
6539
7521
  siteBaseUrl = jayConfig?.site?.baseUrl;
7522
+ site = jayConfig?.site;
6540
7523
  } catch {
6541
7524
  }
6542
7525
  const version = versionOverride || await resolveVersionFromPackageJson(resolvedPath);
@@ -6546,7 +7529,8 @@ async function resolveProductionContext(projectPath, versionOverride) {
6546
7529
  buildRoot: path__default.join(resolvedPath, "build"),
6547
7530
  version,
6548
7531
  tsConfigFilePath: path__default.join(resolvedPath, "tsconfig.json"),
6549
- siteBaseUrl
7532
+ siteBaseUrl,
7533
+ site
6550
7534
  };
6551
7535
  }
6552
7536
  async function resolveVersionFromPackageJson(projectRoot) {
@@ -6574,7 +7558,7 @@ async function runBuild(projectPath, options) {
6574
7558
  }
6575
7559
  const ctx = await resolveProductionContext(projectPath, options.version);
6576
7560
  await generateDefinitionFiles(ctx);
6577
- const { buildVersion } = await import("./index-l6QVpvnY.js");
7561
+ const { buildVersion } = await import("./index-B5oavmfA.js");
6578
7562
  await buildVersion({
6579
7563
  version: ctx.version,
6580
7564
  projectRoot: ctx.resolvedPath,
@@ -6583,7 +7567,8 @@ async function runBuild(projectPath, options) {
6583
7567
  concurrency: 4,
6584
7568
  tsConfigFilePath: ctx.tsConfigFilePath,
6585
7569
  minify: options.minify,
6586
- siteBaseUrl: ctx.siteBaseUrl
7570
+ siteBaseUrl: ctx.siteBaseUrl,
7571
+ site: ctx.site
6587
7572
  });
6588
7573
  }
6589
7574
  async function generateDefinitionFiles(ctx) {
@@ -6650,7 +7635,8 @@ async function runRebuild(projectPath, options) {
6650
7635
  version: ctx.version,
6651
7636
  target,
6652
7637
  tsConfigFilePath: ctx.tsConfigFilePath,
6653
- siteBaseUrl: ctx.siteBaseUrl
7638
+ siteBaseUrl: ctx.siteBaseUrl,
7639
+ site: ctx.site
6654
7640
  });
6655
7641
  if (result.errors.length > 0) {
6656
7642
  for (const err of result.errors) {
@@ -7694,9 +8680,24 @@ program.command("cleanup").description("Delete orphaned files from previous rebu
7694
8680
  process.exit(1);
7695
8681
  }
7696
8682
  });
7697
- 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").action(async (options) => {
8683
+ 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(
8684
+ new Option(
8685
+ "--tier-2",
8686
+ "Run Tier 2: deep validation against an existing build (dynamic-slug links, rendered content, per-instance meta)"
8687
+ )
8688
+ ).addOption(new Option("--t2", "Alias for --tier-2").hideHelp()).option(
8689
+ "--build-dir <dir>",
8690
+ "Build backend dir or build root for Tier 2 (default: highest build/v*)"
8691
+ ).option(
8692
+ "--compile-only",
8693
+ "Parse and compile .jay-html only (no project rules — for plugin route source checks)"
8694
+ ).action(async (options) => {
7698
8695
  try {
7699
- await runValidate(options.path, options);
8696
+ await runValidate(options.path, {
8697
+ ...options,
8698
+ tier2: options.tier2 || options.t2,
8699
+ compileOnly: options.compileOnly
8700
+ });
7700
8701
  } catch (error) {
7701
8702
  if (options.json) {
7702
8703
  getLogger().important(
@@ -7758,6 +8759,7 @@ export {
7758
8759
  listContracts,
7759
8760
  loadConfig,
7760
8761
  materializeContracts,
8762
+ setWellKnownHeaders,
7761
8763
  startDevServer,
7762
8764
  updateConfig
7763
8765
  };