@schemasentry/cli 0.4.0 → 0.6.0

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.
Files changed (3) hide show
  1. package/README.md +138 -12
  2. package/dist/index.js +1836 -1182
  3. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -1,367 +1,584 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { Command as Command6 } from "commander";
5
+
6
+ // src/commands/validate.ts
4
7
  import { Command } from "commander";
5
- import { mkdir, readFile, writeFile } from "fs/promises";
6
- import { readFileSync } from "fs";
7
- import path5 from "path";
8
- import { stableStringify as stableStringify3 } from "@schemasentry/core";
8
+ import { mkdir, readFile, writeFile, access } from "fs/promises";
9
+ import path4 from "path";
10
+ import chalk from "chalk";
11
+ import { stableStringify as stableStringify4 } from "@schemasentry/core";
9
12
 
10
- // src/report.ts
11
- import {
12
- stableStringify,
13
- validateSchema
14
- } from "@schemasentry/core";
13
+ // src/html.ts
14
+ var escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
15
+ var renderRouteIssues = (route) => {
16
+ if (route.issues.length === 0) {
17
+ return '<p class="muted">No issues.</p>';
18
+ }
19
+ const items = route.issues.map((issue) => {
20
+ const severityClass = issue.severity === "error" ? "sev-error" : "sev-warn";
21
+ return `<li class="${severityClass}">
22
+ <span class="sev">${escapeHtml(issue.severity.toUpperCase())}</span>
23
+ <code>${escapeHtml(issue.ruleId)}</code>
24
+ <span>${escapeHtml(issue.message)}</span>
25
+ <small>${escapeHtml(issue.path)}</small>
26
+ </li>`;
27
+ }).join("");
28
+ return `<ul class="issues">${items}</ul>`;
29
+ };
30
+ var renderRoute = (route) => {
31
+ const statusClass = route.ok ? "ok" : "fail";
32
+ const expected = route.expectedTypes.length ? route.expectedTypes.join(", ") : "(none)";
33
+ const found = route.foundTypes.length ? route.foundTypes.join(", ") : "(none)";
34
+ return `<section class="route">
35
+ <header>
36
+ <h3>${escapeHtml(route.route)}</h3>
37
+ <span class="badge ${statusClass}">${route.ok ? "OK" : "FAIL"}</span>
38
+ </header>
39
+ <p><strong>Score:</strong> ${route.score}</p>
40
+ <p><strong>Expected types:</strong> ${escapeHtml(expected)}</p>
41
+ <p><strong>Found types:</strong> ${escapeHtml(found)}</p>
42
+ ${renderRouteIssues(route)}
43
+ </section>`;
44
+ };
45
+ var renderCoverage = (report) => {
46
+ if (!report.summary.coverage) {
47
+ return "<li><strong>Coverage:</strong> not enabled</li>";
48
+ }
49
+ const coverage = report.summary.coverage;
50
+ return `<li><strong>Coverage:</strong> missing_routes=${coverage.missingRoutes} missing_types=${coverage.missingTypes} unlisted_routes=${coverage.unlistedRoutes}</li>`;
51
+ };
52
+ var renderHtmlReport = (report, options) => {
53
+ const title = escapeHtml(options.title);
54
+ const generatedAt = escapeHtml(
55
+ (options.generatedAt ?? /* @__PURE__ */ new Date()).toISOString()
56
+ );
57
+ const routes = report.routes.map(renderRoute).join("");
58
+ return `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8" />
62
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
63
+ <title>${title}</title>
64
+ <style>
65
+ :root { color-scheme: light dark; }
66
+ body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; margin: 24px; line-height: 1.45; }
67
+ h1, h2, h3 { margin: 0 0 8px; }
68
+ .muted { color: #666; }
69
+ .summary, .routes { margin-top: 20px; }
70
+ .route { border: 1px solid #ddd; border-radius: 8px; padding: 12px; margin: 12px 0; }
71
+ .route header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
72
+ .badge { padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
73
+ .badge.ok { background: #dcfce7; color: #166534; }
74
+ .badge.fail { background: #fee2e2; color: #991b1b; }
75
+ .issues { margin: 10px 0 0; padding-left: 16px; }
76
+ .issues li { margin: 8px 0; display: grid; gap: 2px; }
77
+ .sev { font-weight: 700; }
78
+ .sev-error .sev { color: #991b1b; }
79
+ .sev-warn .sev { color: #92400e; }
80
+ code { background: #f5f5f5; padding: 1px 5px; border-radius: 4px; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <h1>${title}</h1>
85
+ <p class="muted">Generated at ${generatedAt}</p>
15
86
 
16
- // src/coverage.ts
17
- var buildCoverageResult = (input2, data) => {
18
- const manifestRoutes = input2.expectedTypesByRoute ?? {};
19
- const dataRoutes = data.routes ?? {};
20
- const derivedRequiredRoutes = Object.entries(manifestRoutes).filter(([, types]) => (types ?? []).length > 0).map(([route]) => route);
21
- const requiredRoutes = /* @__PURE__ */ new Set([
22
- ...input2.requiredRoutes ?? [],
23
- ...derivedRequiredRoutes
24
- ]);
25
- const allRoutes = Array.from(
26
- /* @__PURE__ */ new Set([
27
- ...Object.keys(manifestRoutes),
28
- ...requiredRoutes,
29
- ...Object.keys(dataRoutes)
30
- ])
31
- ).sort();
32
- const issuesByRoute = {};
33
- const summary = {
34
- missingRoutes: 0,
35
- missingTypes: 0,
36
- unlistedRoutes: 0
37
- };
38
- for (const route of allRoutes) {
39
- const issues = [];
40
- const expectedTypes = manifestRoutes[route] ?? [];
41
- const nodes = dataRoutes[route] ?? [];
42
- const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
43
- if (requiredRoutes.has(route) && nodes.length === 0) {
44
- issues.push({
45
- path: `routes["${route}"]`,
46
- message: "No schema blocks found for route",
47
- severity: "error",
48
- ruleId: "coverage.missing_route"
49
- });
50
- summary.missingRoutes += 1;
51
- }
52
- for (const expectedType of expectedTypes) {
53
- if (!foundTypes.includes(expectedType)) {
54
- issues.push({
55
- path: `routes["${route}"].types`,
56
- message: `Missing expected schema type '${expectedType}'`,
57
- severity: "error",
58
- ruleId: "coverage.missing_type"
59
- });
60
- summary.missingTypes += 1;
61
- }
62
- }
63
- if (!manifestRoutes[route] && !requiredRoutes.has(route) && nodes.length > 0) {
64
- issues.push({
65
- path: `routes["${route}"]`,
66
- message: "Route has schema but is missing from manifest",
67
- severity: "warn",
68
- ruleId: "coverage.unlisted_route"
69
- });
70
- summary.unlistedRoutes += 1;
71
- }
72
- if (issues.length > 0) {
73
- issuesByRoute[route] = issues;
87
+ <section class="summary">
88
+ <h2>Summary</h2>
89
+ <ul>
90
+ <li><strong>OK:</strong> ${report.ok}</li>
91
+ <li><strong>Routes:</strong> ${report.summary.routes}</li>
92
+ <li><strong>Errors:</strong> ${report.summary.errors}</li>
93
+ <li><strong>Warnings:</strong> ${report.summary.warnings}</li>
94
+ <li><strong>Score:</strong> ${report.summary.score}</li>
95
+ ${renderCoverage(report)}
96
+ </ul>
97
+ </section>
98
+
99
+ <section class="routes">
100
+ <h2>Routes</h2>
101
+ ${routes}
102
+ </section>
103
+ </body>
104
+ </html>`;
105
+ };
106
+
107
+ // src/annotations.ts
108
+ var escapeCommandValue = (value) => value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
109
+ var escapeCommandProperty = (value) => escapeCommandValue(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
110
+ var formatIssueMessage = (route, issue) => `[${route}] ${issue.ruleId}: ${issue.message} (${issue.path})`;
111
+ var buildGitHubAnnotationLines = (report, commandLabel) => {
112
+ const title = escapeCommandProperty(`Schema Sentry ${commandLabel}`);
113
+ const lines = [];
114
+ for (const route of report.routes) {
115
+ for (const issue of route.issues) {
116
+ const level = issue.severity === "error" ? "error" : "warning";
117
+ const message = escapeCommandValue(formatIssueMessage(route.route, issue));
118
+ lines.push(`::${level} title=${title}::${message}`);
74
119
  }
75
120
  }
76
- return {
77
- allRoutes,
78
- issuesByRoute,
79
- summary
80
- };
121
+ return lines;
122
+ };
123
+ var emitGitHubAnnotations = (report, commandLabel) => {
124
+ const lines = buildGitHubAnnotationLines(report, commandLabel);
125
+ for (const line of lines) {
126
+ console.error(line);
127
+ }
81
128
  };
82
129
 
83
- // src/report.ts
84
- var buildReport = (manifest, data, options = {}) => {
85
- const manifestRoutes = manifest.routes ?? {};
86
- const dataRoutes = data.routes ?? {};
87
- const coverage = buildCoverageResult(
88
- { expectedTypesByRoute: manifestRoutes },
89
- data
90
- );
91
- const routes = coverage.allRoutes.map((route) => {
92
- const expectedTypes = manifestRoutes[route] ?? [];
93
- const nodes = dataRoutes[route] ?? [];
94
- const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
95
- const validation = validateSchema(nodes, options);
96
- const issues = [
97
- ...validation.issues,
98
- ...coverage.issuesByRoute[route] ?? []
99
- ];
100
- const errorCount = issues.filter((issue) => issue.severity === "error").length;
101
- const warnCount = issues.filter((issue) => issue.severity === "warn").length;
102
- const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
103
- return {
104
- route,
105
- ok: errorCount === 0,
106
- score,
107
- issues,
108
- expectedTypes,
109
- foundTypes
110
- };
111
- });
112
- const summaryErrors = routes.reduce(
113
- (count, route) => count + route.issues.filter((i) => i.severity === "error").length,
114
- 0
115
- );
116
- const summaryWarnings = routes.reduce(
117
- (count, route) => count + route.issues.filter((i) => i.severity === "warn").length,
118
- 0
119
- );
120
- const summaryScore = routes.length === 0 ? 0 : Math.round(
121
- routes.reduce((total, route) => total + route.score, 0) / routes.length
122
- );
123
- return {
124
- ok: summaryErrors === 0,
125
- summary: {
126
- routes: routes.length,
127
- errors: summaryErrors,
128
- warnings: summaryWarnings,
129
- score: summaryScore,
130
- coverage: coverage.summary
131
- },
132
- routes
133
- };
130
+ // src/summary.ts
131
+ var formatDuration = (durationMs) => {
132
+ if (!Number.isFinite(durationMs) || durationMs < 0) {
133
+ return "0ms";
134
+ }
135
+ if (durationMs < 1e3) {
136
+ return `${Math.round(durationMs)}ms`;
137
+ }
138
+ const seconds = durationMs / 1e3;
139
+ return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
134
140
  };
135
141
 
136
- // src/init.ts
142
+ // src/reality.ts
143
+ import {
144
+ validateSchema
145
+ } from "@schemasentry/core";
146
+
147
+ // src/collect.ts
137
148
  import { promises as fs } from "fs";
138
149
  import path from "path";
139
- import { SCHEMA_CONTEXT } from "@schemasentry/core";
140
- var DEFAULT_ANSWERS = {
141
- siteName: "Acme Corp",
142
- siteUrl: "https://acme.com",
143
- authorName: "Jane Doe"
144
- };
145
- var getDefaultAnswers = () => ({ ...DEFAULT_ANSWERS });
146
- var buildManifest = (scannedRoutes = []) => {
147
- const routes = {
148
- "/": ["Organization", "WebSite"],
149
- "/blog/[slug]": ["Article"]
150
- };
151
- for (const route of scannedRoutes) {
152
- if (!routes[route]) {
153
- routes[route] = ["WebPage"];
154
- }
155
- }
156
- return { routes };
157
- };
158
- var formatDate = (value) => value.toISOString().slice(0, 10);
159
- var buildData = (answers, options = {}) => {
160
- const { siteName, siteUrl, authorName } = answers;
161
- const normalizedSiteUrl = normalizeUrl(siteUrl);
162
- const today = options.today ?? /* @__PURE__ */ new Date();
163
- const date = formatDate(today);
164
- const logoUrl = new URL("/logo.png", normalizedSiteUrl).toString();
165
- const articleUrl = new URL("/blog/hello-world", normalizedSiteUrl).toString();
166
- const imageUrl = new URL("/blog/hello-world.png", normalizedSiteUrl).toString();
167
- const routes = {
168
- "/": [
169
- {
170
- "@context": SCHEMA_CONTEXT,
171
- "@type": "Organization",
172
- name: siteName,
173
- url: normalizedSiteUrl,
174
- logo: logoUrl,
175
- description: `Official website of ${siteName}`
176
- },
177
- {
178
- "@context": SCHEMA_CONTEXT,
179
- "@type": "WebSite",
180
- name: siteName,
181
- url: normalizedSiteUrl,
182
- description: `Learn more about ${siteName}`
183
- }
184
- ],
185
- "/blog/[slug]": [
186
- {
187
- "@context": SCHEMA_CONTEXT,
188
- "@type": "Article",
189
- headline: "Hello World",
190
- author: {
191
- "@type": "Person",
192
- name: authorName
193
- },
194
- datePublished: date,
195
- dateModified: date,
196
- description: `An introduction to ${siteName}`,
197
- image: imageUrl,
198
- url: articleUrl
199
- }
200
- ]
201
- };
202
- for (const route of options.scannedRoutes ?? []) {
203
- if (routes[route]) {
150
+ import { stableStringify } from "@schemasentry/core";
151
+ var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "node_modules", ".pnpm-store"]);
152
+ var SCRIPT_TAG_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
153
+ var JSON_LD_TYPE_REGEX = /\btype\s*=\s*(?:"application\/ld\+json"|'application\/ld\+json'|application\/ld\+json)/i;
154
+ var collectSchemaData = async (options) => {
155
+ const rootDir = path.resolve(options.rootDir);
156
+ const requestedRoutes = normalizeRouteFilter(options.routes ?? []);
157
+ const htmlFiles = (await walkHtmlFiles(rootDir)).sort((a, b) => a.localeCompare(b));
158
+ const routes = {};
159
+ const warnings = [];
160
+ let blockCount = 0;
161
+ let invalidBlocks = 0;
162
+ for (const filePath of htmlFiles) {
163
+ const route = filePathToRoute(rootDir, filePath);
164
+ if (!route) {
204
165
  continue;
205
166
  }
206
- routes[route] = [
207
- {
208
- "@context": SCHEMA_CONTEXT,
209
- "@type": "WebPage",
210
- name: routeToName(route),
211
- url: new URL(route, normalizedSiteUrl).toString()
167
+ const html = await fs.readFile(filePath, "utf8");
168
+ const extracted = extractSchemaNodes(html, filePath);
169
+ if (extracted.nodes.length > 0) {
170
+ routes[route] = [...routes[route] ?? [], ...extracted.nodes];
171
+ blockCount += extracted.nodes.length;
172
+ }
173
+ invalidBlocks += extracted.invalidBlocks;
174
+ warnings.push(...extracted.warnings);
175
+ }
176
+ const missingRoutes = [];
177
+ const filteredRoutes = requestedRoutes.length > 0 ? filterRoutesByAllowlist(routes, requestedRoutes) : routes;
178
+ if (requestedRoutes.length > 0) {
179
+ for (const route of requestedRoutes) {
180
+ if (!Object.prototype.hasOwnProperty.call(filteredRoutes, route)) {
181
+ missingRoutes.push(route);
212
182
  }
213
- ];
183
+ }
214
184
  }
215
- return { routes };
185
+ const filteredBlockCount = Object.values(filteredRoutes).reduce(
186
+ (total, nodes) => total + nodes.length,
187
+ 0
188
+ );
189
+ return {
190
+ data: {
191
+ routes: sortRoutes(filteredRoutes)
192
+ },
193
+ stats: {
194
+ htmlFiles: htmlFiles.length,
195
+ routes: Object.keys(filteredRoutes).length,
196
+ blocks: filteredBlockCount,
197
+ invalidBlocks
198
+ },
199
+ warnings,
200
+ requestedRoutes,
201
+ missingRoutes
202
+ };
216
203
  };
217
- var writeInitFiles = async (options) => {
218
- const {
219
- manifestPath,
220
- dataPath,
221
- overwriteManifest,
222
- overwriteData,
223
- answers,
224
- today,
225
- scannedRoutes
226
- } = options;
227
- const manifest = buildManifest(scannedRoutes ?? []);
228
- const data = buildData(answers, { today, scannedRoutes });
229
- const manifestResult = await writeJsonFile(
230
- manifestPath,
231
- manifest,
232
- overwriteManifest
204
+ var compareSchemaData = (existing, collected) => {
205
+ const existingRoutes = existing.routes ?? {};
206
+ const collectedRoutes = collected.routes ?? {};
207
+ const existingKeys = Object.keys(existingRoutes);
208
+ const collectedKeys = Object.keys(collectedRoutes);
209
+ const addedRoutes = collectedKeys.filter((route) => !Object.prototype.hasOwnProperty.call(existingRoutes, route)).sort();
210
+ const removedRoutes = existingKeys.filter((route) => !Object.prototype.hasOwnProperty.call(collectedRoutes, route)).sort();
211
+ const changedRoutes = existingKeys.filter((route) => Object.prototype.hasOwnProperty.call(collectedRoutes, route)).filter(
212
+ (route) => stableStringify(existingRoutes[route]) !== stableStringify(collectedRoutes[route])
213
+ ).sort();
214
+ const changedRouteDetails = changedRoutes.map(
215
+ (route) => buildRouteDriftDetail(route, existingRoutes[route] ?? [], collectedRoutes[route] ?? [])
233
216
  );
234
- const dataResult = await writeJsonFile(dataPath, data, overwriteData);
235
217
  return {
236
- manifest: manifestResult,
237
- data: dataResult
218
+ hasChanges: addedRoutes.length > 0 || removedRoutes.length > 0 || changedRoutes.length > 0,
219
+ addedRoutes,
220
+ removedRoutes,
221
+ changedRoutes,
222
+ changedRouteDetails
238
223
  };
239
224
  };
240
- var writeJsonFile = async (filePath, payload, overwrite) => {
241
- const exists = await fileExists(filePath);
242
- if (exists && !overwrite) {
243
- return "skipped";
225
+ var formatSchemaDataDrift = (drift, maxRoutes = 5) => {
226
+ if (!drift.hasChanges) {
227
+ return "No schema data drift detected.";
244
228
  }
245
- await fs.mkdir(path.dirname(filePath), { recursive: true });
246
- const json = JSON.stringify(payload, null, 2);
247
- await fs.writeFile(filePath, `${json}
248
- `, "utf8");
249
- return exists ? "overwritten" : "created";
229
+ const lines = [
230
+ `Schema data drift detected: added_routes=${drift.addedRoutes.length} removed_routes=${drift.removedRoutes.length} changed_routes=${drift.changedRoutes.length}`
231
+ ];
232
+ if (drift.addedRoutes.length > 0) {
233
+ lines.push(formatRoutePreview("Added routes", drift.addedRoutes, maxRoutes));
234
+ }
235
+ if (drift.removedRoutes.length > 0) {
236
+ lines.push(formatRoutePreview("Removed routes", drift.removedRoutes, maxRoutes));
237
+ }
238
+ if (drift.changedRoutes.length > 0) {
239
+ lines.push(formatRoutePreview("Changed routes", drift.changedRoutes, maxRoutes));
240
+ const details = drift.changedRouteDetails.slice(0, maxRoutes).map((detail) => formatRouteDriftDetail(detail));
241
+ if (details.length > 0) {
242
+ lines.push("Changed route details:");
243
+ for (const detail of details) {
244
+ lines.push(`- ${detail}`);
245
+ }
246
+ }
247
+ }
248
+ return lines.join("\n");
250
249
  };
251
- var fileExists = async (filePath) => {
252
- try {
253
- await fs.access(filePath);
254
- return true;
255
- } catch {
256
- return false;
250
+ var formatRoutePreview = (label, routes, maxRoutes) => {
251
+ const preview = routes.slice(0, maxRoutes);
252
+ const suffix = routes.length > maxRoutes ? ` (+${routes.length - maxRoutes} more)` : "";
253
+ return `${label}: ${preview.join(", ")}${suffix}`;
254
+ };
255
+ var formatRouteDriftDetail = (detail) => {
256
+ const added = detail.addedTypes.length > 0 ? detail.addedTypes.join(",") : "(none)";
257
+ const removed = detail.removedTypes.length > 0 ? detail.removedTypes.join(",") : "(none)";
258
+ return `${detail.route} blocks ${detail.beforeBlocks}->${detail.afterBlocks} | +types ${added} | -types ${removed}`;
259
+ };
260
+ var sortRoutes = (routes) => Object.fromEntries(
261
+ Object.entries(routes).sort(([a], [b]) => a.localeCompare(b))
262
+ );
263
+ var filterRoutesByAllowlist = (routes, allowlist) => {
264
+ const filtered = {};
265
+ for (const route of allowlist) {
266
+ if (Object.prototype.hasOwnProperty.call(routes, route)) {
267
+ filtered[route] = routes[route];
268
+ }
257
269
  }
270
+ return filtered;
258
271
  };
259
- var normalizeUrl = (value) => {
260
- try {
261
- return new URL(value).toString();
262
- } catch {
263
- return new URL(`https://${value}`).toString();
272
+ var normalizeRouteFilter = (input2) => {
273
+ const normalized = input2.flatMap((entry) => entry.split(",")).map((route) => route.trim()).filter((route) => route.length > 0);
274
+ return Array.from(new Set(normalized)).sort();
275
+ };
276
+ var walkHtmlFiles = async (rootDir) => {
277
+ const entries = await fs.readdir(rootDir, { withFileTypes: true });
278
+ const files = [];
279
+ for (const entry of entries) {
280
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) {
281
+ continue;
282
+ }
283
+ const resolved = path.join(rootDir, entry.name);
284
+ if (entry.isDirectory()) {
285
+ files.push(...await walkHtmlFiles(resolved));
286
+ continue;
287
+ }
288
+ if (entry.isFile() && entry.name.endsWith(".html")) {
289
+ files.push(resolved);
290
+ }
264
291
  }
292
+ return files;
265
293
  };
266
- var routeToName = (route) => {
267
- if (route === "/") {
268
- return "Home";
294
+ var filePathToRoute = (rootDir, filePath) => {
295
+ const relative = path.relative(rootDir, filePath).replace(/\\/g, "/");
296
+ if (relative === "index.html") {
297
+ return "/";
269
298
  }
270
- const cleaned = route.replace(/\[|\]/g, "").split("/").filter(Boolean).join(" ");
271
- return cleaned.split(/\s+/).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(" ");
299
+ if (relative.endsWith("/index.html")) {
300
+ return `/${relative.slice(0, -"/index.html".length)}`;
301
+ }
302
+ if (relative.endsWith(".html")) {
303
+ return `/${relative.slice(0, -".html".length)}`;
304
+ }
305
+ return null;
272
306
  };
273
-
274
- // src/audit.ts
275
- import {
276
- validateSchema as validateSchema2
277
- } from "@schemasentry/core";
278
- var buildAuditReport = (data, options = {}) => {
279
- const dataRoutes = data.routes ?? {};
280
- const manifestRoutes = options.manifest?.routes ?? {};
281
- const coverageEnabled = Boolean(options.manifest || options.requiredRoutes?.length);
282
- const coverage = coverageEnabled ? buildCoverageResult(
283
- {
284
- expectedTypesByRoute: manifestRoutes,
285
- requiredRoutes: options.requiredRoutes
286
- },
287
- data
288
- ) : null;
289
- const allRoutes = coverageEnabled ? coverage?.allRoutes ?? [] : Object.keys(dataRoutes).sort();
290
- const routes = allRoutes.map((route) => {
291
- const expectedTypes = coverageEnabled ? manifestRoutes[route] ?? [] : [];
292
- const nodes = dataRoutes[route] ?? [];
293
- const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
294
- const validation = validateSchema2(nodes, options);
295
- const issues = [
296
- ...validation.issues,
297
- ...coverage?.issuesByRoute[route] ?? []
298
- ];
299
- const errorCount = issues.filter((issue) => issue.severity === "error").length;
300
- const warnCount = issues.filter((issue) => issue.severity === "warn").length;
301
- const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
302
- return {
303
- route,
304
- ok: errorCount === 0,
305
- score,
306
- issues,
307
- expectedTypes,
308
- foundTypes
309
- };
310
- });
311
- const summaryErrors = routes.reduce(
312
- (count, route) => count + route.issues.filter((i) => i.severity === "error").length,
313
- 0
314
- );
315
- const summaryWarnings = routes.reduce(
316
- (count, route) => count + route.issues.filter((i) => i.severity === "warn").length,
317
- 0
318
- );
319
- const summaryScore = routes.length === 0 ? 0 : Math.round(
320
- routes.reduce((total, route) => total + route.score, 0) / routes.length
321
- );
307
+ var extractSchemaNodes = (html, filePath) => {
308
+ const nodes = [];
309
+ const warnings = [];
310
+ let invalidBlocks = 0;
311
+ let scriptIndex = 0;
312
+ for (const match of html.matchAll(SCRIPT_TAG_REGEX)) {
313
+ scriptIndex += 1;
314
+ const attributes = match[1] ?? "";
315
+ if (!JSON_LD_TYPE_REGEX.test(attributes)) {
316
+ continue;
317
+ }
318
+ const scriptBody = (match[2] ?? "").trim();
319
+ if (!scriptBody) {
320
+ continue;
321
+ }
322
+ let parsed;
323
+ try {
324
+ parsed = JSON.parse(scriptBody);
325
+ } catch {
326
+ invalidBlocks += 1;
327
+ warnings.push({
328
+ file: filePath,
329
+ message: `Invalid JSON-LD block at script #${scriptIndex}`
330
+ });
331
+ continue;
332
+ }
333
+ const normalized = normalizeParsedBlock(parsed);
334
+ nodes.push(...normalized);
335
+ }
336
+ return { nodes, invalidBlocks, warnings };
337
+ };
338
+ var normalizeParsedBlock = (value) => {
339
+ if (Array.isArray(value)) {
340
+ return value.filter(isJsonObject);
341
+ }
342
+ if (!isJsonObject(value)) {
343
+ return [];
344
+ }
345
+ const graph = value["@graph"];
346
+ if (Array.isArray(graph)) {
347
+ return graph.filter(isJsonObject);
348
+ }
349
+ return [value];
350
+ };
351
+ var isJsonObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
352
+ var buildRouteDriftDetail = (route, beforeNodes, afterNodes) => {
353
+ const beforeTypes = new Set(beforeNodes.map((node) => schemaTypeLabel(node)));
354
+ const afterTypes = new Set(afterNodes.map((node) => schemaTypeLabel(node)));
355
+ const addedTypes = Array.from(afterTypes).filter((type) => !beforeTypes.has(type)).sort();
356
+ const removedTypes = Array.from(beforeTypes).filter((type) => !afterTypes.has(type)).sort();
322
357
  return {
323
- ok: summaryErrors === 0,
324
- summary: {
325
- routes: routes.length,
326
- errors: summaryErrors,
327
- warnings: summaryWarnings,
328
- score: summaryScore,
329
- ...coverageEnabled ? { coverage: coverage?.summary } : {}
330
- },
331
- routes
358
+ route,
359
+ beforeBlocks: beforeNodes.length,
360
+ afterBlocks: afterNodes.length,
361
+ addedTypes,
362
+ removedTypes
332
363
  };
333
364
  };
365
+ var schemaTypeLabel = (node) => {
366
+ const type = node["@type"];
367
+ return typeof type === "string" && type.trim().length > 0 ? type : "(unknown)";
368
+ };
334
369
 
335
- // src/summary.ts
336
- var formatDuration = (durationMs) => {
337
- if (!Number.isFinite(durationMs) || durationMs < 0) {
338
- return "0ms";
370
+ // src/source.ts
371
+ import { promises as fs2 } from "fs";
372
+ import path2 from "path";
373
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
374
+ ".git",
375
+ "node_modules",
376
+ ".pnpm-store",
377
+ ".next",
378
+ "dist",
379
+ "build",
380
+ "out"
381
+ ]);
382
+ var SCHEMA_IMPORT_PATTERN = /from\s+["']@schemasentry\/(?:next|core)["']/;
383
+ var SCHEMA_COMPONENT_PATTERN = /<Schema\s/;
384
+ var BUILDER_IMPORT_PATTERN = /import\s*\{([^}]+)\}\s*from\s*["']@schemasentry\/(?:next|core)["']/g;
385
+ var BUILDER_NAME_PATTERN = /^(Organization|Person|Place|LocalBusiness|WebSite|WebPage|Article|BlogPosting|Product|VideoObject|ImageObject|Event|Review|FAQPage|HowTo|BreadcrumbList)$/;
386
+ var scanSourceFiles = async (options) => {
387
+ const appDir = path2.resolve(options.rootDir, options.appDir ?? "app");
388
+ const pageFiles = await findPageFiles(appDir);
389
+ const routes = [];
390
+ for (const filePath of pageFiles) {
391
+ const route = filePathToRoute2(appDir, filePath);
392
+ if (!route) continue;
393
+ const content = await fs2.readFile(filePath, "utf8");
394
+ const info = analyzeSourceFile(filePath, route, content);
395
+ routes.push(info);
396
+ }
397
+ const filesWithSchema = routes.filter(
398
+ (r) => r.hasSchemaImport && r.hasSchemaUsage
399
+ ).length;
400
+ return {
401
+ routes,
402
+ totalFiles: routes.length,
403
+ filesWithSchema,
404
+ filesMissingSchema: routes.length - filesWithSchema
405
+ };
406
+ };
407
+ var findPageFiles = async (dir) => {
408
+ const files = [];
409
+ try {
410
+ const entries = await fs2.readdir(dir, { withFileTypes: true });
411
+ for (const entry of entries) {
412
+ const fullPath = path2.join(dir, entry.name);
413
+ if (entry.isDirectory()) {
414
+ if (!IGNORED_DIRS.has(entry.name)) {
415
+ files.push(...await findPageFiles(fullPath));
416
+ }
417
+ } else if (entry.isFile() && (entry.name === "page.tsx" || entry.name === "page.jsx" || entry.name === "page.ts" || entry.name === "page.js")) {
418
+ files.push(fullPath);
419
+ }
420
+ }
421
+ } catch {
339
422
  }
340
- if (durationMs < 1e3) {
341
- return `${Math.round(durationMs)}ms`;
423
+ return files;
424
+ };
425
+ var filePathToRoute2 = (appDir, filePath) => {
426
+ const relative = path2.relative(appDir, filePath).replace(/\\/g, "/");
427
+ const withoutExt = relative.replace(/\/page\.(tsx|jsx|ts|js)$/, "");
428
+ if (withoutExt === "") {
429
+ return "/";
342
430
  }
343
- const seconds = durationMs / 1e3;
344
- return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
431
+ return `/${withoutExt}`;
345
432
  };
346
- var formatSummaryLine = (label, stats) => {
347
- const parts = [
348
- `Routes: ${stats.routes}`,
349
- `Errors: ${stats.errors}`,
350
- `Warnings: ${stats.warnings}`,
351
- `Score: ${stats.score}`,
352
- `Duration: ${formatDuration(stats.durationMs)}`
353
- ];
354
- if (stats.coverage) {
355
- parts.push(
356
- `Coverage: missing_routes=${stats.coverage.missingRoutes} missing_types=${stats.coverage.missingTypes} unlisted_routes=${stats.coverage.unlistedRoutes}`
357
- );
433
+ var analyzeSourceFile = (filePath, route, content) => {
434
+ const hasSchemaImport = SCHEMA_IMPORT_PATTERN.test(content);
435
+ const hasSchemaUsage = SCHEMA_COMPONENT_PATTERN.test(content);
436
+ const importedBuilders = [];
437
+ const importMatch = BUILDER_IMPORT_PATTERN.exec(content);
438
+ if (importMatch && importMatch[1]) {
439
+ const imports = importMatch[1].split(",").map((s) => s.trim()).filter((s) => s.length > 0);
440
+ for (const imp of imports) {
441
+ const name = imp.split(/\s+as\s+/)[0].trim();
442
+ if (BUILDER_NAME_PATTERN.test(name)) {
443
+ importedBuilders.push(name);
444
+ }
445
+ }
446
+ }
447
+ return {
448
+ route,
449
+ filePath,
450
+ hasSchemaImport,
451
+ hasSchemaUsage,
452
+ importedBuilders
453
+ };
454
+ };
455
+
456
+ // src/reality.ts
457
+ var performRealityCheck = async (options) => {
458
+ const sourceScan = await scanSourceFiles({ rootDir: options.sourceDir });
459
+ const collected = await collectSchemaData({ rootDir: options.builtOutputDir });
460
+ return buildRealityReport({
461
+ manifest: options.manifest,
462
+ sourceScan,
463
+ collected,
464
+ validationOptions: { recommended: options.recommended }
465
+ });
466
+ };
467
+ var buildRealityReport = (input2) => {
468
+ const { manifest, sourceScan, collected, validationOptions } = input2;
469
+ const manifestRoutes = manifest.routes ?? {};
470
+ const collectedRoutes = collected.data.routes ?? {};
471
+ const allRoutes = /* @__PURE__ */ new Set([
472
+ ...Object.keys(manifestRoutes),
473
+ ...sourceScan.routes.map((r) => r.route),
474
+ ...Object.keys(collectedRoutes)
475
+ ]);
476
+ const routeReports = [];
477
+ let validRoutes = 0;
478
+ let missingInHtml = 0;
479
+ let missingInSource = 0;
480
+ let missingFromManifest = 0;
481
+ let typeMismatches = 0;
482
+ let totalErrors = 0;
483
+ let totalWarnings = 0;
484
+ for (const route of allRoutes) {
485
+ const sourceInfo = sourceScan.routes.find((r) => r.route === route);
486
+ const htmlNodes = collectedRoutes[route] ?? [];
487
+ const expectedTypes = manifestRoutes[route] ?? [];
488
+ const sourceHasComponent = sourceInfo?.hasSchemaImport && sourceInfo?.hasSchemaUsage;
489
+ const htmlHasSchema = htmlNodes.length > 0;
490
+ const validation = validateSchema(htmlNodes, validationOptions);
491
+ const foundTypes = htmlNodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
492
+ let status;
493
+ const issues = [...validation.issues];
494
+ if (expectedTypes.length > 0 && !sourceHasComponent) {
495
+ status = "missing_in_source";
496
+ missingInSource++;
497
+ issues.push({
498
+ path: `routes["${route}"]`,
499
+ message: "Manifest expects schema but source file has no <Schema> component",
500
+ severity: "error",
501
+ ruleId: "reality.missing_source_component"
502
+ });
503
+ } else if (sourceHasComponent && !htmlHasSchema) {
504
+ status = "missing_in_html";
505
+ missingInHtml++;
506
+ issues.push({
507
+ path: `routes["${route}"]`,
508
+ message: "Source has <Schema> component but no JSON-LD found in built HTML. Did you build the app?",
509
+ severity: "error",
510
+ ruleId: "reality.missing_html_output"
511
+ });
512
+ } else if (htmlHasSchema && expectedTypes.length === 0) {
513
+ status = "missing_from_manifest";
514
+ missingFromManifest++;
515
+ issues.push({
516
+ path: `routes["${route}"]`,
517
+ message: `Route has schema in HTML but is not listed in manifest`,
518
+ severity: "warn",
519
+ ruleId: "reality.unlisted_route"
520
+ });
521
+ } else if (expectedTypes.length > 0 && htmlHasSchema && !expectedTypes.every((t) => foundTypes.includes(t))) {
522
+ status = "type_mismatch";
523
+ typeMismatches++;
524
+ const missingTypes = expectedTypes.filter((t) => !foundTypes.includes(t));
525
+ issues.push({
526
+ path: `routes["${route}"].types`,
527
+ message: `Missing expected schema types: ${missingTypes.join(", ")}`,
528
+ severity: "error",
529
+ ruleId: "reality.type_mismatch"
530
+ });
531
+ } else if (htmlHasSchema) {
532
+ status = "valid";
533
+ validRoutes++;
534
+ } else {
535
+ status = "valid";
536
+ validRoutes++;
537
+ }
538
+ const errorCount = issues.filter((i) => i.severity === "error").length;
539
+ const warnCount = issues.filter((i) => i.severity === "warn").length;
540
+ totalErrors += errorCount;
541
+ totalWarnings += warnCount;
542
+ const score = Math.max(0, 100 - errorCount * 10 - warnCount * 2);
543
+ routeReports.push({
544
+ route,
545
+ status,
546
+ sourceHasComponent: sourceHasComponent ?? false,
547
+ htmlHasSchema,
548
+ expectedTypes,
549
+ foundTypes,
550
+ issues,
551
+ score
552
+ });
358
553
  }
359
- return `${label} | ${parts.join(" | ")}`;
554
+ routeReports.sort((a, b) => a.route.localeCompare(b.route));
555
+ const avgScore = routeReports.length === 0 ? 100 : Math.round(
556
+ routeReports.reduce((sum, r) => sum + r.score, 0) / routeReports.length
557
+ );
558
+ return {
559
+ ok: totalErrors === 0,
560
+ summary: {
561
+ routes: routeReports.length,
562
+ errors: totalErrors,
563
+ warnings: totalWarnings,
564
+ score: avgScore,
565
+ validRoutes,
566
+ missingInHtml,
567
+ missingInSource,
568
+ missingFromManifest,
569
+ typeMismatches
570
+ },
571
+ routes: routeReports
572
+ };
360
573
  };
361
574
 
575
+ // src/commands/utils.ts
576
+ import { readFileSync } from "fs";
577
+ import { stableStringify as stableStringify3 } from "@schemasentry/core";
578
+
362
579
  // src/config.ts
363
- import { promises as fs2 } from "fs";
364
- import path2 from "path";
580
+ import { promises as fs3 } from "fs";
581
+ import path3 from "path";
365
582
  var ConfigError = class extends Error {
366
583
  constructor(code, message, suggestion) {
367
584
  super(message);
@@ -373,8 +590,8 @@ var DEFAULT_CONFIG_PATH = "schema-sentry.config.json";
373
590
  var loadConfig = async (options) => {
374
591
  const cwd = options.cwd ?? process.cwd();
375
592
  const explicit = Boolean(options.configPath);
376
- const resolvedPath = path2.resolve(cwd, options.configPath ?? DEFAULT_CONFIG_PATH);
377
- const exists = await fileExists2(resolvedPath);
593
+ const resolvedPath = path3.resolve(cwd, options.configPath ?? DEFAULT_CONFIG_PATH);
594
+ const exists = await fileExists(resolvedPath);
378
595
  if (!exists) {
379
596
  if (explicit) {
380
597
  throw new ConfigError(
@@ -387,7 +604,7 @@ var loadConfig = async (options) => {
387
604
  }
388
605
  let raw;
389
606
  try {
390
- raw = await fs2.readFile(resolvedPath, "utf8");
607
+ raw = await fs3.readFile(resolvedPath, "utf8");
391
608
  } catch (error) {
392
609
  throw new ConfigError(
393
610
  "config.read_failed",
@@ -425,464 +642,559 @@ var isConfig = (value) => {
425
642
  }
426
643
  return true;
427
644
  };
428
- var fileExists2 = async (filePath) => {
645
+ var fileExists = async (filePath) => {
429
646
  try {
430
- await fs2.access(filePath);
647
+ await fs3.access(filePath);
431
648
  return true;
432
649
  } catch {
433
650
  return false;
434
651
  }
435
652
  };
436
653
 
437
- // src/routes.ts
438
- import { promises as fs3 } from "fs";
439
- import path3 from "path";
440
- var scanRoutes = async (options) => {
441
- const rootDir = options.rootDir;
442
- const routes = /* @__PURE__ */ new Set();
443
- if (options.includeApp !== false) {
444
- const appDir = path3.join(rootDir, "app");
445
- if (await dirExists(appDir)) {
446
- const files = await walkDir(appDir);
447
- for (const file of files) {
448
- if (!isAppPageFile(file)) {
449
- continue;
450
- }
451
- const route = toAppRoute(appDir, file);
452
- if (route) {
453
- routes.add(route);
454
- }
455
- }
456
- }
654
+ // src/commands/utils.ts
655
+ function resolveCliVersion() {
656
+ try {
657
+ const raw = readFileSync(new URL("../../package.json", import.meta.url), "utf8");
658
+ const parsed = JSON.parse(raw);
659
+ return parsed.version ?? "0.0.0";
660
+ } catch {
661
+ return "0.0.0";
457
662
  }
458
- if (options.includePages !== false) {
459
- const pagesDir = path3.join(rootDir, "pages");
460
- if (await dirExists(pagesDir)) {
461
- const files = await walkDir(pagesDir);
462
- for (const file of files) {
463
- if (!isPagesFile(file)) {
464
- continue;
465
- }
466
- const route = toPagesRoute(pagesDir, file);
467
- if (route) {
468
- routes.add(route);
469
- }
470
- }
663
+ }
664
+ function resolveOutputFormat(value) {
665
+ const format = (value ?? "json").trim().toLowerCase();
666
+ if (format === "json" || format === "html") {
667
+ return format;
668
+ }
669
+ printCliError(
670
+ "output.invalid_format",
671
+ `Unsupported report format '${value ?? ""}'`,
672
+ "Use --format json or --format html."
673
+ );
674
+ process.exit(1);
675
+ return "json";
676
+ }
677
+ function resolveCollectOutputFormat(value) {
678
+ const format = (value ?? "json").trim().toLowerCase();
679
+ if (format === "json") {
680
+ return format;
681
+ }
682
+ printCliError(
683
+ "output.invalid_format",
684
+ `Unsupported collect output format '${value ?? ""}'`,
685
+ "Use --format json."
686
+ );
687
+ process.exit(1);
688
+ return "json";
689
+ }
690
+ function resolveAnnotationsMode(value) {
691
+ const mode = (value ?? "none").trim().toLowerCase();
692
+ if (mode === "none" || mode === "github") {
693
+ return mode;
694
+ }
695
+ printCliError(
696
+ "annotations.invalid_provider",
697
+ `Unsupported annotations provider '${value ?? ""}'`,
698
+ "Use --annotations none or --annotations github."
699
+ );
700
+ process.exit(1);
701
+ return "none";
702
+ }
703
+ async function resolveRecommendedOption(configPath) {
704
+ const override = getRecommendedOverride(process.argv);
705
+ try {
706
+ const config = await loadConfig({ configPath });
707
+ return resolveRecommended(override, config);
708
+ } catch (error) {
709
+ if (error instanceof ConfigError) {
710
+ printCliError(error.code, error.message, error.suggestion);
711
+ process.exit(1);
712
+ return true;
471
713
  }
714
+ throw error;
472
715
  }
473
- return Array.from(routes).sort();
474
- };
475
- var isAppPageFile = (filePath) => /\/page\.(t|j)sx?$/.test(filePath) || /\/page\.mdx$/.test(filePath);
476
- var isPagesFile = (filePath) => /\.(t|j)sx?$/.test(filePath) || /\.mdx$/.test(filePath);
477
- var toAppRoute = (appDir, filePath) => {
478
- const relative = path3.relative(appDir, filePath);
479
- const segments = relative.split(path3.sep);
480
- if (segments.length === 0) {
481
- return null;
716
+ }
717
+ function getRecommendedOverride(argv) {
718
+ if (argv.includes("--recommended")) {
719
+ return true;
482
720
  }
483
- segments.pop();
484
- const routeSegments = segments.filter((segment) => segment.length > 0).filter((segment) => !isGroupSegment(segment)).filter((segment) => !isParallelSegment(segment));
485
- if (routeSegments.length === 0) {
486
- return "/";
721
+ if (argv.includes("--no-recommended")) {
722
+ return false;
487
723
  }
488
- return `/${routeSegments.join("/")}`;
489
- };
490
- var toPagesRoute = (pagesDir, filePath) => {
491
- const relative = path3.relative(pagesDir, filePath);
492
- const segments = relative.split(path3.sep);
493
- if (segments.length === 0) {
494
- return null;
724
+ return void 0;
725
+ }
726
+ function printCliError(code, message, suggestion) {
727
+ console.error(
728
+ stableStringify3({
729
+ ok: false,
730
+ errors: [
731
+ {
732
+ code,
733
+ message,
734
+ ...suggestion !== void 0 ? { suggestion } : {}
735
+ }
736
+ ]
737
+ })
738
+ );
739
+ }
740
+ function isManifest(value) {
741
+ if (!value || typeof value !== "object") {
742
+ return false;
495
743
  }
496
- if (segments[0] === "api") {
497
- return null;
744
+ const manifest = value;
745
+ if (!manifest.routes || typeof manifest.routes !== "object") {
746
+ return false;
498
747
  }
499
- const fileName = segments.pop();
500
- if (!fileName) {
501
- return null;
748
+ for (const entry of Object.values(manifest.routes)) {
749
+ if (!Array.isArray(entry) || entry.some((item) => typeof item !== "string")) {
750
+ return false;
751
+ }
502
752
  }
503
- const baseName = fileName.replace(/\.[^/.]+$/, "");
504
- if (baseName.startsWith("_")) {
505
- return null;
753
+ return true;
754
+ }
755
+ function isSchemaData(value) {
756
+ if (!value || typeof value !== "object") {
757
+ return false;
758
+ }
759
+ const data = value;
760
+ if (!data.routes || typeof data.routes !== "object") {
761
+ return false;
762
+ }
763
+ for (const entry of Object.values(data.routes)) {
764
+ if (!Array.isArray(entry)) {
765
+ return false;
766
+ }
506
767
  }
507
- const filtered = segments.filter((segment) => segment.length > 0);
508
- if (baseName !== "index") {
509
- filtered.push(baseName);
768
+ return true;
769
+ }
770
+
771
+ // src/commands/validate.ts
772
+ var validateCommand = new Command("validate").description("Validate schema by checking built HTML output against manifest (validates reality, not just config files)").option(
773
+ "-m, --manifest <path>",
774
+ "Path to manifest JSON",
775
+ "schema-sentry.manifest.json"
776
+ ).option(
777
+ "-r, --root <path>",
778
+ "Root directory containing built HTML output (e.g., ./out or ./.next/server/app)",
779
+ "./out"
780
+ ).option(
781
+ "--app-dir <path>",
782
+ "Path to Next.js app directory for source scanning",
783
+ "./app"
784
+ ).option("-c, --config <path>", "Path to config JSON").option("--format <format>", "Report format (json|html)", "json").option("--annotations <provider>", "Emit CI annotations (none|github)", "none").option("-o, --output <path>", "Write report output to file").option("--recommended", "Enable recommended field checks").option("--no-recommended", "Disable recommended field checks").action(async (options) => {
785
+ const start = Date.now();
786
+ const format = resolveOutputFormat(options.format);
787
+ const annotationsMode = resolveAnnotationsMode(options.annotations);
788
+ const recommended = await resolveRecommendedOption(options.config);
789
+ const manifestPath = path4.resolve(process.cwd(), options.manifest);
790
+ const builtOutputDir = path4.resolve(process.cwd(), options.root);
791
+ const appDir = path4.resolve(process.cwd(), options.appDir ?? "./app");
792
+ let manifest;
793
+ try {
794
+ const raw = await readFile(manifestPath, "utf8");
795
+ manifest = JSON.parse(raw);
796
+ } catch (error) {
797
+ printCliError(
798
+ "manifest.not_found",
799
+ `Manifest not found at ${manifestPath}`,
800
+ "Run `schemasentry init` to generate starter files."
801
+ );
802
+ process.exit(1);
803
+ return;
510
804
  }
511
- if (filtered.length === 0) {
512
- return "/";
805
+ if (!isManifest(manifest)) {
806
+ printCliError(
807
+ "manifest.invalid_shape",
808
+ "Manifest must contain a 'routes' object with string array values",
809
+ "Ensure each route maps to an array of schema type names."
810
+ );
811
+ process.exit(1);
812
+ return;
513
813
  }
514
- return `/${filtered.join("/")}`;
515
- };
516
- var isGroupSegment = (segment) => segment.startsWith("(") && segment.endsWith(")");
517
- var isParallelSegment = (segment) => segment.startsWith("@");
518
- var dirExists = async (dirPath) => {
814
+ console.error(chalk.blue.bold("\u{1F50D} Schema Sentry Reality Check"));
815
+ console.error(chalk.gray("Validating actual built HTML against manifest expectations...\n"));
519
816
  try {
520
- const stat = await fs3.stat(dirPath);
521
- return stat.isDirectory();
817
+ await access(builtOutputDir);
522
818
  } catch {
523
- return false;
819
+ printCliError(
820
+ "validate.no_build_output",
821
+ `No built HTML output found at ${builtOutputDir}`,
822
+ "Build your Next.js app first with `next build`, then run validate."
823
+ );
824
+ process.exit(1);
825
+ return;
524
826
  }
525
- };
526
- var walkDir = async (dirPath) => {
527
- const entries = await fs3.readdir(dirPath, { withFileTypes: true });
528
- const files = [];
529
- for (const entry of entries) {
530
- const resolved = path3.join(dirPath, entry.name);
531
- if (entry.isDirectory()) {
532
- files.push(...await walkDir(resolved));
533
- } else if (entry.isFile()) {
534
- files.push(resolved);
827
+ let report;
828
+ try {
829
+ report = await performRealityCheck({
830
+ manifest,
831
+ builtOutputDir,
832
+ sourceDir: appDir,
833
+ recommended
834
+ });
835
+ } catch (error) {
836
+ const message = error instanceof Error ? error.message : "Unknown error";
837
+ printCliError(
838
+ "validate.check_failed",
839
+ `Failed to validate: ${message}`,
840
+ "Ensure your app is built and the --root points to the output directory."
841
+ );
842
+ process.exit(1);
843
+ return;
844
+ }
845
+ if (format === "json") {
846
+ if (options.output) {
847
+ const outputPath = path4.resolve(process.cwd(), options.output);
848
+ await mkdir(path4.dirname(outputPath), { recursive: true });
849
+ await writeFile(outputPath, stableStringify4(report), "utf8");
850
+ console.error(chalk.green(`
851
+ \u2713 Report written to ${outputPath}`));
852
+ } else {
853
+ console.log(stableStringify4(report));
854
+ }
855
+ } else {
856
+ if (options.output) {
857
+ const outputPath = path4.resolve(process.cwd(), options.output);
858
+ await mkdir(path4.dirname(outputPath), { recursive: true });
859
+ const legacyReport = convertRealityToLegacyReport(report);
860
+ await writeFile(outputPath, renderHtmlReport(legacyReport, { title: "Schema Sentry Validate Report" }), "utf8");
861
+ console.error(chalk.green(`
862
+ \u2713 HTML report written to ${outputPath}`));
535
863
  }
536
864
  }
537
- return files;
538
- };
539
-
540
- // src/html.ts
541
- var escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
542
- var renderRouteIssues = (route) => {
543
- if (route.issues.length === 0) {
544
- return '<p class="muted">No issues.</p>';
865
+ printRealitySummary(report, Date.now() - start);
866
+ if (annotationsMode === "github") {
867
+ const legacyReport = convertRealityToLegacyReport(report);
868
+ emitGitHubAnnotations(legacyReport, "validate");
545
869
  }
546
- const items = route.issues.map((issue) => {
547
- const severityClass = issue.severity === "error" ? "sev-error" : "sev-warn";
548
- return `<li class="${severityClass}">
549
- <span class="sev">${escapeHtml(issue.severity.toUpperCase())}</span>
550
- <code>${escapeHtml(issue.ruleId)}</code>
551
- <span>${escapeHtml(issue.message)}</span>
552
- <small>${escapeHtml(issue.path)}</small>
553
- </li>`;
554
- }).join("");
555
- return `<ul class="issues">${items}</ul>`;
556
- };
557
- var renderRoute = (route) => {
558
- const statusClass = route.ok ? "ok" : "fail";
559
- const expected = route.expectedTypes.length ? route.expectedTypes.join(", ") : "(none)";
560
- const found = route.foundTypes.length ? route.foundTypes.join(", ") : "(none)";
561
- return `<section class="route">
562
- <header>
563
- <h3>${escapeHtml(route.route)}</h3>
564
- <span class="badge ${statusClass}">${route.ok ? "OK" : "FAIL"}</span>
565
- </header>
566
- <p><strong>Score:</strong> ${route.score}</p>
567
- <p><strong>Expected types:</strong> ${escapeHtml(expected)}</p>
568
- <p><strong>Found types:</strong> ${escapeHtml(found)}</p>
569
- ${renderRouteIssues(route)}
570
- </section>`;
571
- };
572
- var renderCoverage = (report) => {
573
- if (!report.summary.coverage) {
574
- return "<li><strong>Coverage:</strong> not enabled</li>";
870
+ process.exit(report.ok ? 0 : 1);
871
+ });
872
+ function printRealitySummary(report, durationMs) {
873
+ console.error("");
874
+ if (report.ok) {
875
+ console.error(chalk.green.bold("\u2705 All routes pass reality check"));
876
+ } else {
877
+ console.error(chalk.red.bold("\u274C Reality check failed"));
878
+ }
879
+ console.error(chalk.gray(`
880
+ \u{1F4CA} Summary:`));
881
+ console.error(` Routes checked: ${report.summary.routes}`);
882
+ console.error(` Score: ${report.summary.score}/100`);
883
+ console.error(` Duration: ${formatDuration(durationMs)}`);
884
+ if (report.summary.validRoutes > 0) {
885
+ console.error(chalk.green(` \u2705 Valid: ${report.summary.validRoutes}`));
886
+ }
887
+ if (report.summary.missingInSource > 0) {
888
+ console.error(chalk.red(` \u274C Missing in source: ${report.summary.missingInSource}`));
889
+ }
890
+ if (report.summary.missingInHtml > 0) {
891
+ console.error(chalk.red(` \u274C Missing in HTML: ${report.summary.missingInHtml}`));
892
+ }
893
+ if (report.summary.missingFromManifest > 0) {
894
+ console.error(chalk.yellow(` \u26A0\uFE0F Missing from manifest: ${report.summary.missingFromManifest}`));
895
+ }
896
+ if (report.summary.typeMismatches > 0) {
897
+ console.error(chalk.red(` \u274C Type mismatches: ${report.summary.typeMismatches}`));
898
+ }
899
+ if (report.summary.errors > 0 || report.summary.warnings > 0) {
900
+ console.error(chalk.gray(`
901
+ \u{1F4DD} Details:`));
902
+ console.error(` Errors: ${chalk.red(report.summary.errors.toString())}`);
903
+ console.error(` Warnings: ${chalk.yellow(report.summary.warnings.toString())}`);
904
+ }
905
+ const problemRoutes = report.routes.filter((r) => r.issues.length > 0);
906
+ if (problemRoutes.length > 0) {
907
+ console.error(chalk.gray(`
908
+ \u{1F50D} Problem routes:`));
909
+ for (const route of problemRoutes.slice(0, 5)) {
910
+ console.error(chalk.white(`
911
+ ${route.route}`));
912
+ for (const issue of route.issues) {
913
+ const icon = issue.severity === "error" ? chalk.red("\u274C") : chalk.yellow("\u26A0\uFE0F");
914
+ console.error(` ${icon} ${issue.message}`);
915
+ }
916
+ }
917
+ if (problemRoutes.length > 5) {
918
+ console.error(chalk.gray(`
919
+ ... and ${problemRoutes.length - 5} more`));
920
+ }
575
921
  }
576
- const coverage = report.summary.coverage;
577
- return `<li><strong>Coverage:</strong> missing_routes=${coverage.missingRoutes} missing_types=${coverage.missingTypes} unlisted_routes=${coverage.unlistedRoutes}</li>`;
578
- };
579
- var renderHtmlReport = (report, options) => {
580
- const title = escapeHtml(options.title);
581
- const generatedAt = escapeHtml(
582
- (options.generatedAt ?? /* @__PURE__ */ new Date()).toISOString()
583
- );
584
- const routes = report.routes.map(renderRoute).join("");
585
- return `<!doctype html>
586
- <html lang="en">
587
- <head>
588
- <meta charset="utf-8" />
589
- <meta name="viewport" content="width=device-width, initial-scale=1" />
590
- <title>${title}</title>
591
- <style>
592
- :root { color-scheme: light dark; }
593
- body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; margin: 24px; line-height: 1.45; }
594
- h1, h2, h3 { margin: 0 0 8px; }
595
- .muted { color: #666; }
596
- .summary, .routes { margin-top: 20px; }
597
- .route { border: 1px solid #ddd; border-radius: 8px; padding: 12px; margin: 12px 0; }
598
- .route header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
599
- .badge { padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
600
- .badge.ok { background: #dcfce7; color: #166534; }
601
- .badge.fail { background: #fee2e2; color: #991b1b; }
602
- .issues { margin: 10px 0 0; padding-left: 16px; }
603
- .issues li { margin: 8px 0; display: grid; gap: 2px; }
604
- .sev { font-weight: 700; }
605
- .sev-error .sev { color: #991b1b; }
606
- .sev-warn .sev { color: #92400e; }
607
- code { background: #f5f5f5; padding: 1px 5px; border-radius: 4px; }
608
- </style>
609
- </head>
610
- <body>
611
- <h1>${title}</h1>
612
- <p class="muted">Generated at ${generatedAt}</p>
922
+ console.error("");
923
+ }
924
+ function convertRealityToLegacyReport(report) {
925
+ return {
926
+ ok: report.ok,
927
+ summary: {
928
+ routes: report.summary.routes,
929
+ errors: report.summary.errors,
930
+ warnings: report.summary.warnings,
931
+ score: report.summary.score,
932
+ coverage: {
933
+ missingRoutes: report.summary.missingInSource + report.summary.missingInHtml,
934
+ missingTypes: report.summary.typeMismatches,
935
+ unlistedRoutes: report.summary.missingFromManifest
936
+ }
937
+ },
938
+ routes: report.routes.map((r) => ({
939
+ route: r.route,
940
+ ok: r.status === "valid" && r.issues.filter((i) => i.severity === "error").length === 0,
941
+ score: r.score,
942
+ issues: r.issues,
943
+ expectedTypes: r.expectedTypes,
944
+ foundTypes: r.foundTypes
945
+ }))
946
+ };
947
+ }
613
948
 
614
- <section class="summary">
615
- <h2>Summary</h2>
616
- <ul>
617
- <li><strong>OK:</strong> ${report.ok}</li>
618
- <li><strong>Routes:</strong> ${report.summary.routes}</li>
619
- <li><strong>Errors:</strong> ${report.summary.errors}</li>
620
- <li><strong>Warnings:</strong> ${report.summary.warnings}</li>
621
- <li><strong>Score:</strong> ${report.summary.score}</li>
622
- ${renderCoverage(report)}
623
- </ul>
624
- </section>
949
+ // src/commands/init.ts
950
+ import { Command as Command2 } from "commander";
951
+ import path7 from "path";
625
952
 
626
- <section class="routes">
627
- <h2>Routes</h2>
628
- ${routes}
629
- </section>
630
- </body>
631
- </html>`;
953
+ // src/init.ts
954
+ import { promises as fs4 } from "fs";
955
+ import path5 from "path";
956
+ import { SCHEMA_CONTEXT } from "@schemasentry/core";
957
+ var DEFAULT_ANSWERS = {
958
+ siteName: "Acme Corp",
959
+ siteUrl: "https://acme.com",
960
+ authorName: "Jane Doe"
632
961
  };
633
-
634
- // src/annotations.ts
635
- var escapeCommandValue = (value) => value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
636
- var escapeCommandProperty = (value) => escapeCommandValue(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
637
- var formatIssueMessage = (route, issue) => `[${route}] ${issue.ruleId}: ${issue.message} (${issue.path})`;
638
- var buildGitHubAnnotationLines = (report, commandLabel) => {
639
- const title = escapeCommandProperty(`Schema Sentry ${commandLabel}`);
640
- const lines = [];
641
- for (const route of report.routes) {
642
- for (const issue of route.issues) {
643
- const level = issue.severity === "error" ? "error" : "warning";
644
- const message = escapeCommandValue(formatIssueMessage(route.route, issue));
645
- lines.push(`::${level} title=${title}::${message}`);
962
+ var getDefaultAnswers = () => ({ ...DEFAULT_ANSWERS });
963
+ var buildManifest = (scannedRoutes = []) => {
964
+ const routes = {
965
+ "/": ["Organization", "WebSite"],
966
+ "/blog/[slug]": ["Article"]
967
+ };
968
+ for (const route of scannedRoutes) {
969
+ if (!routes[route]) {
970
+ routes[route] = ["WebPage"];
646
971
  }
647
972
  }
648
- return lines;
649
- };
650
- var emitGitHubAnnotations = (report, commandLabel) => {
651
- const lines = buildGitHubAnnotationLines(report, commandLabel);
652
- for (const line of lines) {
653
- console.error(line);
654
- }
973
+ return { routes };
655
974
  };
656
-
657
- // src/collect.ts
658
- import { promises as fs4 } from "fs";
659
- import path4 from "path";
660
- import { stableStringify as stableStringify2 } from "@schemasentry/core";
661
- var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "node_modules", ".pnpm-store"]);
662
- var SCRIPT_TAG_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
663
- var JSON_LD_TYPE_REGEX = /\btype\s*=\s*(?:"application\/ld\+json"|'application\/ld\+json'|application\/ld\+json)/i;
664
- var collectSchemaData = async (options) => {
665
- const rootDir = path4.resolve(options.rootDir);
666
- const requestedRoutes = normalizeRouteFilter(options.routes ?? []);
667
- const htmlFiles = (await walkHtmlFiles(rootDir)).sort((a, b) => a.localeCompare(b));
668
- const routes = {};
669
- const warnings = [];
670
- let blockCount = 0;
671
- let invalidBlocks = 0;
672
- for (const filePath of htmlFiles) {
673
- const route = filePathToRoute(rootDir, filePath);
674
- if (!route) {
975
+ var formatDate = (value) => value.toISOString().slice(0, 10);
976
+ var buildData = (answers, options = {}) => {
977
+ const { siteName, siteUrl, authorName } = answers;
978
+ const normalizedSiteUrl = normalizeUrl(siteUrl);
979
+ const today = options.today ?? /* @__PURE__ */ new Date();
980
+ const date = formatDate(today);
981
+ const logoUrl = new URL("/logo.png", normalizedSiteUrl).toString();
982
+ const articleUrl = new URL("/blog/hello-world", normalizedSiteUrl).toString();
983
+ const imageUrl = new URL("/blog/hello-world.png", normalizedSiteUrl).toString();
984
+ const routes = {
985
+ "/": [
986
+ {
987
+ "@context": SCHEMA_CONTEXT,
988
+ "@type": "Organization",
989
+ name: siteName,
990
+ url: normalizedSiteUrl,
991
+ logo: logoUrl,
992
+ description: `Official website of ${siteName}`
993
+ },
994
+ {
995
+ "@context": SCHEMA_CONTEXT,
996
+ "@type": "WebSite",
997
+ name: siteName,
998
+ url: normalizedSiteUrl,
999
+ description: `Learn more about ${siteName}`
1000
+ }
1001
+ ],
1002
+ "/blog/[slug]": [
1003
+ {
1004
+ "@context": SCHEMA_CONTEXT,
1005
+ "@type": "Article",
1006
+ headline: "Hello World",
1007
+ author: {
1008
+ "@type": "Person",
1009
+ name: authorName
1010
+ },
1011
+ datePublished: date,
1012
+ dateModified: date,
1013
+ description: `An introduction to ${siteName}`,
1014
+ image: imageUrl,
1015
+ url: articleUrl
1016
+ }
1017
+ ]
1018
+ };
1019
+ for (const route of options.scannedRoutes ?? []) {
1020
+ if (routes[route]) {
675
1021
  continue;
676
1022
  }
677
- const html = await fs4.readFile(filePath, "utf8");
678
- const extracted = extractSchemaNodes(html, filePath);
679
- if (extracted.nodes.length > 0) {
680
- routes[route] = [...routes[route] ?? [], ...extracted.nodes];
681
- blockCount += extracted.nodes.length;
682
- }
683
- invalidBlocks += extracted.invalidBlocks;
684
- warnings.push(...extracted.warnings);
685
- }
686
- const missingRoutes = [];
687
- const filteredRoutes = requestedRoutes.length > 0 ? filterRoutesByAllowlist(routes, requestedRoutes) : routes;
688
- if (requestedRoutes.length > 0) {
689
- for (const route of requestedRoutes) {
690
- if (!Object.prototype.hasOwnProperty.call(filteredRoutes, route)) {
691
- missingRoutes.push(route);
1023
+ routes[route] = [
1024
+ {
1025
+ "@context": SCHEMA_CONTEXT,
1026
+ "@type": "WebPage",
1027
+ name: routeToName(route),
1028
+ url: new URL(route, normalizedSiteUrl).toString()
692
1029
  }
693
- }
1030
+ ];
694
1031
  }
695
- const filteredBlockCount = Object.values(filteredRoutes).reduce(
696
- (total, nodes) => total + nodes.length,
697
- 0
698
- );
699
- return {
700
- data: {
701
- routes: sortRoutes(filteredRoutes)
702
- },
703
- stats: {
704
- htmlFiles: htmlFiles.length,
705
- routes: Object.keys(filteredRoutes).length,
706
- blocks: filteredBlockCount,
707
- invalidBlocks
708
- },
709
- warnings,
710
- requestedRoutes,
711
- missingRoutes
712
- };
1032
+ return { routes };
713
1033
  };
714
- var compareSchemaData = (existing, collected) => {
715
- const existingRoutes = existing.routes ?? {};
716
- const collectedRoutes = collected.routes ?? {};
717
- const existingKeys = Object.keys(existingRoutes);
718
- const collectedKeys = Object.keys(collectedRoutes);
719
- const addedRoutes = collectedKeys.filter((route) => !Object.prototype.hasOwnProperty.call(existingRoutes, route)).sort();
720
- const removedRoutes = existingKeys.filter((route) => !Object.prototype.hasOwnProperty.call(collectedRoutes, route)).sort();
721
- const changedRoutes = existingKeys.filter((route) => Object.prototype.hasOwnProperty.call(collectedRoutes, route)).filter(
722
- (route) => stableStringify2(existingRoutes[route]) !== stableStringify2(collectedRoutes[route])
723
- ).sort();
724
- const changedRouteDetails = changedRoutes.map(
725
- (route) => buildRouteDriftDetail(route, existingRoutes[route] ?? [], collectedRoutes[route] ?? [])
1034
+ var writeInitFiles = async (options) => {
1035
+ const {
1036
+ manifestPath,
1037
+ dataPath,
1038
+ overwriteManifest,
1039
+ overwriteData,
1040
+ answers,
1041
+ today,
1042
+ scannedRoutes
1043
+ } = options;
1044
+ const manifest = buildManifest(scannedRoutes ?? []);
1045
+ const data = buildData(answers, { today, scannedRoutes });
1046
+ const manifestResult = await writeJsonFile(
1047
+ manifestPath,
1048
+ manifest,
1049
+ overwriteManifest
726
1050
  );
1051
+ const dataResult = await writeJsonFile(dataPath, data, overwriteData);
727
1052
  return {
728
- hasChanges: addedRoutes.length > 0 || removedRoutes.length > 0 || changedRoutes.length > 0,
729
- addedRoutes,
730
- removedRoutes,
731
- changedRoutes,
732
- changedRouteDetails
1053
+ manifest: manifestResult,
1054
+ data: dataResult
733
1055
  };
734
1056
  };
735
- var formatSchemaDataDrift = (drift, maxRoutes = 5) => {
736
- if (!drift.hasChanges) {
737
- return "No schema data drift detected.";
738
- }
739
- const lines = [
740
- `Schema data drift detected: added_routes=${drift.addedRoutes.length} removed_routes=${drift.removedRoutes.length} changed_routes=${drift.changedRoutes.length}`
741
- ];
742
- if (drift.addedRoutes.length > 0) {
743
- lines.push(formatRoutePreview("Added routes", drift.addedRoutes, maxRoutes));
744
- }
745
- if (drift.removedRoutes.length > 0) {
746
- lines.push(formatRoutePreview("Removed routes", drift.removedRoutes, maxRoutes));
747
- }
748
- if (drift.changedRoutes.length > 0) {
749
- lines.push(formatRoutePreview("Changed routes", drift.changedRoutes, maxRoutes));
750
- const details = drift.changedRouteDetails.slice(0, maxRoutes).map((detail) => formatRouteDriftDetail(detail));
751
- if (details.length > 0) {
752
- lines.push("Changed route details:");
753
- for (const detail of details) {
754
- lines.push(`- ${detail}`);
755
- }
756
- }
1057
+ var writeJsonFile = async (filePath, payload, overwrite) => {
1058
+ const exists = await fileExists2(filePath);
1059
+ if (exists && !overwrite) {
1060
+ return "skipped";
757
1061
  }
758
- return lines.join("\n");
759
- };
760
- var formatRoutePreview = (label, routes, maxRoutes) => {
761
- const preview = routes.slice(0, maxRoutes);
762
- const suffix = routes.length > maxRoutes ? ` (+${routes.length - maxRoutes} more)` : "";
763
- return `${label}: ${preview.join(", ")}${suffix}`;
1062
+ await fs4.mkdir(path5.dirname(filePath), { recursive: true });
1063
+ const json = JSON.stringify(payload, null, 2);
1064
+ await fs4.writeFile(filePath, `${json}
1065
+ `, "utf8");
1066
+ return exists ? "overwritten" : "created";
764
1067
  };
765
- var formatRouteDriftDetail = (detail) => {
766
- const added = detail.addedTypes.length > 0 ? detail.addedTypes.join(",") : "(none)";
767
- const removed = detail.removedTypes.length > 0 ? detail.removedTypes.join(",") : "(none)";
768
- return `${detail.route} blocks ${detail.beforeBlocks}->${detail.afterBlocks} | +types ${added} | -types ${removed}`;
1068
+ var fileExists2 = async (filePath) => {
1069
+ try {
1070
+ await fs4.access(filePath);
1071
+ return true;
1072
+ } catch {
1073
+ return false;
1074
+ }
769
1075
  };
770
- var sortRoutes = (routes) => Object.fromEntries(
771
- Object.entries(routes).sort(([a], [b]) => a.localeCompare(b))
772
- );
773
- var filterRoutesByAllowlist = (routes, allowlist) => {
774
- const filtered = {};
775
- for (const route of allowlist) {
776
- if (Object.prototype.hasOwnProperty.call(routes, route)) {
777
- filtered[route] = routes[route];
778
- }
1076
+ var normalizeUrl = (value) => {
1077
+ try {
1078
+ return new URL(value).toString();
1079
+ } catch {
1080
+ return new URL(`https://${value}`).toString();
779
1081
  }
780
- return filtered;
781
1082
  };
782
- var normalizeRouteFilter = (input2) => {
783
- const normalized = input2.flatMap((entry) => entry.split(",")).map((route) => route.trim()).filter((route) => route.length > 0);
784
- return Array.from(new Set(normalized)).sort();
1083
+ var routeToName = (route) => {
1084
+ if (route === "/") {
1085
+ return "Home";
1086
+ }
1087
+ const cleaned = route.replace(/\[|\]/g, "").split("/").filter(Boolean).join(" ");
1088
+ return cleaned.split(/\s+/).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(" ");
785
1089
  };
786
- var walkHtmlFiles = async (rootDir) => {
787
- const entries = await fs4.readdir(rootDir, { withFileTypes: true });
788
- const files = [];
789
- for (const entry of entries) {
790
- if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) {
791
- continue;
792
- }
793
- const resolved = path4.join(rootDir, entry.name);
794
- if (entry.isDirectory()) {
795
- files.push(...await walkHtmlFiles(resolved));
796
- continue;
1090
+
1091
+ // src/routes.ts
1092
+ import { promises as fs5 } from "fs";
1093
+ import path6 from "path";
1094
+ var scanRoutes = async (options) => {
1095
+ const rootDir = options.rootDir;
1096
+ const routes = /* @__PURE__ */ new Set();
1097
+ if (options.includeApp !== false) {
1098
+ const appDir = path6.join(rootDir, "app");
1099
+ if (await dirExists(appDir)) {
1100
+ const files = await walkDir(appDir);
1101
+ for (const file of files) {
1102
+ if (!isAppPageFile(file)) {
1103
+ continue;
1104
+ }
1105
+ const route = toAppRoute(appDir, file);
1106
+ if (route) {
1107
+ routes.add(route);
1108
+ }
1109
+ }
797
1110
  }
798
- if (entry.isFile() && entry.name.endsWith(".html")) {
799
- files.push(resolved);
1111
+ }
1112
+ if (options.includePages !== false) {
1113
+ const pagesDir = path6.join(rootDir, "pages");
1114
+ if (await dirExists(pagesDir)) {
1115
+ const files = await walkDir(pagesDir);
1116
+ for (const file of files) {
1117
+ if (!isPagesFile(file)) {
1118
+ continue;
1119
+ }
1120
+ const route = toPagesRoute(pagesDir, file);
1121
+ if (route) {
1122
+ routes.add(route);
1123
+ }
1124
+ }
800
1125
  }
801
1126
  }
802
- return files;
1127
+ return Array.from(routes).sort();
803
1128
  };
804
- var filePathToRoute = (rootDir, filePath) => {
805
- const relative = path4.relative(rootDir, filePath).replace(/\\/g, "/");
806
- if (relative === "index.html") {
807
- return "/";
808
- }
809
- if (relative.endsWith("/index.html")) {
810
- return `/${relative.slice(0, -"/index.html".length)}`;
1129
+ var isAppPageFile = (filePath) => /\/page\.(t|j)sx?$/.test(filePath) || /\/page\.mdx$/.test(filePath);
1130
+ var isPagesFile = (filePath) => /\.(t|j)sx?$/.test(filePath) || /\.mdx$/.test(filePath);
1131
+ var toAppRoute = (appDir, filePath) => {
1132
+ const relative = path6.relative(appDir, filePath);
1133
+ const segments = relative.split(path6.sep);
1134
+ if (segments.length === 0) {
1135
+ return null;
811
1136
  }
812
- if (relative.endsWith(".html")) {
813
- return `/${relative.slice(0, -".html".length)}`;
1137
+ segments.pop();
1138
+ const routeSegments = segments.filter((segment) => segment.length > 0).filter((segment) => !isGroupSegment(segment)).filter((segment) => !isParallelSegment(segment));
1139
+ if (routeSegments.length === 0) {
1140
+ return "/";
814
1141
  }
815
- return null;
1142
+ return `/${routeSegments.join("/")}`;
816
1143
  };
817
- var extractSchemaNodes = (html, filePath) => {
818
- const nodes = [];
819
- const warnings = [];
820
- let invalidBlocks = 0;
821
- let scriptIndex = 0;
822
- for (const match of html.matchAll(SCRIPT_TAG_REGEX)) {
823
- scriptIndex += 1;
824
- const attributes = match[1] ?? "";
825
- if (!JSON_LD_TYPE_REGEX.test(attributes)) {
826
- continue;
827
- }
828
- const scriptBody = (match[2] ?? "").trim();
829
- if (!scriptBody) {
830
- continue;
831
- }
832
- let parsed;
833
- try {
834
- parsed = JSON.parse(scriptBody);
835
- } catch {
836
- invalidBlocks += 1;
837
- warnings.push({
838
- file: filePath,
839
- message: `Invalid JSON-LD block at script #${scriptIndex}`
840
- });
841
- continue;
842
- }
843
- const normalized = normalizeParsedBlock(parsed);
844
- nodes.push(...normalized);
1144
+ var toPagesRoute = (pagesDir, filePath) => {
1145
+ const relative = path6.relative(pagesDir, filePath);
1146
+ const segments = relative.split(path6.sep);
1147
+ if (segments.length === 0) {
1148
+ return null;
1149
+ }
1150
+ if (segments[0] === "api") {
1151
+ return null;
845
1152
  }
846
- return { nodes, invalidBlocks, warnings };
847
- };
848
- var normalizeParsedBlock = (value) => {
849
- if (Array.isArray(value)) {
850
- return value.filter(isJsonObject);
1153
+ const fileName = segments.pop();
1154
+ if (!fileName) {
1155
+ return null;
851
1156
  }
852
- if (!isJsonObject(value)) {
853
- return [];
1157
+ const baseName = fileName.replace(/\.[^/.]+$/, "");
1158
+ if (baseName.startsWith("_")) {
1159
+ return null;
854
1160
  }
855
- const graph = value["@graph"];
856
- if (Array.isArray(graph)) {
857
- return graph.filter(isJsonObject);
1161
+ const filtered = segments.filter((segment) => segment.length > 0);
1162
+ if (baseName !== "index") {
1163
+ filtered.push(baseName);
858
1164
  }
859
- return [value];
1165
+ if (filtered.length === 0) {
1166
+ return "/";
1167
+ }
1168
+ return `/${filtered.join("/")}`;
860
1169
  };
861
- var isJsonObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
862
- var buildRouteDriftDetail = (route, beforeNodes, afterNodes) => {
863
- const beforeTypes = new Set(beforeNodes.map((node) => schemaTypeLabel(node)));
864
- const afterTypes = new Set(afterNodes.map((node) => schemaTypeLabel(node)));
865
- const addedTypes = Array.from(afterTypes).filter((type) => !beforeTypes.has(type)).sort();
866
- const removedTypes = Array.from(beforeTypes).filter((type) => !afterTypes.has(type)).sort();
867
- return {
868
- route,
869
- beforeBlocks: beforeNodes.length,
870
- afterBlocks: afterNodes.length,
871
- addedTypes,
872
- removedTypes
873
- };
1170
+ var isGroupSegment = (segment) => segment.startsWith("(") && segment.endsWith(")");
1171
+ var isParallelSegment = (segment) => segment.startsWith("@");
1172
+ var dirExists = async (dirPath) => {
1173
+ try {
1174
+ const stat = await fs5.stat(dirPath);
1175
+ return stat.isDirectory();
1176
+ } catch {
1177
+ return false;
1178
+ }
874
1179
  };
875
- var schemaTypeLabel = (node) => {
876
- const type = node["@type"];
877
- return typeof type === "string" && type.trim().length > 0 ? type : "(unknown)";
1180
+ var walkDir = async (dirPath) => {
1181
+ const entries = await fs5.readdir(dirPath, { withFileTypes: true });
1182
+ const files = [];
1183
+ for (const entry of entries) {
1184
+ const resolved = path6.join(dirPath, entry.name);
1185
+ if (entry.isDirectory()) {
1186
+ files.push(...await walkDir(resolved));
1187
+ } else if (entry.isFile()) {
1188
+ files.push(resolved);
1189
+ }
1190
+ }
1191
+ return files;
878
1192
  };
879
1193
 
880
- // src/index.ts
1194
+ // src/commands/init.ts
881
1195
  import { createInterface } from "readline/promises";
882
1196
  import { stdin as input, stdout as output } from "process";
883
- var program = new Command();
884
- program.name("schemasentry").description("Schema Sentry CLI").version(resolveCliVersion());
885
- program.command("validate").description("Validate schema coverage and rules").option(
1197
+ var initCommand = new Command2("init").description("Interactive setup wizard").option(
886
1198
  "-m, --manifest <path>",
887
1199
  "Path to manifest JSON",
888
1200
  "schema-sentry.manifest.json"
@@ -890,230 +1202,383 @@ program.command("validate").description("Validate schema coverage and rules").op
890
1202
  "-d, --data <path>",
891
1203
  "Path to schema data JSON",
892
1204
  "schema-sentry.data.json"
893
- ).option("-c, --config <path>", "Path to config JSON").option("--format <format>", "Report format (json|html)", "json").option("--annotations <provider>", "Emit CI annotations (none|github)", "none").option("-o, --output <path>", "Write report output to file").option("--recommended", "Enable recommended field checks").option("--no-recommended", "Disable recommended field checks").action(async (options) => {
894
- const start = Date.now();
895
- const format = resolveOutputFormat(options.format);
896
- const annotationsMode = resolveAnnotationsMode(options.annotations);
897
- const recommended = await resolveRecommendedOption(options.config);
898
- const manifestPath = path5.resolve(process.cwd(), options.manifest);
899
- const dataPath = path5.resolve(process.cwd(), options.data);
900
- let raw;
901
- try {
902
- raw = await readFile(manifestPath, "utf8");
903
- } catch (error) {
904
- printCliError(
905
- "manifest.not_found",
906
- `Manifest not found at ${manifestPath}`,
907
- "Run `schemasentry init` to generate starter files."
908
- );
909
- process.exit(1);
910
- return;
1205
+ ).option("-y, --yes", "Use defaults and skip prompts").option("-f, --force", "Overwrite existing files").option("--scan", "Scan the filesystem for routes and add WebPage entries").option("--root <path>", "Project root for scanning", ".").action(async (options) => {
1206
+ const manifestPath = path7.resolve(process.cwd(), options.manifest);
1207
+ const dataPath = path7.resolve(process.cwd(), options.data);
1208
+ const force = options.force ?? false;
1209
+ const useDefaults = options.yes ?? false;
1210
+ const answers = useDefaults ? getDefaultAnswers() : await promptAnswers();
1211
+ const scannedRoutes = options.scan ? await scanRoutes({ rootDir: path7.resolve(process.cwd(), options.root ?? ".") }) : [];
1212
+ if (options.scan && scannedRoutes.length === 0) {
1213
+ console.error("No routes found during scan.");
911
1214
  }
912
- let manifest;
1215
+ const [overwriteManifest, overwriteData] = await resolveOverwrites({
1216
+ manifestPath,
1217
+ dataPath,
1218
+ force,
1219
+ interactive: !useDefaults
1220
+ });
1221
+ const result = await writeInitFiles({
1222
+ manifestPath,
1223
+ dataPath,
1224
+ overwriteManifest,
1225
+ overwriteData,
1226
+ answers,
1227
+ scannedRoutes
1228
+ });
1229
+ printInitSummary({ manifestPath, dataPath, result });
1230
+ });
1231
+ async function promptAnswers() {
1232
+ const defaults = getDefaultAnswers();
1233
+ const rl = createInterface({ input, output });
913
1234
  try {
914
- manifest = JSON.parse(raw);
915
- } catch (error) {
916
- printCliError(
917
- "manifest.invalid_json",
918
- "Manifest is not valid JSON",
919
- "Check the JSON syntax or regenerate with `schemasentry init`."
920
- );
921
- process.exit(1);
922
- return;
1235
+ const siteName = await ask(rl, "Site name", defaults.siteName);
1236
+ const siteUrl = await ask(rl, "Base URL", defaults.siteUrl);
1237
+ const authorName = await ask(rl, "Primary author name", defaults.authorName);
1238
+ return { siteName, siteUrl, authorName };
1239
+ } finally {
1240
+ rl.close();
923
1241
  }
924
- if (!isManifest(manifest)) {
925
- printCliError(
926
- "manifest.invalid_shape",
927
- "Manifest must contain a 'routes' object with string array values",
928
- "Ensure each route maps to an array of schema type names."
929
- );
930
- process.exit(1);
931
- return;
1242
+ }
1243
+ async function ask(rl, question, fallback) {
1244
+ const answer = (await rl.question(`${question} (${fallback}): `)).trim();
1245
+ return answer.length > 0 ? answer : fallback;
1246
+ }
1247
+ async function resolveOverwrites(options) {
1248
+ const { manifestPath, dataPath, force, interactive } = options;
1249
+ if (force) {
1250
+ return [true, true];
932
1251
  }
933
- let dataRaw;
934
- try {
935
- dataRaw = await readFile(dataPath, "utf8");
936
- } catch (error) {
937
- printCliError(
938
- "data.not_found",
939
- `Schema data not found at ${dataPath}`,
940
- "Run `schemasentry init` to generate starter files."
941
- );
942
- process.exit(1);
943
- return;
1252
+ const manifestExists = await fileExists2(manifestPath);
1253
+ const dataExists = await fileExists2(dataPath);
1254
+ if (!interactive) {
1255
+ return [false, false];
944
1256
  }
945
- let data;
1257
+ const rl = createInterface({ input, output });
946
1258
  try {
947
- data = JSON.parse(dataRaw);
948
- } catch (error) {
949
- printCliError(
950
- "data.invalid_json",
951
- "Schema data is not valid JSON",
952
- "Check the JSON syntax or regenerate with `schemasentry init`."
953
- );
954
- process.exit(1);
955
- return;
1259
+ const overwriteManifest = manifestExists ? await confirm(rl, `Manifest exists at ${manifestPath}. Overwrite?`, false) : false;
1260
+ const overwriteData = dataExists ? await confirm(rl, `Data file exists at ${dataPath}. Overwrite?`, false) : false;
1261
+ return [overwriteManifest, overwriteData];
1262
+ } finally {
1263
+ rl.close();
956
1264
  }
957
- if (!isSchemaData(data)) {
958
- printCliError(
959
- "data.invalid_shape",
960
- "Schema data must contain a 'routes' object with array values",
961
- "Ensure each route maps to an array of JSON-LD blocks."
962
- );
963
- process.exit(1);
964
- return;
1265
+ }
1266
+ async function confirm(rl, question, defaultValue) {
1267
+ const hint = defaultValue ? "Y/n" : "y/N";
1268
+ const answer = (await rl.question(`${question} (${hint}): `)).trim().toLowerCase();
1269
+ if (!answer) {
1270
+ return defaultValue;
965
1271
  }
966
- const report = buildReport(manifest, data, { recommended });
967
- await emitReport({
968
- report,
969
- format,
970
- outputPath: options.output,
971
- title: "Schema Sentry Validate Report"
972
- });
973
- emitAnnotations(report, annotationsMode, "validate");
974
- printValidateSummary(report, Date.now() - start);
975
- process.exit(report.ok ? 0 : 1);
976
- });
977
- program.command("init").description("Interactive setup wizard").option(
978
- "-m, --manifest <path>",
979
- "Path to manifest JSON",
980
- "schema-sentry.manifest.json"
981
- ).option(
982
- "-d, --data <path>",
983
- "Path to schema data JSON",
984
- "schema-sentry.data.json"
985
- ).option("-y, --yes", "Use defaults and skip prompts").option("-f, --force", "Overwrite existing files").option("--scan", "Scan the filesystem for routes and add WebPage entries").option("--root <path>", "Project root for scanning", ".").action(async (options) => {
986
- const manifestPath = path5.resolve(process.cwd(), options.manifest);
987
- const dataPath = path5.resolve(process.cwd(), options.data);
988
- const force = options.force ?? false;
989
- const useDefaults = options.yes ?? false;
990
- const answers = useDefaults ? getDefaultAnswers() : await promptAnswers();
991
- const scannedRoutes = options.scan ? await scanRoutes({ rootDir: path5.resolve(process.cwd(), options.root ?? ".") }) : [];
992
- if (options.scan && scannedRoutes.length === 0) {
993
- console.error("No routes found during scan.");
1272
+ return answer === "y" || answer === "yes";
1273
+ }
1274
+ function printInitSummary(options) {
1275
+ const { manifestPath, dataPath, result } = options;
1276
+ const created = [];
1277
+ if (result.manifest !== "skipped") {
1278
+ created.push(`${manifestPath} (${result.manifest})`);
1279
+ }
1280
+ if (result.data !== "skipped") {
1281
+ created.push(`${dataPath} (${result.data})`);
1282
+ }
1283
+ if (created.length > 0) {
1284
+ console.log("Schema Sentry init complete.");
1285
+ console.log(`Created ${created.length} file(s):`);
1286
+ created.forEach((entry) => console.log(`- ${entry}`));
1287
+ }
1288
+ if (result.manifest === "skipped" || result.data === "skipped") {
1289
+ console.log("Some files were skipped. Use --force to overwrite.");
1290
+ }
1291
+ if (result.manifest === "skipped" && result.data === "skipped") {
1292
+ console.log("No files were written.");
1293
+ }
1294
+ if (created.length > 0) {
1295
+ console.log("Next: run `schemasentry validate` to verify your setup.");
1296
+ }
1297
+ }
1298
+
1299
+ // src/commands/audit.ts
1300
+ import { Command as Command3 } from "commander";
1301
+ import { readFile as readFile2 } from "fs/promises";
1302
+ import { promises as fs6 } from "fs";
1303
+ import path8 from "path";
1304
+ import chalk2 from "chalk";
1305
+
1306
+ // src/audit.ts
1307
+ import {
1308
+ validateSchema as validateSchema2
1309
+ } from "@schemasentry/core";
1310
+
1311
+ // src/coverage.ts
1312
+ var buildCoverageResult = (input2, data) => {
1313
+ const manifestRoutes = input2.expectedTypesByRoute ?? {};
1314
+ const dataRoutes = data.routes ?? {};
1315
+ const derivedRequiredRoutes = Object.entries(manifestRoutes).filter(([, types]) => (types ?? []).length > 0).map(([route]) => route);
1316
+ const requiredRoutes = /* @__PURE__ */ new Set([
1317
+ ...input2.requiredRoutes ?? [],
1318
+ ...derivedRequiredRoutes
1319
+ ]);
1320
+ const allRoutes = Array.from(
1321
+ /* @__PURE__ */ new Set([
1322
+ ...Object.keys(manifestRoutes),
1323
+ ...requiredRoutes,
1324
+ ...Object.keys(dataRoutes)
1325
+ ])
1326
+ ).sort();
1327
+ const issuesByRoute = {};
1328
+ const summary = {
1329
+ missingRoutes: 0,
1330
+ missingTypes: 0,
1331
+ unlistedRoutes: 0
1332
+ };
1333
+ for (const route of allRoutes) {
1334
+ const issues = [];
1335
+ const expectedTypes = manifestRoutes[route] ?? [];
1336
+ const nodes = dataRoutes[route] ?? [];
1337
+ const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
1338
+ if (requiredRoutes.has(route) && nodes.length === 0) {
1339
+ issues.push({
1340
+ path: `routes["${route}"]`,
1341
+ message: "No schema blocks found for route",
1342
+ severity: "error",
1343
+ ruleId: "coverage.missing_route"
1344
+ });
1345
+ summary.missingRoutes += 1;
1346
+ }
1347
+ for (const expectedType of expectedTypes) {
1348
+ if (!foundTypes.includes(expectedType)) {
1349
+ issues.push({
1350
+ path: `routes["${route}"].types`,
1351
+ message: `Missing expected schema type '${expectedType}'`,
1352
+ severity: "error",
1353
+ ruleId: "coverage.missing_type"
1354
+ });
1355
+ summary.missingTypes += 1;
1356
+ }
1357
+ }
1358
+ if (!manifestRoutes[route] && !requiredRoutes.has(route) && nodes.length > 0) {
1359
+ issues.push({
1360
+ path: `routes["${route}"]`,
1361
+ message: "Route has schema but is missing from manifest",
1362
+ severity: "warn",
1363
+ ruleId: "coverage.unlisted_route"
1364
+ });
1365
+ summary.unlistedRoutes += 1;
1366
+ }
1367
+ if (issues.length > 0) {
1368
+ issuesByRoute[route] = issues;
1369
+ }
994
1370
  }
995
- const [overwriteManifest, overwriteData] = await resolveOverwrites({
996
- manifestPath,
997
- dataPath,
998
- force,
999
- interactive: !useDefaults
1000
- });
1001
- const result = await writeInitFiles({
1002
- manifestPath,
1003
- dataPath,
1004
- overwriteManifest,
1005
- overwriteData,
1006
- answers,
1007
- scannedRoutes
1371
+ return {
1372
+ allRoutes,
1373
+ issuesByRoute,
1374
+ summary
1375
+ };
1376
+ };
1377
+
1378
+ // src/audit.ts
1379
+ var buildAuditReport = (data, options = {}) => {
1380
+ const dataRoutes = data.routes ?? {};
1381
+ const manifestRoutes = options.manifest?.routes ?? {};
1382
+ const coverageEnabled = Boolean(options.manifest || options.requiredRoutes?.length);
1383
+ const coverage = coverageEnabled ? buildCoverageResult(
1384
+ {
1385
+ expectedTypesByRoute: manifestRoutes,
1386
+ requiredRoutes: options.requiredRoutes
1387
+ },
1388
+ data
1389
+ ) : null;
1390
+ const allRoutes = coverageEnabled ? coverage?.allRoutes ?? [] : Object.keys(dataRoutes).sort();
1391
+ const routes = allRoutes.map((route) => {
1392
+ const expectedTypes = coverageEnabled ? manifestRoutes[route] ?? [] : [];
1393
+ const nodes = dataRoutes[route] ?? [];
1394
+ const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
1395
+ const validation = validateSchema2(nodes, options);
1396
+ const issues = [
1397
+ ...validation.issues,
1398
+ ...coverage?.issuesByRoute[route] ?? []
1399
+ ];
1400
+ const errorCount = issues.filter((issue) => issue.severity === "error").length;
1401
+ const warnCount = issues.filter((issue) => issue.severity === "warn").length;
1402
+ const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
1403
+ return {
1404
+ route,
1405
+ ok: errorCount === 0,
1406
+ score,
1407
+ issues,
1408
+ expectedTypes,
1409
+ foundTypes
1410
+ };
1008
1411
  });
1009
- printInitSummary({ manifestPath, dataPath, result });
1010
- });
1011
- program.command("audit").description("Analyze schema health and report issues").option(
1412
+ const summaryErrors = routes.reduce(
1413
+ (count, route) => count + route.issues.filter((i) => i.severity === "error").length,
1414
+ 0
1415
+ );
1416
+ const summaryWarnings = routes.reduce(
1417
+ (count, route) => count + route.issues.filter((i) => i.severity === "warn").length,
1418
+ 0
1419
+ );
1420
+ const summaryScore = routes.length === 0 ? 0 : Math.round(
1421
+ routes.reduce((total, route) => total + route.score, 0) / routes.length
1422
+ );
1423
+ return {
1424
+ ok: summaryErrors === 0,
1425
+ summary: {
1426
+ routes: routes.length,
1427
+ errors: summaryErrors,
1428
+ warnings: summaryWarnings,
1429
+ score: summaryScore,
1430
+ ...coverageEnabled ? { coverage: coverage?.summary } : {}
1431
+ },
1432
+ routes
1433
+ };
1434
+ };
1435
+
1436
+ // src/commands/audit.ts
1437
+ var auditCommand = new Command3("audit").description("Analyze schema health and check for ghost routes (routes in manifest without Schema components)").option(
1012
1438
  "-d, --data <path>",
1013
- "Path to schema data JSON",
1439
+ "Path to schema data JSON (optional, for legacy mode)",
1014
1440
  "schema-sentry.data.json"
1015
- ).option("-m, --manifest <path>", "Path to manifest JSON (optional)").option("--scan", "Scan the filesystem for routes").option("--root <path>", "Project root for scanning", ".").option("-c, --config <path>", "Path to config JSON").option("--format <format>", "Report format (json|html)", "json").option("--annotations <provider>", "Emit CI annotations (none|github)", "none").option("-o, --output <path>", "Write report output to file").option("--recommended", "Enable recommended field checks").option("--no-recommended", "Disable recommended field checks").action(async (options) => {
1441
+ ).option("-m, --manifest <path>", "Path to manifest JSON (optional)", "schema-sentry.manifest.json").option("--scan", "Scan the filesystem for routes").option("--root <path>", "Project root for scanning", ".").option("--app-dir <path>", "Path to Next.js app directory for source scanning", "./app").option("--source-scan", "Enable source file scanning for ghost route detection", true).option("-c, --config <path>", "Path to config JSON").option("--format <format>", "Report format (json|html)", "json").option("--annotations <provider>", "Emit CI annotations (none|github)", "none").option("-o, --output <path>", "Write report output to file").option("--recommended", "Enable recommended field checks").option("--no-recommended", "Disable recommended field checks").action(async (options) => {
1016
1442
  const start = Date.now();
1443
+ const skipSourceScan = process.env.SKIP_SOURCE_SCAN === "1";
1444
+ const sourceScanEnabled = skipSourceScan ? false : options.sourceScan ?? true;
1017
1445
  const format = resolveOutputFormat(options.format);
1018
1446
  const annotationsMode = resolveAnnotationsMode(options.annotations);
1019
1447
  const recommended = await resolveRecommendedOption(options.config);
1020
- const dataPath = path5.resolve(process.cwd(), options.data);
1021
- let dataRaw;
1022
- try {
1023
- dataRaw = await readFile(dataPath, "utf8");
1024
- } catch (error) {
1025
- printCliError(
1026
- "data.not_found",
1027
- `Schema data not found at ${dataPath}`,
1028
- "Run `schemasentry init` to generate starter files."
1029
- );
1030
- process.exit(1);
1031
- return;
1448
+ const appDir = path8.resolve(process.cwd(), options.appDir ?? "./app");
1449
+ console.error(chalk2.blue.bold("\u{1F50D} Schema Sentry Audit"));
1450
+ console.error(chalk2.gray("Analyzing schema health and checking for ghost routes...\n"));
1451
+ let manifest;
1452
+ const manifestPath = options.manifest ? path8.resolve(process.cwd(), options.manifest) : void 0;
1453
+ if (manifestPath) {
1454
+ try {
1455
+ const manifestRaw = await readFile2(manifestPath, "utf8");
1456
+ manifest = JSON.parse(manifestRaw);
1457
+ if (!isManifest(manifest)) {
1458
+ printCliError(
1459
+ "manifest.invalid_shape",
1460
+ "Manifest must contain a 'routes' object with string array values",
1461
+ "Ensure each route maps to an array of schema type names."
1462
+ );
1463
+ process.exit(1);
1464
+ return;
1465
+ }
1466
+ } catch {
1467
+ manifest = void 0;
1468
+ }
1032
1469
  }
1033
1470
  let data;
1471
+ const dataPath = path8.resolve(process.cwd(), options.data);
1034
1472
  try {
1473
+ const dataRaw = await readFile2(dataPath, "utf8");
1035
1474
  data = JSON.parse(dataRaw);
1036
- } catch (error) {
1037
- printCliError(
1038
- "data.invalid_json",
1039
- "Schema data is not valid JSON",
1040
- "Check the JSON syntax or regenerate with `schemasentry init`."
1041
- );
1042
- process.exit(1);
1043
- return;
1044
- }
1045
- if (!isSchemaData(data)) {
1046
- printCliError(
1047
- "data.invalid_shape",
1048
- "Schema data must contain a 'routes' object with array values",
1049
- "Ensure each route maps to an array of JSON-LD blocks."
1050
- );
1051
- process.exit(1);
1052
- return;
1053
- }
1054
- let manifest;
1055
- if (options.manifest) {
1056
- const manifestPath = path5.resolve(process.cwd(), options.manifest);
1057
- let manifestRaw;
1058
- try {
1059
- manifestRaw = await readFile(manifestPath, "utf8");
1060
- } catch (error) {
1061
- printCliError(
1062
- "manifest.not_found",
1063
- `Manifest not found at ${manifestPath}`,
1064
- "Run `schemasentry init` to generate starter files."
1065
- );
1066
- process.exit(1);
1067
- return;
1068
- }
1069
- try {
1070
- manifest = JSON.parse(manifestRaw);
1071
- } catch (error) {
1072
- printCliError(
1073
- "manifest.invalid_json",
1074
- "Manifest is not valid JSON",
1075
- "Check the JSON syntax or regenerate with `schemasentry init`."
1076
- );
1077
- process.exit(1);
1078
- return;
1475
+ if (!isSchemaData(data)) {
1476
+ data = void 0;
1079
1477
  }
1080
- if (!isManifest(manifest)) {
1081
- printCliError(
1082
- "manifest.invalid_shape",
1083
- "Manifest must contain a 'routes' object with string array values",
1084
- "Ensure each route maps to an array of schema type names."
1085
- );
1086
- process.exit(1);
1087
- return;
1478
+ } catch {
1479
+ data = void 0;
1480
+ }
1481
+ const sourceScanResult = !sourceScanEnabled ? { routes: [], totalFiles: 0, filesWithSchema: 0, filesMissingSchema: 0 } : await scanSourceFiles({ rootDir: options.root ?? ".", appDir });
1482
+ const scannedRoutes = options.scan ? await scanRoutes({ rootDir: path8.resolve(process.cwd(), options.root ?? ".") }) : [];
1483
+ const ghostRoutes = [];
1484
+ if (manifest && sourceScanEnabled) {
1485
+ for (const route of Object.keys(manifest.routes)) {
1486
+ const sourceInfo = sourceScanResult.routes.find((r) => r.route === route);
1487
+ if (!sourceInfo?.hasSchemaUsage) {
1488
+ ghostRoutes.push(route);
1489
+ }
1088
1490
  }
1089
1491
  }
1090
- const requiredRoutes = options.scan ? await scanRoutes({ rootDir: path5.resolve(process.cwd(), options.root ?? ".") }) : [];
1091
- if (options.scan && requiredRoutes.length === 0) {
1092
- console.error("No routes found during scan.");
1093
- }
1094
- const report = buildAuditReport(data, {
1492
+ const report = buildAuditReport(data ?? { routes: {} }, {
1095
1493
  recommended,
1096
1494
  manifest,
1097
- requiredRoutes: requiredRoutes.length > 0 ? requiredRoutes : void 0
1495
+ requiredRoutes: scannedRoutes.length > 0 ? scannedRoutes : void 0
1098
1496
  });
1099
- await emitReport({
1497
+ if (format === "json") {
1498
+ if (options.output) {
1499
+ const outputPath = path8.resolve(process.cwd(), options.output);
1500
+ console.log(JSON.stringify(report, null, 2));
1501
+ } else {
1502
+ console.log(JSON.stringify(report, null, 2));
1503
+ }
1504
+ } else {
1505
+ if (options.output) {
1506
+ const outputPath = path8.resolve(process.cwd(), options.output);
1507
+ const html = renderHtmlReport(report, { title: "Schema Sentry Audit Report" });
1508
+ await fs6.writeFile(outputPath, html, "utf8");
1509
+ console.error(chalk2.green(`
1510
+ \u2713 HTML report written to ${outputPath}`));
1511
+ }
1512
+ }
1513
+ if (annotationsMode === "github") {
1514
+ emitGitHubAnnotations(report, "audit");
1515
+ }
1516
+ printEnhancedAuditSummary({
1100
1517
  report,
1101
- format,
1102
- outputPath: options.output,
1103
- title: "Schema Sentry Audit Report"
1518
+ hasManifest: Boolean(manifest),
1519
+ ghostRoutes,
1520
+ sourceScan: sourceScanResult,
1521
+ durationMs: Date.now() - start
1104
1522
  });
1105
- emitAnnotations(report, annotationsMode, "audit");
1106
- printAuditSummary(report, Boolean(manifest), Date.now() - start);
1107
- process.exit(report.ok ? 0 : 1);
1523
+ process.exit(report.ok && ghostRoutes.length === 0 ? 0 : 1);
1108
1524
  });
1109
- program.command("collect").description("Collect JSON-LD blocks from built HTML output").option("--root <path>", "Root directory to scan for HTML files", ".").option("--routes <routes...>", "Only collect specific routes (repeat or comma-separated)").option("--strict-routes", "Fail when any route passed via --routes is missing").option("--format <format>", "Output format (json)", "json").option("-o, --output <path>", "Write collected schema data to file").option("--check", "Compare collected output with an existing schema data file").option(
1525
+ function printEnhancedAuditSummary(options) {
1526
+ const { report, hasManifest, ghostRoutes, sourceScan, durationMs } = options;
1527
+ console.error("");
1528
+ if (report.ok && ghostRoutes.length === 0) {
1529
+ console.error(chalk2.green.bold("\u2705 Audit passed"));
1530
+ } else {
1531
+ console.error(chalk2.red.bold("\u274C Audit found issues"));
1532
+ }
1533
+ console.error(chalk2.gray(`
1534
+ \u{1F4CA} Summary:`));
1535
+ console.error(` Routes analyzed: ${report.summary.routes}`);
1536
+ console.error(` Score: ${report.summary.score}/100`);
1537
+ console.error(` Duration: ${formatDuration(durationMs)}`);
1538
+ if (sourceScan.totalFiles > 0) {
1539
+ console.error(chalk2.gray(`
1540
+ \u{1F4C1} Source Code Analysis:`));
1541
+ console.error(` Page files found: ${sourceScan.totalFiles}`);
1542
+ console.error(chalk2.green(` \u2705 With Schema components: ${sourceScan.filesWithSchema}`));
1543
+ if (sourceScan.filesMissingSchema > 0) {
1544
+ console.error(chalk2.red(` \u274C Missing Schema: ${sourceScan.filesMissingSchema}`));
1545
+ }
1546
+ }
1547
+ if (hasManifest && ghostRoutes.length > 0) {
1548
+ console.error(chalk2.red(`
1549
+ \u{1F47B} Ghost Routes (${ghostRoutes.length}):`));
1550
+ console.error(chalk2.gray(" Routes in manifest but no <Schema> component in source:"));
1551
+ for (const route of ghostRoutes.slice(0, 5)) {
1552
+ console.error(chalk2.red(` \u274C ${route}`));
1553
+ }
1554
+ if (ghostRoutes.length > 5) {
1555
+ console.error(chalk2.gray(` ... and ${ghostRoutes.length - 5} more`));
1556
+ }
1557
+ console.error(chalk2.gray("\n These routes are in your manifest but don't have Schema components."));
1558
+ console.error(chalk2.gray(" Run `schemasentry scaffold` to see what code to add."));
1559
+ }
1560
+ if (report.summary.errors > 0 || report.summary.warnings > 0) {
1561
+ console.error(chalk2.gray(`
1562
+ \u{1F4DD} Data File Issues:`));
1563
+ console.error(` Errors: ${chalk2.red(report.summary.errors.toString())}`);
1564
+ console.error(` Warnings: ${chalk2.yellow(report.summary.warnings.toString())}`);
1565
+ }
1566
+ console.error("");
1567
+ }
1568
+
1569
+ // src/commands/collect.ts
1570
+ import { Command as Command4 } from "commander";
1571
+ import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3 } from "fs/promises";
1572
+ import path9 from "path";
1573
+ import { stableStringify as stableStringify5 } from "@schemasentry/core";
1574
+ var collectCommand = new Command4("collect").description("Collect JSON-LD blocks from built HTML output").option("--root <path>", "Root directory to scan for HTML files", ".").option("--routes <routes...>", "Only collect specific routes (repeat or comma-separated)").option("--strict-routes", "Fail when any route passed via --routes is missing").option("--format <format>", "Output format (json)", "json").option("-o, --output <path>", "Write collected schema data to file").option("--check", "Compare collected output with an existing schema data file").option(
1110
1575
  "-d, --data <path>",
1111
1576
  "Path to existing schema data JSON for --check",
1112
1577
  "schema-sentry.data.json"
1113
1578
  ).action(async (options) => {
1114
1579
  const start = Date.now();
1115
1580
  const format = resolveCollectOutputFormat(options.format);
1116
- const rootDir = path5.resolve(process.cwd(), options.root ?? ".");
1581
+ const rootDir = path9.resolve(process.cwd(), options.root ?? ".");
1117
1582
  const check = options.check ?? false;
1118
1583
  const requestedRoutes = normalizeRouteFilter(options.routes ?? []);
1119
1584
  const strictRoutes = options.strictRoutes ?? false;
@@ -1150,10 +1615,10 @@ program.command("collect").description("Collect JSON-LD blocks from built HTML o
1150
1615
  }
1151
1616
  let driftDetected = false;
1152
1617
  if (check) {
1153
- const existingPath = path5.resolve(process.cwd(), options.data);
1618
+ const existingPath = path9.resolve(process.cwd(), options.data);
1154
1619
  let existingRaw;
1155
1620
  try {
1156
- existingRaw = await readFile(existingPath, "utf8");
1621
+ existingRaw = await readFile3(existingPath, "utf8");
1157
1622
  } catch (error) {
1158
1623
  printCliError(
1159
1624
  "data.not_found",
@@ -1182,224 +1647,65 @@ program.command("collect").description("Collect JSON-LD blocks from built HTML o
1182
1647
  "Ensure each route maps to an array of JSON-LD blocks."
1183
1648
  );
1184
1649
  process.exit(1);
1185
- return;
1186
- }
1187
- const existingDataForCompare = requestedRoutes.length > 0 ? filterSchemaDataByRoutes(existingData, requestedRoutes) : existingData;
1188
- const drift = compareSchemaData(existingDataForCompare, collected.data);
1189
- driftDetected = drift.hasChanges;
1190
- if (driftDetected) {
1191
- console.error(formatSchemaDataDrift(drift));
1192
- } else {
1193
- console.error("collect | No schema data drift detected.");
1194
- }
1195
- }
1196
- const content = formatCollectOutput(collected.data, format);
1197
- if (options.output) {
1198
- const resolvedPath = path5.resolve(process.cwd(), options.output);
1199
- try {
1200
- await mkdir(path5.dirname(resolvedPath), { recursive: true });
1201
- await writeFile(resolvedPath, `${content}
1202
- `, "utf8");
1203
- console.error(`Collected data written to ${resolvedPath}`);
1204
- } catch (error) {
1205
- const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
1206
- printCliError(
1207
- "output.write_failed",
1208
- `Could not write collected data to ${resolvedPath}: ${reason}`
1209
- );
1210
- process.exit(1);
1211
- return;
1212
- }
1213
- } else if (!check) {
1214
- console.log(content);
1215
- }
1216
- printCollectWarnings(collected.warnings);
1217
- printCollectSummary({
1218
- stats: collected.stats,
1219
- durationMs: Date.now() - start,
1220
- checked: check,
1221
- driftDetected,
1222
- requestedRoutes: collected.requestedRoutes,
1223
- missingRoutes: collected.missingRoutes,
1224
- strictRoutes
1225
- });
1226
- process.exit(driftDetected ? 1 : 0);
1227
- });
1228
- function isManifest(value) {
1229
- if (!value || typeof value !== "object") {
1230
- return false;
1231
- }
1232
- const manifest = value;
1233
- if (!manifest.routes || typeof manifest.routes !== "object") {
1234
- return false;
1235
- }
1236
- for (const entry of Object.values(manifest.routes)) {
1237
- if (!Array.isArray(entry) || entry.some((item) => typeof item !== "string")) {
1238
- return false;
1239
- }
1240
- }
1241
- return true;
1242
- }
1243
- function isSchemaData(value) {
1244
- if (!value || typeof value !== "object") {
1245
- return false;
1246
- }
1247
- const data = value;
1248
- if (!data.routes || typeof data.routes !== "object") {
1249
- return false;
1250
- }
1251
- for (const entry of Object.values(data.routes)) {
1252
- if (!Array.isArray(entry)) {
1253
- return false;
1254
- }
1255
- }
1256
- return true;
1257
- }
1258
- function resolveOutputFormat(value) {
1259
- const format = (value ?? "json").trim().toLowerCase();
1260
- if (format === "json" || format === "html") {
1261
- return format;
1262
- }
1263
- printCliError(
1264
- "output.invalid_format",
1265
- `Unsupported report format '${value ?? ""}'`,
1266
- "Use --format json or --format html."
1267
- );
1268
- process.exit(1);
1269
- return "json";
1270
- }
1271
- function resolveAnnotationsMode(value) {
1272
- const mode = (value ?? "none").trim().toLowerCase();
1273
- if (mode === "none" || mode === "github") {
1274
- return mode;
1275
- }
1276
- printCliError(
1277
- "annotations.invalid_provider",
1278
- `Unsupported annotations provider '${value ?? ""}'`,
1279
- "Use --annotations none or --annotations github."
1280
- );
1281
- process.exit(1);
1282
- return "none";
1283
- }
1284
- function resolveCollectOutputFormat(value) {
1285
- const format = (value ?? "json").trim().toLowerCase();
1286
- if (format === "json") {
1287
- return format;
1650
+ return;
1651
+ }
1652
+ const existingDataForCompare = requestedRoutes.length > 0 ? filterSchemaDataByRoutes(existingData, requestedRoutes) : existingData;
1653
+ const drift = compareSchemaData(existingDataForCompare, collected.data);
1654
+ driftDetected = drift.hasChanges;
1655
+ if (driftDetected) {
1656
+ console.error(formatSchemaDataDrift(drift));
1657
+ } else {
1658
+ console.error("collect | No schema data drift detected.");
1659
+ }
1288
1660
  }
1289
- printCliError(
1290
- "output.invalid_format",
1291
- `Unsupported collect output format '${value ?? ""}'`,
1292
- "Use --format json."
1293
- );
1294
- process.exit(1);
1295
- return "json";
1296
- }
1297
- function formatReportOutput(report, format, title) {
1298
- if (format === "html") {
1299
- return renderHtmlReport(report, { title });
1661
+ const content = formatCollectOutput(collected.data, format);
1662
+ if (options.output) {
1663
+ const resolvedPath = path9.resolve(process.cwd(), options.output);
1664
+ try {
1665
+ await mkdir2(path9.dirname(resolvedPath), { recursive: true });
1666
+ await writeFile2(resolvedPath, `${content}
1667
+ `, "utf8");
1668
+ console.error(`Collected data written to ${resolvedPath}`);
1669
+ } catch (error) {
1670
+ const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
1671
+ printCliError(
1672
+ "output.write_failed",
1673
+ `Could not write collected data to ${resolvedPath}: ${reason}`
1674
+ );
1675
+ process.exit(1);
1676
+ return;
1677
+ }
1678
+ } else if (!check) {
1679
+ console.log(content);
1300
1680
  }
1301
- return stableStringify3(report);
1302
- }
1681
+ printCollectWarnings(collected.warnings);
1682
+ printCollectSummary({
1683
+ stats: collected.stats,
1684
+ durationMs: Date.now() - start,
1685
+ checked: check,
1686
+ driftDetected,
1687
+ requestedRoutes: collected.requestedRoutes,
1688
+ missingRoutes: collected.missingRoutes,
1689
+ strictRoutes
1690
+ });
1691
+ process.exit(driftDetected ? 1 : 0);
1692
+ });
1303
1693
  function formatCollectOutput(data, format) {
1304
1694
  if (format === "json") {
1305
- return stableStringify3(data);
1306
- }
1307
- return stableStringify3(data);
1308
- }
1309
- async function emitReport(options) {
1310
- const { report, format, outputPath, title } = options;
1311
- const content = formatReportOutput(report, format, title);
1312
- if (!outputPath) {
1313
- console.log(content);
1314
- return;
1315
- }
1316
- const resolvedPath = path5.resolve(process.cwd(), outputPath);
1317
- try {
1318
- await mkdir(path5.dirname(resolvedPath), { recursive: true });
1319
- await writeFile(resolvedPath, content, "utf8");
1320
- console.error(`Report written to ${resolvedPath}`);
1321
- } catch (error) {
1322
- const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
1323
- printCliError(
1324
- "output.write_failed",
1325
- `Could not write report to ${resolvedPath}: ${reason}`
1326
- );
1327
- process.exit(1);
1328
- }
1329
- }
1330
- function emitAnnotations(report, mode, commandLabel) {
1331
- if (mode !== "github") {
1332
- return;
1695
+ return stableStringify5(data);
1333
1696
  }
1334
- emitGitHubAnnotations(report, commandLabel);
1335
- }
1336
- function printCliError(code, message, suggestion) {
1337
- console.error(
1338
- stableStringify3({
1339
- ok: false,
1340
- errors: [
1341
- {
1342
- code,
1343
- message,
1344
- ...suggestion !== void 0 ? { suggestion } : {}
1345
- }
1346
- ]
1347
- })
1348
- );
1697
+ return stableStringify5(data);
1349
1698
  }
1350
- async function resolveRecommendedOption(configPath) {
1351
- const override = getRecommendedOverride(process.argv);
1352
- try {
1353
- const config = await loadConfig({ configPath });
1354
- return resolveRecommended(override, config);
1355
- } catch (error) {
1356
- if (error instanceof ConfigError) {
1357
- printCliError(error.code, error.message, error.suggestion);
1358
- process.exit(1);
1359
- return true;
1699
+ function filterSchemaDataByRoutes(data, routes) {
1700
+ const filteredRoutes = {};
1701
+ for (const route of routes) {
1702
+ if (Object.prototype.hasOwnProperty.call(data.routes, route)) {
1703
+ filteredRoutes[route] = data.routes[route];
1360
1704
  }
1361
- throw error;
1362
- }
1363
- }
1364
- function getRecommendedOverride(argv) {
1365
- if (argv.includes("--recommended")) {
1366
- return true;
1367
- }
1368
- if (argv.includes("--no-recommended")) {
1369
- return false;
1370
- }
1371
- return void 0;
1372
- }
1373
- function resolveCliVersion() {
1374
- try {
1375
- const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
1376
- const parsed = JSON.parse(raw);
1377
- return parsed.version ?? "0.0.0";
1378
- } catch {
1379
- return "0.0.0";
1380
- }
1381
- }
1382
- program.parse();
1383
- function printValidateSummary(report, durationMs) {
1384
- console.error(
1385
- formatSummaryLine("validate", {
1386
- ...report.summary,
1387
- durationMs,
1388
- coverage: report.summary.coverage
1389
- })
1390
- );
1391
- }
1392
- function printAuditSummary(report, coverageEnabled, durationMs) {
1393
- console.error(
1394
- formatSummaryLine("audit", {
1395
- ...report.summary,
1396
- durationMs,
1397
- coverage: report.summary.coverage
1398
- })
1399
- );
1400
- if (!coverageEnabled) {
1401
- console.error("Coverage checks skipped (no manifest provided).");
1402
1705
  }
1706
+ return {
1707
+ routes: filteredRoutes
1708
+ };
1403
1709
  }
1404
1710
  function printCollectWarnings(warnings) {
1405
1711
  if (warnings.length === 0) {
@@ -1445,81 +1751,429 @@ function printCollectSummary(options) {
1445
1751
  }
1446
1752
  console.error(`collect | ${parts.join(" | ")}`);
1447
1753
  }
1448
- function filterSchemaDataByRoutes(data, routes) {
1449
- const filteredRoutes = {};
1754
+
1755
+ // src/commands/scaffold.ts
1756
+ import { Command as Command5 } from "commander";
1757
+ import path11 from "path";
1758
+ import chalk4 from "chalk";
1759
+
1760
+ // src/scaffold.ts
1761
+ import { promises as fs7 } from "fs";
1762
+ import path10 from "path";
1763
+ import chalk3 from "chalk";
1764
+ import { stableStringify as stableStringify6 } from "@schemasentry/core";
1765
+
1766
+ // src/patterns.ts
1767
+ var DEFAULT_PATTERNS = [
1768
+ { pattern: "/blog/*", schemaType: "BlogPosting", priority: 10 },
1769
+ { pattern: "/blog", schemaType: "WebPage", priority: 5 },
1770
+ { pattern: "/products/*", schemaType: "Product", priority: 10 },
1771
+ { pattern: "/product/*", schemaType: "Product", priority: 10 },
1772
+ { pattern: "/faq", schemaType: "FAQPage", priority: 10 },
1773
+ { pattern: "/faqs", schemaType: "FAQPage", priority: 10 },
1774
+ { pattern: "/how-to/*", schemaType: "HowTo", priority: 10 },
1775
+ { pattern: "/howto/*", schemaType: "HowTo", priority: 10 },
1776
+ { pattern: "/events/*", schemaType: "Event", priority: 10 },
1777
+ { pattern: "/event/*", schemaType: "Event", priority: 10 },
1778
+ { pattern: "/reviews/*", schemaType: "Review", priority: 10 },
1779
+ { pattern: "/review/*", schemaType: "Review", priority: 10 },
1780
+ { pattern: "/videos/*", schemaType: "VideoObject", priority: 10 },
1781
+ { pattern: "/video/*", schemaType: "VideoObject", priority: 10 },
1782
+ { pattern: "/images/*", schemaType: "ImageObject", priority: 10 },
1783
+ { pattern: "/image/*", schemaType: "ImageObject", priority: 10 },
1784
+ { pattern: "/about", schemaType: "WebPage", priority: 10 },
1785
+ { pattern: "/contact", schemaType: "WebPage", priority: 10 },
1786
+ { pattern: "/", schemaType: "WebSite", priority: 1 }
1787
+ ];
1788
+ var matchRouteToPatterns = (route, patterns = DEFAULT_PATTERNS) => {
1789
+ const matches = [];
1790
+ for (const rule of patterns) {
1791
+ if (routeMatchesPattern(route, rule.pattern)) {
1792
+ matches.push({
1793
+ type: rule.schemaType,
1794
+ priority: rule.priority ?? 5
1795
+ });
1796
+ }
1797
+ }
1798
+ matches.sort((a, b) => b.priority - a.priority);
1799
+ return [...new Set(matches.map((m) => m.type))];
1800
+ };
1801
+ var routeMatchesPattern = (route, pattern) => {
1802
+ if (pattern === route) {
1803
+ return true;
1804
+ }
1805
+ if (pattern.endsWith("/*")) {
1806
+ const prefix = pattern.slice(0, -1);
1807
+ return route.startsWith(prefix);
1808
+ }
1809
+ const patternRegex = pattern.replace(/\*/g, "[^/]+").replace(/\?/g, ".");
1810
+ const regex = new RegExp(`^${patternRegex}$`);
1811
+ return regex.test(route);
1812
+ };
1813
+ var inferSchemaTypes = (routes, customPatterns) => {
1814
+ const patterns = customPatterns ?? DEFAULT_PATTERNS;
1815
+ const result = /* @__PURE__ */ new Map();
1450
1816
  for (const route of routes) {
1451
- if (Object.prototype.hasOwnProperty.call(data.routes, route)) {
1452
- filteredRoutes[route] = data.routes[route];
1817
+ const types = matchRouteToPatterns(route, patterns);
1818
+ if (types.length > 0) {
1819
+ result.set(route, types);
1453
1820
  }
1454
1821
  }
1822
+ return result;
1823
+ };
1824
+ var generateManifestEntries = (routes, customPatterns) => {
1825
+ const inferred = inferSchemaTypes(routes, customPatterns);
1826
+ const entries = {};
1827
+ for (const [route, types] of inferred) {
1828
+ entries[route] = types;
1829
+ }
1830
+ return entries;
1831
+ };
1832
+
1833
+ // src/scaffold.ts
1834
+ var scaffoldSchema = async (options) => {
1835
+ const manifest = await loadManifest(options.manifestPath);
1836
+ const data = await loadData(options.dataPath);
1837
+ const discoveredRoutes = await scanRoutes({ rootDir: options.rootDir });
1838
+ const routesNeedingSchema = discoveredRoutes.filter(
1839
+ (route) => !data.routes[route] || data.routes[route].length === 0
1840
+ );
1841
+ const inferredTypes = inferSchemaTypes(routesNeedingSchema, options.customPatterns);
1842
+ const manifestEntries = generateManifestEntries(
1843
+ routesNeedingSchema,
1844
+ options.customPatterns
1845
+ );
1846
+ const generatedSchemas = /* @__PURE__ */ new Map();
1847
+ for (const [route, types] of inferredTypes) {
1848
+ const schemas = types.map((type) => generateSchemaStub(type, route));
1849
+ generatedSchemas.set(route, schemas);
1850
+ }
1851
+ const wouldUpdate = routesNeedingSchema.length > 0;
1455
1852
  return {
1456
- routes: filteredRoutes
1853
+ routesToScaffold: routesNeedingSchema,
1854
+ generatedSchemas,
1855
+ manifestUpdates: manifestEntries,
1856
+ wouldUpdate
1457
1857
  };
1458
- }
1459
- async function promptAnswers() {
1460
- const defaults = getDefaultAnswers();
1461
- const rl = createInterface({ input, output });
1858
+ };
1859
+ var loadManifest = async (manifestPath) => {
1462
1860
  try {
1463
- const siteName = await ask(rl, "Site name", defaults.siteName);
1464
- const siteUrl = await ask(rl, "Base URL", defaults.siteUrl);
1465
- const authorName = await ask(rl, "Primary author name", defaults.authorName);
1466
- return { siteName, siteUrl, authorName };
1467
- } finally {
1468
- rl.close();
1861
+ const raw = await fs7.readFile(manifestPath, "utf8");
1862
+ return JSON.parse(raw);
1863
+ } catch {
1864
+ return { routes: {} };
1469
1865
  }
1470
- }
1471
- async function ask(rl, question, fallback) {
1472
- const answer = (await rl.question(`${question} (${fallback}): `)).trim();
1473
- return answer.length > 0 ? answer : fallback;
1474
- }
1475
- async function resolveOverwrites(options) {
1476
- const { manifestPath, dataPath, force, interactive } = options;
1477
- if (force) {
1478
- return [true, true];
1866
+ };
1867
+ var loadData = async (dataPath) => {
1868
+ try {
1869
+ const raw = await fs7.readFile(dataPath, "utf8");
1870
+ return JSON.parse(raw);
1871
+ } catch {
1872
+ return { routes: {} };
1479
1873
  }
1480
- const manifestExists = await fileExists(manifestPath);
1481
- const dataExists = await fileExists(dataPath);
1482
- if (!interactive) {
1483
- return [false, false];
1874
+ };
1875
+ var generateSchemaStub = (type, route) => {
1876
+ const base = {
1877
+ "@context": "https://schema.org",
1878
+ "@type": type
1879
+ };
1880
+ switch (type) {
1881
+ case "BlogPosting":
1882
+ return {
1883
+ ...base,
1884
+ headline: "Blog Post Title",
1885
+ author: {
1886
+ "@type": "Person",
1887
+ name: "Author Name"
1888
+ },
1889
+ datePublished: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
1890
+ url: route
1891
+ };
1892
+ case "Product":
1893
+ return {
1894
+ ...base,
1895
+ name: "Product Name",
1896
+ description: "Product description",
1897
+ offers: {
1898
+ "@type": "Offer",
1899
+ price: "0.00",
1900
+ priceCurrency: "USD"
1901
+ }
1902
+ };
1903
+ case "FAQPage":
1904
+ return {
1905
+ ...base,
1906
+ mainEntity: []
1907
+ };
1908
+ case "HowTo":
1909
+ return {
1910
+ ...base,
1911
+ name: "How-To Title",
1912
+ step: []
1913
+ };
1914
+ case "Event":
1915
+ return {
1916
+ ...base,
1917
+ name: "Event Name",
1918
+ startDate: (/* @__PURE__ */ new Date()).toISOString()
1919
+ };
1920
+ case "Organization":
1921
+ return {
1922
+ ...base,
1923
+ name: "Organization Name",
1924
+ url: route
1925
+ };
1926
+ case "WebSite":
1927
+ return {
1928
+ ...base,
1929
+ name: "Website Name",
1930
+ url: route
1931
+ };
1932
+ case "Article":
1933
+ return {
1934
+ ...base,
1935
+ headline: "Article Headline",
1936
+ author: {
1937
+ "@type": "Person",
1938
+ name: "Author Name"
1939
+ },
1940
+ datePublished: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1941
+ };
1942
+ default:
1943
+ return {
1944
+ ...base,
1945
+ name: `${type} Name`
1946
+ };
1484
1947
  }
1485
- const rl = createInterface({ input, output });
1486
- try {
1487
- const overwriteManifest = manifestExists ? await confirm(rl, `Manifest exists at ${manifestPath}. Overwrite?`, false) : false;
1488
- const overwriteData = dataExists ? await confirm(rl, `Data file exists at ${dataPath}. Overwrite?`, false) : false;
1489
- return [overwriteManifest, overwriteData];
1490
- } finally {
1491
- rl.close();
1948
+ };
1949
+ var generateComponentCode = (route, types, siteUrl = "https://yoursite.com") => {
1950
+ const filePath = routeToFilePath(route);
1951
+ const builders = [];
1952
+ const imports = /* @__PURE__ */ new Set(["Schema"]);
1953
+ for (const type of types) {
1954
+ imports.add(type);
1955
+ const builderCode = generateBuilderCode(type, route, siteUrl);
1956
+ builders.push(builderCode);
1957
+ }
1958
+ const code = `import { ${Array.from(imports).join(", ")} } from "@schemasentry/next";
1959
+
1960
+ ${builders.join("\n\n")}
1961
+
1962
+ export default function Page() {
1963
+ return (
1964
+ <>
1965
+ <Schema data={[${types.join(", ").toLowerCase()}]} />
1966
+ {/* Your existing page content */}
1967
+ </>
1968
+ );
1969
+ }`;
1970
+ return {
1971
+ route,
1972
+ filePath,
1973
+ code,
1974
+ imports: Array.from(imports),
1975
+ builders
1976
+ };
1977
+ };
1978
+ var routeToFilePath = (route) => {
1979
+ if (route === "/") {
1980
+ return "app/page.tsx";
1492
1981
  }
1493
- }
1494
- async function confirm(rl, question, defaultValue) {
1495
- const hint = defaultValue ? "Y/n" : "y/N";
1496
- const answer = (await rl.question(`${question} (${hint}): `)).trim().toLowerCase();
1497
- if (!answer) {
1498
- return defaultValue;
1982
+ const segments = route.split("/").filter(Boolean);
1983
+ return `app/${segments.join("/")}/page.tsx`;
1984
+ };
1985
+ var generateBuilderCode = (type, route, siteUrl) => {
1986
+ const normalizedRoute = route === "/" ? "" : route;
1987
+ const fullUrl = `${siteUrl}${normalizedRoute}`;
1988
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1989
+ const varName = type.charAt(0).toLowerCase() + type.slice(1);
1990
+ switch (type) {
1991
+ case "BlogPosting":
1992
+ return `const ${varName} = BlogPosting({
1993
+ headline: "Blog Post Title",
1994
+ authorName: "Author Name",
1995
+ datePublished: "${today}",
1996
+ url: "${fullUrl}"
1997
+ });`;
1998
+ case "Article":
1999
+ return `const ${varName} = Article({
2000
+ headline: "Article Headline",
2001
+ authorName: "Author Name",
2002
+ datePublished: "${today}",
2003
+ url: "${fullUrl}"
2004
+ });`;
2005
+ case "Product":
2006
+ return `const ${varName} = Product({
2007
+ name: "Product Name",
2008
+ description: "Product description",
2009
+ url: "${fullUrl}"
2010
+ });`;
2011
+ case "Organization":
2012
+ return `const ${varName} = Organization({
2013
+ name: "Organization Name",
2014
+ url: "${siteUrl}"
2015
+ });`;
2016
+ case "WebSite":
2017
+ return `const ${varName} = WebSite({
2018
+ name: "Website Name",
2019
+ url: "${siteUrl}"
2020
+ });`;
2021
+ case "WebPage":
2022
+ return `const ${varName} = WebPage({
2023
+ name: "Page Name",
2024
+ url: "${fullUrl}"
2025
+ });`;
2026
+ case "FAQPage":
2027
+ return `const ${varName} = FAQPage({
2028
+ questions: [
2029
+ { question: "Question 1?", answer: "Answer 1" }
2030
+ ]
2031
+ });`;
2032
+ case "HowTo":
2033
+ return `const ${varName} = HowTo({
2034
+ name: "How-To Title",
2035
+ steps: [
2036
+ { text: "Step 1 description" }
2037
+ ]
2038
+ });`;
2039
+ case "Event":
2040
+ return `const ${varName} = Event({
2041
+ name: "Event Name",
2042
+ startDate: "${today}"
2043
+ });`;
2044
+ default:
2045
+ return `const ${varName} = ${type}({
2046
+ name: "${type} Name"
2047
+ });`;
1499
2048
  }
1500
- return answer === "y" || answer === "yes";
1501
- }
1502
- function printInitSummary(options) {
1503
- const { manifestPath, dataPath, result } = options;
1504
- const created = [];
1505
- if (result.manifest !== "skipped") {
1506
- created.push(`${manifestPath} (${result.manifest})`);
2049
+ };
2050
+ var formatScaffoldPreview = (result) => {
2051
+ if (result.routesToScaffold.length === 0) {
2052
+ return "\u2705 No routes need schema generation. All routes have schema!";
1507
2053
  }
1508
- if (result.data !== "skipped") {
1509
- created.push(`${dataPath} (${result.data})`);
2054
+ const lines = [
2055
+ `\u{1F4CB} Found ${result.routesToScaffold.length} route(s) that need schema:
2056
+ `
2057
+ ];
2058
+ for (const route of result.routesToScaffold.slice(0, 5)) {
2059
+ const types = result.manifestUpdates[route] || [];
2060
+ const example = generateComponentCode(route, types);
2061
+ lines.push(chalk3.cyan.bold(`\u{1F4C4} ${route}`));
2062
+ lines.push(chalk3.gray(` File: ${example.filePath}`));
2063
+ lines.push(chalk3.gray(` Types: ${types.join(", ") || "WebPage"}`));
2064
+ lines.push("");
2065
+ lines.push(chalk3.yellow(" Add this code to your page:"));
2066
+ lines.push(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
2067
+ lines.push(example.code.split("\n").map((l) => " " + l).join("\n"));
2068
+ lines.push(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
2069
+ }
2070
+ if (result.routesToScaffold.length > 5) {
2071
+ lines.push(chalk3.gray(`... and ${result.routesToScaffold.length - 5} more routes`));
2072
+ }
2073
+ lines.push(chalk3.blue("\n\u{1F4A1} Next steps:"));
2074
+ lines.push(" 1. Copy the code above into each route's page.tsx file");
2075
+ lines.push(" 2. Customize the values (titles, names, URLs)");
2076
+ lines.push(" 3. Run `next build` to build your app");
2077
+ lines.push(" 4. Run `schemasentry validate` to verify\n");
2078
+ lines.push(chalk3.gray(" Note: Use --write to also update the manifest and data JSON files"));
2079
+ return lines.join("\n");
2080
+ };
2081
+ var applyScaffold = async (result, options) => {
2082
+ if (!result.wouldUpdate) {
2083
+ return;
1510
2084
  }
1511
- if (created.length > 0) {
1512
- console.log("Schema Sentry init complete.");
1513
- console.log(`Created ${created.length} file(s):`);
1514
- created.forEach((entry) => console.log(`- ${entry}`));
2085
+ const manifest = await loadManifest(options.manifestPath);
2086
+ const data = await loadData(options.dataPath);
2087
+ for (const [route, types] of Object.entries(result.manifestUpdates)) {
2088
+ if (!manifest.routes[route]) {
2089
+ manifest.routes[route] = types;
2090
+ }
1515
2091
  }
1516
- if (result.manifest === "skipped" || result.data === "skipped") {
1517
- console.log("Some files were skipped. Use --force to overwrite.");
2092
+ for (const [route, schemas] of result.generatedSchemas) {
2093
+ if (!data.routes[route]) {
2094
+ data.routes[route] = schemas;
2095
+ }
1518
2096
  }
1519
- if (result.manifest === "skipped" && result.data === "skipped") {
1520
- console.log("No files were written.");
2097
+ await fs7.mkdir(path10.dirname(options.manifestPath), { recursive: true });
2098
+ await fs7.mkdir(path10.dirname(options.dataPath), { recursive: true });
2099
+ await fs7.writeFile(
2100
+ options.manifestPath,
2101
+ stableStringify6(manifest),
2102
+ "utf8"
2103
+ );
2104
+ await fs7.writeFile(options.dataPath, stableStringify6(data), "utf8");
2105
+ };
2106
+
2107
+ // src/commands/scaffold.ts
2108
+ var scaffoldCommand = new Command5("scaffold").description("Generate schema stubs for routes without schema (dry-run by default)").option(
2109
+ "-m, --manifest <path>",
2110
+ "Path to manifest JSON",
2111
+ "schema-sentry.manifest.json"
2112
+ ).option(
2113
+ "-d, --data <path>",
2114
+ "Path to schema data JSON",
2115
+ "schema-sentry.data.json"
2116
+ ).option("--root <path>", "Project root for scanning", ".").option("--write", "Apply changes (default is dry-run)").option("-f, --force", "Skip confirmation prompts").action(async (options) => {
2117
+ const start = Date.now();
2118
+ const manifestPath = path11.resolve(process.cwd(), options.manifest);
2119
+ const dataPath = path11.resolve(process.cwd(), options.data);
2120
+ const rootDir = path11.resolve(process.cwd(), options.root ?? ".");
2121
+ const dryRun = !(options.write ?? false);
2122
+ const force = options.force ?? false;
2123
+ console.error(chalk4.blue.bold("\u{1F3D7}\uFE0F Schema Sentry Scaffold\n"));
2124
+ console.error(chalk4.gray("Scanning for routes that need schema...\n"));
2125
+ const result = await scaffoldSchema({
2126
+ manifestPath,
2127
+ dataPath,
2128
+ rootDir,
2129
+ dryRun,
2130
+ force
2131
+ });
2132
+ console.error(formatScaffoldPreview(result));
2133
+ if (!result.wouldUpdate) {
2134
+ console.error(chalk4.green("\n\u2705 All routes have schema!"));
2135
+ process.exit(0);
2136
+ return;
1521
2137
  }
1522
- if (created.length > 0) {
1523
- console.log("Next: run `schemasentry validate` to verify your setup.");
2138
+ if (dryRun) {
2139
+ console.error(chalk4.yellow("\n\u26A0\uFE0F Dry run complete."));
2140
+ console.error("Use --write to update JSON files (does NOT modify source code).\n");
2141
+ process.exit(0);
2142
+ return;
1524
2143
  }
1525
- }
2144
+ if (!force) {
2145
+ console.error(chalk4.yellow("\n\u26A0\uFE0F Scaffolding will update:"));
2146
+ console.error(` - ${manifestPath}`);
2147
+ console.error(` - ${dataPath}`);
2148
+ console.error("\nUse --force to skip this confirmation.");
2149
+ }
2150
+ try {
2151
+ await applyScaffold(result, {
2152
+ manifestPath,
2153
+ dataPath,
2154
+ rootDir,
2155
+ dryRun,
2156
+ force
2157
+ });
2158
+ console.error(chalk4.green(`
2159
+ \u2713 Scaffold complete in ${Date.now() - start}ms`));
2160
+ console.error(chalk4.gray("\nRemember: You still need to add the Schema components to your source files!"));
2161
+ process.exit(0);
2162
+ } catch (error) {
2163
+ const message = error instanceof Error ? error.message : "Unknown error";
2164
+ console.error(chalk4.red(`
2165
+ \u274C Failed to apply scaffold: ${message}`));
2166
+ console.error("Check file permissions or disk space.");
2167
+ process.exit(1);
2168
+ }
2169
+ });
2170
+
2171
+ // src/index.ts
2172
+ var program = new Command6();
2173
+ program.name("schemasentry").description("Schema Sentry CLI - Type-safe JSON-LD structured data for Next.js").version(resolveCliVersion());
2174
+ program.addCommand(initCommand);
2175
+ program.addCommand(validateCommand);
2176
+ program.addCommand(auditCommand);
2177
+ program.addCommand(collectCommand);
2178
+ program.addCommand(scaffoldCommand);
2179
+ program.parse();