@jxsuite/compiler 1.2.0 → 1.4.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/LICENSE +21 -0
- package/dist/cli.js +219 -210
- package/dist/compiler.js +54 -54
- package/package.json +8 -7
- package/src/cli.ts +9 -12
- package/src/shared.ts +3 -2
- package/src/site/asset-mounts.ts +140 -0
- package/src/site/image-transform.ts +25 -7
- package/src/site/schema-command.ts +22 -9
- package/src/site/site-build.ts +43 -1
- package/src/site/validate-command.ts +264 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate-command — `jx validate` whole-project implementation.
|
|
3
|
+
*
|
|
4
|
+
* Extends the original project.json check into the full compliance walk:
|
|
5
|
+
*
|
|
6
|
+
* 1. `project.json` against the generated entry schema (validateProjectFile);
|
|
7
|
+
* 2. The committed entry documents are SELF-CONTAINED (no residual relative `$ref`s — the
|
|
8
|
+
* editor-resolution guarantee of the bundled form, specs/extensions.md §5.4);
|
|
9
|
+
* 3. Every document under `components/`, `pages/`, and `layouts/` against the bundled document schema
|
|
10
|
+
* (core + extension `$paths` union — the same schema the studio consumes);
|
|
11
|
+
* 4. Every project-local `*.class.json` against the generated class schema;
|
|
12
|
+
* 5. Every enabled extension's schema fragments compile standalone against the shipped default union
|
|
13
|
+
* resources.
|
|
14
|
+
*
|
|
15
|
+
* Validation failures are data (issues in the result); infrastructure failures throw.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
import { join, relative, resolve } from "node:path";
|
|
21
|
+
import { generateClassSchema } from "@jxsuite/schema";
|
|
22
|
+
import { validateProjectFile } from "@jxsuite/schema/validate-project";
|
|
23
|
+
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
24
|
+
import { loadProjectConfig } from "./site-loader.ts";
|
|
25
|
+
import { readBundledProjectSchemas } from "./schema-command.ts";
|
|
26
|
+
|
|
27
|
+
/** Directories that hold Jx documents validated against the document schema. */
|
|
28
|
+
const DOCUMENT_DIRS = ["components", "pages", "layouts"] as const;
|
|
29
|
+
|
|
30
|
+
/** Directory names never walked for class files. */
|
|
31
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
|
|
32
|
+
|
|
33
|
+
/** Shipped default union resources fragments may reference by canonical URI. */
|
|
34
|
+
const SHIPPED_RESOURCE_SPECIFIERS = [
|
|
35
|
+
"@jxsuite/schema/schemas/project.core.schema.json",
|
|
36
|
+
"@jxsuite/schema/schemas/project.fields.schema.json",
|
|
37
|
+
"@jxsuite/schema/schemas/document.paths.schema.json",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export interface ProjectTreeIssue {
|
|
41
|
+
/** Project-relative file path (or absolute for extension fragments outside the root). */
|
|
42
|
+
file: string;
|
|
43
|
+
/** Ajv error objects, or `{ message }` records for structural issues. */
|
|
44
|
+
errors: unknown[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ProjectTreeValidationResult {
|
|
48
|
+
valid: boolean;
|
|
49
|
+
/** Number of files checked across all five walks. */
|
|
50
|
+
checked: number;
|
|
51
|
+
issues: ProjectTreeIssue[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Minimal structural types for the optional `ajv` dependency (same pattern as validate-project).
|
|
55
|
+
interface AjvValidateFn {
|
|
56
|
+
(doc: unknown): boolean;
|
|
57
|
+
errors?: unknown[] | null;
|
|
58
|
+
}
|
|
59
|
+
interface AjvInstance {
|
|
60
|
+
compile: (schema: unknown) => AjvValidateFn;
|
|
61
|
+
addSchema: (schema: unknown) => AjvInstance;
|
|
62
|
+
}
|
|
63
|
+
type AjvCtor = new (opts: {
|
|
64
|
+
allErrors: boolean;
|
|
65
|
+
ownProperties: boolean;
|
|
66
|
+
strict: boolean;
|
|
67
|
+
}) => AjvInstance;
|
|
68
|
+
type AddFormatsFn = (ajv: AjvInstance) => void;
|
|
69
|
+
|
|
70
|
+
async function loadAjv(): Promise<{ Ajv: AjvCtor; addFormats: AddFormatsFn }> {
|
|
71
|
+
try {
|
|
72
|
+
// The schemas are JSON Schema 2020-12, so use the matching Ajv build.
|
|
73
|
+
const { default: Ajv } = (await import("ajv/dist/2020")) as unknown as { default: AjvCtor };
|
|
74
|
+
// @ts-expect-error — resolved through @jxsuite/schema's dependency
|
|
75
|
+
const { default: addFormats } = (await import("ajv-formats")) as { default: AddFormatsFn };
|
|
76
|
+
return { addFormats, Ajv };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new Error("Project validation requires ajv and ajv-formats: bun add ajv ajv-formats", {
|
|
79
|
+
cause: error,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate a whole project tree. See the module docstring for the five walks.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} projectRoot - Absolute path to the project directory
|
|
88
|
+
* @returns {Promise<ProjectTreeValidationResult>}
|
|
89
|
+
*/
|
|
90
|
+
export async function validateProjectTree(
|
|
91
|
+
projectRoot: string,
|
|
92
|
+
): Promise<ProjectTreeValidationResult> {
|
|
93
|
+
const root = resolve(projectRoot);
|
|
94
|
+
const issues: ProjectTreeIssue[] = [];
|
|
95
|
+
let checked = 0;
|
|
96
|
+
|
|
97
|
+
// 1. project.json against the generated entry schema.
|
|
98
|
+
const projectResult = await validateProjectFile(root);
|
|
99
|
+
checked += 1;
|
|
100
|
+
if (!projectResult.valid) {
|
|
101
|
+
issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Committed entry documents must be self-contained (the editor-resolution guarantee).
|
|
105
|
+
for (const name of ["project.schema.json", "document.schema.json"]) {
|
|
106
|
+
const path = resolve(root, name);
|
|
107
|
+
if (!existsSync(path)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
checked += 1;
|
|
111
|
+
const residual: string[] = [];
|
|
112
|
+
collectRelativeRefs(JSON.parse(readFileSync(path, "utf8")), residual);
|
|
113
|
+
if (residual.length > 0) {
|
|
114
|
+
issues.push({
|
|
115
|
+
errors: residual.map((ref) => ({
|
|
116
|
+
message:
|
|
117
|
+
`relative $ref "${ref}" — committed entry documents must be self-contained; ` +
|
|
118
|
+
`regenerate with \`jx schema\``,
|
|
119
|
+
})),
|
|
120
|
+
file: name,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { addFormats, Ajv } = await loadAjv();
|
|
126
|
+
|
|
127
|
+
// 3. Documents against the bundled document schema (the same schema the studio consumes).
|
|
128
|
+
const { document: documentSchema } = await readBundledProjectSchemas(root);
|
|
129
|
+
const docAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
|
|
130
|
+
addFormats(docAjv);
|
|
131
|
+
const validateDoc = docAjv.compile(documentSchema);
|
|
132
|
+
for (const dir of DOCUMENT_DIRS) {
|
|
133
|
+
for (const file of walkJsonFiles(resolve(root, dir))) {
|
|
134
|
+
if (file.endsWith(".class.json")) {
|
|
135
|
+
continue; // Validated against the class schema below.
|
|
136
|
+
}
|
|
137
|
+
checked += 1;
|
|
138
|
+
const doc = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
139
|
+
if (!validateDoc(doc)) {
|
|
140
|
+
issues.push({ errors: validateDoc.errors ?? [], file: relative(root, file) });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. Project-local class definitions against the generated class schema.
|
|
146
|
+
const classAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
|
|
147
|
+
addFormats(classAjv);
|
|
148
|
+
const validateClass = classAjv.compile(generateClassSchema());
|
|
149
|
+
for (const file of walkClassFiles(root)) {
|
|
150
|
+
checked += 1;
|
|
151
|
+
const doc = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
152
|
+
if (!validateClass(doc)) {
|
|
153
|
+
issues.push({ errors: validateClass.errors ?? [], file: relative(root, file) });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 5. Enabled extensions' schema fragments compile standalone against the shipped defaults.
|
|
158
|
+
const { config } = loadProjectConfig(root);
|
|
159
|
+
const registry = await buildProjectExtensionRegistry(root, config);
|
|
160
|
+
const selfRequire = createRequire(import.meta.url);
|
|
161
|
+
for (const ext of registry.extensions) {
|
|
162
|
+
for (const kind of ["project", "document", "fields"] as const) {
|
|
163
|
+
const fragmentPath = ext.schemas[kind];
|
|
164
|
+
if (!fragmentPath) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
checked += 1;
|
|
168
|
+
const fragAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
|
|
169
|
+
addFormats(fragAjv);
|
|
170
|
+
for (const specifier of SHIPPED_RESOURCE_SPECIFIERS) {
|
|
171
|
+
const resourcePath = selfRequire.resolve(specifier);
|
|
172
|
+
const resource = JSON.parse(readFileSync(resourcePath, "utf8")) as Record<string, unknown>;
|
|
173
|
+
fragAjv.addSchema(resource);
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
fragAjv.compile(JSON.parse(readFileSync(fragmentPath, "utf8")));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
issues.push({
|
|
179
|
+
errors: [{ message: error instanceof Error ? error.message : String(error) }],
|
|
180
|
+
file: `${ext.specifier} (${kind} fragment)`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { checked, issues, valid: issues.length === 0 };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Collect relative-path `$ref`s (anything without a URI scheme that isn't a `#` pointer). */
|
|
190
|
+
function collectRelativeRefs(node: unknown, out: string[]): void {
|
|
191
|
+
if (Array.isArray(node)) {
|
|
192
|
+
for (const item of node) {
|
|
193
|
+
collectRelativeRefs(item, out);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (node === null || typeof node !== "object") {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
201
|
+
if (
|
|
202
|
+
key === "$ref" &&
|
|
203
|
+
typeof value === "string" &&
|
|
204
|
+
!value.startsWith("#") &&
|
|
205
|
+
!/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
|
|
206
|
+
) {
|
|
207
|
+
out.push(value);
|
|
208
|
+
}
|
|
209
|
+
collectRelativeRefs(value, out);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Recursively list `.json` files under a directory (absent directories yield nothing). */
|
|
214
|
+
function walkJsonFiles(dir: string): string[] {
|
|
215
|
+
if (!existsSync(dir)) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const out: string[] = [];
|
|
219
|
+
for (const name of readdirSync(dir)) {
|
|
220
|
+
const path = join(dir, name);
|
|
221
|
+
if (statSync(path).isDirectory()) {
|
|
222
|
+
out.push(...walkJsonFiles(path));
|
|
223
|
+
} else if (name.endsWith(".json")) {
|
|
224
|
+
out.push(path);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out.toSorted();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Recursively list `*.class.json` files under the root, skipping vendored/output dirs. */
|
|
231
|
+
function walkClassFiles(root: string): string[] {
|
|
232
|
+
const out: string[] = [];
|
|
233
|
+
const walk = (dir: string) => {
|
|
234
|
+
for (const name of readdirSync(dir)) {
|
|
235
|
+
if (SKIP_DIRS.has(name)) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const path = join(dir, name);
|
|
239
|
+
if (statSync(path).isDirectory()) {
|
|
240
|
+
walk(path);
|
|
241
|
+
} else if (name.endsWith(".class.json")) {
|
|
242
|
+
out.push(path);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
walk(root);
|
|
247
|
+
return out.toSorted();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Format a result's issues as printable lines (one per error) for the CLI. */
|
|
251
|
+
export function formatProjectTreeIssues(result: ProjectTreeValidationResult): string[] {
|
|
252
|
+
const lines: string[] = [];
|
|
253
|
+
for (const issue of result.issues) {
|
|
254
|
+
lines.push(`${issue.file}:`);
|
|
255
|
+
for (const error of issue.errors) {
|
|
256
|
+
const { instancePath, message } = (error ?? {}) as {
|
|
257
|
+
instancePath?: string;
|
|
258
|
+
message?: string;
|
|
259
|
+
};
|
|
260
|
+
lines.push(` - ${instancePath || "/"}: ${message ?? JSON.stringify(error)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return lines;
|
|
264
|
+
}
|