@jxsuite/schema 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.
@@ -105,6 +105,24 @@
105
105
  },
106
106
  "required": ["$ref"],
107
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": {},
125
+ "type": "object"
108
126
  }
109
127
  },
110
128
  "$id": "https://jxsuite.com/schema/project/core/v2",
@@ -366,11 +384,11 @@
366
384
  "type": "number"
367
385
  },
368
386
  {
369
- "type": "object"
387
+ "$ref": "#/$defs/StyleObject"
370
388
  }
371
389
  ]
372
390
  },
373
- "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.",
374
392
  "type": "object"
375
393
  },
376
394
  "url": {
@@ -1,11 +1,14 @@
1
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()`
2
+ * Deterministic mapping from Function-def `$src` module specifiers to bundled asset URLs, plus the
3
+ * asset-mount math that publishes files living outside the project root at a stable site URL.
4
+ * Shared between the compiler's sidecar bundler (which writes the bundle) and extension `lower()`
4
5
  * implementations (which emit the URL into client code) so neither needs to depend on the other.
5
6
  *
6
7
  * The mapping is intentionally hash-free: the same specifier always yields the same URL, letting
7
8
  * lowered defs reference bundles the compiler produces later in the build. Distinct specifiers that
8
9
  * collide on a slug are rejected by the bundler at build time.
10
+ *
11
+ * Pure string math only — no node imports — so browser hosts can import this module.
9
12
  */
10
13
 
11
14
  /** Prefix for npm package specifiers in `$src` (spec §12): `npm:<pkg>[/subpath]`. */
@@ -40,3 +43,135 @@ export function sidecarAssetPath(specifier: string): string {
40
43
  .replaceAll(/^-|-$/g, "");
41
44
  return `${SIDECAR_ASSET_DIR}${base}.js`;
42
45
  }
46
+
47
+ // ─── Asset mounts (specs/extensions.md §8.5) ─────────────────────────────────
48
+
49
+ /**
50
+ * A site URL prefix backed by a real directory. Extensions declaring the `assets` capability return
51
+ * mounts so hosts can publish files that live outside the project root (an external content
52
+ * source's co-located images) without knowing anything about the section that owns them.
53
+ */
54
+ export interface AssetMount {
55
+ /** Site-absolute URL prefix, e.g. `/content/docs` (no trailing slash). */
56
+ urlPrefix: string;
57
+ /** Absolute directory the prefix maps onto. */
58
+ dir: string;
59
+ }
60
+
61
+ /** Normalize a path or prefix for comparison: forward slashes, no trailing slash. */
62
+ function normalizeDir(value: string): string {
63
+ const forward = value.replaceAll("\\", "/");
64
+ return forward.length > 1 && forward.endsWith("/") ? forward.slice(0, -1) : forward;
65
+ }
66
+
67
+ /** Normalize a mount's URL prefix: leading slash, no trailing slash. */
68
+ export function normalizeAssetPrefix(prefix: string): string {
69
+ const forward = normalizeDir(prefix);
70
+ return forward.startsWith("/") ? forward : `/${forward}`;
71
+ }
72
+
73
+ /** Mounts sorted so the most specific (longest) candidate matches first. */
74
+ function bySpecificity(mounts: readonly AssetMount[], key: "dir" | "urlPrefix"): AssetMount[] {
75
+ return [...mounts].toSorted((a, b) => b[key].length - a[key].length);
76
+ }
77
+
78
+ /** True when `path` is `dir` itself or sits underneath it (both already normalized). */
79
+ function isUnder(path: string, dir: string): boolean {
80
+ return path === dir || path.startsWith(`${dir}/`);
81
+ }
82
+
83
+ /**
84
+ * Map an absolute file path to its mounted site URL, or null when no mount contains it. Path
85
+ * segments are percent-encoded so the URL survives HTML attributes; `resolveAssetUrl` reverses it.
86
+ *
87
+ * @param {readonly AssetMount[]} mounts
88
+ * @param {string} absolutePath - Absolute filesystem path
89
+ * @returns {string | null}
90
+ */
91
+ export function assetUrlFor(mounts: readonly AssetMount[], absolutePath: string): string | null {
92
+ const path = normalizeDir(absolutePath);
93
+ for (const mount of bySpecificity(mounts, "dir")) {
94
+ const dir = normalizeDir(mount.dir);
95
+ if (!isUnder(path, dir) || path === dir) {
96
+ continue;
97
+ }
98
+ const rest = path.slice(dir.length + 1);
99
+ const encoded = rest
100
+ .split("/")
101
+ .map((segment) => encodeURIComponent(segment))
102
+ .join("/");
103
+ return `${normalizeAssetPrefix(mount.urlPrefix)}/${encoded}`;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Map a mounted site URL back to its absolute file path, or null when it matches no mount. Query
110
+ * and hash are dropped, the path is decoded ONCE (a still-encoded dot or slash after that decode is
111
+ * a bypass attempt and is rejected), and `.`/`..` segments are refused outright — so the result can
112
+ * never escape the mount directory.
113
+ *
114
+ * @param {readonly AssetMount[]} mounts
115
+ * @param {string} url - Site-absolute URL (e.g. `/content/docs/images/hero.png`)
116
+ * @returns {string | null}
117
+ */
118
+ export function resolveAssetUrl(mounts: readonly AssetMount[], url: string): string | null {
119
+ const pathname = url.split("#")[0]!.split("?")[0]!;
120
+ if (!pathname.startsWith("/")) {
121
+ return null;
122
+ }
123
+ let decoded: string;
124
+ try {
125
+ decoded = decodeURIComponent(pathname);
126
+ } catch {
127
+ return null;
128
+ }
129
+ if (/%2e|%2f/i.test(decoded)) {
130
+ return null;
131
+ }
132
+ for (const mount of bySpecificity(mounts, "urlPrefix")) {
133
+ const prefix = normalizeAssetPrefix(mount.urlPrefix);
134
+ if (!decoded.startsWith(`${prefix}/`)) {
135
+ continue;
136
+ }
137
+ const rest = decoded.slice(prefix.length + 1);
138
+ const segments = rest.split("/");
139
+ if (segments.some((s) => s === "" || s === "." || s === "..")) {
140
+ return null;
141
+ }
142
+ return `${normalizeDir(mount.dir)}/${segments.join("/")}`;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** Characters that terminate a URL inside markup: quotes, whitespace, CSS `url()` parens, `<>`. */
148
+ const URL_TERMINATOR = String.raw`"'\s<>()\\`;
149
+
150
+ /**
151
+ * Collect every mounted URL referenced by a compiled artifact (HTML, CSS, JSON-LD). Scanning output
152
+ * text rather than the document tree catches references wherever they came from — markdown images,
153
+ * hand-authored pages, `$head` metadata, `url()` in stylesheets — with no per-attribute knowledge.
154
+ *
155
+ * @param {string} text - Compiled HTML or CSS
156
+ * @param {readonly AssetMount[]} mounts
157
+ * @returns {string[]} Unique mounted URLs, in first-seen order
158
+ */
159
+ export function collectAssetUrls(text: string, mounts: readonly AssetMount[]): string[] {
160
+ const found = new Set<string>();
161
+ for (const mount of mounts) {
162
+ const prefix = normalizeAssetPrefix(mount.urlPrefix);
163
+ const pattern = new RegExp(
164
+ `${prefix.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)}/[^${URL_TERMINATOR}]+`,
165
+ "g",
166
+ );
167
+ for (const match of text.matchAll(pattern)) {
168
+ const url = match[0].split("#")[0]!.split("?")[0]!;
169
+ // A trailing slash names a directory, not a file — prose quoting the mount prefix
170
+ // ("published under /content/docs/images/") is a mention, not a reference.
171
+ if (!url.endsWith("/")) {
172
+ found.add(url);
173
+ }
174
+ }
175
+ }
176
+ return [...found].filter(Boolean);
177
+ }
@@ -156,6 +156,11 @@ export class ExtensionRegistry {
156
156
  return this.#classes.filter((e) => e.capabilities.emit !== undefined);
157
157
  }
158
158
 
159
+ /** Classes declaring an `assets` capability (static asset mounts, §8.5), in declaration order. */
160
+ assetProviders(): FormatEntry[] {
161
+ return this.#classes.filter((e) => e.capabilities.assets !== undefined);
162
+ }
163
+
159
164
  /** Server-mount classes, sorted by `server.order` (ascending; default 100), then name. */
160
165
  serverMounts(): FormatEntry[] {
161
166
  return this.#classes
@@ -22,8 +22,8 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
22
22
  /**
23
23
  * All well-known static capability roles across admission blocks (specs/extensions.md §8): format
24
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.
25
+ * asset emission (`emit`), static asset mounts (`assets`), server-mount (`mount`), and connector
26
+ * (`dialect`, `deploySchema`, `bindings`, `testConnection`) roles.
27
27
  */
28
28
  export type ExtensionCapability =
29
29
  | FormatCapability
@@ -31,6 +31,7 @@ export type ExtensionCapability =
31
31
  | "resolvePaths"
32
32
  | "lower"
33
33
  | "emit"
34
+ | "assets"
34
35
  | "mount"
35
36
  | "dialect"
36
37
  | "deploySchema"
@@ -43,6 +44,7 @@ export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
43
44
  "resolvePaths",
44
45
  "lower",
45
46
  "emit",
47
+ "assets",
46
48
  "mount",
47
49
  "dialect",
48
50
  "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,
@@ -415,6 +425,7 @@ export function generateProjectSchema() {
415
425
  return {
416
426
  $defs: {
417
427
  ImageConfig: imageConfigSchema,
428
+ StyleObject: styleObjectSchema,
418
429
  },
419
430
  $id: "https://jxsuite.com/schema/project/v1",
420
431
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -446,6 +457,7 @@ export function generateProjectCoreSchema() {
446
457
  ImageConfig: imageConfigSchema,
447
458
  JxFieldSchema: jxFieldSchemaDef,
448
459
  RelationshipRef: relationshipRefSchema,
460
+ StyleObject: styleObjectSchema,
449
461
  },
450
462
  $id: "https://jxsuite.com/schema/project/core/v2",
451
463
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -514,7 +526,10 @@ export function generateClassSchema() {
514
526
  ClassFieldDef: classFieldDefSchema,
515
527
  ClassMethodDef: classMethodDefSchema,
516
528
  ClassParameterDef: classParameterDefSchema,
529
+ ConnectorBlockDef: connectorBlockDefSchema,
517
530
  FormatDef: formatDefSchema,
531
+ ProjectBlockDef: projectBlockDefSchema,
532
+ ServerBlockDef: serverBlockDefSchema,
518
533
  StudioHints: studioHintsSchema,
519
534
  },
520
535
  $id: "https://jxsuite.com/schema/class/v1",
@@ -565,6 +580,7 @@ export function generateClassSchema() {
565
580
  },
566
581
  $schema: { type: "string" },
567
582
  $studio: { $ref: "#/$defs/StudioHints" },
583
+ connector: { $ref: "#/$defs/ConnectorBlockDef" },
568
584
  description: { type: "string" },
569
585
  extends: {
570
586
  description: "Base class — string name or $ref to another .class.json.",
@@ -579,6 +595,8 @@ export function generateClassSchema() {
579
595
  ],
580
596
  },
581
597
  format: { $ref: "#/$defs/FormatDef" },
598
+ project: { $ref: "#/$defs/ProjectBlockDef" },
599
+ server: { $ref: "#/$defs/ServerBlockDef" },
582
600
  title: {
583
601
  description: "PascalCase class name, used as the export name.",
584
602
  examples: ["MarkdownFile", "DataSource", "Calculator"],
@@ -613,7 +631,19 @@ type AjvCtor = new (opts: {
613
631
  }) => AjvInstance;
614
632
  type AddFormatsFn = (ajv: AjvInstance) => void;
615
633
 
616
- 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
+ ) {
617
647
  let Ajv: AjvCtor, addFormats: AddFormatsFn;
618
648
  try {
619
649
  // The generated schema is JSON Schema 2020-12, so use the matching Ajv build
@@ -628,13 +658,21 @@ export async function validateDocument(doc: Record<string, unknown>) {
628
658
  const ajv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
629
659
  addFormats(ajv);
630
660
 
631
- const schema = await generateSchema();
632
661
  const validate = ajv.compile(schema);
633
662
  const valid = validate(doc);
634
663
 
635
664
  return { errors: validate.errors ?? null, valid };
636
665
  }
637
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
+
638
676
  // ─── CLI ──────────────────────────────────────────────────────────────────────
639
677
 
640
678
  async function runSchemaCli() {
@@ -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`,