@jxsuite/compiler 1.2.0 → 1.3.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 +220 -211
- package/dist/compiler.js +54 -54
- package/package.json +7 -7
- package/src/cli.ts +9 -12
- package/src/shared.ts +3 -2
- package/src/site/schema-command.ts +22 -9
- package/src/site/validate-command.ts +264 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -45,16 +45,16 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@apidevtools/json-schema-ref-parser": "^15.5.0",
|
|
48
|
-
"@jxsuite/create": "^1.
|
|
49
|
-
"@jxsuite/runtime": "^1.
|
|
50
|
-
"@jxsuite/schema": "^1.
|
|
48
|
+
"@jxsuite/create": "^1.2.0",
|
|
49
|
+
"@jxsuite/runtime": "^1.3.0",
|
|
50
|
+
"@jxsuite/schema": "^1.3.0",
|
|
51
51
|
"esbuild": "^0.28.1",
|
|
52
52
|
"sharp": "^0.35.3"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@happy-dom/global-registrator": "^20.
|
|
56
|
-
"@jxsuite/connector": "^0.
|
|
57
|
-
"@jxsuite/parser": "^1.
|
|
55
|
+
"@happy-dom/global-registrator": "^20.11.0",
|
|
56
|
+
"@jxsuite/connector": "^0.4.0",
|
|
57
|
+
"@jxsuite/parser": "^1.3.0",
|
|
58
58
|
"@types/mdast": "^4.0.4"
|
|
59
59
|
}
|
|
60
60
|
}
|
package/src/cli.ts
CHANGED
|
@@ -26,7 +26,7 @@ Commands:
|
|
|
26
26
|
dev [root] Start the dev server (requires @jxsuite/server and Bun)
|
|
27
27
|
preview [root] Serve an already-built dist/ directory
|
|
28
28
|
schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
|
|
29
|
-
validate [root] Validate project.json
|
|
29
|
+
validate [root] Validate the whole project: project.json, documents, classes, and fragments
|
|
30
30
|
db push [root] Sync the data section's tables to their connections (additive-only)
|
|
31
31
|
|
|
32
32
|
Options:
|
|
@@ -161,18 +161,15 @@ Options:
|
|
|
161
161
|
}
|
|
162
162
|
} else if (command === "validate") {
|
|
163
163
|
try {
|
|
164
|
-
const {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
const { formatProjectTreeIssues, validateProjectTree } =
|
|
165
|
+
await import("./site/validate-command.ts");
|
|
166
|
+
const result = await validateProjectTree(projectRoot);
|
|
167
|
+
if (result.valid) {
|
|
168
|
+
console.log(`Project is valid (${result.checked} files checked in ${projectRoot})`);
|
|
168
169
|
} else {
|
|
169
|
-
console.error(`
|
|
170
|
-
for (const
|
|
171
|
-
|
|
172
|
-
instancePath?: string;
|
|
173
|
-
message?: string;
|
|
174
|
-
};
|
|
175
|
-
console.error(` - ${instancePath || "/"}: ${message ?? JSON.stringify(error)}`);
|
|
170
|
+
console.error(`Project is INVALID (${projectRoot}):`);
|
|
171
|
+
for (const line of formatProjectTreeIssues(result)) {
|
|
172
|
+
console.error(line);
|
|
176
173
|
}
|
|
177
174
|
process.exit(1);
|
|
178
175
|
}
|
package/src/shared.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
isExpressionDef,
|
|
24
24
|
isFunctionDef,
|
|
25
25
|
isMappedArray,
|
|
26
|
+
bodyReturnsValue,
|
|
26
27
|
isNamedFormulaDef,
|
|
27
28
|
isPrototypeDef,
|
|
28
29
|
isRef,
|
|
@@ -79,7 +80,7 @@ export function emitFormulaFn(def: { parameters?: unknown[] }, compiledBody: str
|
|
|
79
80
|
export type { ExpressionNode } from "@jxsuite/runtime/expression";
|
|
80
81
|
|
|
81
82
|
// CDN defaults
|
|
82
|
-
export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.
|
|
83
|
+
export const DEFAULT_REACTIVITY_SRC = "https://esm.sh/@vue/reactivity@3.5.40";
|
|
83
84
|
export const DEFAULT_LIT_HTML_SRC = "https://esm.sh/lit-html@3.3.0";
|
|
84
85
|
|
|
85
86
|
// ─── Schema keywords & detection ─────────────────────────────────────────────
|
|
@@ -363,7 +364,7 @@ export function buildInitialScope(
|
|
|
363
364
|
const fn = new Function("state", ...names, body) as (
|
|
364
365
|
state: Record<string, unknown>,
|
|
365
366
|
) => unknown;
|
|
366
|
-
if (body
|
|
367
|
+
if (bodyReturnsValue(body)) {
|
|
367
368
|
defineLazyScopeValue(scope, key, () => fn(scope));
|
|
368
369
|
} else {
|
|
369
370
|
setOwnScopeValue(scope, key, fn);
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Composes the per-project entry documents (project.schema.json and document.schema.json) from the
|
|
5
5
|
* core schemas and each enabled extension's shipped fragments, then writes them into the project
|
|
6
|
-
* root as committed
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* root as committed, SELF-CONTAINED bundles: every fragment ref is resolved through
|
|
7
|
+
* {@link bundleSchema}, embedding the core + fragment resources under `$defs` keyed by their
|
|
8
|
+
* canonical `$id`s. Relative `./node_modules/...` refs only resolve through Node module resolution,
|
|
9
|
+
* which editors do not perform (hoisted workspaces leave projects without their own node_modules) —
|
|
10
|
+
* the bundled form resolves offline for ajv `jx validate`, VS Code's JSON language service, and the
|
|
11
|
+
* studio's Monaco alike, with one resolution behavior for all consumers.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -57,11 +59,22 @@ export async function writeProjectSchemas(projectRoot: string) {
|
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// Bundle before committing: the emitted relative refs are an intermediate form only.
|
|
63
|
+
const root = resolve(projectRoot);
|
|
64
|
+
const loadJson = restrictedSchemaLoader(root);
|
|
65
|
+
const projectSchema = await bundleSchema(
|
|
66
|
+
emitProjectSchema({ corePath, fieldSchemaRefs, fragments }),
|
|
67
|
+
loadJson,
|
|
68
|
+
root,
|
|
69
|
+
);
|
|
70
|
+
const documentSchema = await bundleSchema(
|
|
71
|
+
emitDocumentSchema({
|
|
72
|
+
corePath: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
|
|
73
|
+
pathsValueRefs,
|
|
74
|
+
}),
|
|
75
|
+
loadJson,
|
|
76
|
+
root,
|
|
77
|
+
);
|
|
65
78
|
|
|
66
79
|
const projectSchemaPath = resolve(projectRoot, "project.schema.json");
|
|
67
80
|
const documentSchemaPath = resolve(projectRoot, "document.schema.json");
|
|
@@ -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
|
+
}
|