@magicvr/schema-ui-protocol 0.2.1 → 0.2.3

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.
@@ -0,0 +1,677 @@
1
+ export const DEFAULT_MANIFEST_PATH = "/.well-known/schema-ui/app-manifest.json";
2
+ /**
3
+ * Host manifest-version support set (strict negotiation, ADR-0009): 2.7 for
4
+ * existing production manifests, 2.8 for Host/App interoperability manifests
5
+ * (returnIntentQueryKeys etc.), 2.9 for ADR-0039/ADR-0040 (data.route-binding /
6
+ * form.controls.readonly). Kept additive — older manifests stay accepted.
7
+ */
8
+ export const APP_MANIFEST_SUPPORTED_PROTOCOL_VERSIONS = ["2.7", "2.8", "2.9"];
9
+ export const APP_MANIFEST_PROTOCOL_VERSION = "2.9";
10
+ export const MANIFEST_SOURCE_HEADER = "X-Schema-UI-Manifest-Source";
11
+ export const APP_MANIFEST_SOURCE = "https://github.com/magicvr/schema-ui-docs/tree/81aa1d8"; // v2.9.0 formal release commit
12
+ const APP_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
13
+ // v2.8: capability id segments may contain hyphens (host.failure-recovery).
14
+ const CAPABILITY_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/;
15
+ const RETURN_INTENT_KEY_PATTERN = /^[a-z][a-zA-Z0-9_]*$/;
16
+ const ICON_PATTERN = /^[a-z][a-z0-9-]*$/;
17
+ /** True when `version` (MAJOR.MINOR) is >= the given floor. */
18
+ function versionAtLeast(version, major, minor) {
19
+ const match = /^(\d+)\.(\d+)$/.exec(version);
20
+ if (!match) {
21
+ return false;
22
+ }
23
+ const gotMajor = Number(match[1]);
24
+ const gotMinor = Number(match[2]);
25
+ return gotMajor > major || (gotMajor === major && gotMinor >= minor);
26
+ }
27
+ const PATH_PATTERN = /^\/(?!\/)[^\s\\]*$/;
28
+ const TEMPLATE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
29
+ const EXPRESSION_PATTERN = /^\$context\.(user|features)\.([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s+(==|!=|contains)\s+(.+)$/;
30
+ /** True when `expression` matches the frozen $context expression grammar. */
31
+ export function isValidExpression(expression) {
32
+ if (!expression.startsWith("$context.")) {
33
+ return false;
34
+ }
35
+ const match = EXPRESSION_PATTERN.exec(expression);
36
+ if (!match) {
37
+ return false;
38
+ }
39
+ const literal = match[4].trim();
40
+ return (/^true$|^false$|^-?\d+(?:\.\d+)?$/.test(literal) ||
41
+ /^"(?:[^"\\]|\\.)*"$/.test(literal));
42
+ }
43
+ export class ManifestError extends Error {
44
+ code;
45
+ path;
46
+ /** Optional machine detail (e.g. the missing capability id). */
47
+ detail;
48
+ constructor(code, path, message, detail) {
49
+ super(message);
50
+ this.name = "ManifestError";
51
+ this.code = code;
52
+ this.path = path;
53
+ if (detail !== undefined) {
54
+ this.detail = detail;
55
+ }
56
+ }
57
+ }
58
+ function isRecord(value) {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+ function fail(code, path, message, detail) {
62
+ throw new ManifestError(code, path, message, detail);
63
+ }
64
+ function requireRecord(value, path) {
65
+ if (!isRecord(value)) {
66
+ return fail("INVALID_MANIFEST", path, "Expected an object.");
67
+ }
68
+ return value;
69
+ }
70
+ function requireString(value, path, minLength = 1) {
71
+ if (typeof value !== "string" || value.length < minLength) {
72
+ return fail("INVALID_MANIFEST", path, "Expected a non-empty string.");
73
+ }
74
+ return value;
75
+ }
76
+ function ensureKeys(value, allowed, path) {
77
+ for (const key of Object.keys(value)) {
78
+ if (!allowed.includes(key)) {
79
+ fail("UNKNOWN_MANIFEST_FIELD", path === "$" ? key : `${path}.${key}`, `Unknown manifest field: ${key}.`);
80
+ }
81
+ }
82
+ }
83
+ function requireArray(value, path) {
84
+ if (!Array.isArray(value)) {
85
+ return fail("INVALID_MANIFEST", path, "Expected an array.");
86
+ }
87
+ return value;
88
+ }
89
+ function requireStringArray(value, path) {
90
+ const values = requireArray(value, path);
91
+ const result = values.map((item, index) => requireString(item, `${path}[${index}]`));
92
+ if (new Set(result).size !== result.length) {
93
+ fail("INVALID_MANIFEST", path, "Array values must be unique.");
94
+ }
95
+ return result;
96
+ }
97
+ function validateRelativePath(value, path, allowPlaceholders) {
98
+ const result = requireString(value, path);
99
+ if (!PATH_PATTERN.test(result) ||
100
+ result.includes("?") ||
101
+ result.includes("#") ||
102
+ (!allowPlaceholders && result.includes("{"))) {
103
+ fail("INVALID_PATH", path, "Expected an application-relative path.");
104
+ }
105
+ return result;
106
+ }
107
+ function parseTemplate(route, path) {
108
+ if (route === "/") {
109
+ return [];
110
+ }
111
+ if (route.includes("//")) {
112
+ fail("INVALID_PATH", path, "Route templates cannot contain empty segments.");
113
+ }
114
+ const names = [];
115
+ for (const [index, segment] of route.slice(1).split("/").entries()) {
116
+ if (segment.length === 0) {
117
+ fail("INVALID_PATH", `${path}.route[${index}]`, "Route templates cannot contain empty segments.");
118
+ }
119
+ if (segment.startsWith("{") || segment.endsWith("}")) {
120
+ if (!/^\{[^{}]+\}$/.test(segment)) {
121
+ fail("INVALID_PATH", path, "Invalid route placeholder.");
122
+ }
123
+ const name = segment.slice(1, -1);
124
+ if (!TEMPLATE_NAME_PATTERN.test(name) || names.includes(name)) {
125
+ fail("INVALID_PATH", path, "Invalid or repeated route placeholder.");
126
+ }
127
+ names.push(name);
128
+ }
129
+ else if (segment.includes("{") || segment.includes("}")) {
130
+ fail("INVALID_PATH", path, "Invalid route placeholder.");
131
+ }
132
+ }
133
+ return names;
134
+ }
135
+ function validateExpression(value, path) {
136
+ const expression = requireString(value, path);
137
+ if (!expression.startsWith("$context.")) {
138
+ fail("FORBIDDEN_VARIABLE", path, "Only $context variables are allowed.");
139
+ }
140
+ const match = EXPRESSION_PATTERN.exec(expression);
141
+ if (!match) {
142
+ fail("SYNTAX", path, "Invalid navigation expression.");
143
+ }
144
+ const literal = match[4].trim();
145
+ if (!/^true$|^false$|^-?\d+(?:\.\d+)?$/.test(literal) &&
146
+ !/^"(?:[^"\\]|\\.)*"$/.test(literal)) {
147
+ fail("SYNTAX", path, "Invalid navigation expression literal.");
148
+ }
149
+ return expression;
150
+ }
151
+ function parseVisibleWhen(value, path) {
152
+ const record = requireRecord(value, path);
153
+ ensureKeys(record, ["when"], path);
154
+ return { when: validateExpression(record.when, `${path}.when`) };
155
+ }
156
+ function parsePermissions(value, path) {
157
+ const record = requireRecord(value, path);
158
+ ensureKeys(record, ["view"], path);
159
+ return record.view === undefined
160
+ ? {}
161
+ : { view: validateExpression(record.view, `${path}.view`) };
162
+ }
163
+ function parseIcon(value, path) {
164
+ if (value === undefined) {
165
+ return undefined;
166
+ }
167
+ const icon = requireString(value, path);
168
+ if (!ICON_PATTERN.test(icon)) {
169
+ fail("INVALID_MANIFEST", path, "Invalid semantic icon name.");
170
+ }
171
+ return icon;
172
+ }
173
+ function parseLink(value, path, pages) {
174
+ const record = requireRecord(value, path);
175
+ ensureKeys(record, ["pageRef", "url", "label", "labelKey", "icon", "visibleWhen", "permissions"], path);
176
+ const hasPageRef = record.pageRef !== undefined;
177
+ const hasUrl = record.url !== undefined;
178
+ if (hasPageRef === hasUrl) {
179
+ fail("NAV_LINK_MUTEX", path, "A navigation link requires pageRef xor url.");
180
+ }
181
+ const pageRef = hasPageRef
182
+ ? requireString(record.pageRef, `${path}.pageRef`)
183
+ : undefined;
184
+ if (pageRef !== undefined) {
185
+ if (pages.length === 0) {
186
+ fail("PAGE_REF_WITH_EMPTY_PAGES", `${path}.pageRef`, "No pages are registered.");
187
+ }
188
+ if (!pages.some((page) => page.pageId === pageRef)) {
189
+ fail("NAV_PAGE_REF_UNKNOWN", `${path}.pageRef`, `Unknown pageRef: ${pageRef}.`);
190
+ }
191
+ }
192
+ const url = hasUrl
193
+ ? validateRelativePath(record.url, `${path}.url`, false)
194
+ : undefined;
195
+ if (url !== undefined && record.label === undefined && record.labelKey === undefined) {
196
+ fail("INVALID_MANIFEST", path, "URL navigation links require a label.");
197
+ }
198
+ const label = record.label === undefined
199
+ ? undefined
200
+ : requireString(record.label, `${path}.label`);
201
+ const labelKey = record.labelKey === undefined
202
+ ? undefined
203
+ : requireString(record.labelKey, `${path}.labelKey`);
204
+ const visibleWhen = record.visibleWhen === undefined
205
+ ? undefined
206
+ : parseVisibleWhen(record.visibleWhen, `${path}.visibleWhen`);
207
+ const permissions = record.permissions === undefined
208
+ ? undefined
209
+ : parsePermissions(record.permissions, `${path}.permissions`);
210
+ const icon = parseIcon(record.icon, `${path}.icon`);
211
+ return {
212
+ ...(pageRef === undefined ? {} : { pageRef }),
213
+ ...(url === undefined ? {} : { url }),
214
+ ...(label === undefined ? {} : { label }),
215
+ ...(labelKey === undefined ? {} : { labelKey }),
216
+ ...(icon === undefined ? {} : { icon }),
217
+ ...(visibleWhen === undefined ? {} : { visibleWhen }),
218
+ ...(permissions === undefined ? {} : { permissions }),
219
+ };
220
+ }
221
+ function parseItem(value, path, pages) {
222
+ const record = requireRecord(value, path);
223
+ if (record.items !== undefined) {
224
+ ensureKeys(record, ["label", "labelKey", "icon", "items", "visibleWhen", "permissions"], path);
225
+ const label = record.label === undefined
226
+ ? undefined
227
+ : requireString(record.label, `${path}.label`);
228
+ const labelKey = record.labelKey === undefined
229
+ ? undefined
230
+ : requireString(record.labelKey, `${path}.labelKey`);
231
+ if (label === undefined && labelKey === undefined) {
232
+ fail("INVALID_MANIFEST", path, "Navigation groups require a label.");
233
+ }
234
+ const items = requireArray(record.items, `${path}.items`);
235
+ if (items.length === 0) {
236
+ fail("INVALID_MANIFEST", `${path}.items`, "Navigation groups cannot be empty.");
237
+ }
238
+ const visibleWhen = record.visibleWhen === undefined
239
+ ? undefined
240
+ : parseVisibleWhen(record.visibleWhen, `${path}.visibleWhen`);
241
+ const permissions = record.permissions === undefined
242
+ ? undefined
243
+ : parsePermissions(record.permissions, `${path}.permissions`);
244
+ const icon = parseIcon(record.icon, `${path}.icon`);
245
+ return {
246
+ ...(label === undefined ? {} : { label }),
247
+ ...(labelKey === undefined ? {} : { labelKey }),
248
+ ...(icon === undefined ? {} : { icon }),
249
+ items: items.map((item, index) => {
250
+ const childPath = `${path}.items[${index}]`;
251
+ const child = requireRecord(item, childPath);
252
+ if (child.items !== undefined) {
253
+ fail("NAV_GROUP_NESTED", childPath, "Navigation groups cannot be nested.");
254
+ }
255
+ return parseLink(item, childPath, pages);
256
+ }),
257
+ ...(visibleWhen === undefined ? {} : { visibleWhen }),
258
+ ...(permissions === undefined ? {} : { permissions }),
259
+ };
260
+ }
261
+ return parseLink(value, path, pages);
262
+ }
263
+ function parsePages(value) {
264
+ const entries = requireArray(value, "pages");
265
+ const pageIds = new Set();
266
+ const routes = new Set();
267
+ return entries.map((entry, index) => {
268
+ const path = `pages[${index}]`;
269
+ const record = requireRecord(entry, path);
270
+ ensureKeys(record, ["pageId", "title", "titleKey", "schemaUrl", "route", "returnIntentQueryKeys"], path);
271
+ const pageId = requireString(record.pageId, `${path}.pageId`);
272
+ if (pageIds.has(pageId)) {
273
+ fail("INVALID_MANIFEST", `${path}.pageId`, "pageId values must be unique.");
274
+ }
275
+ pageIds.add(pageId);
276
+ const title = record.title === undefined ? undefined : requireString(record.title, `${path}.title`);
277
+ const titleKey = record.titleKey === undefined
278
+ ? undefined
279
+ : requireString(record.titleKey, `${path}.titleKey`);
280
+ if (title === undefined && titleKey === undefined) {
281
+ fail("INVALID_MANIFEST", path, "Pages require a title or titleKey.");
282
+ }
283
+ const schemaUrl = validateRelativePath(record.schemaUrl, `${path}.schemaUrl`, true);
284
+ const route = validateRelativePath(record.route, `${path}.route`, true);
285
+ const routeNames = parseTemplate(route, `${path}.route`);
286
+ const schemaNames = parseTemplate(schemaUrl, `${path}.schemaUrl`);
287
+ if (schemaNames.some((name) => !routeNames.includes(name))) {
288
+ fail("INVALID_PATH", `${path}.schemaUrl`, "schemaUrl placeholders must be bound by the page route.");
289
+ }
290
+ if (routes.has(route)) {
291
+ fail("INVALID_MANIFEST", `${path}.route`, "Route templates must be unique.");
292
+ }
293
+ routes.add(route);
294
+ let returnIntentQueryKeys;
295
+ if (record.returnIntentQueryKeys !== undefined) {
296
+ const keys = requireArray(record.returnIntentQueryKeys, `${path}.returnIntentQueryKeys`);
297
+ if (keys.length === 0
298
+ || keys.some((key) => typeof key !== "string" || !RETURN_INTENT_KEY_PATTERN.test(key))
299
+ || new Set(keys).size !== keys.length) {
300
+ fail("INVALID_RETURN_INTENT_QUERY_KEYS", `${path}.returnIntentQueryKeys`, "returnIntentQueryKeys must be a non-empty unique array of lowercase query keys.");
301
+ }
302
+ returnIntentQueryKeys = keys;
303
+ }
304
+ return {
305
+ pageId,
306
+ ...(title === undefined ? {} : { title }),
307
+ ...(titleKey === undefined ? {} : { titleKey }),
308
+ schemaUrl,
309
+ route,
310
+ ...(returnIntentQueryKeys === undefined ? {} : { returnIntentQueryKeys }),
311
+ };
312
+ });
313
+ }
314
+ function parseApp(value, pages) {
315
+ const record = requireRecord(value, "app");
316
+ ensureKeys(record, ["appId", "name", "nameKey", "homePageRef", "logo", "description", "descriptionKey"], "app");
317
+ const appId = requireString(record.appId, "app.appId");
318
+ if (!APP_ID_PATTERN.test(appId)) {
319
+ fail("INVALID_MANIFEST", "app.appId", "Invalid appId.");
320
+ }
321
+ const name = record.name === undefined ? undefined : requireString(record.name, "app.name");
322
+ const nameKey = record.nameKey === undefined ? undefined : requireString(record.nameKey, "app.nameKey");
323
+ if (name === undefined && nameKey === undefined) {
324
+ fail("INVALID_MANIFEST", "app", "App requires a name or nameKey.");
325
+ }
326
+ if (pages.length > 0 && record.homePageRef === undefined) {
327
+ fail("INVALID_MANIFEST", "app.homePageRef", "Non-empty pages require homePageRef.");
328
+ }
329
+ if (pages.length === 0 && record.homePageRef !== undefined) {
330
+ fail("PAGE_REF_WITH_EMPTY_PAGES", "app.homePageRef", "An empty page registry cannot declare a homePageRef.");
331
+ }
332
+ const homePageRef = record.homePageRef === undefined
333
+ ? undefined
334
+ : requireString(record.homePageRef, "app.homePageRef");
335
+ if (homePageRef !== undefined) {
336
+ const home = pages.find((page) => page.pageId === homePageRef);
337
+ if (!home) {
338
+ fail("MANIFEST_HOME_PAGE_UNKNOWN", "app.homePageRef", "Unknown home page.");
339
+ }
340
+ if (home.route.includes("{")) {
341
+ fail("MANIFEST_HOME_ROUTE_PARAMETRIC", "app.homePageRef", "Home page route cannot be parametric.");
342
+ }
343
+ }
344
+ let logo;
345
+ if (record.logo !== undefined) {
346
+ const logoRecord = requireRecord(record.logo, "app.logo");
347
+ ensureKeys(logoRecord, ["light", "dark"], "app.logo");
348
+ const light = validateLogoUrl(logoRecord.light, "app.logo.light");
349
+ const dark = logoRecord.dark === undefined
350
+ ? undefined
351
+ : validateLogoUrl(logoRecord.dark, "app.logo.dark");
352
+ logo = dark === undefined ? { light } : { light, dark };
353
+ }
354
+ const description = record.description === undefined
355
+ ? undefined
356
+ : requireString(record.description, "app.description", 0);
357
+ const descriptionKey = record.descriptionKey === undefined
358
+ ? undefined
359
+ : requireString(record.descriptionKey, "app.descriptionKey", 0);
360
+ return {
361
+ appId,
362
+ ...(name === undefined ? {} : { name }),
363
+ ...(nameKey === undefined ? {} : { nameKey }),
364
+ ...(homePageRef === undefined ? {} : { homePageRef }),
365
+ ...(logo === undefined ? {} : { logo }),
366
+ ...(description === undefined ? {} : { description }),
367
+ ...(descriptionKey === undefined ? {} : { descriptionKey }),
368
+ };
369
+ }
370
+ function parseNavigation(value, pages) {
371
+ const record = requireRecord(value, "navigation");
372
+ const navigation = {};
373
+ for (const key of Object.keys(record)) {
374
+ if (key !== "top" && key !== "sidebar" && key !== "user") {
375
+ fail("UNKNOWN_NAV_SLOT", `navigation.${key}`, `Unknown navigation slot: ${key}.`);
376
+ }
377
+ }
378
+ for (const slot of ["top", "sidebar", "user"]) {
379
+ if (record[slot] !== undefined) {
380
+ const items = requireArray(record[slot], `navigation.${slot}`);
381
+ navigation[slot] = items.map((item, index) => parseItem(item, `navigation.${slot}[${index}]`, pages));
382
+ }
383
+ }
384
+ return navigation;
385
+ }
386
+ export function validateAppManifest(value) {
387
+ const record = requireRecord(value, "$");
388
+ ensureKeys(record, ["protocolVersion", "requiredCapabilities", "app", "pages", "navigation"], "$");
389
+ if (record.protocolVersion === undefined) {
390
+ fail("MISSING_PROTOCOL_VERSION", "protocolVersion", "Manifest protocolVersion is required.");
391
+ }
392
+ const protocolVersion = requireString(record.protocolVersion, "protocolVersion");
393
+ if (!/^\d+\.\d+$/.test(protocolVersion)) {
394
+ fail("INVALID_PROTOCOL_VERSION", "protocolVersion", "Expected MAJOR.MINOR.");
395
+ }
396
+ const [majorText, minorText] = protocolVersion.split(".");
397
+ const major = Number(majorText);
398
+ const minor = Number(minorText);
399
+ if (major < 2 || (major === 2 && minor < 5)) {
400
+ fail("PROTOCOL_VERSION_TOO_LOW", "protocolVersion", "App manifest requires protocol >= 2.5.");
401
+ }
402
+ if (!APP_MANIFEST_SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
403
+ fail("UNSUPPORTED_PROTOCOL_VERSION", "protocolVersion", `This host supports ${APP_MANIFEST_SUPPORTED_PROTOCOL_VERSIONS.join(" and ")}.`);
404
+ }
405
+ const requiredCapabilities = requireStringArray(record.requiredCapabilities, "requiredCapabilities");
406
+ if (requiredCapabilities.some((capability) => !CAPABILITY_PATTERN.test(capability))) {
407
+ fail("INVALID_MANIFEST", "requiredCapabilities", "Capabilities must use dotted lowercase names.");
408
+ }
409
+ if (!requiredCapabilities.includes("app.manifest")) {
410
+ // Upstream M1 envelope: CAPABILITY_REQUIRED with the missing id as detail
411
+ // (reference-js/app-manifest.js). Kept aligned so the vendored upstream
412
+ // app-manifest suite runs with zero exclusions.
413
+ fail("CAPABILITY_REQUIRED", "requiredCapabilities", "app.manifest is required.", "app.manifest");
414
+ }
415
+ const pages = parsePages(record.pages);
416
+ // v2.8 return-intent allowlist extension gate (ADR-0036 / 09 §6): presence
417
+ // requires protocolVersion >= 2.8 AND host.failure-recovery capability.
418
+ // v2.9 keeps the same floor (2.9 pages are accepted).
419
+ for (const [index, page] of pages.entries()) {
420
+ if (page.returnIntentQueryKeys === undefined)
421
+ continue;
422
+ if (!versionAtLeast(protocolVersion, 2, 8)) {
423
+ fail("PROTOCOL_VERSION_TOO_LOW", `pages[${index}].returnIntentQueryKeys`, "returnIntentQueryKeys requires manifest protocol >= 2.8.");
424
+ }
425
+ if (!requiredCapabilities.includes("host.failure-recovery")) {
426
+ fail("MISSING_REQUIRED_CAPABILITY", `pages[${index}].returnIntentQueryKeys`, "host.failure-recovery is required when returnIntentQueryKeys is present.", "host.failure-recovery");
427
+ }
428
+ }
429
+ const app = parseApp(record.app, pages);
430
+ let navigation;
431
+ if (record.navigation !== undefined) {
432
+ if (!requiredCapabilities.includes("app.navigation")) {
433
+ fail("CAPABILITY_REQUIRED", "requiredCapabilities", "app.navigation is required when navigation is present.", "app.navigation");
434
+ }
435
+ navigation = parseNavigation(record.navigation, pages);
436
+ }
437
+ return {
438
+ protocolVersion,
439
+ requiredCapabilities,
440
+ app,
441
+ pages,
442
+ ...(navigation === undefined ? {} : { navigation }),
443
+ };
444
+ }
445
+ function validateLogoUrl(value, path) {
446
+ const logo = requireString(value, path);
447
+ if (!((/^\/(?!\/)[^\s\\{}]*$/.test(logo) && !logo.includes("?") && !logo.includes("#")) ||
448
+ /^https:\/\/[^\s\\]+$/.test(logo))) {
449
+ fail("INVALID_LOGO_URL", path, "Logo must be a site-relative or https URL.");
450
+ }
451
+ return logo;
452
+ }
453
+ function decodeSegment(value) {
454
+ try {
455
+ return decodeURIComponent(value);
456
+ }
457
+ catch {
458
+ return undefined;
459
+ }
460
+ }
461
+ function routeSegments(path) {
462
+ const cleanPath = stripPathQuery(path);
463
+ if (!cleanPath.startsWith("/") || cleanPath.includes("//")) {
464
+ return undefined;
465
+ }
466
+ if (cleanPath === "/") {
467
+ return [];
468
+ }
469
+ const segments = cleanPath.slice(1).split("/");
470
+ return segments.some((segment) => segment === "") ? undefined : segments;
471
+ }
472
+ function matchSinglePage(page, path) {
473
+ const inputSegments = routeSegments(path);
474
+ const templateSegments = routeSegments(page.route);
475
+ if (!inputSegments || !templateSegments || inputSegments.length !== templateSegments.length) {
476
+ return undefined;
477
+ }
478
+ const params = {};
479
+ for (const [index, templateSegment] of templateSegments.entries()) {
480
+ const decoded = decodeSegment(inputSegments[index]);
481
+ const expected = decodeSegment(templateSegment);
482
+ if (decoded === undefined || expected === undefined) {
483
+ return undefined;
484
+ }
485
+ if (/^\{[^{}]+\}$/.test(templateSegment)) {
486
+ params[templateSegment.slice(1, -1)] = decoded;
487
+ }
488
+ else if (decoded !== expected) {
489
+ return undefined;
490
+ }
491
+ }
492
+ return params;
493
+ }
494
+ export function matchRoute(pages, path) {
495
+ const inputSegments = routeSegments(path);
496
+ if (!inputSegments) {
497
+ return undefined;
498
+ }
499
+ const candidates = pages.flatMap((page, index) => {
500
+ const params = matchSinglePage(page, path);
501
+ if (!params) {
502
+ return [];
503
+ }
504
+ const templateSegments = routeSegments(page.route) ?? [];
505
+ const literalCount = templateSegments.filter((segment) => !/^\{[^{}]+\}$/.test(segment)).length;
506
+ return [{ page, index, params, literalCount }];
507
+ });
508
+ candidates.sort((left, right) => {
509
+ if (left.literalCount !== right.literalCount) {
510
+ return right.literalCount - left.literalCount;
511
+ }
512
+ if (left.page.route.length !== right.page.route.length) {
513
+ return right.page.route.length - left.page.route.length;
514
+ }
515
+ return left.index - right.index;
516
+ });
517
+ const winner = candidates[0];
518
+ return winner === undefined
519
+ ? undefined
520
+ : { page: winner.page, index: winner.index, params: winner.params };
521
+ }
522
+ export function stripPathQuery(path) {
523
+ const queryIndex = path.search(/[?#]/);
524
+ return queryIndex === -1 ? path : path.slice(0, queryIndex);
525
+ }
526
+ function parseQuery(path) {
527
+ const queryIndex = path.indexOf("?");
528
+ if (queryIndex === -1) {
529
+ return {};
530
+ }
531
+ const query = new URLSearchParams(path.slice(queryIndex + 1).split("#", 1)[0]);
532
+ return Object.fromEntries(query.entries());
533
+ }
534
+ export function resolveInitialRoute(manifest, requestedPath) {
535
+ const requested = stripPathQuery(requestedPath);
536
+ if (requested !== "/") {
537
+ const deepLink = matchRoute(manifest.pages, requested);
538
+ if (deepLink) {
539
+ return {
540
+ ...deepLink,
541
+ path: requested,
542
+ query: parseQuery(requestedPath),
543
+ source: "deepLink",
544
+ };
545
+ }
546
+ return undefined;
547
+ }
548
+ const homePage = manifest.pages.find((page) => page.pageId === manifest.app.homePageRef);
549
+ if (!homePage) {
550
+ return undefined;
551
+ }
552
+ const home = matchRoute(manifest.pages, homePage.route);
553
+ if (!home) {
554
+ return undefined;
555
+ }
556
+ return { ...home, path: homePage.route, query: {}, source: "home" };
557
+ }
558
+ export function resolveSchemaUrl(baseURL, schemaUrl, params) {
559
+ const resolved = resolveTemplate(schemaUrl, params, "schemaUrl");
560
+ return joinBaseURL(baseURL, resolved);
561
+ }
562
+ export function resolveRoutePath(route, params) {
563
+ return resolveTemplate(route, params, "route");
564
+ }
565
+ export function resolveLogoUrl(baseURL, logoUrl) {
566
+ if (/^https:\/\//.test(logoUrl)) {
567
+ return logoUrl;
568
+ }
569
+ if (!/^\/(?!\/)[^\s\\{}]*$/.test(logoUrl)) {
570
+ fail("INVALID_LOGO_URL", "logoUrl", "Logo must be a site-relative or https URL.");
571
+ }
572
+ return joinBaseURL(baseURL, logoUrl);
573
+ }
574
+ function resolveTemplate(template, params, field) {
575
+ return template.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_match, name) => {
576
+ const value = params[name];
577
+ if (value === undefined) {
578
+ fail("MISSING_PATH_BINDING", field + ".{" + name + "}", "Missing path binding: " + name + ".");
579
+ }
580
+ return encodeURIComponent(value);
581
+ });
582
+ }
583
+ function joinBaseURL(baseURL, relativePath) {
584
+ const base = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
585
+ return new URL(relativePath.replace(/^\/+/, ""), base).toString();
586
+ }
587
+ export function pageIdMatches(page, schemaPageId) {
588
+ if (page.pageId !== schemaPageId) {
589
+ throw new ManifestError("MANIFEST_PAGE_ID_MISMATCH", `pages[${page.pageId}].pageId`, "The page schema pageId does not match the manifest pageId.");
590
+ }
591
+ return true;
592
+ }
593
+ export async function loadAppManifest(options = {}) {
594
+ const loaded = await loadAppManifestBytes(options);
595
+ return loaded.manifest;
596
+ }
597
+ /** Loads the manifest with its raw 200 bytes (bootstrap integrity, ADR-0035 D6). */
598
+ export async function loadAppManifestBytes(options = {}) {
599
+ const url = options.url ?? DEFAULT_MANIFEST_PATH;
600
+ const fetcher = options.fetcher ?? globalThis.fetch;
601
+ if (!fetcher) {
602
+ throw new ManifestError("MANIFEST_LOAD_FAILED", url, "Fetch is unavailable.");
603
+ }
604
+ try {
605
+ const response = await fetcher(url);
606
+ if (!response.ok) {
607
+ throw new ManifestError("MANIFEST_LOAD_FAILED", url, `Manifest request failed with HTTP ${response.status}.`);
608
+ }
609
+ if (import.meta.env.DEV &&
610
+ response.url !== "" &&
611
+ response.headers.get(MANIFEST_SOURCE_HEADER) !== "api") {
612
+ console.warn(`[schema-ui] development manifest fixture served at ${url}; API projection was not used.`);
613
+ }
614
+ const bytes = new Uint8Array(await response.arrayBuffer());
615
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
616
+ const manifest = validateAppManifest(JSON.parse(text));
617
+ return { manifest, bytes };
618
+ }
619
+ catch (error) {
620
+ if (error instanceof ManifestError) {
621
+ throw error;
622
+ }
623
+ throw new ManifestError("MANIFEST_LOAD_FAILED", url, "Manifest could not be fetched or parsed.");
624
+ }
625
+ }
626
+ function getContextValue(context, root, path) {
627
+ let current = context[root];
628
+ for (const part of path.split(".")) {
629
+ if (!isRecord(current)) {
630
+ return undefined;
631
+ }
632
+ current = current[part];
633
+ }
634
+ return current;
635
+ }
636
+ function parseLiteral(value) {
637
+ if (value === "true")
638
+ return true;
639
+ if (value === "false")
640
+ return false;
641
+ if (/^-?\d+(?:\.\d+)?$/.test(value))
642
+ return Number(value);
643
+ return JSON.parse(value);
644
+ }
645
+ export function evaluateExpression(expression, context) {
646
+ const match = EXPRESSION_PATTERN.exec(expression);
647
+ if (!match) {
648
+ return false;
649
+ }
650
+ const actual = getContextValue(context, match[1], match[2]);
651
+ const expected = parseLiteral(match[4].trim());
652
+ switch (match[3]) {
653
+ case "contains":
654
+ return Array.isArray(actual)
655
+ ? actual.includes(expected)
656
+ : typeof actual === "string" && typeof expected === "string"
657
+ ? actual.includes(expected)
658
+ : false;
659
+ case "==":
660
+ return Object.is(actual, expected);
661
+ case "!=":
662
+ return !Object.is(actual, expected);
663
+ default:
664
+ return false;
665
+ }
666
+ }
667
+ export function isNavigationItemVisible(item, context) {
668
+ const permission = item.permissions?.view;
669
+ const condition = item.visibleWhen?.when;
670
+ return ((permission === undefined || evaluateExpression(permission, context)) &&
671
+ (condition === undefined || evaluateExpression(condition, context)));
672
+ }
673
+ /** Normalizes a page identifier for contribution-key matching (trim + lowercase).
674
+ * Added in the R4 zero-conflict upgrade drill as a protocol additive sample. */
675
+ export function normalizePageID(id) {
676
+ return id.trim().toLowerCase();
677
+ }