@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.
- package/README.md +138 -12
- package/dist/index.js +1836 -1182
- 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
|
|
7
|
-
import
|
|
8
|
-
import { stableStringify as
|
|
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/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// src/html.ts
|
|
14
|
+
var escapeHtml = (value) => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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/
|
|
84
|
-
var
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
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/
|
|
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 {
|
|
140
|
-
var
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const routes = {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
for (const
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
218
|
-
const {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return "skipped";
|
|
225
|
+
var formatSchemaDataDrift = (drift, maxRoutes = 5) => {
|
|
226
|
+
if (!drift.hasChanges) {
|
|
227
|
+
return "No schema data drift detected.";
|
|
244
228
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
294
|
+
var filePathToRoute = (rootDir, filePath) => {
|
|
295
|
+
const relative = path.relative(rootDir, filePath).replace(/\\/g, "/");
|
|
296
|
+
if (relative === "index.html") {
|
|
297
|
+
return "/";
|
|
269
298
|
}
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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/
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
|
|
431
|
+
return `/${withoutExt}`;
|
|
345
432
|
};
|
|
346
|
-
var
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
|
364
|
-
import
|
|
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 =
|
|
377
|
-
const exists = await
|
|
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
|
|
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
|
|
645
|
+
var fileExists = async (filePath) => {
|
|
429
646
|
try {
|
|
430
|
-
await
|
|
647
|
+
await fs3.access(filePath);
|
|
431
648
|
return true;
|
|
432
649
|
} catch {
|
|
433
650
|
return false;
|
|
434
651
|
}
|
|
435
652
|
};
|
|
436
653
|
|
|
437
|
-
// src/
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
if (routeSegments.length === 0) {
|
|
486
|
-
return "/";
|
|
721
|
+
if (argv.includes("--no-recommended")) {
|
|
722
|
+
return false;
|
|
487
723
|
}
|
|
488
|
-
return
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
497
|
-
|
|
744
|
+
const manifest = value;
|
|
745
|
+
if (!manifest.routes || typeof manifest.routes !== "object") {
|
|
746
|
+
return false;
|
|
498
747
|
}
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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 (
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
return stat.isDirectory();
|
|
817
|
+
await access(builtOutputDir);
|
|
522
818
|
} catch {
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
var escapeHtml = (value) => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
:
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
removedRoutes,
|
|
731
|
-
changedRoutes,
|
|
732
|
-
changedRouteDetails
|
|
1053
|
+
manifest: manifestResult,
|
|
1054
|
+
data: dataResult
|
|
733
1055
|
};
|
|
734
1056
|
};
|
|
735
|
-
var
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
771
|
-
|
|
772
|
-
);
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
799
|
-
|
|
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
|
|
1127
|
+
return Array.from(routes).sort();
|
|
803
1128
|
};
|
|
804
|
-
var
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
if (
|
|
810
|
-
return
|
|
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
|
-
|
|
813
|
-
|
|
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
|
|
1142
|
+
return `/${routeSegments.join("/")}`;
|
|
816
1143
|
};
|
|
817
|
-
var
|
|
818
|
-
const
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
if (Array.isArray(value)) {
|
|
850
|
-
return value.filter(isJsonObject);
|
|
1153
|
+
const fileName = segments.pop();
|
|
1154
|
+
if (!fileName) {
|
|
1155
|
+
return null;
|
|
851
1156
|
}
|
|
852
|
-
|
|
853
|
-
|
|
1157
|
+
const baseName = fileName.replace(/\.[^/.]+$/, "");
|
|
1158
|
+
if (baseName.startsWith("_")) {
|
|
1159
|
+
return null;
|
|
854
1160
|
}
|
|
855
|
-
const
|
|
856
|
-
if (
|
|
857
|
-
|
|
1161
|
+
const filtered = segments.filter((segment) => segment.length > 0);
|
|
1162
|
+
if (baseName !== "index") {
|
|
1163
|
+
filtered.push(baseName);
|
|
858
1164
|
}
|
|
859
|
-
|
|
1165
|
+
if (filtered.length === 0) {
|
|
1166
|
+
return "/";
|
|
1167
|
+
}
|
|
1168
|
+
return `/${filtered.join("/")}`;
|
|
860
1169
|
};
|
|
861
|
-
var
|
|
862
|
-
var
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
|
876
|
-
const
|
|
877
|
-
|
|
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/
|
|
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
|
|
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("-
|
|
894
|
-
const
|
|
895
|
-
const
|
|
896
|
-
const
|
|
897
|
-
const
|
|
898
|
-
const
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
1257
|
+
const rl = createInterface({ input, output });
|
|
946
1258
|
try {
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
"
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
|
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:
|
|
1495
|
+
requiredRoutes: scannedRoutes.length > 0 ? scannedRoutes : void 0
|
|
1098
1496
|
});
|
|
1099
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1518
|
+
hasManifest: Boolean(manifest),
|
|
1519
|
+
ghostRoutes,
|
|
1520
|
+
sourceScan: sourceScanResult,
|
|
1521
|
+
durationMs: Date.now() - start
|
|
1104
1522
|
});
|
|
1105
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
1618
|
+
const existingPath = path9.resolve(process.cwd(), options.data);
|
|
1154
1619
|
let existingRaw;
|
|
1155
1620
|
try {
|
|
1156
|
-
existingRaw = await
|
|
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
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
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
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1853
|
+
routesToScaffold: routesNeedingSchema,
|
|
1854
|
+
generatedSchemas,
|
|
1855
|
+
manifestUpdates: manifestEntries,
|
|
1856
|
+
wouldUpdate
|
|
1457
1857
|
};
|
|
1458
|
-
}
|
|
1459
|
-
async
|
|
1460
|
-
const defaults = getDefaultAnswers();
|
|
1461
|
-
const rl = createInterface({ input, output });
|
|
1858
|
+
};
|
|
1859
|
+
var loadManifest = async (manifestPath) => {
|
|
1462
1860
|
try {
|
|
1463
|
-
const
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
return {
|
|
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
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
2092
|
+
for (const [route, schemas] of result.generatedSchemas) {
|
|
2093
|
+
if (!data.routes[route]) {
|
|
2094
|
+
data.routes[route] = schemas;
|
|
2095
|
+
}
|
|
1518
2096
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
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 (
|
|
1523
|
-
console.
|
|
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();
|