@jxsuite/schema 1.8.0 → 1.9.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
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./src/schema.ts",
12
12
  "./asset-paths": "./src/asset-paths.ts",
13
+ "./component-meta": "./src/component-meta.ts",
13
14
  "./extension-registry": "./src/extension-registry.ts",
14
15
  "./format-registry": "./src/format-registry.ts",
15
16
  "./project-schemas": "./src/project-schemas.ts",
@@ -54,7 +55,7 @@
54
55
  "upgrade": "bunx npm-check-updates -u && bun install"
55
56
  },
56
57
  "type": "module",
57
- "version": "1.8.0",
58
+ "version": "1.9.0",
58
59
  "devDependencies": {
59
60
  "json-schema-to-ts": "^3.1.1"
60
61
  }
@@ -196,3 +196,345 @@ export function collectAssetUrls(text: string, mounts: readonly AssetMount[]): s
196
196
  }
197
197
  return [...found].filter(Boolean);
198
198
  }
199
+
200
+ // ─── Project paths ↔ site URLs (specs/site-architecture.md §9.3) ─────────────
201
+
202
+ /**
203
+ * The project directory whose contents publish at the site root.
204
+ *
205
+ * A convention rather than a setting: `public/logo.png` is `/logo.png` on the built site, which is
206
+ * why an authored `/logo.png` names a file the project tree calls something else.
207
+ */
208
+ export const PUBLIC_DIR = "public";
209
+
210
+ /** Node keys whose value is a media reference. Mirrors `ASSET_KEYS` in the content loader. */
211
+ export const ASSET_KEYS = ["src", "poster"] as const;
212
+
213
+ /**
214
+ * One step in a host's resolution order for a site-absolute URL.
215
+ *
216
+ * - `"mounts"` — an extension asset mount publishes a directory at a URL prefix (extensions.md §8.5).
217
+ * - `"root"` — the URL path is a project-relative path as written.
218
+ * - `"public"` — the URL path is relative to {@link PUBLIC_DIR}.
219
+ */
220
+ export type AssetLane = "mounts" | "root" | "public";
221
+
222
+ /**
223
+ * What a BUILD resolves — mounts, then `public/`, and nothing else.
224
+ *
225
+ * This is the published contract (site-architecture.md §9.3): a file at `<root>/hero.jpg` is not
226
+ * addressable at `/hero.jpg` on a deployed site, because nothing copies it there.
227
+ */
228
+ export const BUILD_LANES: readonly AssetLane[] = ["mounts", "public"];
229
+
230
+ /**
231
+ * What the editing servers resolve — mounts, then the project root, then `public/`.
232
+ *
233
+ * It is deliberately NOT {@link BUILD_LANES}, and the difference is a real one rather than an
234
+ * implementation detail: `serveProjectFile` in `@jxsuite/server` tries the project root BEFORE
235
+ * `public/`, so `<root>/hero.jpg` loads at `/hero.jpg` in a preview and 404s in production. A test
236
+ * asserts the two constants differ, so nobody can quietly assume they agree.
237
+ */
238
+ export const DEV_SERVER_LANES: readonly AssetLane[] = ["mounts", "root", "public"];
239
+
240
+ /**
241
+ * Split a reference into the path part and the `?query#hash` suffix.
242
+ *
243
+ * The suffix must survive every rewrite untouched: `./hero.png?v=2` is one file plus a
244
+ * cache-buster, and a rewrite that drops the buster changes what the browser requests.
245
+ *
246
+ * @param {string} value - An authored reference
247
+ * @returns {{ path: string; suffix: string }} The bare path and everything from the first `?`/`#`
248
+ */
249
+ export function splitRefSuffix(value: string): { path: string; suffix: string } {
250
+ const cut = value.search(/[#?]/);
251
+ return cut === -1
252
+ ? { path: value, suffix: "" }
253
+ : { path: value.slice(0, cut), suffix: value.slice(cut) };
254
+ }
255
+
256
+ /**
257
+ * True when `value` is already a URL, a template, a fragment, or empty — never a project file.
258
+ *
259
+ * Mirrors `isNonRelativeRef` in the content loader. A root-relative `/hero.jpg` counts as
260
+ * non-relative here because it is a SITE URL, not a path relative to the document.
261
+ *
262
+ * @param {string} value - An authored reference
263
+ * @returns {boolean}
264
+ */
265
+ export function isNonRelativeRef(value: string): boolean {
266
+ return (
267
+ value === "" ||
268
+ value.startsWith("/") ||
269
+ value.startsWith("#") ||
270
+ value.includes("${") ||
271
+ /^[a-z][\w+.-]*:/i.test(value)
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Normalize a project-relative path: forward slashes, no `./` prefix, no leading or trailing slash.
277
+ *
278
+ * @param {string} path - A project-relative path in any spelling
279
+ * @returns {string} The canonical spelling
280
+ */
281
+ export function normalizeProjectPath(path: string): string {
282
+ return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
283
+ }
284
+
285
+ /** The directory portion of a project-relative path (`""` for a root-level file). */
286
+ export function dirOfPath(path: string): string {
287
+ const normalized = normalizeProjectPath(path);
288
+ const slash = normalized.lastIndexOf("/");
289
+ return slash === -1 ? "" : normalized.slice(0, slash);
290
+ }
291
+
292
+ /**
293
+ * Resolve `ref` against `dir` in a pure, POSIX-ish way — no `node:path`, and no leading-slash
294
+ * assumption, because project paths are relative to a root the browser does not have.
295
+ *
296
+ * @param {string} dir - Project-relative directory (`""` for the root)
297
+ * @param {string} ref - A relative reference
298
+ * @returns {string | null} The project-relative result, or null when it climbs above the root
299
+ */
300
+ export function joinProjectPath(dir: string, ref: string): string | null {
301
+ const base = dir === "" || dir === "." ? [] : normalizeProjectPath(dir).split("/");
302
+ const out = base.filter((segment) => segment !== "" && segment !== ".");
303
+ for (const segment of ref.replaceAll("\\", "/").split("/")) {
304
+ if (segment === "" || segment === ".") {
305
+ continue;
306
+ }
307
+ if (segment === "..") {
308
+ if (out.length === 0) {
309
+ return null; // Escapes the project root.
310
+ }
311
+ out.pop();
312
+ continue;
313
+ }
314
+ out.push(segment);
315
+ }
316
+ return out.join("/");
317
+ }
318
+
319
+ /**
320
+ * The site URL a project file publishes at under `lanes`, or null when nothing publishes it.
321
+ *
322
+ * The lanes are tried in order, so the answer is the URL the host would resolve FIRST — which is
323
+ * what makes {@link projectPathsForSiteUrl} its inverse. Under {@link DEV_SERVER_LANES} that means
324
+ * `public/logo.png` answers `/public/logo.png` (the root lane, which the server tries first) rather
325
+ * than `/logo.png`; both work there, and only one round-trips.
326
+ *
327
+ * @param {string} path - Project-relative path
328
+ * @param {readonly AssetMount[]} mounts - Project-relative asset mounts
329
+ * @param {readonly AssetLane[]} lanes - The host's resolution order
330
+ * @returns {string | null} A site-absolute URL, or null
331
+ */
332
+ export function siteUrlForPath(
333
+ path: string,
334
+ mounts: readonly AssetMount[],
335
+ lanes: readonly AssetLane[],
336
+ ): string | null {
337
+ const normalized = normalizeProjectPath(path);
338
+ if (normalized === "") {
339
+ return null;
340
+ }
341
+ for (const lane of lanes) {
342
+ if (lane === "mounts") {
343
+ const url = assetUrlFor(mounts, normalized);
344
+ if (url) {
345
+ return url;
346
+ }
347
+ continue;
348
+ }
349
+ if (lane === "root") {
350
+ return `/${encodeProjectPath(normalized)}`;
351
+ }
352
+ if (normalized === PUBLIC_DIR || normalized.startsWith(`${PUBLIC_DIR}/`)) {
353
+ return `/${encodeProjectPath(normalized.slice(PUBLIC_DIR.length + 1))}`;
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+
359
+ /**
360
+ * Every project path a site-absolute URL could name under `lanes`, in the order the host tries
361
+ * them.
362
+ *
363
+ * A list rather than one answer because the lanes genuinely overlap: `/hero.jpg` is `hero.jpg` to
364
+ * the root lane and `public/hero.jpg` to the public lane, and only a filesystem can say which
365
+ * exists. Callers with no filesystem — the Studio canvas — take the first.
366
+ *
367
+ * @param {string} url - A site-absolute URL (query and hash are ignored)
368
+ * @param {readonly AssetMount[]} mounts - Project-relative asset mounts
369
+ * @param {readonly AssetLane[]} lanes - The host's resolution order
370
+ * @returns {string[]} Candidate project-relative paths, most-preferred first
371
+ */
372
+ export function projectPathsForSiteUrl(
373
+ url: string,
374
+ mounts: readonly AssetMount[],
375
+ lanes: readonly AssetLane[],
376
+ ): string[] {
377
+ const { path } = splitRefSuffix(url);
378
+ if (!path.startsWith("/")) {
379
+ return [];
380
+ }
381
+ let decoded: string;
382
+ try {
383
+ decoded = decodeURIComponent(path);
384
+ } catch {
385
+ return [];
386
+ }
387
+ // A still-encoded dot or slash after one decode is a traversal attempt, not a filename.
388
+ if (/%2e|%2f/i.test(decoded)) {
389
+ return [];
390
+ }
391
+ const rest = decoded.replace(/^\/+/, "");
392
+ if (rest === "" || rest.split("/").some((segment) => segment === "." || segment === "..")) {
393
+ return [];
394
+ }
395
+ const out: string[] = [];
396
+ for (const lane of lanes) {
397
+ if (lane === "mounts") {
398
+ const mounted = resolveAssetUrl(mounts, path);
399
+ // Only a mount INSIDE the project is addressable by path; `../../docs` is not.
400
+ if (mounted !== null && !mounted.startsWith("/") && !/^[A-Za-z]:/.test(mounted)) {
401
+ out.push(normalizeProjectPath(mounted));
402
+ }
403
+ continue;
404
+ }
405
+ out.push(lane === "root" ? rest : `${PUBLIC_DIR}/${rest}`);
406
+ }
407
+ return [...new Set(out)];
408
+ }
409
+
410
+ /**
411
+ * The project file an authored reference names, seen from the document that wrote it.
412
+ *
413
+ * The two shapes an author writes resolve differently and neither goes through a site URL:
414
+ *
415
+ * - A content-relative `./images/hero.png` resolves against the document's own directory, which IS a
416
+ * project path already — `content/posts/images/hero.png`. The mount detour that the built site
417
+ * performs is not needed to name the file, only to publish it.
418
+ * - A root-relative `/hero.jpg` is a site URL, so it resolves through {@link projectPathsForSiteUrl}
419
+ * and takes the host's first lane.
420
+ *
421
+ * Everything else — an absolute URL, a `data:` URI, a `${…}` template, a bare fragment — names no
422
+ * project file and returns null, which callers read as "leave it exactly as written".
423
+ *
424
+ * @param {string} value - The authored reference
425
+ * @param {string} documentDir - Project-relative directory of the document that wrote it
426
+ * @param {readonly AssetMount[]} mounts - Project-relative asset mounts
427
+ * @param {readonly AssetLane[]} lanes - The host's resolution order
428
+ * @returns {string | null} A project-relative path, or null
429
+ */
430
+ export function projectPathForRef(
431
+ value: string,
432
+ documentDir: string,
433
+ mounts: readonly AssetMount[],
434
+ lanes: readonly AssetLane[],
435
+ ): string | null {
436
+ const { path } = splitRefSuffix(value);
437
+ if (path === "") {
438
+ return null;
439
+ }
440
+ if (isNonRelativeRef(value)) {
441
+ return path.startsWith("/") ? (projectPathsForSiteUrl(path, mounts, lanes)[0] ?? null) : null;
442
+ }
443
+ // A reference ending in a slash names a DIRECTORY, and no directory is a media file.
444
+ if (path.endsWith("/")) {
445
+ return null;
446
+ }
447
+ let decoded: string;
448
+ try {
449
+ decoded = decodeURIComponent(path);
450
+ } catch {
451
+ return null;
452
+ }
453
+ const joined = joinProjectPath(documentDir, decoded);
454
+ if (joined === null || joined === "" || joined === normalizeProjectPath(documentDir)) {
455
+ return null;
456
+ }
457
+ return joined;
458
+ }
459
+
460
+ /**
461
+ * Percent-encode each path segment so the result survives an HTML attribute.
462
+ *
463
+ * Segment by segment, so the separators stay separators: `encodeURIComponent` on the whole path
464
+ * would turn every `/` into `%2F` and produce one very long filename.
465
+ *
466
+ * @param {string} path - A project-relative path
467
+ * @returns {string} The same path, each segment encoded
468
+ */
469
+ export function encodeProjectPath(path: string): string {
470
+ return path
471
+ .split("/")
472
+ .map((segment) => encodeURIComponent(segment))
473
+ .join("/");
474
+ }
475
+
476
+ // ─── srcset ──────────────────────────────────────────────────────────────────
477
+
478
+ /** One `srcset` candidate: a URL and the descriptor that selects it (`""` when there is none). */
479
+ export interface SrcsetCandidate {
480
+ url: string;
481
+ descriptor: string;
482
+ }
483
+
484
+ /**
485
+ * Parse a `srcset` attribute into its candidates.
486
+ *
487
+ * Splitting on `","` is wrong and quietly so: a `data:` URL contains commas, and so does any URL
488
+ * with a query list. This follows HTML's own algorithm instead — collect non-whitespace as the URL,
489
+ * and if it ends in commas, the descriptor is empty and the next candidate starts immediately.
490
+ *
491
+ * @param {string} value - The attribute value
492
+ * @returns {SrcsetCandidate[]} Candidates in source order
493
+ */
494
+ export function parseSrcset(value: string): SrcsetCandidate[] {
495
+ const out: SrcsetCandidate[] = [];
496
+ let i = 0;
497
+ while (i < value.length) {
498
+ while (i < value.length && (/\s/.test(value[i]!) || value[i] === ",")) {
499
+ i += 1;
500
+ }
501
+ if (i >= value.length) {
502
+ break;
503
+ }
504
+ const start = i;
505
+ while (i < value.length && !/\s/.test(value[i]!)) {
506
+ i += 1;
507
+ }
508
+ let url = value.slice(start, i);
509
+ if (url.endsWith(",")) {
510
+ out.push({ descriptor: "", url: url.replace(/,+$/, "") });
511
+ continue;
512
+ }
513
+ while (i < value.length && /\s/.test(value[i]!)) {
514
+ i += 1;
515
+ }
516
+ const descStart = i;
517
+ while (i < value.length && value[i] !== ",") {
518
+ i += 1;
519
+ }
520
+ const descriptor = value.slice(descStart, i).trim();
521
+ i += 1; // Consume the separating comma.
522
+ url = url.replace(/,+$/, "");
523
+ if (url !== "") {
524
+ out.push({ descriptor, url });
525
+ }
526
+ }
527
+ return out;
528
+ }
529
+
530
+ /**
531
+ * Serialize candidates back into a `srcset` attribute value.
532
+ *
533
+ * @param {readonly SrcsetCandidate[]} candidates
534
+ * @returns {string} The attribute value
535
+ */
536
+ export function formatSrcset(candidates: readonly SrcsetCandidate[]): string {
537
+ return candidates
538
+ .map(({ url, descriptor }) => (descriptor ? `${url} ${descriptor}` : url))
539
+ .join(", ");
540
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Component metadata, derived from a parsed component document.
3
+ *
4
+ * Discovery — "what components does this project have, and what props do they take" — is a pure
5
+ * function of the document. It reads `tagName`, `state` and any `<slot>` in the tree; it executes
6
+ * nothing. That distinction is what lets a backend with a no-project-JS posture discover JSON
7
+ * components safely, which Jx Cloud could not do while this logic lived inside two Bun servers.
8
+ *
9
+ * It lives here because THREE backends need the same answer and two of them had already written it
10
+ * twice: `packages/server/src/studio-api.ts` (the dev server's `/__studio/components`) and
11
+ * `packages/desktop/src/project-session.ts`. A third copy in the cloud adapter would have made
12
+ * "which entries in `state` count as props" a question with three answers.
13
+ *
14
+ * @license MIT
15
+ */
16
+
17
+ import type { JsonValue } from "../types";
18
+
19
+ /** One `<slot>` found in a component's tree. */
20
+ export interface ComponentSlotDef {
21
+ /** The slot's name; `""` for the unnamed slot. */
22
+ name: string;
23
+ /** Fallback children rendered when nothing is slotted in. */
24
+ fallback?: unknown[];
25
+ }
26
+
27
+ /** One editable prop, derived from a `state` entry. */
28
+ export interface ComponentPropDef {
29
+ name: string;
30
+ type?: unknown;
31
+ default?: JsonValue;
32
+ format?: unknown;
33
+ }
34
+
35
+ /** What discovery reports for one component document. */
36
+ export interface ComponentMetaOut {
37
+ tagName: string;
38
+ $id: string | null;
39
+ path: string;
40
+ props: ComponentPropDef[];
41
+ slots?: ComponentSlotDef[];
42
+ hasElements: boolean;
43
+ }
44
+
45
+ /**
46
+ * Collect slot definitions from a parsed component tree.
47
+ *
48
+ * Whitespace-only names count as unnamed (`""`), and only static `children` arrays are walked — a
49
+ * slot produced by a `$map` or a `$switch` has no fixed identity to report.
50
+ *
51
+ * @param {unknown} node - A node of the component document.
52
+ * @param {ComponentSlotDef[]} [out] - Accumulator, for the recursive walk.
53
+ * @returns {ComponentSlotDef[]}
54
+ */
55
+ export function collectSlotDefs(node: unknown, out: ComponentSlotDef[] = []): ComponentSlotDef[] {
56
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
57
+ return out;
58
+ }
59
+ const el = node as Record<string, unknown>;
60
+ if (el.tagName === "slot") {
61
+ const attrs = el.attributes as Record<string, unknown> | undefined;
62
+ const rawName = attrs?.name;
63
+ const name = typeof rawName === "string" ? rawName.trim() : "";
64
+ const { children } = el;
65
+ out.push({
66
+ name,
67
+ ...(Array.isArray(children) && children.length > 0 ? { fallback: children } : {}),
68
+ });
69
+ }
70
+ if (Array.isArray(el.children)) {
71
+ for (const c of el.children) {
72
+ collectSlotDefs(c, out);
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Whether a `state` entry is an editable prop.
80
+ *
81
+ * The shorthand form (`"title": "Hello"`) always is. The full form is one only when it is plain
82
+ * data: a computed value, a handler or a prototype entry is machinery the author does not type
83
+ * into, and offering it as a prop would put a text box in front of a function.
84
+ *
85
+ * @param {unknown} def - The `state` entry.
86
+ * @returns {boolean}
87
+ */
88
+ function isPropEntry(def: unknown): boolean {
89
+ if (def == null) {
90
+ return false;
91
+ }
92
+ if (typeof def !== "object") {
93
+ return true;
94
+ }
95
+ const obj = def as Record<string, unknown>;
96
+ return !obj.$prototype && !obj.$handler && !obj.$compute;
97
+ }
98
+
99
+ /**
100
+ * Metadata for one component document, or null when the document is not a component.
101
+ *
102
+ * A component is a document whose `tagName` is a valid custom-element name — it must contain a
103
+ * hyphen. That single test is what keeps a page or a data file from being reported as a component
104
+ * when a whole project tree is scanned.
105
+ *
106
+ * @param {unknown} doc - The parsed document.
107
+ * @param {string} path - Project-relative path, reported verbatim.
108
+ * @returns {ComponentMetaOut | null}
109
+ */
110
+ export function componentMetaFrom(doc: unknown, path: string): ComponentMetaOut | null {
111
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
112
+ return null;
113
+ }
114
+ const content = doc as Record<string, unknown>;
115
+ const { tagName } = content;
116
+ if (typeof tagName !== "string" || !tagName.includes("-")) {
117
+ return null;
118
+ }
119
+ const slots = collectSlotDefs(content);
120
+ const state = (content.state ?? {}) as Record<string, unknown>;
121
+ return {
122
+ $id: typeof content.$id === "string" && content.$id ? content.$id : null,
123
+ hasElements: Array.isArray(content.$elements) && content.$elements.length > 0,
124
+ path,
125
+ props: Object.entries(state)
126
+ .filter(([, def]) => isPropEntry(def))
127
+ .map(([name, def]) => {
128
+ if (typeof def !== "object" || def === null) {
129
+ // Shorthand: the value IS the default, and its runtime type is the only type on offer.
130
+ return { default: def as JsonValue, name, type: typeof def };
131
+ }
132
+ const obj = def as Record<string, unknown>;
133
+ return {
134
+ default: obj.default as JsonValue,
135
+ format: obj.format,
136
+ name,
137
+ type: obj.type,
138
+ };
139
+ }),
140
+ ...(slots.length > 0 ? { slots } : {}),
141
+ tagName,
142
+ };
143
+ }