@jxsuite/schema 1.1.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.
@@ -3,4 +3,4 @@
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "description": "Default $paths-value union for documents. Per-project entry documents override this resource with the union of extension-contributed paths shapes.",
5
5
  "title": "Jx Document Paths Union"
6
- }
6
+ }
@@ -41,10 +41,7 @@
41
41
  },
42
42
  "service": {
43
43
  "description": "How images are optimized: \"build\" runs Sharp at build time; \"cloudflare\" emits /cdn-cgi/image transform URLs served by Cloudflare Image Transformations (requires the feature to be enabled on the serving zone).",
44
- "enum": [
45
- "build",
46
- "cloudflare"
47
- ],
44
+ "enum": ["build", "cloudflare"],
48
45
  "type": "string"
49
46
  },
50
47
  "sizes": {
@@ -88,20 +85,11 @@
88
85
  "type": "array"
89
86
  },
90
87
  "type": {
91
- "enum": [
92
- "string",
93
- "number",
94
- "integer",
95
- "boolean",
96
- "array",
97
- "object"
98
- ],
88
+ "enum": ["string", "number", "integer", "boolean", "array", "object"],
99
89
  "type": "string"
100
90
  }
101
91
  },
102
- "required": [
103
- "type"
104
- ],
92
+ "required": ["type"],
105
93
  "type": "object"
106
94
  },
107
95
  "RelationshipRef": {
@@ -115,9 +103,25 @@
115
103
  "type": "string"
116
104
  }
117
105
  },
118
- "required": [
119
- "$ref"
120
- ],
106
+ "required": ["$ref"],
107
+ "type": "object"
108
+ },
109
+ "StyleObject": {
110
+ "additionalProperties": {
111
+ "oneOf": [
112
+ {
113
+ "type": "string"
114
+ },
115
+ {
116
+ "type": "number"
117
+ },
118
+ {
119
+ "$ref": "#/$defs/StyleObject"
120
+ }
121
+ ]
122
+ },
123
+ "description": "CSS style definition. camelCase property names follow CSSOM convention. Keys starting with :, ., &, or [ are treated as nested CSS selectors. Keys matching $media breakpoint names (or other @-prefixed at-rules) group nested rules. Nesting is recursive: selector and at-rule groups may nest to arbitrary depth (e.g. breakpoint → selector → pseudo-class), mirroring the compiler's recursive emission.",
124
+ "properties": {},
121
125
  "type": "object"
122
126
  }
123
127
  },
@@ -140,9 +144,7 @@
140
144
  "type": "string"
141
145
  }
142
146
  },
143
- "required": [
144
- "$ref"
145
- ],
147
+ "required": ["$ref"],
146
148
  "type": "object"
147
149
  },
148
150
  {
@@ -192,9 +194,10 @@
192
194
  "additionalProperties": {
193
195
  "type": "string"
194
196
  },
195
- "description": "Named media breakpoints following CSS @custom-media convention. Available in all component style objects.",
197
+ "description": "Named media breakpoints following CSS @custom-media convention. Available in all component style objects. Declaring a pure color-scheme query (e.g. \"--dark\": \"(prefers-color-scheme: dark)\") additionally opts the site into the forced-scheme contract: @--dark style blocks are dual-emitted so a data-color-scheme attribute on <html> overrides the OS preference, color-scheme is declared on :root, and a pre-paint script restores the visitor's persisted choice (spec §9.5).",
196
198
  "examples": [
197
199
  {
200
+ "--dark": "(prefers-color-scheme: dark)",
198
201
  "--lg": "(min-width: 1024px)",
199
202
  "--md": "(min-width: 768px)",
200
203
  "--sm": "(min-width: 640px)"
@@ -211,13 +214,7 @@
211
214
  "properties": {
212
215
  "adapter": {
213
216
  "description": "Platform adapter for deployment-specific output.",
214
- "enum": [
215
- "static",
216
- "cloudflare-pages",
217
- "cloudflare-workers",
218
- "node",
219
- "bun"
220
- ],
217
+ "enum": ["static", "cloudflare-pages", "cloudflare-workers", "node", "bun"],
221
218
  "type": "string"
222
219
  },
223
220
  "deploy": {
@@ -239,26 +236,17 @@
239
236
  },
240
237
  "provider": {
241
238
  "description": "Hosting provider the project is connected to.",
242
- "enum": [
243
- "cloudflare-pages"
244
- ],
239
+ "enum": ["cloudflare-pages"],
245
240
  "type": "string"
246
241
  }
247
242
  },
248
- "required": [
249
- "provider",
250
- "accountId",
251
- "projectName"
252
- ],
243
+ "required": ["provider", "accountId", "projectName"],
253
244
  "type": "object"
254
245
  },
255
246
  "format": {
256
247
  "default": "directory",
257
248
  "description": "Reserved; currently unused. Accepted for forward compatibility with single-file output (desktop single-file mode) — the build emits per-route directories regardless.",
258
- "enum": [
259
- "directory",
260
- "single"
261
- ],
249
+ "enum": ["directory", "single"],
262
250
  "type": "string"
263
251
  },
264
252
  "outDir": {
@@ -274,11 +262,7 @@
274
262
  "trailingSlash": {
275
263
  "default": "always",
276
264
  "description": "Trailing slash behavior for generated URLs.",
277
- "enum": [
278
- "always",
279
- "never",
280
- "ignore"
281
- ],
265
+ "enum": ["always", "never", "ignore"],
282
266
  "type": "string"
283
267
  }
284
268
  },
@@ -307,9 +291,7 @@
307
291
  "layout": {
308
292
  "default": null,
309
293
  "description": "Default layout file path applied to all pages. Set to null to render pages without a layout.",
310
- "examples": [
311
- "./layouts/base.json"
312
- ],
294
+ "examples": ["./layouts/base.json"],
313
295
  "oneOf": [
314
296
  {
315
297
  "type": "string"
@@ -324,11 +306,7 @@
324
306
  },
325
307
  "extensions": {
326
308
  "description": "Extension packages: bare package names (resolved project-first through the package exports map) or relative paths. Each must export jx-extension.json.",
327
- "examples": [
328
- [
329
- "@jxsuite/parser"
330
- ]
331
- ],
309
+ "examples": [["@jxsuite/parser"]],
332
310
  "items": {
333
311
  "type": "string"
334
312
  },
@@ -339,20 +317,12 @@
339
317
  "properties": {
340
318
  "defaultLocale": {
341
319
  "description": "Default locale code.",
342
- "examples": [
343
- "en"
344
- ],
320
+ "examples": ["en"],
345
321
  "type": "string"
346
322
  },
347
323
  "locales": {
348
324
  "description": "Available locale codes.",
349
- "examples": [
350
- [
351
- "en",
352
- "fr",
353
- "de"
354
- ]
355
- ],
325
+ "examples": [["en", "fr", "de"]],
356
326
  "items": {
357
327
  "type": "string"
358
328
  },
@@ -360,10 +330,7 @@
360
330
  },
361
331
  "routing": {
362
332
  "description": "Locale routing strategy.",
363
- "enum": [
364
- "prefix-except-default",
365
- "prefix-always"
366
- ],
333
+ "enum": ["prefix-except-default", "prefix-always"],
367
334
  "type": "string"
368
335
  }
369
336
  },
@@ -388,10 +355,7 @@
388
355
  "name": {
389
356
  "default": "Jx Site",
390
357
  "description": "Human-readable project name.",
391
- "examples": [
392
- "My Portfolio",
393
- "Jx Example Site"
394
- ],
358
+ "examples": ["My Portfolio", "Jx Example Site"],
395
359
  "type": "string"
396
360
  },
397
361
  "redirects": {
@@ -420,22 +384,19 @@
420
384
  "type": "number"
421
385
  },
422
386
  {
423
- "type": "object"
387
+ "$ref": "#/$defs/StyleObject"
424
388
  }
425
389
  ]
426
390
  },
427
- "description": "Global CSS styles applied to the <body> element. Uses the same camelCase CSSOM convention as component styles.",
391
+ "description": "Global CSS styles applied to the <body> element. Uses the same camelCase CSSOM convention and recursive selector/at-rule nesting as component styles.",
428
392
  "type": "object"
429
393
  },
430
394
  "url": {
431
395
  "description": "Production URL of the deployed site.",
432
- "examples": [
433
- "https://example.com",
434
- "https://jxsuite.com"
435
- ],
396
+ "examples": ["https://example.com", "https://jxsuite.com"],
436
397
  "type": "string"
437
398
  }
438
399
  },
439
400
  "title": "Jx Project Core",
440
401
  "type": "object"
441
- }
402
+ }
@@ -11,4 +11,4 @@
11
11
  ],
12
12
  "description": "Default field-schema union for section entry schemas: the core field shape plus relationship references. Per-project entry documents override this resource with the effective union. See specs/extensions.md §5.3.",
13
13
  "title": "Jx Project Field Union"
14
- }
14
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Deterministic mapping from Function-def `$src` module specifiers to bundled asset URLs. Shared
3
+ * between the compiler's sidecar bundler (which writes the bundle) and extension `lower()`
4
+ * implementations (which emit the URL into client code) so neither needs to depend on the other.
5
+ *
6
+ * The mapping is intentionally hash-free: the same specifier always yields the same URL, letting
7
+ * lowered defs reference bundles the compiler produces later in the build. Distinct specifiers that
8
+ * collide on a slug are rejected by the bundler at build time.
9
+ */
10
+
11
+ /** Prefix for npm package specifiers in `$src` (spec §12): `npm:<pkg>[/subpath]`. */
12
+ export const NPM_SPECIFIER_PREFIX = "npm:";
13
+
14
+ /** URL directory (under the site root) where bundled sidecars are written. */
15
+ export const SIDECAR_ASSET_DIR = "/assets/";
16
+
17
+ /** True when a `$src` specifier names an npm package rather than a project file. */
18
+ export function isNpmSpecifier(specifier: string): boolean {
19
+ return specifier.startsWith(NPM_SPECIFIER_PREFIX);
20
+ }
21
+
22
+ /**
23
+ * Map a Function-def `$src` specifier to its bundled asset URL.
24
+ *
25
+ * `npm:@jxsuite/search/client` → `/assets/jxsuite-search-client.js` `./lib/search-helpers.ts` →
26
+ * `/assets/lib-search-helpers.js`
27
+ */
28
+ export function sidecarAssetPath(specifier: string): string {
29
+ let base = specifier;
30
+ if (isNpmSpecifier(base)) {
31
+ base = base.slice(NPM_SPECIFIER_PREFIX.length);
32
+ }
33
+ base = base
34
+ .replace(/^\.\//, "")
35
+ .replace(/\.(js|ts|mjs|mts)$/, "")
36
+ .replaceAll("@", "")
37
+ .replaceAll(/[/\\]+/g, "-")
38
+ .replaceAll(/[^\w.-]/g, "-")
39
+ .replaceAll(/-+/g, "-")
40
+ .replaceAll(/^-|-$/g, "");
41
+ return `${SIDECAR_ASSET_DIR}${base}.js`;
42
+ }
@@ -151,6 +151,11 @@ export class ExtensionRegistry {
151
151
  return this.#classes.find((e) => e.capabilities.resolvePaths?.discriminator === key);
152
152
  }
153
153
 
154
+ /** Classes declaring an `emit` capability (build-time asset emission), in declaration order. */
155
+ emitters(): FormatEntry[] {
156
+ return this.#classes.filter((e) => e.capabilities.emit !== undefined);
157
+ }
158
+
154
159
  /** Server-mount classes, sorted by `server.order` (ascending; default 100), then name. */
155
160
  serverMounts(): FormatEntry[] {
156
161
  return this.#classes
@@ -21,14 +21,16 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
21
21
 
22
22
  /**
23
23
  * All well-known static capability roles across admission blocks (specs/extensions.md §8): format
24
- * roles plus project-section (`projectData`, `resolvePaths`), compile-away (`lower`), server-mount
25
- * (`mount`), and connector (`dialect`, `deploySchema`, `bindings`, `testConnection`) roles.
24
+ * roles plus project-section (`projectData`, `resolvePaths`), compile-away (`lower`), build-time
25
+ * asset emission (`emit`), server-mount (`mount`), and connector (`dialect`, `deploySchema`,
26
+ * `bindings`, `testConnection`) roles.
26
27
  */
27
28
  export type ExtensionCapability =
28
29
  | FormatCapability
29
30
  | "projectData"
30
31
  | "resolvePaths"
31
32
  | "lower"
33
+ | "emit"
32
34
  | "mount"
33
35
  | "dialect"
34
36
  | "deploySchema"
@@ -40,6 +42,7 @@ export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
40
42
  "projectData",
41
43
  "resolvePaths",
42
44
  "lower",
45
+ "emit",
43
46
  "mount",
44
47
  "dialect",
45
48
  "deploySchema",
package/src/guards.ts CHANGED
@@ -174,6 +174,16 @@ export function isTemplateString(value?: unknown): value is string {
174
174
  return typeof value === "string" && value.includes("${");
175
175
  }
176
176
 
177
+ /**
178
+ * Whether a function `body` returns a value — the heuristic that classifies a `$prototype:
179
+ * "Function"` as a computed value vs an event handler. Uses a word boundary so identifiers like
180
+ * `returned` do not match. Shared by the runtime interpreter and the compiler so both agree on the
181
+ * classification.
182
+ */
183
+ export function bodyReturnsValue(body: string): boolean {
184
+ return /\breturn\b/.test(body);
185
+ }
186
+
177
187
  // ─── Style guards & accessors ───────────────────────────────────────────────────
178
188
 
179
189
  /** A nested style object (selector/media block), as opposed to a scalar CSS value. */
package/src/schema.ts CHANGED
@@ -92,7 +92,10 @@ import {
92
92
  classFieldDefSchema,
93
93
  classMethodDefSchema,
94
94
  classParameterDefSchema,
95
+ connectorBlockDefSchema,
95
96
  formatDefSchema,
97
+ projectBlockDefSchema,
98
+ serverBlockDefSchema,
96
99
  studioHintsSchema,
97
100
  } from "../defs/class-def.schema";
98
101
  import { extensionManifestSchema } from "../defs/extension-manifest.schema";
@@ -182,7 +185,11 @@ function buildEventHandlerProperties(eventHandlers: string[]) {
182
185
  for (const name of eventHandlers) {
183
186
  properties[name] = {
184
187
  description: `Event handler for the "${name.slice(2)}" event.`,
185
- oneOf: [{ $ref: "#/$defs/RefObject" }, { $ref: "#/$defs/ExpressionEntry" }],
188
+ oneOf: [
189
+ { $ref: "#/$defs/RefObject" },
190
+ { $ref: "#/$defs/ExpressionEntry" },
191
+ { $ref: "#/$defs/FunctionDef" },
192
+ ],
186
193
  };
187
194
  }
188
195
  return properties;
@@ -216,6 +223,9 @@ export async function generateSchema() {
216
223
  ClassConstructorDef: classConstructorDefSchema,
217
224
  ClassMethodDef: classMethodDefSchema,
218
225
  FormatDef: formatDefSchema,
226
+ ProjectBlockDef: projectBlockDefSchema,
227
+ ServerBlockDef: serverBlockDefSchema,
228
+ ConnectorBlockDef: connectorBlockDefSchema,
219
229
  StudioHints: studioHintsSchema,
220
230
  ExternalClassDef: externalClassDefSchema,
221
231
  ExpressionPointer: expressionPointerSchema,
@@ -338,7 +348,10 @@ export async function generateSchema() {
338
348
  additionalProperties: { type: "string" },
339
349
  description:
340
350
  "Named media breakpoints following CSS @custom-media convention. " +
341
- "Keys use the CSS custom property -- prefix.",
351
+ "Keys use the CSS custom property -- prefix. Pure color-scheme queries " +
352
+ '(e.g. "--dark": "(prefers-color-scheme: dark)") are dual-emitted so a ' +
353
+ "data-color-scheme attribute on the root element forces the scheme " +
354
+ "regardless of the OS preference (spec §9.5).",
342
355
  examples: [
343
356
  {
344
357
  "--dark": "(prefers-color-scheme: dark)",
@@ -412,6 +425,7 @@ export function generateProjectSchema() {
412
425
  return {
413
426
  $defs: {
414
427
  ImageConfig: imageConfigSchema,
428
+ StyleObject: styleObjectSchema,
415
429
  },
416
430
  $id: "https://jxsuite.com/schema/project/v1",
417
431
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -443,6 +457,7 @@ export function generateProjectCoreSchema() {
443
457
  ImageConfig: imageConfigSchema,
444
458
  JxFieldSchema: jxFieldSchemaDef,
445
459
  RelationshipRef: relationshipRefSchema,
460
+ StyleObject: styleObjectSchema,
446
461
  },
447
462
  $id: "https://jxsuite.com/schema/project/core/v2",
448
463
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -511,7 +526,10 @@ export function generateClassSchema() {
511
526
  ClassFieldDef: classFieldDefSchema,
512
527
  ClassMethodDef: classMethodDefSchema,
513
528
  ClassParameterDef: classParameterDefSchema,
529
+ ConnectorBlockDef: connectorBlockDefSchema,
514
530
  FormatDef: formatDefSchema,
531
+ ProjectBlockDef: projectBlockDefSchema,
532
+ ServerBlockDef: serverBlockDefSchema,
515
533
  StudioHints: studioHintsSchema,
516
534
  },
517
535
  $id: "https://jxsuite.com/schema/class/v1",
@@ -562,6 +580,7 @@ export function generateClassSchema() {
562
580
  },
563
581
  $schema: { type: "string" },
564
582
  $studio: { $ref: "#/$defs/StudioHints" },
583
+ connector: { $ref: "#/$defs/ConnectorBlockDef" },
565
584
  description: { type: "string" },
566
585
  extends: {
567
586
  description: "Base class — string name or $ref to another .class.json.",
@@ -576,6 +595,8 @@ export function generateClassSchema() {
576
595
  ],
577
596
  },
578
597
  format: { $ref: "#/$defs/FormatDef" },
598
+ project: { $ref: "#/$defs/ProjectBlockDef" },
599
+ server: { $ref: "#/$defs/ServerBlockDef" },
579
600
  title: {
580
601
  description: "PascalCase class name, used as the export name.",
581
602
  examples: ["MarkdownFile", "DataSource", "Calculator"],
@@ -610,7 +631,19 @@ type AjvCtor = new (opts: {
610
631
  }) => AjvInstance;
611
632
  type AddFormatsFn = (ajv: AjvInstance) => void;
612
633
 
613
- export async function validateDocument(doc: Record<string, unknown>) {
634
+ /**
635
+ * Validate a document against an arbitrary JSON Schema 2020-12 document (e.g. a bundled per-project
636
+ * document.schema.json from `jx schema`). The schema must be self-contained — external refs are not
637
+ * resolved here.
638
+ *
639
+ * @param {Record<string, unknown>} doc - The document to validate
640
+ * @param {Record<string, unknown>} schema - A self-contained 2020-12 schema
641
+ * @returns {Promise<{ errors: unknown[] | null; valid: boolean }>}
642
+ */
643
+ export async function validateWithSchema(
644
+ doc: Record<string, unknown>,
645
+ schema: Record<string, unknown>,
646
+ ) {
614
647
  let Ajv: AjvCtor, addFormats: AddFormatsFn;
615
648
  try {
616
649
  // The generated schema is JSON Schema 2020-12, so use the matching Ajv build
@@ -625,13 +658,21 @@ export async function validateDocument(doc: Record<string, unknown>) {
625
658
  const ajv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
626
659
  addFormats(ajv);
627
660
 
628
- const schema = await generateSchema();
629
661
  const validate = ajv.compile(schema);
630
662
  const valid = validate(doc);
631
663
 
632
664
  return { errors: validate.errors ?? null, valid };
633
665
  }
634
666
 
667
+ export async function validateDocument(doc: Record<string, unknown>) {
668
+ return validateWithSchema(doc, (await generateSchema()) as Record<string, unknown>);
669
+ }
670
+
671
+ /** Validate a .class.json class definition against the generated class schema. */
672
+ export async function validateClass(doc: Record<string, unknown>) {
673
+ return validateWithSchema(doc, generateClassSchema() as Record<string, unknown>);
674
+ }
675
+
635
676
  // ─── CLI ──────────────────────────────────────────────────────────────────────
636
677
 
637
678
  async function runSchemaCli() {
@@ -674,6 +715,30 @@ async function runSchemaCli() {
674
715
  writeFileSync(resolve(schemaDir, "schemas", "project.fields.schema.json"), fieldsStr, "utf8");
675
716
  writeFileSync(resolve(schemaDir, "schemas", "document.paths.schema.json"), pathsStr, "utf8");
676
717
  writeFileSync(resolve(schemaDir, "extension-manifest.schema.json"), manifestStr, "utf8");
718
+ // The schemas/ fragments are oxfmt-managed (unlike packages/schema/*.json, which .oxfmtrc
719
+ // Ignores), so emit them formatted — otherwise every `bun run generate:schema` reintroduces
720
+ // Raw-stringify formatting and dirties the tree. This is a dev-hygiene step only: the JSON
721
+ // Above is already written and valid, and oxfmt just prettifies it (e.g. collapsing short enum
722
+ // Arrays onto one line). Inside a sandboxed/offline build (e.g. `nix build`) oxfmt exits
723
+ // Non-zero with no diagnostic output, so treat a failure as non-fatal and keep the valid JSON
724
+ // Rather than aborting the build over cosmetic formatting.
725
+ const fmt = Bun.spawnSync(
726
+ [
727
+ "bunx",
728
+ "oxfmt",
729
+ resolve(schemaDir, "schemas", "project.core.schema.json"),
730
+ resolve(schemaDir, "schemas", "project.fields.schema.json"),
731
+ resolve(schemaDir, "schemas", "document.paths.schema.json"),
732
+ ],
733
+ { cwd: schemaDir },
734
+ );
735
+ if (fmt.exitCode !== 0) {
736
+ const detail =
737
+ fmt.stderr.toString() ||
738
+ fmt.stdout.toString() ||
739
+ `exit ${fmt.exitCode ?? "null"}${fmt.signalCode ? `, signal ${fmt.signalCode}` : ""}`;
740
+ console.error(`Warning: skipped oxfmt reformat of generated schemas (${detail})`);
741
+ }
677
742
  console.error("Generated:");
678
743
  console.error(" schema.json (component)");
679
744
  console.error(" project-schema.json");
@@ -133,6 +133,19 @@ async function loadProjectSchemaRef(uri: string, root: string): Promise<Record<s
133
133
  const selfRequire = createRequire(import.meta.url);
134
134
  resolved = selfRequire.resolve(specifier);
135
135
  } catch (error) {
136
+ // Exports-map subpath resolution is brittle against version-skewed shadowing installs (a
137
+ // Stale local copy terminates Node's walk-up with ERR_PACKAGE_PATH_NOT_EXPORTED). Our own
138
+ // Schema files are pure data and the running validator's copy is guaranteed to match, so
139
+ // Read them straight off this package's disk as the last resort.
140
+ const ownPrefix = "@jxsuite/schema/";
141
+ if (specifier.startsWith(ownPrefix)) {
142
+ const ownFile = fileURLToPath(
143
+ new URL(`../${specifier.slice(ownPrefix.length)}`, import.meta.url),
144
+ );
145
+ if (existsSync(ownFile)) {
146
+ return JSON.parse(readFileSync(ownFile, "utf8")) as Record<string, unknown>;
147
+ }
148
+ }
136
149
  throw new Error(
137
150
  `Schema ref "${uri}" does not exist and "${specifier}" is not resolvable from the ` +
138
151
  `project or the host`,