@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "1.2.0",
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.1.1",
49
- "@jxsuite/runtime": "^1.2.0",
50
- "@jxsuite/schema": "^1.2.0",
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.10.6",
56
- "@jxsuite/connector": "^0.3.1",
57
- "@jxsuite/parser": "^1.2.0",
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 against its generated project.schema.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 { validateProjectFile } = await import("@jxsuite/schema/validate-project");
165
- const { valid, errors } = await validateProjectFile(projectRoot);
166
- if (valid) {
167
- console.log(`project.json is valid (${projectRoot})`);
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(`project.json is INVALID (${projectRoot}):`);
170
- for (const error of errors ?? []) {
171
- const { instancePath, message } = (error ?? {}) as {
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.32";
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.includes("return")) {
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 artifacts. All fragment refs are PROJECT-RELATIVE, POSIX-style `./` paths:
7
- * paths that resolve inside the project are emitted as-is; bare-specifier packages resolving
8
- * through workspace symlinks (outside the root) fall back to the conventional
9
- * `./node_modules/<package>/<subpath>` form, which resolves once dependencies are installed.
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
- const projectSchema = emitProjectSchema({ corePath, fieldSchemaRefs, fragments });
61
- const documentSchema = emitDocumentSchema({
62
- corePath: coreSchemaRef(projectRoot, CORE_DOCUMENT_SPECIFIER),
63
- pathsValueRefs,
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
+ }