@nubbin/core 0.1.0-rc.5 → 0.1.0-rc.7

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +238 -62
  2. package/dist/index.js +602 -137
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,3 +1,80 @@
1
+ // src/NubbinIssueCode.ts
2
+ var NubbinIssueCode = {
3
+ // Registration — a block, catalog or registry the developer wrote cannot be used as written.
4
+ BlockVersion: "block-version",
5
+ SlotBounds: "slot-bounds",
6
+ SlotAllowUnknown: "slot-allow-unknown",
7
+ DuplicateBlockName: "duplicate-block-name",
8
+ InvalidDefaults: "invalid-defaults",
9
+ HintPathUnresolvable: "hint-path-unresolvable",
10
+ HintNotAddressable: "hint-not-addressable",
11
+ // Schema — what the consumer brought does not answer the one door core reads a schema through.
12
+ NotStandardSchema: "not-standard-schema",
13
+ NoJsonSchema: "no-json-schema",
14
+ // Structure — the document's graph cannot become a tree.
15
+ NoRoots: "no-roots",
16
+ UnknownBlock: "unknown-block",
17
+ DanglingChild: "dangling-child",
18
+ Cycle: "cycle",
19
+ Unreachable: "unreachable",
20
+ SlotNotAllowed: "slot-not-allowed",
21
+ SlotMin: "slot-min",
22
+ SlotMax: "slot-max",
23
+ // Props — the values a node carries, judged against the block's schema.
24
+ InvalidProps: "invalid-props",
25
+ UnknownProp: "unknown-prop",
26
+ // Document operations — the caller named something the document does not hold.
27
+ NoSuchNode: "no-such-node",
28
+ DuplicateNodeId: "duplicate-node-id",
29
+ PathNotAddressable: "path-not-addressable",
30
+ InvalidRoute: "invalid-route",
31
+ // Render — the artifact and the registry serving it disagree.
32
+ BlockNotLoaded: "block-not-loaded",
33
+ NoHoleResolver: "no-hole-resolver",
34
+ NotOneHostElement: "not-one-host-element",
35
+ // Store — a write the store cannot honour.
36
+ ArtifactNotStored: "artifact-not-stored"
37
+ };
38
+
39
+ // src/summariseIssues.ts
40
+ function summariseIssues(issues) {
41
+ const lines = issues.map(
42
+ (issue) => issue.at === void 0 ? `${issue.message} [${issue.code}]` : `${issue.at} [${issue.code}]: ${issue.message}`
43
+ );
44
+ const only = lines[0];
45
+ if (lines.length === 1 && only !== void 0) {
46
+ return only;
47
+ }
48
+ return `Refused with ${issues.length} issue(s):
49
+ ${lines.join("\n")}`;
50
+ }
51
+
52
+ // src/NubbinError.ts
53
+ var NubbinError = class extends Error {
54
+ /**
55
+ * The first issue's code, so the common case reads as
56
+ * `error.code === NubbinIssueCode.UnknownProp` with no array to index and no string to typo.
57
+ * Every refusal but `compile`'s carries exactly one issue; read `issues` for the rest.
58
+ */
59
+ code;
60
+ issues;
61
+ constructor(issues) {
62
+ const first = issues[0];
63
+ if (first === void 0) {
64
+ throw new Error("NubbinError needs an issue \u2014 a refusal with no cause names nothing");
65
+ }
66
+ super(summariseIssues(issues));
67
+ this.name = "NubbinError";
68
+ this.code = first.code;
69
+ this.issues = issues;
70
+ }
71
+ };
72
+
73
+ // src/refuse.ts
74
+ function refuse(code, message, at) {
75
+ throw new NubbinError([at === void 0 ? { code, message } : { code, message, at }]);
76
+ }
77
+
1
78
  // src/adapters/isStandardJsonSchemaCapable.ts
2
79
  function isStandardJsonSchemaCapable(value) {
3
80
  if (typeof value !== "object" || value === null || !("~standard" in value)) return false;
@@ -16,7 +93,10 @@ var OPTIONS = {
16
93
  };
17
94
  function projectJsonSchema(schema) {
18
95
  if (!isStandardJsonSchemaCapable(schema)) {
19
- throw new Error("Schema does not expose the Standard JSON Schema converter (spec >= 1.1)");
96
+ refuse(
97
+ NubbinIssueCode.NoJsonSchema,
98
+ "Schema does not expose the Standard JSON Schema converter (spec >= 1.1)"
99
+ );
20
100
  }
21
101
  return schema["~standard"].jsonSchema.input(OPTIONS);
22
102
  }
@@ -106,17 +186,47 @@ var zodAdapter = {
106
186
  }
107
187
  };
108
188
 
109
- // src/CompileError.ts
110
- var CompileError = class extends Error {
111
- issues;
112
- constructor(issues) {
113
- const summary = issues.map((issue) => `${issue.nodeId} ${issue.path} [${issue.code}]: ${issue.message}`).join("\n");
114
- super(`Compile failed with ${issues.length} issue(s):
115
- ${summary}`);
116
- this.name = "CompileError";
117
- this.issues = issues;
189
+ // src/requireNode.ts
190
+ function requireNode(version, nodeId) {
191
+ const node = version.elements[nodeId];
192
+ if (node === void 0) {
193
+ refuse(
194
+ NubbinIssueCode.NoSuchNode,
195
+ `no node "${nodeId}" in document "${version.documentId}"`,
196
+ nodeId
197
+ );
118
198
  }
119
- };
199
+ return node;
200
+ }
201
+
202
+ // src/withElements.ts
203
+ function withElements(version, ...nodes) {
204
+ const elements = { ...version.elements };
205
+ for (const node of nodes) {
206
+ elements[node.id] = node;
207
+ }
208
+ return { ...version, elements };
209
+ }
210
+
211
+ // src/withSlotChild.ts
212
+ function withSlotChild(node, slot, childId, index) {
213
+ const children = [...node.slots?.[slot] ?? []];
214
+ children.splice(index ?? children.length, 0, childId);
215
+ return { ...node, slots: { ...node.slots, [slot]: children } };
216
+ }
217
+
218
+ // src/addNode.ts
219
+ function addNode(version, parentId, slot, node, index) {
220
+ const parent = requireNode(version, parentId);
221
+ if (version.elements[node.id] !== void 0) {
222
+ refuse(
223
+ NubbinIssueCode.DuplicateNodeId,
224
+ `document "${version.documentId}" already holds a node "${node.id}"`,
225
+ node.id
226
+ );
227
+ }
228
+ return withElements(version, withSlotChild(parent, slot, node.id, index), node);
229
+ }
120
230
 
121
231
  // src/checkRollback.ts
122
232
  function checkRollback(artifact, registry) {
@@ -152,6 +262,43 @@ function checkCompatibility(live, registry) {
152
262
  return { checked: live.length, compatible: incompatible.length === 0, incompatible };
153
263
  }
154
264
 
265
+ // src/routeSegmentIssue.ts
266
+ var SEGMENT = /^[A-Za-z0-9\-._~%!$&'()+,;=:@]+$/;
267
+ var PARAM = /^\[[A-Za-z0-9_-]+\]$/;
268
+ function routeSegmentIssue(segment, isLast) {
269
+ if (segment === "") {
270
+ return "a segment names nothing";
271
+ }
272
+ if (segment === "*") {
273
+ return isLast ? void 0 : "a prefix star is only the final segment";
274
+ }
275
+ if (segment.startsWith("[") || segment.endsWith("]")) {
276
+ return PARAM.test(segment) ? void 0 : `"${segment}" is not a param segment`;
277
+ }
278
+ return SEGMENT.test(segment) ? void 0 : `"${segment}" carries a character a route cannot`;
279
+ }
280
+
281
+ // src/assertValidRoute.ts
282
+ function assertValidRoute(route) {
283
+ const unaddressable = (why) => refuse(NubbinIssueCode.InvalidRoute, `route "${route}" is not addressable: ${why}`, route);
284
+ if (!route.startsWith("/")) {
285
+ unaddressable("a route starts at the root, with a slash");
286
+ }
287
+ if (route === "/") {
288
+ return;
289
+ }
290
+ if (route.endsWith("/")) {
291
+ unaddressable("a trailing slash would key a second pointer to one page");
292
+ }
293
+ const segments = route.slice(1).split("/");
294
+ for (const [index, segment] of segments.entries()) {
295
+ const issue = routeSegmentIssue(segment, index === segments.length - 1);
296
+ if (issue !== void 0) {
297
+ unaddressable(issue);
298
+ }
299
+ }
300
+ }
301
+
155
302
  // src/artifactNodeOf.ts
156
303
  function artifactNodeOf(node, resolve) {
157
304
  const { props, holes } = resolve(node);
@@ -191,7 +338,7 @@ function wireSlots(version, built) {
191
338
 
192
339
  // src/denormalize.ts
193
340
  function denormalize(version, resolve) {
194
- const pending = [version.root];
341
+ const pending = [...version.roots];
195
342
  const built = /* @__PURE__ */ new Map();
196
343
  while (pending.length > 0) {
197
344
  const id = pending.pop();
@@ -204,22 +351,25 @@ function denormalize(version, resolve) {
204
351
  }
205
352
  }
206
353
  wireSlots(version, built);
207
- const root = built.get(version.root);
208
- return root === void 0 ? [] : [root];
354
+ return version.roots.flatMap((id) => {
355
+ const root = built.get(id);
356
+ return root === void 0 ? [] : [root];
357
+ });
209
358
  }
210
359
 
211
360
  // src/fnv1a.ts
212
- var OFFSET_BASIS = 2166136261;
213
- var PRIME = 16777619;
361
+ var OFFSET_BASIS = 14695981039346656037n;
362
+ var PRIME = 1099511628211n;
363
+ var MASK = 0xffffffffffffffffn;
214
364
  var HEX_RADIX = 16;
215
- var HEX_WIDTH = 8;
365
+ var HEX_WIDTH = 16;
216
366
  function fnv1a(input) {
217
367
  let hash = OFFSET_BASIS;
218
368
  for (let index = 0; index < input.length; index += 1) {
219
- hash ^= input.charCodeAt(index);
220
- hash = Math.imul(hash, PRIME);
369
+ hash ^= BigInt(input.charCodeAt(index));
370
+ hash = hash * PRIME & MASK;
221
371
  }
222
- return (hash >>> 0).toString(HEX_RADIX).padStart(HEX_WIDTH, "0");
372
+ return hash.toString(HEX_RADIX).padStart(HEX_WIDTH, "0");
223
373
  }
224
374
 
225
375
  // src/hashArtifact.ts
@@ -232,18 +382,66 @@ function hashArtifact(artifact) {
232
382
  return fnv1a(JSON.stringify(artifact, sortKeys));
233
383
  }
234
384
 
385
+ // src/isUnknownProps.ts
386
+ function isUnknownProps(value) {
387
+ if (typeof value !== "object") return false;
388
+ return value !== null && !Array.isArray(value);
389
+ }
390
+
391
+ // src/droppedKeyPaths.ts
392
+ function droppedKeyPaths(written, parsed, prefix = "") {
393
+ const dropped = [];
394
+ for (const [key, value] of Object.entries(written)) {
395
+ const path = prefix === "" ? key : `${prefix}.${key}`;
396
+ if (!Object.hasOwn(parsed, key)) {
397
+ dropped.push(path);
398
+ continue;
399
+ }
400
+ const kept = parsed[key];
401
+ if (isUnknownProps(value) && isUnknownProps(kept)) {
402
+ dropped.push(...droppedKeyPaths(value, kept, path));
403
+ }
404
+ }
405
+ return dropped;
406
+ }
407
+
408
+ // src/splitPath.ts
409
+ function splitPath(path) {
410
+ const [head, ...tail] = path.split(".");
411
+ if (head === void 0 || head === "" || head.includes("[]")) {
412
+ refuse(NubbinIssueCode.PathNotAddressable, `path "${path}" is not addressable`, path);
413
+ }
414
+ return { head, tail };
415
+ }
416
+
417
+ // src/takeAtPath.ts
418
+ function takeAtPath(target, path) {
419
+ const { head, tail } = splitPath(path);
420
+ if (!Object.hasOwn(target, head)) return { rest: target, taken: false };
421
+ if (tail.length === 0) {
422
+ const remaining = { ...target };
423
+ delete remaining[head];
424
+ return { rest: remaining, taken: true };
425
+ }
426
+ const child = target[head];
427
+ if (typeof child !== "object" || child === null || Array.isArray(child)) {
428
+ return { rest: target, taken: false };
429
+ }
430
+ const inner = takeAtPath(child, tail.join("."));
431
+ if (!inner.taken) return { rest: target, taken: false };
432
+ return { rest: { ...target, [head]: inner.rest }, taken: true };
433
+ }
434
+
235
435
  // src/partitionProps.ts
236
436
  function partitionProps(validated, hints) {
237
- const props = {};
437
+ let props = { ...validated };
238
438
  const holes = {};
239
- const fields = hints?.fields ?? {};
240
- for (const [key, value] of Object.entries(validated)) {
241
- const data = fields[key]?.data;
242
- if (data === void 0) {
243
- props[key] = value;
244
- } else {
245
- holes[key] = data;
246
- }
439
+ for (const [path, hint] of Object.entries(hints?.fields ?? {})) {
440
+ if (hint.data === void 0) continue;
441
+ const { rest, taken } = takeAtPath(props, path);
442
+ if (!taken) continue;
443
+ props = rest;
444
+ holes[path] = hint.data;
247
445
  }
248
446
  return { props, holes };
249
447
  }
@@ -256,12 +454,6 @@ function formatIssuePath(path) {
256
454
  ).join(".");
257
455
  }
258
456
 
259
- // src/isUnknownProps.ts
260
- function isUnknownProps(value) {
261
- if (typeof value !== "object") return false;
262
- return value !== null && !Array.isArray(value);
263
- }
264
-
265
457
  // src/isStandardSchema.ts
266
458
  function isStandardSchema(value) {
267
459
  if (typeof value !== "object" || value === null) return false;
@@ -274,11 +466,15 @@ function isStandardSchema(value) {
274
466
  // src/standardValidate.ts
275
467
  function standardValidate(schema, value) {
276
468
  if (!isStandardSchema(schema)) {
277
- throw new Error("Schema does not implement Standard Schema (`~standard.validate`)");
469
+ refuse(
470
+ NubbinIssueCode.NotStandardSchema,
471
+ "Schema does not implement Standard Schema (`~standard.validate`)"
472
+ );
278
473
  }
279
474
  const result = schema["~standard"].validate(value);
280
475
  if (result instanceof Promise) {
281
- throw new Error(
476
+ refuse(
477
+ NubbinIssueCode.NotStandardSchema,
282
478
  "Schema validates asynchronously; compile and registration require synchronous validation"
283
479
  );
284
480
  }
@@ -290,9 +486,9 @@ function validateNodeProps(node, schema) {
290
486
  const result = standardValidate(schema, node.props);
291
487
  if (result.issues !== void 0) {
292
488
  const issues = result.issues.map((issue) => ({
293
- nodeId: node.id,
489
+ at: node.id,
294
490
  path: formatIssuePath(issue.path),
295
- code: "invalid-props",
491
+ code: NubbinIssueCode.InvalidProps,
296
492
  message: issue.message
297
493
  }));
298
494
  return { issues };
@@ -301,9 +497,9 @@ function validateNodeProps(node, schema) {
301
497
  return {
302
498
  issues: [
303
499
  {
304
- nodeId: node.id,
500
+ at: node.id,
305
501
  path: "",
306
- code: "invalid-props",
502
+ code: NubbinIssueCode.InvalidProps,
307
503
  message: "block props must parse to an object"
308
504
  }
309
505
  ]
@@ -316,20 +512,29 @@ function validateNodeProps(node, schema) {
316
512
  function resolveAllProps(version, catalog) {
317
513
  const resolved = /* @__PURE__ */ new Map();
318
514
  const issues = [];
515
+ const reported = [];
319
516
  for (const node of Object.values(version.elements)) {
320
517
  const entry = catalog[node.block];
321
518
  if (entry === void 0) {
322
519
  const message = `"${node.block}" has no catalog entry, so its props cannot be validated`;
323
- issues.push({ code: "unknown-block", message, nodeId: node.id, path: "block" });
520
+ issues.push({ code: NubbinIssueCode.UnknownBlock, message, at: node.id, path: "block" });
324
521
  continue;
325
522
  }
326
523
  const { value, issues: propIssues } = validateNodeProps(node, entry.schema);
327
524
  issues.push(...propIssues);
328
525
  if (value !== void 0) {
526
+ for (const path of droppedKeyPaths(node.props, value)) {
527
+ reported.push({
528
+ code: NubbinIssueCode.UnknownProp,
529
+ message: `"${path}" is not a field of ${node.block}, so the schema did not keep it`,
530
+ at: node.id,
531
+ path
532
+ });
533
+ }
329
534
  resolved.set(node.id, partitionProps(value, entry.ui));
330
535
  }
331
536
  }
332
- return { resolved, issues };
537
+ return { resolved, issues, reported };
333
538
  }
334
539
 
335
540
  // src/usedBlockVersions.ts
@@ -352,12 +557,21 @@ function pushCycleFrame(stack, state, version, id) {
352
557
  stack.push({ id, edges: slotEdges(node), next: 0 });
353
558
  }
354
559
 
355
- // src/findCycles.ts
356
- function findCycles(version) {
357
- const state = /* @__PURE__ */ new Map();
560
+ // src/toIssue.ts
561
+ function toIssue(code, message, at, path) {
562
+ return {
563
+ code,
564
+ message,
565
+ ...at === void 0 ? {} : { at },
566
+ ...path === void 0 ? {} : { path }
567
+ };
568
+ }
569
+
570
+ // src/findCyclesFrom.ts
571
+ function findCyclesFrom(version, root, state) {
358
572
  const stack = [];
359
573
  const issues = [];
360
- pushCycleFrame(stack, state, version, version.root);
574
+ pushCycleFrame(stack, state, version, root);
361
575
  while (stack.length > 0) {
362
576
  const frame = stack.at(-1);
363
577
  if (frame === void 0) break;
@@ -369,12 +583,14 @@ function findCycles(version) {
369
583
  }
370
584
  frame.next += 1;
371
585
  if (state.get(edge.childId) === "visiting") {
372
- issues.push({
373
- nodeId: frame.id,
374
- path: `slots.${edge.slot}`,
375
- code: "cycle",
376
- message: `"${frame.id}" reaches back to "${edge.childId}", so the graph cannot flatten into a tree`
377
- });
586
+ issues.push(
587
+ toIssue(
588
+ NubbinIssueCode.Cycle,
589
+ `"${frame.id}" reaches back to "${edge.childId}", so the graph cannot flatten into a tree`,
590
+ frame.id,
591
+ `slots.${edge.slot}`
592
+ )
593
+ );
378
594
  } else if (!state.has(edge.childId)) {
379
595
  pushCycleFrame(stack, state, version, edge.childId);
380
596
  }
@@ -382,32 +598,58 @@ function findCycles(version) {
382
598
  return issues;
383
599
  }
384
600
 
601
+ // src/findCycles.ts
602
+ function findCycles(version) {
603
+ const state = /* @__PURE__ */ new Map();
604
+ return version.roots.flatMap(
605
+ (root) => state.has(root) ? [] : findCyclesFrom(version, root, state)
606
+ );
607
+ }
608
+
385
609
  // src/findDanglingChildren.ts
386
610
  function findDanglingChildren(version) {
387
611
  const issues = [];
388
- if (version.elements[version.root] === void 0) {
389
- issues.push({
390
- nodeId: version.root,
391
- path: "root",
392
- code: "dangling-child",
393
- message: `root "${version.root}" has no matching element`
394
- });
395
- }
396
612
  for (const node of Object.values(version.elements)) {
397
613
  for (const edge of slotEdges(node)) {
398
614
  if (version.elements[edge.childId] === void 0) {
399
- issues.push({
400
- nodeId: node.id,
401
- path: `slots.${edge.slot}`,
402
- code: "dangling-child",
403
- message: `child "${edge.childId}" has no matching element`
404
- });
615
+ issues.push(
616
+ toIssue(
617
+ NubbinIssueCode.DanglingChild,
618
+ `child "${edge.childId}" has no matching element`,
619
+ node.id,
620
+ `slots.${edge.slot}`
621
+ )
622
+ );
405
623
  }
406
624
  }
407
625
  }
408
626
  return issues;
409
627
  }
410
628
 
629
+ // src/findRootIssues.ts
630
+ function findRootIssues(version) {
631
+ if (version.roots.length === 0) {
632
+ return [
633
+ {
634
+ at: "",
635
+ path: "roots",
636
+ code: NubbinIssueCode.NoRoots,
637
+ message: "a document needs at least one root, and this one names none"
638
+ }
639
+ ];
640
+ }
641
+ return version.roots.flatMap(
642
+ (root) => version.elements[root] === void 0 ? [
643
+ {
644
+ at: root,
645
+ path: "roots",
646
+ code: NubbinIssueCode.DanglingChild,
647
+ message: `root "${root}" has no matching element`
648
+ }
649
+ ] : []
650
+ );
651
+ }
652
+
411
653
  // src/disallowedChildren.ts
412
654
  function disallowedChildren(parent, path, childIds, allow, version) {
413
655
  if (allow === void 0) return [];
@@ -416,9 +658,9 @@ function disallowedChildren(parent, path, childIds, allow, version) {
416
658
  const child = version.elements[childId];
417
659
  if (child === void 0 || allow.includes(child.block)) continue;
418
660
  issues.push({
419
- nodeId: childId,
661
+ at: childId,
420
662
  path,
421
- code: "slot-not-allowed",
663
+ code: NubbinIssueCode.SlotNotAllowed,
422
664
  message: `"${child.block}" is not allowed in ${path} of "${parent.block}"; allowed: ${allow.join(", ")}`
423
665
  });
424
666
  }
@@ -430,20 +672,20 @@ function slotBoundIssues(parentId, path, count, constraint) {
430
672
  const { min, max } = constraint;
431
673
  const bounds = [
432
674
  {
433
- code: "slot-min",
675
+ code: NubbinIssueCode.SlotMin,
434
676
  limit: min,
435
677
  breached: min !== void 0 && count < min,
436
678
  sense: "at least"
437
679
  },
438
680
  {
439
- code: "slot-max",
681
+ code: NubbinIssueCode.SlotMax,
440
682
  limit: max,
441
683
  breached: max !== void 0 && count > max,
442
684
  sense: "at most"
443
685
  }
444
686
  ];
445
687
  return bounds.filter((bound) => bound.breached).map((bound) => ({
446
- nodeId: parentId,
688
+ at: parentId,
447
689
  path,
448
690
  code: bound.code,
449
691
  message: `${path} holds ${count} of ${bound.sense} ${bound.limit}`
@@ -456,9 +698,9 @@ function slotIssuesAt(parent, slotName, childIds, constraint, version) {
456
698
  if (constraint === void 0) {
457
699
  return [
458
700
  {
459
- nodeId: parent.id,
701
+ at: parent.id,
460
702
  path,
461
- code: "slot-not-allowed",
703
+ code: NubbinIssueCode.SlotNotAllowed,
462
704
  message: `"${parent.block}" declares no slot "${slotName}"`
463
705
  }
464
706
  ];
@@ -485,28 +727,41 @@ function findSlotViolations(version, registry) {
485
727
  // src/findUnknownBlocks.ts
486
728
  function findUnknownBlocks(version, registry) {
487
729
  return Object.values(version.elements).filter((node) => registry.get(node.block) === void 0).map((node) => ({
488
- nodeId: node.id,
730
+ at: node.id,
489
731
  path: "block",
490
- code: "unknown-block",
732
+ code: NubbinIssueCode.UnknownBlock,
491
733
  message: `"${node.block}" is not a registered block`
492
734
  }));
493
735
  }
494
736
 
495
- // src/reachableIds.ts
496
- function reachableIds(version) {
497
- const seen = /* @__PURE__ */ new Set([version.root]);
498
- const queue = [version.root];
499
- while (queue.length > 0) {
500
- const id = queue.pop();
501
- const node = id === void 0 ? void 0 : version.elements[id];
502
- if (node === void 0) continue;
737
+ // src/idsReachableFrom.ts
738
+ function idsReachableFrom(elements, seeds) {
739
+ const found = /* @__PURE__ */ new Set();
740
+ const pending = [...seeds];
741
+ while (pending.length > 0) {
742
+ const id = pending.pop();
743
+ const node = id === void 0 ? void 0 : elements[id];
744
+ if (node === void 0) {
745
+ continue;
746
+ }
503
747
  for (const edge of slotEdges(node)) {
504
- if (seen.has(edge.childId)) continue;
505
- seen.add(edge.childId);
506
- queue.push(edge.childId);
748
+ if (found.has(edge.childId)) {
749
+ continue;
750
+ }
751
+ found.add(edge.childId);
752
+ pending.push(edge.childId);
507
753
  }
508
754
  }
509
- return seen;
755
+ return found;
756
+ }
757
+
758
+ // src/reachableIds.ts
759
+ function reachableIds(version) {
760
+ const reached = idsReachableFrom(version.elements, version.roots);
761
+ for (const root of version.roots) {
762
+ reached.add(root);
763
+ }
764
+ return reached;
510
765
  }
511
766
 
512
767
  // src/findUnreachable.ts
@@ -516,10 +771,10 @@ function findUnreachable(version) {
516
771
  for (const node of Object.values(version.elements)) {
517
772
  if (reached.has(node.id)) continue;
518
773
  issues.push({
519
- nodeId: node.id,
774
+ at: node.id,
520
775
  path: "",
521
- code: "unreachable",
522
- message: `no slot reaches "${node.id}" from the root`
776
+ code: NubbinIssueCode.Unreachable,
777
+ message: `no slot reaches "${node.id}" from any root`
523
778
  });
524
779
  }
525
780
  return issues;
@@ -527,8 +782,11 @@ function findUnreachable(version) {
527
782
 
528
783
  // src/validateStructure.ts
529
784
  function validateStructure(version, registry) {
785
+ const rootIssues = findRootIssues(version);
786
+ if (version.roots.length === 0) return rootIssues;
530
787
  return [
531
788
  ...findUnknownBlocks(version, registry),
789
+ ...rootIssues,
532
790
  ...findDanglingChildren(version),
533
791
  ...findCycles(version),
534
792
  ...findUnreachable(version),
@@ -537,26 +795,26 @@ function validateStructure(version, registry) {
537
795
  }
538
796
 
539
797
  // src/version.constants.ts
540
- var NUBBIN_VERSION = "0.1.0-rc.5";
798
+ var NUBBIN_VERSION = "0.1.0-rc.7";
541
799
 
542
800
  // src/compile.ts
543
801
  function compile(version, catalog, registry, route) {
802
+ assertValidRoute(route);
544
803
  const structural = validateStructure(version, registry);
545
- if (structural.length > 0) throw new CompileError(structural);
546
- const { resolved, issues } = resolveAllProps(version, catalog);
547
- if (issues.length > 0) throw new CompileError(issues);
804
+ if (structural.length > 0) throw new NubbinError(structural);
805
+ const { resolved, issues, reported } = resolveAllProps(version, catalog);
806
+ if (issues.length > 0) throw new NubbinError(issues);
548
807
  const tree = denormalize(version, (node) => resolved.get(node.id) ?? { props: {}, holes: {} });
549
808
  const content = {
550
809
  route,
551
810
  documentId: version.documentId,
552
811
  documentVersion: version.version,
553
- registryFingerprint: registry.fingerprint(),
554
812
  blockVersions: usedBlockVersions(version, registry),
555
813
  tree,
556
814
  meta: version.meta,
557
815
  compiledWith: NUBBIN_VERSION
558
816
  };
559
- return { ...content, hash: hashArtifact(content) };
817
+ return { artifact: { ...content, hash: hashArtifact(content) }, issues: reported };
560
818
  }
561
819
 
562
820
  // src/unknownAllowEntries.ts
@@ -571,7 +829,8 @@ function assertSlotAllows(blocks) {
571
829
  const known = new Set(blocks.map((block) => block.name));
572
830
  const unresolved = blocks.flatMap((block) => unknownAllowEntries(block, known));
573
831
  if (unresolved.length > 0) {
574
- throw new Error(
832
+ refuse(
833
+ NubbinIssueCode.SlotAllowUnknown,
575
834
  `Slot allow lists name ${unresolved.join(", ")}, which no registered block defines. Registered blocks: ${[...known].sort().join(", ")}`
576
835
  );
577
836
  }
@@ -582,37 +841,25 @@ function createRegistry(blocks) {
582
841
  const byName = /* @__PURE__ */ new Map();
583
842
  for (const block of blocks) {
584
843
  if (byName.has(block.name)) {
585
- throw new Error(
586
- `Duplicate block name "${block.name}" \u2014 names are the identity nodes resolve through`
844
+ refuse(
845
+ NubbinIssueCode.DuplicateBlockName,
846
+ `Duplicate block name "${block.name}" \u2014 names are the identity nodes resolve through`,
847
+ block.name
587
848
  );
588
849
  }
589
850
  byName.set(block.name, block);
590
851
  }
591
852
  assertSlotAllows([...byName.values()]);
592
- const signature = [...byName.values()].map((block) => `${block.name}@${block.version}`).sort().join("\n");
593
- const fingerprint = fnv1a(signature);
594
853
  return {
595
854
  get: (name) => byName.get(name),
596
- names: () => [...byName.keys()],
597
- fingerprint: () => fingerprint
855
+ names: () => [...byName.keys()]
598
856
  };
599
857
  }
600
858
 
601
859
  // src/assertBlockVersion.ts
602
860
  function assertBlockVersion(name, version) {
603
861
  if (!Number.isInteger(version) || version < 1) {
604
- throw new Error(`${name}: version must be an integer of 1 or more`);
605
- }
606
- }
607
-
608
- // src/assertMigrateKeys.ts
609
- var FIRST_MIGRATABLE_VERSION = 2;
610
- function assertMigrateKeys(name, version, migrate) {
611
- for (const key of Object.keys(migrate ?? {})) {
612
- const target = Number(key);
613
- if (target < FIRST_MIGRATABLE_VERSION || target > version) {
614
- throw new Error(`${name}: migrate key ${key} is outside the reachable range 2..${version}`);
615
- }
862
+ refuse(NubbinIssueCode.BlockVersion, "version must be an integer of 1 or more", name);
616
863
  }
617
864
  }
618
865
 
@@ -620,7 +867,7 @@ function assertMigrateKeys(name, version, migrate) {
620
867
  function assertSlotBounds(name, slots) {
621
868
  for (const [slot, { min, max }] of Object.entries(slots)) {
622
869
  if (min !== void 0 && max !== void 0 && min > max) {
623
- throw new Error(`${name}: slot "${slot}" has min ${min} above max ${max}`);
870
+ refuse(NubbinIssueCode.SlotBounds, `min ${min} is above max ${max}`, `${name} slots.${slot}`);
624
871
  }
625
872
  }
626
873
  }
@@ -629,7 +876,6 @@ function assertSlotBounds(name, slots) {
629
876
  function defineBlock(block) {
630
877
  assertBlockVersion(block.name, block.version);
631
878
  assertSlotBounds(block.name, block.slots);
632
- assertMigrateKeys(block.name, block.version, block.migrate);
633
879
  return block;
634
880
  }
635
881
 
@@ -638,20 +884,37 @@ function resolveHintPaths(blockName, schema, fields) {
638
884
  const known = new Set(zodAdapter.describe(schema).map((field) => field.path));
639
885
  const unresolved = Object.keys(fields).filter((path) => !known.has(path));
640
886
  if (unresolved.length > 0) {
641
- throw new Error(
642
- `${blockName}: ui.fields references ${unresolved.map((p) => `"${p}"`).join(", ")}, which the schema does not define. Known paths: ${[...known].join(", ")}`
887
+ refuse(
888
+ NubbinIssueCode.HintPathUnresolvable,
889
+ `ui.fields references ${unresolved.map((p) => `"${p}"`).join(", ")}, which the schema does not define. Known paths: ${[...known].join(", ")}`,
890
+ blockName
643
891
  );
644
892
  }
645
893
  }
646
894
 
647
895
  // src/assertDataHintAddressable.ts
648
896
  function assertDataHintAddressable(blockName, fields) {
897
+ const seen = [];
649
898
  for (const [path, hint] of Object.entries(fields)) {
650
- if (hint.data !== void 0 && path.includes("[]")) {
651
- throw new Error(
652
- `${blockName}: ui.fields["${path}"] sets \`data\`, but a hole cannot address an array member \u2014 "[]" has no single target`
899
+ if (hint.data === void 0) continue;
900
+ if (path.includes("[]")) {
901
+ refuse(
902
+ NubbinIssueCode.HintNotAddressable,
903
+ `ui.fields["${path}"] sets \`data\`, but a hole cannot address an array member \u2014 "[]" has no single target`,
904
+ blockName
905
+ );
906
+ }
907
+ const nested = seen.find(
908
+ (other) => path.startsWith(`${other}.`) || other.startsWith(`${path}.`)
909
+ );
910
+ if (nested !== void 0) {
911
+ refuse(
912
+ NubbinIssueCode.HintNotAddressable,
913
+ `ui.fields["${nested}"] and ui.fields["${path}"] both set \`data\`, but their paths overlap \u2014 two holes over one value have no defined order of application`,
914
+ blockName
653
915
  );
654
916
  }
917
+ seen.push(path);
655
918
  }
656
919
  }
657
920
 
@@ -660,7 +923,11 @@ function assertValidDefaults(blockName, schema, defaults) {
660
923
  const result = standardValidate(schema, defaults);
661
924
  if (result.issues === void 0) return;
662
925
  const detail = result.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`).join("; ");
663
- throw new Error(`${blockName}: defaults do not satisfy the schema \u2014 ${detail}`);
926
+ refuse(
927
+ NubbinIssueCode.InvalidDefaults,
928
+ `defaults do not satisfy the schema \u2014 ${detail}`,
929
+ blockName
930
+ );
664
931
  }
665
932
 
666
933
  // src/defineCatalog.ts
@@ -698,8 +965,40 @@ function formatCompatibilityReport(report) {
698
965
  return [summary, ...report.incompatible.map(formatRouteIncompatibility)].join("\n");
699
966
  }
700
967
 
968
+ // src/withoutSlotChildren.ts
969
+ function withoutSlotChildren(node, removed) {
970
+ const entries = Object.entries(node.slots ?? {});
971
+ const holdsRemoved = entries.some(([, children]) => children.some((id) => removed.has(id)));
972
+ if (!holdsRemoved) {
973
+ return node;
974
+ }
975
+ const slots = Object.fromEntries(
976
+ entries.map(([slot, children]) => [slot, children.filter((id) => !removed.has(id))])
977
+ );
978
+ return { ...node, slots };
979
+ }
980
+
981
+ // src/detachIds.ts
982
+ function detachIds(version, ids) {
983
+ const elements = {};
984
+ for (const [id, node] of Object.entries(version.elements)) {
985
+ elements[id] = withoutSlotChildren(node, ids);
986
+ }
987
+ return { ...version, roots: version.roots.filter((id) => !ids.has(id)), elements };
988
+ }
989
+
990
+ // src/moveNode.ts
991
+ function moveNode(version, nodeId, toParentId, toSlot, index) {
992
+ requireNode(version, nodeId);
993
+ requireNode(version, toParentId);
994
+ const detached = detachIds(version, /* @__PURE__ */ new Set([nodeId]));
995
+ const parent = requireNode(detached, toParentId);
996
+ return withElements(detached, withSlotChild(parent, toSlot, nodeId, index));
997
+ }
998
+
701
999
  // src/parseMatchKind.ts
702
1000
  function parseMatchKind(route) {
1001
+ assertValidRoute(route);
703
1002
  if (route.endsWith("/*")) {
704
1003
  return "prefix";
705
1004
  }
@@ -709,31 +1008,193 @@ function parseMatchKind(route) {
709
1008
  return "exact";
710
1009
  }
711
1010
 
1011
+ // src/removeNode.ts
1012
+ function removeNode(version, nodeId) {
1013
+ requireNode(version, nodeId);
1014
+ const removed = idsReachableFrom(version.elements, [nodeId]).add(nodeId);
1015
+ const detached = detachIds(version, removed);
1016
+ const elements = {};
1017
+ for (const [id, node] of Object.entries(detached.elements)) {
1018
+ if (!removed.has(id)) {
1019
+ elements[id] = node;
1020
+ }
1021
+ }
1022
+ return { ...detached, elements };
1023
+ }
1024
+
1025
+ // src/defineStandardSchema.ts
1026
+ var STANDARD_SCHEMA_VERSION = 1;
1027
+ function defineStandardSchema(issuesOf, jsonSchemaOf) {
1028
+ return {
1029
+ "~standard": {
1030
+ version: STANDARD_SCHEMA_VERSION,
1031
+ vendor: "nubbin",
1032
+ validate: (value) => {
1033
+ const issues = issuesOf(value);
1034
+ return issues.length > 0 ? { issues } : { value };
1035
+ },
1036
+ jsonSchema: { input: jsonSchemaOf, output: jsonSchemaOf }
1037
+ }
1038
+ };
1039
+ }
1040
+
1041
+ // src/richText.constants.ts
1042
+ var RICH_TEXT_MARKS = ["strong", "em", "code"];
1043
+ var RICH_TEXT_BLOCK_KINDS = ["paragraph", "listItem"];
1044
+ var RICH_TEXT_SPAN_KEYS = ["text", "marks", "href"];
1045
+ var RICH_TEXT_BLOCK_KEYS = ["kind", "spans"];
1046
+
1047
+ // src/isRichTextBlockKind.ts
1048
+ function isRichTextBlockKind(value) {
1049
+ return RICH_TEXT_BLOCK_KINDS.some((kind) => kind === value);
1050
+ }
1051
+
1052
+ // src/nestedSchemaIssues.ts
1053
+ function nestedSchemaIssues(schema, value, prefix) {
1054
+ const result = standardValidate(schema, value);
1055
+ if (result.issues === void 0) return [];
1056
+ return result.issues.map((issue) => ({
1057
+ message: issue.message,
1058
+ path: [...prefix, ...issue.path ?? []]
1059
+ }));
1060
+ }
1061
+
1062
+ // src/isRichTextMark.ts
1063
+ function isRichTextMark(value) {
1064
+ return RICH_TEXT_MARKS.some((mark) => mark === value);
1065
+ }
1066
+
1067
+ // src/richTextMarkIssues.ts
1068
+ function richTextMarkIssues(marks) {
1069
+ if (marks === void 0) return [];
1070
+ if (!Array.isArray(marks)) return [{ message: "marks must be an array", path: ["marks"] }];
1071
+ return marks.flatMap(
1072
+ (mark, index) => isRichTextMark(mark) ? [] : [
1073
+ {
1074
+ message: `unknown mark ${JSON.stringify(mark)}; expected one of ${RICH_TEXT_MARKS.join(", ")}`,
1075
+ path: ["marks", index]
1076
+ }
1077
+ ]
1078
+ );
1079
+ }
1080
+
1081
+ // src/unexpectedKeyIssues.ts
1082
+ function unexpectedKeyIssues(value, allowed) {
1083
+ return Object.keys(value).filter((key) => !allowed.includes(key)).map((key) => ({ message: `unexpected key "${key}"`, path: [key] }));
1084
+ }
1085
+
1086
+ // src/richTextSpanIssues.ts
1087
+ function richTextSpanIssues(value) {
1088
+ if (!isUnknownProps(value)) return [{ message: "a span must be an object", path: [] }];
1089
+ const issues = [];
1090
+ if (typeof value.text !== "string") {
1091
+ issues.push({ message: "text must be a string", path: ["text"] });
1092
+ }
1093
+ issues.push(...richTextMarkIssues(value.marks));
1094
+ if (value.href !== void 0 && typeof value.href !== "string") {
1095
+ issues.push({ message: "href must be a string", path: ["href"] });
1096
+ }
1097
+ issues.push(...unexpectedKeyIssues(value, RICH_TEXT_SPAN_KEYS));
1098
+ return issues;
1099
+ }
1100
+
1101
+ // src/richTextSpanSchema.ts
1102
+ var richTextSpanSchema = defineStandardSchema(richTextSpanIssues, () => ({
1103
+ type: "object",
1104
+ properties: {
1105
+ text: { type: "string" },
1106
+ marks: { type: "array", items: { type: "string", enum: [...RICH_TEXT_MARKS] } },
1107
+ href: { type: "string" }
1108
+ },
1109
+ required: ["text"],
1110
+ additionalProperties: false
1111
+ }));
1112
+
1113
+ // src/richTextBlockIssues.ts
1114
+ function richTextBlockIssues(value) {
1115
+ if (!isUnknownProps(value)) return [{ message: "a block must be an object", path: [] }];
1116
+ const issues = [];
1117
+ if (!isRichTextBlockKind(value.kind)) {
1118
+ issues.push({
1119
+ message: `unknown kind ${JSON.stringify(value.kind)}; expected one of ${RICH_TEXT_BLOCK_KINDS.join(", ")}`,
1120
+ path: ["kind"]
1121
+ });
1122
+ }
1123
+ if (Array.isArray(value.spans)) {
1124
+ issues.push(
1125
+ ...value.spans.flatMap(
1126
+ (span, index) => nestedSchemaIssues(richTextSpanSchema, span, ["spans", index])
1127
+ )
1128
+ );
1129
+ } else {
1130
+ issues.push({ message: "spans must be an array", path: ["spans"] });
1131
+ }
1132
+ issues.push(...unexpectedKeyIssues(value, RICH_TEXT_BLOCK_KEYS));
1133
+ return issues;
1134
+ }
1135
+
1136
+ // src/richTextBlockSchema.ts
1137
+ var richTextBlockSchema = defineStandardSchema(
1138
+ richTextBlockIssues,
1139
+ (options) => ({
1140
+ type: "object",
1141
+ properties: {
1142
+ kind: { type: "string", enum: [...RICH_TEXT_BLOCK_KINDS] },
1143
+ spans: { type: "array", items: richTextSpanSchema["~standard"].jsonSchema.input(options) }
1144
+ },
1145
+ required: ["kind", "spans"],
1146
+ additionalProperties: false
1147
+ })
1148
+ );
1149
+
1150
+ // src/richTextIssues.ts
1151
+ function richTextIssues(value) {
1152
+ if (!Array.isArray(value)) {
1153
+ return [{ message: "rich text must be an array of blocks", path: [] }];
1154
+ }
1155
+ return value.flatMap(
1156
+ (block, index) => nestedSchemaIssues(richTextBlockSchema, block, [index])
1157
+ );
1158
+ }
1159
+
1160
+ // src/richTextSchema.ts
1161
+ var richTextSchema = defineStandardSchema(richTextIssues, (options) => ({
1162
+ type: "array",
1163
+ items: richTextBlockSchema["~standard"].jsonSchema.input(options)
1164
+ }));
1165
+
1166
+ // src/richText.ts
1167
+ function richText() {
1168
+ return richTextSchema;
1169
+ }
1170
+
712
1171
  // src/setAtPath.ts
713
1172
  function setAtPath(target, path, value) {
714
- const [head, ...rest] = path.split(".");
715
- if (head === void 0 || head === "" || head.includes("[]")) {
716
- throw new Error(`path "${path}" is not addressable`);
717
- }
718
- if (rest.length === 0) {
1173
+ const { head, tail } = splitPath(path);
1174
+ if (tail.length === 0) {
719
1175
  return { ...target, [head]: value };
720
1176
  }
721
1177
  const child = target[head];
1178
+ if (Array.isArray(child)) {
1179
+ refuse(
1180
+ NubbinIssueCode.PathNotAddressable,
1181
+ `path "${path}" descends into an array at "${head}", which addresses no field`,
1182
+ path
1183
+ );
1184
+ }
722
1185
  const base = typeof child === "object" && child !== null ? child : {};
723
- return { ...target, [head]: setAtPath(base, rest.join("."), value) };
1186
+ return { ...target, [head]: setAtPath(base, tail.join("."), value) };
724
1187
  }
725
1188
 
726
1189
  // src/setNodeProp.ts
727
1190
  function setNodeProp(version, nodeId, path, value) {
728
- const node = version.elements[nodeId];
729
- if (node === void 0) {
730
- throw new Error(`no node "${nodeId}" in document "${version.documentId}"`);
731
- }
732
- const edited = { ...node, props: setAtPath(node.props, path, value) };
733
- return { ...version, elements: { ...version.elements, [nodeId]: edited } };
1191
+ const node = requireNode(version, nodeId);
1192
+ return withElements(version, { ...node, props: setAtPath(node.props, path, value) });
734
1193
  }
735
1194
  export {
736
- CompileError,
1195
+ NubbinError,
1196
+ NubbinIssueCode,
1197
+ addNode,
737
1198
  checkCompatibility,
738
1199
  checkRollback,
739
1200
  compile,
@@ -741,7 +1202,11 @@ export {
741
1202
  defineBlock,
742
1203
  defineCatalog,
743
1204
  formatCompatibilityReport,
1205
+ moveNode,
744
1206
  parseMatchKind,
1207
+ refuse,
1208
+ removeNode,
1209
+ richText,
745
1210
  setAtPath,
746
1211
  setNodeProp,
747
1212
  zodAdapter