@jxsuite/schema 0.35.0 → 1.0.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/src/schema.ts CHANGED
@@ -90,7 +90,13 @@ import {
90
90
  formatDefSchema,
91
91
  studioHintsSchema,
92
92
  } from "../defs/class-def.schema";
93
- import { contentTypeDefSchema } from "../defs/content-type-def.schema";
93
+ import { extensionManifestSchema } from "../defs/extension-manifest.schema";
94
+ import {
95
+ DOCUMENT_PATHS_SCHEMA_ID,
96
+ PROJECT_FIELDS_SCHEMA_ID,
97
+ jxFieldSchemaDef,
98
+ relationshipRefSchema,
99
+ } from "../defs/field-schema.schema";
94
100
  import { imageConfigSchema } from "../defs/image-config.schema";
95
101
  import { projectConfigSchema } from "../defs/project-config.schema";
96
102
 
@@ -261,7 +267,6 @@ export async function generateSchema() {
261
267
  JsonSchemaType: jsonSchemaTypeSchema,
262
268
  HeadEntry: headEntrySchema,
263
269
  ImageConfig: imageConfigSchema,
264
- ContentTypeDef: contentTypeDefSchema,
265
270
  },
266
271
  $id: "https://jxsuite.com/schema/v1",
267
272
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -395,23 +400,97 @@ export async function generateSchema() {
395
400
  export function generateProjectSchema() {
396
401
  return {
397
402
  $defs: {
398
- ContentTypeDef: contentTypeDefSchema,
399
403
  ImageConfig: imageConfigSchema,
400
404
  },
401
405
  $id: "https://jxsuite.com/schema/project/v1",
402
406
  $schema: "https://json-schema.org/draft/2020-12/schema",
403
- additionalProperties: false,
404
407
  description:
405
408
  "Schema for Jx project.json files. " +
406
409
  "A project.json file is the root anchor file for a Jx project, " +
407
- "declaring site metadata, default settings, global styles, content types, " +
408
- "and build configuration.",
410
+ "declaring site metadata, default settings, global styles, extensions, " +
411
+ "and build configuration. Open by design: extension-contributed sections " +
412
+ "are opaque top-level keys; the generated per-project entry document " +
413
+ "(project.schema.json) closes the composition.",
409
414
  properties: projectConfigSchema.properties,
410
415
  title: "Jx Project",
411
416
  type: "object",
412
417
  };
413
418
  }
414
419
 
420
+ // ─── Project Core Fragment Generator ─────────────────────────────────────────
421
+
422
+ /**
423
+ * The core fragment of the composed per-project schema (specs/extensions.md §5.1): core
424
+ * project.json properties only — no extension sections — and deliberately open, because closure
425
+ * (`unevaluatedProperties: false`) happens in the generated entry document. Publishes the
426
+ * `JxFieldSchema` and `RelationshipRef` defs the entry document unions into the `jxFieldSchema`
427
+ * dynamic anchor.
428
+ */
429
+ export function generateProjectCoreSchema() {
430
+ return {
431
+ $defs: {
432
+ ImageConfig: imageConfigSchema,
433
+ JxFieldSchema: jxFieldSchemaDef,
434
+ RelationshipRef: relationshipRefSchema,
435
+ },
436
+ $id: "https://jxsuite.com/schema/project/core/v2",
437
+ $schema: "https://json-schema.org/draft/2020-12/schema",
438
+ description:
439
+ "Core fragment of the Jx project.json schema. Open by design: extension fragments " +
440
+ "contribute their own top-level sections and the generated per-project entry document " +
441
+ "(project.schema.json) closes the composition. See specs/extensions.md §5.",
442
+ properties: projectConfigSchema.properties,
443
+ title: "Jx Project Core",
444
+ type: "object",
445
+ };
446
+ }
447
+
448
+ // ─── Union Resource Generators ───────────────────────────────────────────────
449
+
450
+ /**
451
+ * The shipped DEFAULT field-union resource. The generated per-project entry document re-embeds a
452
+ * resource under the same $id with the effective union (adding extension extras); by standard
453
+ * compound-document $id resolution the embed shadows this default wherever both are in play.
454
+ */
455
+ export function generateProjectFieldsSchema() {
456
+ return {
457
+ $id: PROJECT_FIELDS_SCHEMA_ID,
458
+ $schema: "https://json-schema.org/draft/2020-12/schema",
459
+ anyOf: [
460
+ { $ref: "https://jxsuite.com/schema/project/core/v2#/$defs/JxFieldSchema" },
461
+ { $ref: "https://jxsuite.com/schema/project/core/v2#/$defs/RelationshipRef" },
462
+ ],
463
+ description:
464
+ "Default field-schema union for section entry schemas: the core field shape plus " +
465
+ "relationship references. Per-project entry documents override this resource with the " +
466
+ "effective union. See specs/extensions.md §5.3.",
467
+ title: "Jx Project Field Union",
468
+ };
469
+ }
470
+
471
+ /** The shipped DEFAULT $paths-value resource: permissive until extensions contribute shapes. */
472
+ export function generateDocumentPathsSchema() {
473
+ return {
474
+ $id: DOCUMENT_PATHS_SCHEMA_ID,
475
+ $schema: "https://json-schema.org/draft/2020-12/schema",
476
+ description:
477
+ "Default $paths-value union for documents. Per-project entry documents override this " +
478
+ "resource with the union of extension-contributed paths shapes.",
479
+ title: "Jx Document Paths Union",
480
+ };
481
+ }
482
+
483
+ // ─── Extension Manifest Schema Generator ─────────────────────────────────────
484
+
485
+ export function generateExtensionManifestSchema() {
486
+ return {
487
+ $id: "https://jxsuite.com/schema/extension-manifest/v1",
488
+ $schema: "https://json-schema.org/draft/2020-12/schema",
489
+ title: "Jx Extension Manifest",
490
+ ...extensionManifestSchema,
491
+ };
492
+ }
493
+
415
494
  // ─── Class Schema Generator ─────────────────────────────────────────────────
416
495
 
417
496
  export function generateClassSchema() {
@@ -545,7 +624,7 @@ export async function validateDocument(doc: Record<string, unknown>) {
545
624
  // ─── CLI ──────────────────────────────────────────────────────────────────────
546
625
 
547
626
  async function runSchemaCli() {
548
- const { writeFileSync } = await import("node:fs");
627
+ const { mkdirSync, writeFileSync } = await import("node:fs");
549
628
  const { resolve, dirname } = await import("node:path");
550
629
 
551
630
  const schemaDir = dirname(resolve(process.argv[1] as string, ".."));
@@ -553,10 +632,18 @@ async function runSchemaCli() {
553
632
  const componentSchema = await generateSchema();
554
633
  const projectSchema = generateProjectSchema();
555
634
  const classSchema = generateClassSchema();
635
+ const projectCoreSchema = generateProjectCoreSchema();
636
+ const manifestSchema = generateExtensionManifestSchema();
637
+ const fieldsSchema = generateProjectFieldsSchema();
638
+ const pathsSchema = generateDocumentPathsSchema();
556
639
 
557
640
  const componentStr = JSON.stringify(componentSchema, null, 2);
558
641
  const projectStr = JSON.stringify(projectSchema, null, 2);
559
642
  const classStr = JSON.stringify(classSchema, null, 2);
643
+ const projectCoreStr = JSON.stringify(projectCoreSchema, null, 2);
644
+ const manifestStr = JSON.stringify(manifestSchema, null, 2);
645
+ const fieldsStr = JSON.stringify(fieldsSchema, null, 2);
646
+ const pathsStr = JSON.stringify(pathsSchema, null, 2);
560
647
 
561
648
  const [out] = process.argv.slice(2);
562
649
 
@@ -567,10 +654,23 @@ async function runSchemaCli() {
567
654
  writeFileSync(resolve(schemaDir, "schema.json"), componentStr, "utf8");
568
655
  writeFileSync(resolve(schemaDir, "project-schema.json"), projectStr, "utf8");
569
656
  writeFileSync(resolve(schemaDir, "class-schema.json"), classStr, "utf8");
657
+ mkdirSync(resolve(schemaDir, "schemas"), { recursive: true });
658
+ writeFileSync(
659
+ resolve(schemaDir, "schemas", "project.core.schema.json"),
660
+ projectCoreStr,
661
+ "utf8",
662
+ );
663
+ writeFileSync(resolve(schemaDir, "schemas", "project.fields.schema.json"), fieldsStr, "utf8");
664
+ writeFileSync(resolve(schemaDir, "schemas", "document.paths.schema.json"), pathsStr, "utf8");
665
+ writeFileSync(resolve(schemaDir, "extension-manifest.schema.json"), manifestStr, "utf8");
570
666
  console.error("Generated:");
571
667
  console.error(" schema.json (component)");
572
668
  console.error(" project-schema.json");
573
669
  console.error(" class-schema.json");
670
+ console.error(" schemas/project.core.schema.json");
671
+ console.error(" schemas/project.fields.schema.json");
672
+ console.error(" schemas/document.paths.schema.json");
673
+ console.error(" extension-manifest.schema.json");
574
674
  }
575
675
  }
576
676
 
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Validate-project — project.json validation against the generated per-project entry schema
3
+ * (specs/extensions.md §5.4).
4
+ *
5
+ * Node-only by design (mirrors the dynamic-ajv-import pattern of `validateDocument`): reads
6
+ * `<root>/project.json` and `<root>/project.schema.json`, compiles the entry document with
7
+ * ajv-2020, and resolves its relative `$ref`s through a file loader restricted to the project root
8
+ * and its node_modules. The entry file's URL is injected as `$id` at load time so the committed,
9
+ * machine-path-free relative refs resolve.
10
+ *
11
+ * When a `./node_modules/...` ref does not exist on disk (workspace hoisting leaves in-repo
12
+ * starters without their own node_modules), the loader falls back to host resolution of the bare
13
+ * specifier — the same project-first-then-host order as createNodeFormatIO.
14
+ */
15
+
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { createRequire } from "node:module";
18
+ import { isAbsolute, relative, resolve } from "node:path";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+
21
+ export interface ProjectValidationResult {
22
+ valid: boolean;
23
+ errors: unknown[] | null;
24
+ }
25
+
26
+ // Minimal structural types for the optional `ajv` peer dependency (no direct dependency, and it
27
+ // Ships no types usable from here) — same pattern as validateDocument in src/schema.ts.
28
+ interface AjvValidateFn {
29
+ (doc: unknown): boolean;
30
+ errors?: unknown[] | null;
31
+ }
32
+ interface AjvInstance {
33
+ compileAsync: (schema: unknown) => Promise<AjvValidateFn>;
34
+ }
35
+ type AjvCtor = new (opts: {
36
+ allErrors: boolean;
37
+ strict: boolean;
38
+ loadSchema: (uri: string) => Promise<Record<string, unknown>>;
39
+ }) => AjvInstance;
40
+
41
+ /**
42
+ * Validate `<projectRoot>/project.json` against `<projectRoot>/project.schema.json`.
43
+ *
44
+ * Validation failures are data (`{ valid: false, errors }`); infrastructure failures — missing
45
+ * files, unresolvable refs, refs escaping the project root — throw.
46
+ *
47
+ * @param {string} projectRoot - Absolute path to the project directory
48
+ * @returns {Promise<ProjectValidationResult>}
49
+ */
50
+ export async function validateProjectFile(projectRoot: string): Promise<ProjectValidationResult> {
51
+ const root = resolve(projectRoot);
52
+ const projectPath = resolve(root, "project.json");
53
+ const schemaPath = resolve(root, "project.schema.json");
54
+
55
+ if (!existsSync(projectPath)) {
56
+ throw new Error(`project.json not found in ${root}`);
57
+ }
58
+ if (!existsSync(schemaPath)) {
59
+ throw new Error(
60
+ `project.schema.json not found in ${root} — run \`jx schema\` to generate it from ` +
61
+ `project.json#/extensions`,
62
+ );
63
+ }
64
+
65
+ const project = JSON.parse(readFileSync(projectPath, "utf8")) as Record<string, unknown>;
66
+ const entrySchema = JSON.parse(readFileSync(schemaPath, "utf8")) as Record<string, unknown>;
67
+ // The committed entry document is machine-path-free; injecting its file URL as $id makes its
68
+ // Relative ./node_modules/... refs resolve against the project root.
69
+ entrySchema.$id = pathToFileURL(schemaPath).href;
70
+
71
+ let Ajv: AjvCtor;
72
+ try {
73
+ // The entry document is JSON Schema 2020-12, so use the matching Ajv build.
74
+ ({ default: Ajv } = (await import("ajv/dist/2020")) as { default: AjvCtor });
75
+ } catch (error) {
76
+ throw new Error("Project validation requires ajv: bun add ajv", { cause: error });
77
+ }
78
+
79
+ const ajv = new Ajv({
80
+ allErrors: true,
81
+ loadSchema: (uri) => loadProjectSchemaRef(uri, root),
82
+ strict: false,
83
+ });
84
+
85
+ const validate = await ajv.compileAsync(entrySchema);
86
+ const valid = validate(project);
87
+ return { errors: validate.errors ?? null, valid };
88
+ }
89
+
90
+ /**
91
+ * Restricted schema loader: only `file:` URLs inside the project root are served. Missing files
92
+ * under `<root>/node_modules/` fall back to host resolution of the corresponding bare specifier
93
+ * (in-repo starters have no local node_modules; the fragments still ship with the host).
94
+ *
95
+ * @param {string} uri - The `$ref` target ajv could not resolve from already-loaded resources
96
+ * @param {string} root - Absolute project root
97
+ * @returns {Promise<Record<string, unknown>>}
98
+ */
99
+ async function loadProjectSchemaRef(uri: string, root: string): Promise<Record<string, unknown>> {
100
+ if (!uri.startsWith("file:")) {
101
+ throw new Error(
102
+ `Cannot resolve schema ref "${uri}": only project-relative file refs are loadable ` +
103
+ `(canonical URIs must resolve from already-loaded fragments)`,
104
+ );
105
+ }
106
+
107
+ const filePath = fileURLToPath(uri);
108
+ const rel = relative(root, filePath);
109
+ if (rel.startsWith("..") || isAbsolute(rel)) {
110
+ throw new Error(`Schema ref "${uri}" escapes the project root ${root}`);
111
+ }
112
+
113
+ if (existsSync(filePath)) {
114
+ return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
115
+ }
116
+
117
+ // Host fallback: <root>/node_modules/<specifier> is absent — resolve the specifier through the
118
+ // Project's own resolution first, then the host's (workspace symlinks resolve transparently).
119
+ const posixRel = rel.split("\\").join("/");
120
+ const marker = "node_modules/";
121
+ const markerIndex = posixRel.lastIndexOf(marker);
122
+ if (markerIndex === -1) {
123
+ throw new Error(`Schema ref "${uri}" does not exist`);
124
+ }
125
+ const specifier = posixRel.slice(markerIndex + marker.length);
126
+
127
+ let resolved: string;
128
+ try {
129
+ const projRequire = createRequire(resolve(root, "package.json"));
130
+ resolved = projRequire.resolve(specifier);
131
+ } catch {
132
+ try {
133
+ const selfRequire = createRequire(import.meta.url);
134
+ resolved = selfRequire.resolve(specifier);
135
+ } catch (error) {
136
+ throw new Error(
137
+ `Schema ref "${uri}" does not exist and "${specifier}" is not resolvable from the ` +
138
+ `project or the host`,
139
+ { cause: error },
140
+ );
141
+ }
142
+ }
143
+ return JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
144
+ }
package/types.ts CHANGED
@@ -3,7 +3,6 @@ import type { FromSchema } from "json-schema-to-ts";
3
3
  import type { headEntrySchema } from "./defs/head-entry.schema";
4
4
  import type { imageConfigSchema } from "./defs/image-config.schema";
5
5
  import type { cemEventSchema, cemParameterSchema } from "./defs/cem.schema";
6
- import type { contentTypeDefSchema } from "./defs/content-type-def.schema";
7
6
  import type { projectConfigSchema } from "./defs/project-config.schema";
8
7
  import type { refObjectSchema } from "./defs/ref-object.schema";
9
8
  import type { styleObjectSchema } from "./defs/style-object.schema";
@@ -32,7 +31,6 @@ export namespace Strict {
32
31
  export type ImageConfig = FromSchema<typeof imageConfigSchema>;
33
32
  export type CemParameter = FromSchema<typeof cemParameterSchema>;
34
33
  export type CemEvent = FromSchema<typeof cemEventSchema>;
35
- export type ContentTypeDef = FromSchema<typeof contentTypeDefSchema>;
36
34
  export type RefObject = FromSchema<typeof refObjectSchema>;
37
35
  export type Style = FromSchema<typeof styleObjectSchema>;
38
36
  export type Element = FromSchema<typeof elementDefSchema>;
@@ -186,8 +184,6 @@ export interface CemEvent {
186
184
  deprecated?: boolean | string;
187
185
  }
188
186
 
189
- export type ContentTypeDef = FromSchema<typeof contentTypeDefSchema>;
190
-
191
187
  export type RefObject = FromSchema<typeof refObjectSchema>;
192
188
 
193
189
  export interface JxStyle {
@@ -445,6 +441,8 @@ export interface DeployConfig {
445
441
  }
446
442
 
447
443
  export interface ProjectConfig {
444
+ /** Relative path to the generated per-project schema (written by `jx schema`). */
445
+ $schema?: string;
448
446
  name?: string;
449
447
  url?: string;
450
448
  state?: Record<string, unknown>;
@@ -460,7 +458,12 @@ export interface ProjectConfig {
460
458
  };
461
459
  images?: ImageConfig;
462
460
  imports?: Record<string, string>;
463
- contentTypes?: Record<string, ContentTypeDef>;
461
+ /**
462
+ * Extension packages: bare package names (resolved project-first) or relative paths. Each must
463
+ * export jx-extension.json. Extension-contributed sections (e.g. `content`) are opaque top-level
464
+ * keys absorbed by the index signature.
465
+ */
466
+ extensions?: string[];
464
467
  /** Redirect map: source path → destination (or destination with HTTP status). */
465
468
  redirects?: Record<string, string | { destination: string; status?: number }>;
466
469
  defaults?: {
@@ -480,13 +483,15 @@ export interface ProjectConfig {
480
483
  export type JxAttributeValue = Bindable<string | number | boolean>;
481
484
 
482
485
  /**
483
- * A dynamic-route path source (spec §4.3): content-type based, explicit values, data-file ref, or a
484
- * legacy array of param objects.
486
+ * A dynamic-route path source (spec §4.3): explicit values, data-file ref, a legacy array of param
487
+ * objects, or an extension-discriminated source (an object carrying a `resolvePaths` discriminator
488
+ * key registered by an enabled extension, e.g. the parser's `contentType` — the extension owns the
489
+ * narrow shape).
485
490
  */
486
491
  export type JxPathsDef =
487
- | { contentType: string; param?: string; field?: string }
488
492
  | { values: JsonValue[]; param?: string }
489
493
  | { $ref: string; param?: string; field?: string }
494
+ | { param?: string; field?: string; [discriminator: string]: unknown }
490
495
  | Record<string, JsonValue>[];
491
496
 
492
497
  /**
@@ -541,6 +546,8 @@ export interface JxMutableNode {
541
546
  export type JxPath = (string | number)[];
542
547
 
543
548
  // ─── Content Type Schema ────────────────────────────────────────────────────────
549
+ // Part-3 cleanup: move to parser — the parser extension owns the content-section shapes now; these
550
+ // Stay here only while the studio still imports them from @jxsuite/schema/types.
544
551
 
545
552
  /** One field within a content-type JSON schema; recursive for nested objects. */
546
553
  export interface ContentTypeSchemaField {
@@ -558,3 +565,29 @@ export interface ContentTypeSchema {
558
565
  required?: string[];
559
566
  [key: string]: unknown;
560
567
  }
568
+
569
+ // ─── Content Wire Types ─────────────────────────────────────────────────────────
570
+ // Core wire shapes for content loading (specs/extensions.md §8): format classes' `load`
571
+ // Capabilities return ContentLoaderEntry[], and hosts pass the entries through untouched.
572
+ // Extensions implement them; core only threads them.
573
+
574
+ /** A table-of-contents entry extracted from a loaded content document. */
575
+ export interface TocEntry {
576
+ depth: number;
577
+ text: string;
578
+ id: string;
579
+ }
580
+
581
+ /** One loaded content entry, as produced by a format class's `load` capability. */
582
+ export interface ContentLoaderEntry {
583
+ id: string;
584
+ data: Record<string, unknown>;
585
+ body: string | null;
586
+ $children?: (JxElement | string)[];
587
+ _meta?: {
588
+ excerpt?: string;
589
+ toc?: TocEntry[];
590
+ readingTime?: number;
591
+ wordCount?: number;
592
+ };
593
+ }
@@ -1,37 +0,0 @@
1
- export const contentTypeDefSchema = {
2
- description:
3
- "Content type definition. Defines the source directory, frontmatter schema, and element dependencies.",
4
- properties: {
5
- $elements: {
6
- description: "Custom elements available in markdown directives for this content type.",
7
- items: {
8
- oneOf: [
9
- {
10
- properties: { $ref: { type: "string" } },
11
- required: ["$ref"],
12
- type: "object",
13
- },
14
- { type: "string" },
15
- ],
16
- },
17
- type: "array",
18
- },
19
- format: {
20
- description:
21
- "Import name of a format class declared in the project imports map " +
22
- '(e.g. "Markdown", "Csv"). When omitted, the format is derived from the ' +
23
- 'source file extension via the format registry. "json" is the only built-in.',
24
- type: "string",
25
- },
26
- schema: {
27
- description: "JSON Schema for validating frontmatter of content type entries.",
28
- type: "object",
29
- },
30
- source: {
31
- description: "Path to the content directory relative to the project root.",
32
- examples: ["./content/blog/", "./content/docs/"],
33
- type: "string",
34
- },
35
- },
36
- type: "object",
37
- } as const;