@rohal12/spindle 0.52.9 → 0.54.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.
Files changed (50) hide show
  1. package/README.md +5 -1
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +6484 -2558
  4. package/dist/pkg/story-variables.js +4009 -940
  5. package/dist/pkg/tooling.js +13 -1
  6. package/package.json +6 -1
  7. package/src/class-registry.ts +252 -250
  8. package/src/components/App.tsx +2 -0
  9. package/src/components/Passage.tsx +3 -3
  10. package/src/components/PassageDialog.tsx +2 -4
  11. package/src/components/RuntimeErrors.tsx +46 -0
  12. package/src/components/StoryInterface.tsx +2 -4
  13. package/src/components/macros/Computed.tsx +5 -2
  14. package/src/components/macros/Do.tsx +4 -2
  15. package/src/components/macros/Goto.tsx +13 -3
  16. package/src/components/macros/Include.tsx +2 -3
  17. package/src/components/macros/MacroError.tsx +14 -7
  18. package/src/components/macros/SaveManager.tsx +2 -2
  19. package/src/components/macros/VarDisplay.tsx +1 -1
  20. package/src/components/macros/Widget.tsx +5 -33
  21. package/src/index.tsx +57 -37
  22. package/src/interpolation.ts +4 -5
  23. package/src/markup/ast.ts +11 -226
  24. package/src/markup/code-attributes.ts +3 -3
  25. package/src/markup/code-end.ts +121 -0
  26. package/src/markup/parse.ts +118 -0
  27. package/src/markup/render.tsx +1 -1
  28. package/src/markup/spindle.d.peggy.ts +24 -0
  29. package/src/markup/spindle.peggy +463 -0
  30. package/src/markup/tokens.ts +95 -0
  31. package/src/markup/validate.ts +219 -0
  32. package/src/parser.ts +19 -4
  33. package/src/runtime-errors.ts +65 -0
  34. package/src/saves/format.ts +199 -0
  35. package/src/saves/save-manager.ts +45 -63
  36. package/src/saves/types.ts +71 -88
  37. package/src/store.ts +167 -87
  38. package/src/story-init.ts +3 -5
  39. package/src/story-variables.ts +18 -4
  40. package/src/structural.ts +266 -57
  41. package/src/styles.css +45 -0
  42. package/src/tooling.ts +50 -0
  43. package/src/types-drift-check.ts +28 -1
  44. package/src/utils/object-path.ts +13 -12
  45. package/src/utils/stable-key.ts +2 -1
  46. package/src/utils/value-kinds.ts +45 -0
  47. package/src/widgets/widget-def.ts +60 -0
  48. package/types/index.d.ts +32 -8
  49. package/types/tooling.d.ts +42 -0
  50. package/src/markup/tokenizer.ts +0 -1112
package/src/structural.ts CHANGED
@@ -2,12 +2,21 @@
2
2
  // and merging the differences between two versions of a value. Mutation
3
3
  // commits, history and save hooks all go through these, so they agree on
4
4
  // what a change is.
5
+ //
6
+ // They handle the value types a save holds (see serialize() in
7
+ // class-registry.ts): plain and prototype-less objects, arrays (holes kept),
8
+ // registered class instances, Date, RegExp, Map, Set, typed arrays,
9
+ // ArrayBuffer, DataView, URL, URLSearchParams, boxed primitives, errors and
10
+ // Temporal values, with shared references and cycles.
5
11
 
6
12
  import { registeredClassName } from './class-registry';
7
13
  import { hasOwn, setOwn } from './utils/namespace';
8
14
  import { deleteByPath, getByPath, setByPath } from './utils/object-path';
15
+ import { isAtomic, isBoxed, isTemporal } from './utils/value-kinds';
9
16
 
10
- // --- Deep Clone ---
17
+ export { isAtomic };
18
+
19
+ // --- Value kinds ---
11
20
 
12
21
  function isPlainObject(value: unknown): value is Record<string, unknown> {
13
22
  if (typeof value !== 'object' || value === null) return false;
@@ -15,6 +24,11 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
24
  return proto === Object.prototype || proto === null;
16
25
  }
17
26
 
27
+ /** Own keys of an error that are not enumerable but are data. */
28
+ const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
29
+
30
+ // --- Deep Clone ---
31
+
18
32
  export interface DeepCloneOptions {
19
33
  /**
20
34
  * Return instances of unregistered classes (DOM nodes, promises, other
@@ -24,31 +38,85 @@ export interface DeepCloneOptions {
24
38
  keepUnregistered?: boolean;
25
39
  }
26
40
 
41
+ type TypedArrayCtor = new (
42
+ buffer: ArrayBufferLike,
43
+ byteOffset: number,
44
+ length: number,
45
+ ) => ArrayBufferView;
46
+
27
47
  export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
28
48
  const seen = new Map<object, object>();
29
49
 
50
+ /** Record `copy` as the copy of `obj` before its contents are copied. */
51
+ const keep = <C extends object>(obj: object, copy: C): C => {
52
+ seen.set(obj, copy);
53
+ return copy;
54
+ };
55
+
56
+ /** Copy the own (enumerable) keys of `obj` into `copy`. */
57
+ function copyKeys(obj: object, copy: object): object {
58
+ for (const key of Object.keys(obj)) {
59
+ setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
60
+ }
61
+ return copy;
62
+ }
63
+
64
+ function cloneBuiltin(val: object): object | undefined {
65
+ if (val instanceof Date) return keep(val, new Date(val.getTime()));
66
+ if (val instanceof RegExp) {
67
+ return keep(val, new RegExp(val.source, val.flags));
68
+ }
69
+ if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
70
+ if (ArrayBuffer.isView(val)) {
71
+ // The buffer through the copies, so views of one buffer share it
72
+ const buffer = clone(val.buffer) as ArrayBuffer;
73
+ if (val instanceof DataView) {
74
+ return keep(val, new DataView(buffer, val.byteOffset, val.byteLength));
75
+ }
76
+ const ctor = val.constructor as TypedArrayCtor;
77
+ const length = (val as unknown as { length: number }).length;
78
+ return keep(val, new ctor(buffer, val.byteOffset, length));
79
+ }
80
+ if (val instanceof URL) return keep(val, new URL(val.href));
81
+ if (val instanceof URLSearchParams) {
82
+ return keep(val, new URLSearchParams(val));
83
+ }
84
+ if (isBoxed(val)) return keep(val, Object((val as Number).valueOf()));
85
+ if (isTemporal(val)) return val;
86
+ if (val instanceof Error) {
87
+ // Keeps its class (built-in, or a registered or other subclass)
88
+ const copy = keep(val, Object.create(Object.getPrototypeOf(val)));
89
+ for (const key of [...ERROR_HIDDEN_KEYS, 'stack']) {
90
+ if (hasOwn(val, key)) {
91
+ Object.defineProperty(copy, key, {
92
+ value: clone((val as unknown as Record<string, unknown>)[key]),
93
+ writable: true,
94
+ configurable: true,
95
+ });
96
+ }
97
+ }
98
+ return copyKeys(val, copy);
99
+ }
100
+ return undefined;
101
+ }
102
+
30
103
  function clone(val: unknown): unknown {
31
104
  if (val === null || typeof val !== 'object') return val;
32
105
 
33
106
  const obj = val as object;
34
107
  if (seen.has(obj)) return seen.get(obj);
35
108
 
36
- if (val instanceof Date) return new Date(val.getTime()) as unknown;
37
- if (val instanceof RegExp)
38
- return new RegExp(val.source, val.flags) as unknown;
39
-
40
109
  if (Array.isArray(val)) {
41
- const arr: unknown[] = [];
42
- seen.set(obj, arr);
110
+ // Holes stay holes
111
+ const arr = keep(obj, new Array(val.length) as unknown[]);
43
112
  for (let i = 0; i < val.length; i++) {
44
- arr[i] = clone(val[i]);
113
+ if (i in val) arr[i] = clone(val[i]);
45
114
  }
46
115
  return arr;
47
116
  }
48
117
 
49
118
  if (val instanceof Map) {
50
- const copy = new Map();
51
- seen.set(obj, copy);
119
+ const copy = keep(obj, new Map());
52
120
  for (const [k, v] of val) {
53
121
  copy.set(clone(k), clone(v));
54
122
  }
@@ -56,30 +124,29 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
56
124
  }
57
125
 
58
126
  if (val instanceof Set) {
59
- const copy = new Set();
60
- seen.set(obj, copy);
127
+ const copy = keep(obj, new Set());
61
128
  for (const v of val) {
62
129
  copy.add(clone(v));
63
130
  }
64
131
  return copy;
65
132
  }
66
133
 
134
+ const builtin = cloneBuiltin(obj);
135
+ if (builtin !== undefined) return builtin;
136
+
67
137
  // A registered class instance keeps its class, and a plain object its
68
138
  // prototype (which may be null). An instance of an unregistered class
69
139
  // is treated as plain, unless it is kept by reference.
70
140
  const keepsProto =
71
141
  registeredClassName(obj) !== undefined || isPlainObject(val);
72
142
  if (!keepsProto && options.keepUnregistered) return val;
73
- const copy = (
143
+ const copy = keep(
144
+ obj,
74
145
  keepsProto
75
146
  ? Object.create(Object.getPrototypeOf(obj) as object | null)
76
- : {}
77
- ) as Record<string, unknown>;
78
- seen.set(obj, copy);
79
- for (const key of Object.keys(obj)) {
80
- setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
81
- }
82
- return copy;
147
+ : {},
148
+ );
149
+ return copyKeys(obj, copy);
83
150
  }
84
151
 
85
152
  return clone(value) as T;
@@ -88,27 +155,179 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
88
155
  // --- Deep Equal ---
89
156
 
90
157
  /**
91
- * Structural equality over the value types deepClone() supports: primitives,
92
- * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
93
- * at any depth). Map and Set entries are compared in insertion order. Arrays
94
- * are compared by length and index, so a hole equals an undefined element
95
- * (deepClone() and save/load turn holes into undefined elements).
158
+ * Structural equality over the value types deepClone() supports, nested at
159
+ * any depth and with cycles. Objects must have the same prototype. Map and
160
+ * Set entries are compared in insertion order, so reordering one is a
161
+ * change. Arrays are compared by length and index; a hole differs from an
162
+ * undefined element. Errors are compared by class, message, cause, errors
163
+ * and own keys (not their stack).
96
164
  */
97
165
  export function deepEqual(a: unknown, b: unknown): boolean {
98
- return equal(a, b, new Map());
166
+ return equal(a, b, loosePairs());
99
167
  }
100
168
 
101
169
  /**
102
- * `assumed` holds the pairs already being compared: meeting one again (a
103
- * cycle) assumes it equal. Every object may pair with several others, since
104
- * cycles of different lengths can still unfold to the same value.
170
+ * Called on meeting the pair (a, b) of objects to compare: true when the
171
+ * pair is already being compared (a cycle: assume it equal), false when it
172
+ * cannot be equal, undefined to compare it.
105
173
  */
106
- function equal(
107
- a: unknown,
108
- b: unknown,
109
- assumed: Map<object, Set<object>>,
110
- ): boolean {
111
- if (Object.is(a, b)) return true;
174
+ type Pairs = ((a: object, b: object) => boolean | undefined) & {
175
+ /** Identical objects are paired and compared too, not taken as equal. */
176
+ strict?: boolean;
177
+ };
178
+
179
+ /**
180
+ * Pairs for deepEqual(). Every object may pair with several others, since
181
+ * cycles of different lengths can still unfold to the same value, and
182
+ * shared references may equal distinct copies.
183
+ */
184
+ function loosePairs(): Pairs {
185
+ const assumed = new Map<object, Set<object>>();
186
+ return (a, b) => {
187
+ const pairs = assumed.get(a);
188
+ if (pairs?.has(b)) return true;
189
+ if (pairs) pairs.add(b);
190
+ else assumed.set(a, new Set([b]));
191
+ return undefined;
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Pairs that also require the same reference structure: each object of one
197
+ * value pairs with exactly one of the other, so shared references and
198
+ * cycles are shared at the same places.
199
+ */
200
+ function strictPairs(): Pairs {
201
+ const ab = new Map<object, object>();
202
+ const ba = new Map<object, object>();
203
+ const pairs: Pairs = (a, b) => {
204
+ const known = ab.get(a);
205
+ if (known !== undefined) return known === b;
206
+ if (ba.has(b)) return false;
207
+ ab.set(a, b);
208
+ ba.set(b, a);
209
+ return undefined;
210
+ };
211
+ pairs.strict = true;
212
+ return pairs;
213
+ }
214
+
215
+ /**
216
+ * Whether `a` and `b` are equal as deepEqual() has it and also share
217
+ * references at the same places (a save of one loads as the other).
218
+ */
219
+ export function deepEqualStrict(a: unknown, b: unknown): boolean {
220
+ return equal(a, b, strictPairs());
221
+ }
222
+
223
+ /**
224
+ * `curr` with each part that equals (see deepEqualStrict) the part of
225
+ * `prev` at the same place replaced by `prev`'s: the history moments of a
226
+ * save then share what did not change between them, which a save stores
227
+ * once. Containers on the way to a change are copied; neither value is
228
+ * changed. Shared references and cycles within `curr` stay so.
229
+ */
230
+ export function shareEqual<T>(prev: unknown, curr: T): T {
231
+ const done = new Map<object, unknown>();
232
+ function share(p: unknown, c: unknown): unknown {
233
+ if (p === c || !isObjectValue(c) || !isObjectValue(p)) return c;
234
+ if (done.has(c)) return done.get(c);
235
+ if (deepEqualStrict(p, c)) {
236
+ done.set(c, p);
237
+ return p;
238
+ }
239
+ if (c instanceof Map && p instanceof Map) {
240
+ const out = new Map();
241
+ done.set(c, out);
242
+ for (const [k, v] of c) out.set(k, p.has(k) ? share(p.get(k), v) : v);
243
+ return out;
244
+ }
245
+ if (Array.isArray(c) && Array.isArray(p)) {
246
+ const out = new Array(c.length) as unknown[];
247
+ done.set(c, out);
248
+ for (let i = 0; i < c.length; i++) {
249
+ if (i in c) out[i] = i < p.length ? share(p[i], c[i]) : c[i];
250
+ }
251
+ return out;
252
+ }
253
+ if (mergesWith(p, c, false)) {
254
+ const out = Object.create(Object.getPrototypeOf(c) as object | null);
255
+ done.set(c, out);
256
+ const cr = c as Record<string, unknown>;
257
+ for (const key of Object.keys(cr)) {
258
+ setOwn(out, key, hasOwn(p, key) ? share(p[key], cr[key]) : cr[key]);
259
+ }
260
+ return out;
261
+ }
262
+ done.set(c, c);
263
+ return c;
264
+ }
265
+ return share(prev, curr) as T;
266
+ }
267
+
268
+ const isObjectValue = (v: unknown): v is object =>
269
+ typeof v === 'object' && v !== null;
270
+
271
+ function equalBytes(a: ArrayBufferView | ArrayBuffer, b: typeof a): boolean {
272
+ const x = ArrayBuffer.isView(a)
273
+ ? new Uint8Array(a.buffer, a.byteOffset, a.byteLength)
274
+ : new Uint8Array(a);
275
+ const y = ArrayBuffer.isView(b)
276
+ ? new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
277
+ : new Uint8Array(b as ArrayBuffer);
278
+ if (x.length !== y.length) return false;
279
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
280
+ return true;
281
+ }
282
+
283
+ /** Equality of the content of atomic built-ins (see isAtomic) but Map/Set. */
284
+ function equalBuiltin(
285
+ a: object,
286
+ b: object,
287
+ assumed: Pairs,
288
+ ): boolean | undefined {
289
+ if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
290
+ if (a instanceof RegExp) return String(a) === String(b);
291
+ if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer) {
292
+ return equalBytes(a, b as ArrayBuffer);
293
+ }
294
+ if (a instanceof URL || a instanceof URLSearchParams) {
295
+ return String(a) === String(b);
296
+ }
297
+ if (isBoxed(a)) {
298
+ return Object.is((a as Number).valueOf(), (b as Number).valueOf());
299
+ }
300
+ if (isTemporal(a)) return String(a) === String(b);
301
+ if (a instanceof Error) {
302
+ return (
303
+ ERROR_HIDDEN_KEYS.every(
304
+ (key) =>
305
+ hasOwn(a, key) === hasOwn(b, key) &&
306
+ equal(
307
+ (a as unknown as Record<string, unknown>)[key],
308
+ (b as unknown as Record<string, unknown>)[key],
309
+ assumed,
310
+ ),
311
+ ) && equalKeys(a, b, assumed)
312
+ );
313
+ }
314
+ return undefined;
315
+ }
316
+
317
+ function equalKeys(a: object, b: object, assumed: Pairs): boolean {
318
+ const ao = a as Record<string, unknown>;
319
+ const bo = b as Record<string, unknown>;
320
+ const keys = Object.keys(ao);
321
+ if (keys.length !== Object.keys(bo).length) return false;
322
+ for (const key of keys) {
323
+ if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
324
+ }
325
+ return true;
326
+ }
327
+
328
+ /** `assumed` tracks the pairs of objects met (see Pairs). */
329
+ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
330
+ if (Object.is(a, b) && !(assumed.strict && isObjectValue(a))) return true;
112
331
  if (
113
332
  a === null ||
114
333
  b === null ||
@@ -118,13 +337,9 @@ function equal(
118
337
  return false;
119
338
  }
120
339
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
121
- const pairs = assumed.get(a);
122
- if (pairs?.has(b)) return true;
123
- if (pairs) pairs.add(b);
124
- else assumed.set(a, new Set([b]));
340
+ const met = assumed(a, b);
341
+ if (met !== undefined) return met;
125
342
 
126
- if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
127
- if (a instanceof RegExp) return String(a) === String(b);
128
343
  if (a instanceof Map || a instanceof Set) {
129
344
  const bc = b as Map<unknown, unknown> | Set<unknown>;
130
345
  if (a.size !== bc.size) return false;
@@ -140,42 +355,36 @@ function equal(
140
355
  return true;
141
356
  }
142
357
 
358
+ const builtin = equalBuiltin(a, b, assumed);
359
+ if (builtin !== undefined) return builtin;
360
+
143
361
  if (Array.isArray(a)) {
144
362
  const bc = b as unknown[];
145
363
  if (a.length !== bc.length) return false;
146
364
  for (let i = 0; i < a.length; i++) {
147
- if (!equal(a[i], bc[i], assumed)) return false;
365
+ if (i in a !== i in bc || !equal(a[i], bc[i], assumed)) return false;
148
366
  }
149
367
  return true;
150
368
  }
151
369
 
152
- const ao = a as Record<string, unknown>;
153
- const bo = b as Record<string, unknown>;
154
- const keys = Object.keys(ao);
155
- if (keys.length !== Object.keys(bo).length) return false;
156
- for (const key of keys) {
157
- if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
158
- }
159
- return true;
370
+ return equalKeys(a, b, assumed);
160
371
  }
161
372
 
162
373
  // --- Structural diff ---
163
374
 
164
375
  /**
165
376
  * Objects merged property by property: plain objects and (registered)
166
- * class instances. Arrays, Map, Set, Date and RegExp are values that are
167
- * replaced as a whole, since their elements have no stable identity to
168
- * merge by (a shift moves every index).
377
+ * class instances. Arrays and atomic built-ins (Map, Set, Date, typed
378
+ * arrays, errors..., see isAtomic) are values that are replaced as a whole,
379
+ * since their elements have no stable identity to merge by (a shift moves
380
+ * every index) or their content is not in their keys.
169
381
  */
170
382
  export function isMergeable(value: unknown): value is Record<string, unknown> {
171
383
  return (
172
384
  typeof value === 'object' &&
173
385
  value !== null &&
174
386
  !Array.isArray(value) &&
175
- !(value instanceof Map) &&
176
- !(value instanceof Set) &&
177
- !(value instanceof Date) &&
178
- !(value instanceof RegExp)
387
+ !isAtomic(value)
179
388
  );
180
389
  }
181
390
 
package/src/styles.css CHANGED
@@ -118,6 +118,51 @@ tw-storydata {
118
118
  background: rgba(239, 83, 80, 0.1);
119
119
  }
120
120
 
121
+ /* Runtime errors shown on the page (see src/components/RuntimeErrors.tsx) */
122
+ .spindle-error-banners {
123
+ position: fixed;
124
+ top: 0.75em;
125
+ left: 50%;
126
+ z-index: 300;
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 0.5em;
130
+ width: min(42em, calc(100% - 1.5em));
131
+ transform: translateX(-50%);
132
+ }
133
+
134
+ .spindle-error-banner {
135
+ position: relative;
136
+ padding: 0.75em 2.75em 0.75em 1em;
137
+ border: 1px solid #ef5350;
138
+ border-radius: 4px;
139
+ background: #2b1a1e;
140
+ color: #ffcdd2;
141
+ font-size: 0.9em;
142
+ line-height: 1.5;
143
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
144
+ }
145
+
146
+ .spindle-error-banner-message {
147
+ font-family: monospace;
148
+ color: #ef9a9a;
149
+ }
150
+
151
+ .spindle-error-banner-dismiss {
152
+ position: absolute;
153
+ top: 0.5em;
154
+ right: 0.5em;
155
+ background: transparent;
156
+ border: none;
157
+ color: inherit;
158
+ font-size: 1em;
159
+ cursor: pointer;
160
+ }
161
+
162
+ .spindle-error-banner-dismiss:hover {
163
+ color: #fff;
164
+ }
165
+
121
166
  .loading {
122
167
  text-align: center;
123
168
  padding: 2em;
package/src/tooling.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What the tooling entry point (`@rohal12/spindle/tooling`) bundles as
3
+ * dist/pkg/story-variables.js: the StoryVariables parser and the markup
4
+ * validator, with no Preact or DOM dependencies. pkg/tooling.js re-exports
5
+ * them, binding the validator to its macro metadata.
6
+ */
7
+ import {
8
+ validateMarkup,
9
+ type MarkupDiagnostic,
10
+ type MarkupPassage,
11
+ } from './markup/validate';
12
+ import { isBlockMacro } from './markup/ast';
13
+ import { blockWidgetNames } from './widgets/widget-def';
14
+
15
+ export { parseStoryVariables } from './story-variables';
16
+ export { formatDiagnostic } from './markup/validate';
17
+ export type { MarkupDiagnostic, MarkupPassage } from './markup/validate';
18
+
19
+ /** What tooling knows about a macro (see MacroMetadata). */
20
+ export interface ToolingMacro {
21
+ name: string;
22
+ block: boolean;
23
+ subMacros: string[];
24
+ }
25
+
26
+ /**
27
+ * Validate the markup of a story's passages as Spindle does when the story
28
+ * starts, against the given macros (built-in and user-defined): malformed
29
+ * markup and unknown macros, with their passage, line and column.
30
+ */
31
+ export function validateStoryMarkup(
32
+ passages: Iterable<MarkupPassage>,
33
+ macros: Iterable<ToolingMacro>,
34
+ ): MarkupDiagnostic[] {
35
+ const list = [...passages];
36
+ const known = new Set<string>();
37
+ const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
38
+ for (const macro of macros) {
39
+ const name = macro.name.toLowerCase();
40
+ known.add(name);
41
+ for (const sub of macro.subMacros) known.add(sub.toLowerCase());
42
+ if (macro.block) blocks.add(name);
43
+ }
44
+ return validateMarkup(list, {
45
+ isKnownMacro: (name) => known.has(name),
46
+ macroNames: known,
47
+ isBlockMacro: (name) =>
48
+ blocks.has(name.toLowerCase()) || isBlockMacro(name),
49
+ });
50
+ }
@@ -21,7 +21,19 @@ import type {
21
21
  MacroProps as PublishedMacroProps,
22
22
  ASTNode as PublishedASTNode,
23
23
  } from '../types/index';
24
- import type { parseStoryVariables as PublishedParse } from '../types/tooling';
24
+ import type {
25
+ parseStoryVariables as PublishedParse,
26
+ formatDiagnostic as PublishedFormatDiagnostic,
27
+ MarkupDiagnostic as PublishedMarkupDiagnostic,
28
+ MarkupPassage as PublishedMarkupPassage,
29
+ validateMarkup as PublishedValidateMarkup,
30
+ } from '../types/tooling';
31
+ import type {
32
+ formatDiagnostic as SourceFormatDiagnostic,
33
+ MarkupDiagnostic as SourceMarkupDiagnostic,
34
+ MarkupPassage as SourceMarkupPassage,
35
+ validateStoryMarkup as SourceValidateStoryMarkup,
36
+ } from './tooling';
25
37
  import type { bootStory as PublishedBootStory } from '../types/headless';
26
38
 
27
39
  /** What the source declares, by published name. */
@@ -30,6 +42,11 @@ interface Source {
30
42
  // Tooling entry point (`@rohal12/spindle/tooling`): the parser that
31
43
  // dist/pkg/tooling.js re-exports.
32
44
  parseStoryVariables: typeof SourceParse;
45
+ // Its markup validator (pkg/tooling.js binds validateStoryMarkup to the
46
+ // macro metadata, so validateMarkup itself takes only the passages).
47
+ MarkupPassage: SourceMarkupPassage;
48
+ MarkupDiagnostic: SourceMarkupDiagnostic;
49
+ formatDiagnostic: typeof SourceFormatDiagnostic;
33
50
  // Custom macro API: the MacroContext/MacroDefinition defineMacro()
34
51
  // actually passes and accepts.
35
52
  MacroContext: SourceMacroContext;
@@ -44,6 +61,9 @@ interface Source {
44
61
  interface Published {
45
62
  StoryAPI: PublishedAPI;
46
63
  parseStoryVariables: typeof PublishedParse;
64
+ MarkupPassage: PublishedMarkupPassage;
65
+ MarkupDiagnostic: PublishedMarkupDiagnostic;
66
+ formatDiagnostic: typeof PublishedFormatDiagnostic;
47
67
  MacroContext: PublishedMacroContext;
48
68
  MacroDefinition: PublishedMacroDefinition;
49
69
  MacroProps: PublishedMacroProps;
@@ -90,3 +110,10 @@ const _exampleMacro: PublishedMacroDefinition = {
90
110
  );
91
111
  },
92
112
  };
113
+
114
+ // pkg/tooling.js's validateMarkup is validateStoryMarkup bound to the macro
115
+ // metadata.
116
+ declare const validateStoryMarkup: typeof SourceValidateStoryMarkup;
117
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
118
+ const _validateMarkup: typeof PublishedValidateMarkup = (passages) =>
119
+ validateStoryMarkup(passages, []);
@@ -1,5 +1,6 @@
1
- import { isDraft } from 'immer';
1
+ import { current as draftState, isDraft } from 'immer';
2
2
  import { hasOwn } from './namespace';
3
+ import { atomicName } from './value-kinds';
3
4
 
4
5
  /**
5
6
  * Traverse dot-path segments on an object and return the nested value.
@@ -29,22 +30,13 @@ const isArrayKey = (key: string): boolean =>
29
30
  * Built-ins whose content is not their properties: a property written into
30
31
  * one would be dropped by clones and saves (and by Immer, for Map and Set).
31
32
  */
32
- const builtinName = (value: object): string | undefined =>
33
- value instanceof Map
34
- ? 'a Map'
35
- : value instanceof Set
36
- ? 'a Set'
37
- : value instanceof Date
38
- ? 'a Date'
39
- : value instanceof RegExp
40
- ? 'a RegExp'
41
- : undefined;
33
+ const builtinName = atomicName;
42
34
 
43
35
  /**
44
36
  * Shallow copy that keeps the prototype, so a registered class instance
45
37
  * stays an instance of its class (with the same own keys deepClone copies).
46
38
  */
47
- function shallowCopy(value: object): Record<string, unknown> {
39
+ export function shallowCopy(value: object): Record<string, unknown> {
48
40
  const copy = Array.isArray(value)
49
41
  ? []
50
42
  : (Object.create(Object.getPrototypeOf(value) as object | null) as object);
@@ -120,6 +112,15 @@ export function deleteByPath(
120
112
  return;
121
113
  }
122
114
  const parent = walkToParent(root, segments, false);
115
+ if (Array.isArray(parent) && isDraft(parent) && segments.length > 1) {
116
+ // Immer turns `delete` of an array element into writing undefined: to
117
+ // leave a hole, as `delete` does, put in a copy of the array with one
118
+ const copy = (draftState(parent) as unknown[]).slice();
119
+ delete copy[Number(last)];
120
+ const holder = walkToParent(root, segments.slice(0, -1), false);
121
+ holder[segments[segments.length - 2]!] = copy;
122
+ return;
123
+ }
123
124
  delete parent[last];
124
125
  }
125
126
 
@@ -55,7 +55,8 @@ function keyOf(val: unknown, ancestors: object[]): string {
55
55
  try {
56
56
  if (Array.isArray(val)) {
57
57
  // Array.from reads a hole as undefined (map would skip it, giving
58
- // `[,]` the key of `[]`), matching deepClone, which fills holes.
58
+ // `[,]` the key of `[]`): as JSON.stringify, a key does not tell a
59
+ // hole from an undefined element (deepEqual does)
59
60
  return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
60
61
  }
61
62
  if (val instanceof Map) {