@rohal12/spindle 0.59.12 → 0.59.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +416 -94
- package/dist/pkg/story-variables.js +19 -12
- package/package.json +1 -1
- package/src/class-registry.ts +133 -14
- package/src/components/macros/Computed.tsx +23 -3
- package/src/components/macros/For.tsx +6 -3
- package/src/components/macros/Type.tsx +8 -4
- package/src/components/macros/WidgetInvocation.tsx +7 -2
- package/src/define-macro.ts +1 -1
- package/src/execute-mutation.ts +36 -0
- package/src/hooks/use-modal-focus.ts +67 -17
- package/src/interpolation.ts +25 -5
- package/src/markup/render.tsx +39 -7
- package/src/story-api.ts +2 -1
- package/src/story-variables.ts +12 -2
- package/src/structural.ts +85 -21
- package/src/utils/control-edits.ts +36 -3
- package/src/utils/value-kinds.ts +14 -0
- package/src/widgets/widget-def.ts +27 -15
- package/types/tooling.d.ts +7 -1
package/src/interpolation.ts
CHANGED
|
@@ -40,6 +40,25 @@ export function hasInterpolation(s: string): boolean {
|
|
|
40
40
|
return s.includes('{') || s.includes('}');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The children an invocation passes to a block widget, for `{@children}`.
|
|
45
|
+
* `outer` is the children context the invocation itself was written in: a
|
|
46
|
+
* `{@children}` among `nodes` forwards the enclosing widget's children, so it
|
|
47
|
+
* resolves there, not against `nodes` again (#386).
|
|
48
|
+
*/
|
|
49
|
+
export interface WidgetChildren {
|
|
50
|
+
readonly nodes: ASTNode[];
|
|
51
|
+
readonly outer: WidgetChildren | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The children context for an invocation's `children`, or null if none. */
|
|
55
|
+
export function widgetChildrenOf(
|
|
56
|
+
children: ASTNode[] | undefined,
|
|
57
|
+
outer: WidgetChildren | null | undefined,
|
|
58
|
+
): WidgetChildren | null {
|
|
59
|
+
return children?.length ? { nodes: children, outer: outer ?? null } : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
/** The scope text-only markup is evaluated in. */
|
|
44
63
|
export interface TextScope {
|
|
45
64
|
variables: Record<string, unknown>;
|
|
@@ -47,7 +66,7 @@ export interface TextScope {
|
|
|
47
66
|
locals: Record<string, unknown>;
|
|
48
67
|
transient: Record<string, unknown>;
|
|
49
68
|
/** Invocation children of the enclosing block widget, for `{@children}`. */
|
|
50
|
-
widgetChildren?:
|
|
69
|
+
widgetChildren?: WidgetChildren | null;
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/** A macro, expression or markup error met while evaluating text. */
|
|
@@ -177,11 +196,12 @@ class TextEvaluator {
|
|
|
177
196
|
case 'variable':
|
|
178
197
|
if (node.scope === 'local' && node.name === 'children') {
|
|
179
198
|
// As {@children} in a widget body: the invocation's children, in
|
|
180
|
-
// the scope of the body
|
|
199
|
+
// the scope of the body, and in the children context they were
|
|
200
|
+
// written in.
|
|
181
201
|
const children = scope.widgetChildren;
|
|
182
202
|
if (!children) return '';
|
|
183
|
-
return this.nested('children', children, scope, depth, {
|
|
184
|
-
widgetChildren:
|
|
203
|
+
return this.nested('children', children.nodes, scope, depth, {
|
|
204
|
+
widgetChildren: children.outer,
|
|
185
205
|
});
|
|
186
206
|
}
|
|
187
207
|
return this.variable(node.scope, node.name, scope);
|
|
@@ -262,7 +282,7 @@ class TextEvaluator {
|
|
|
262
282
|
}
|
|
263
283
|
return this.nested(node.name, widget.body, scope, depth, {
|
|
264
284
|
locals,
|
|
265
|
-
widgetChildren: node.children
|
|
285
|
+
widgetChildren: widgetChildrenOf(node.children, scope.widgetChildren),
|
|
266
286
|
});
|
|
267
287
|
}
|
|
268
288
|
|
package/src/markup/render.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { markdownToHtml } from './markdown';
|
|
|
9
9
|
import { h } from 'preact';
|
|
10
10
|
import type { ASTNode, HtmlNode, MacroNode } from './ast';
|
|
11
11
|
import { useTextScope } from '../hooks/use-interpolate';
|
|
12
|
+
import type { WidgetChildren } from '../interpolation';
|
|
12
13
|
import { useRenderOptions } from '../hooks/use-render-options';
|
|
13
14
|
import {
|
|
14
15
|
hasInterpolation,
|
|
@@ -52,7 +53,7 @@ export const InlineContext = createContext(false);
|
|
|
52
53
|
/**
|
|
53
54
|
* True while rendering inside an element whose content is not markdown: SVG
|
|
54
55
|
* (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
|
|
55
|
-
* and `<
|
|
56
|
+
* `<textarea>` and `<style>`, whose text (indentation, `#`, `*`, ...) is literal.
|
|
56
57
|
* Macro and widget bodies read it so their content stays literal too.
|
|
57
58
|
*/
|
|
58
59
|
export const RawTextContext = createContext(false);
|
|
@@ -66,8 +67,8 @@ const SvgContext = createContext(false);
|
|
|
66
67
|
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
67
68
|
|
|
68
69
|
/** Elements whose content is literal text, not markdown. */
|
|
69
|
-
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
|
|
70
|
-
export const WidgetChildrenContext = createContext<
|
|
70
|
+
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
|
|
71
|
+
export const WidgetChildrenContext = createContext<WidgetChildren | null>(null);
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
74
|
* Components rendered for the non-text nodes of one renderNodes() call. Each
|
|
@@ -392,6 +393,7 @@ const INLINE_ELEMENTS = new Set([
|
|
|
392
393
|
'span',
|
|
393
394
|
'strong',
|
|
394
395
|
'sub',
|
|
396
|
+
'summary',
|
|
395
397
|
'sup',
|
|
396
398
|
'time',
|
|
397
399
|
'u',
|
|
@@ -632,6 +634,15 @@ function resolveAttributeValue(
|
|
|
632
634
|
return result.text;
|
|
633
635
|
}
|
|
634
636
|
|
|
637
|
+
/** The index of the summary element opening `nodes`, or -1 when none does. */
|
|
638
|
+
function leadingSummaryIndex(nodes: ASTNode[]): number {
|
|
639
|
+
const at = nodes.findIndex((n) => n.type !== 'text' || n.value.trim() !== '');
|
|
640
|
+
const first = nodes[at];
|
|
641
|
+
return first?.type === 'html' && first.tag.toLowerCase() === 'summary'
|
|
642
|
+
? at
|
|
643
|
+
: -1;
|
|
644
|
+
}
|
|
645
|
+
|
|
635
646
|
function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
636
647
|
const scope = useTextScope();
|
|
637
648
|
const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
|
|
@@ -658,7 +669,22 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
658
669
|
if (inRaw || isRawRoot) {
|
|
659
670
|
children = renderInlineNodes(node.children);
|
|
660
671
|
} else {
|
|
661
|
-
|
|
672
|
+
// A details element's summary stays its direct child: it is rendered
|
|
673
|
+
// apart from the markdown of the rest of the content.
|
|
674
|
+
const summaryAt =
|
|
675
|
+
tag === 'details' ? leadingSummaryIndex(node.children) : -1;
|
|
676
|
+
const body = (nodes: ASTNode[]) =>
|
|
677
|
+
renderNodes(nodes, { nobr, locals, inline: isInline });
|
|
678
|
+
children =
|
|
679
|
+
summaryAt === -1 ? (
|
|
680
|
+
body(node.children)
|
|
681
|
+
) : (
|
|
682
|
+
<>
|
|
683
|
+
{body(node.children.slice(0, summaryAt))}
|
|
684
|
+
{renderSingleNode(node.children[summaryAt]!)}
|
|
685
|
+
{body(node.children.slice(summaryAt + 1))}
|
|
686
|
+
</>
|
|
687
|
+
);
|
|
662
688
|
if (isInline !== parentInline) {
|
|
663
689
|
children = (
|
|
664
690
|
<InlineContext.Provider value={isInline}>
|
|
@@ -683,10 +709,16 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
683
709
|
}
|
|
684
710
|
|
|
685
711
|
function ChildrenSlot() {
|
|
686
|
-
const
|
|
712
|
+
const children = useContext(WidgetChildrenContext);
|
|
687
713
|
const renderOptions = useRenderOptions();
|
|
688
|
-
if (!
|
|
689
|
-
|
|
714
|
+
if (!children) return null;
|
|
715
|
+
// The children render with the widget's locals, but a {@children} among
|
|
716
|
+
// them forwards the enclosing widget's children (#386).
|
|
717
|
+
return (
|
|
718
|
+
<WidgetChildrenContext.Provider value={children.outer}>
|
|
719
|
+
{renderNodes(children.nodes, renderOptions)}
|
|
720
|
+
</WidgetChildrenContext.Provider>
|
|
721
|
+
);
|
|
690
722
|
}
|
|
691
723
|
|
|
692
724
|
/**
|
package/src/story-api.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { getBackendType } from './saves/storage';
|
|
|
23
23
|
import { registerClass } from './class-registry';
|
|
24
24
|
import {
|
|
25
25
|
frozenCopy,
|
|
26
|
+
readOnlyValue,
|
|
26
27
|
getActiveMutationScope,
|
|
27
28
|
mutateState,
|
|
28
29
|
} from './execute-mutation';
|
|
@@ -286,7 +287,7 @@ function createStoryAPI(): StoryAPI {
|
|
|
286
287
|
const value = key.includes('.')
|
|
287
288
|
? getByPath(namespace, key.split('.'))
|
|
288
289
|
: ownValue(namespace, key);
|
|
289
|
-
return scope ? frozenCopy(value) : value;
|
|
290
|
+
return scope ? frozenCopy(value) : readOnlyValue(value);
|
|
290
291
|
},
|
|
291
292
|
|
|
292
293
|
set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
|
package/src/story-variables.ts
CHANGED
|
@@ -32,7 +32,13 @@ function tokensOf(text: string, textMode: boolean): Token[] {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export type VarType =
|
|
35
|
+
export type VarType =
|
|
36
|
+
| 'number'
|
|
37
|
+
| 'string'
|
|
38
|
+
| 'boolean'
|
|
39
|
+
| 'array'
|
|
40
|
+
| 'object'
|
|
41
|
+
| 'null';
|
|
36
42
|
|
|
37
43
|
export interface FieldSchema {
|
|
38
44
|
type: VarType;
|
|
@@ -66,7 +72,9 @@ function inferSchema(value: unknown): FieldSchema {
|
|
|
66
72
|
if (Array.isArray(value)) {
|
|
67
73
|
return { type: 'array' };
|
|
68
74
|
}
|
|
69
|
-
|
|
75
|
+
// A default of null means "nothing yet"; the value may later be anything
|
|
76
|
+
if (value === null) return { type: 'null' };
|
|
77
|
+
if (typeof value === 'object') {
|
|
70
78
|
const fields = new Map<string, FieldSchema>();
|
|
71
79
|
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
72
80
|
fields.set(key, inferSchema(val));
|
|
@@ -156,6 +164,8 @@ function validateRef(
|
|
|
156
164
|
// Arrays have built-in methods/properties (push, find, length, etc.)
|
|
157
165
|
// so any field access on an array is allowed.
|
|
158
166
|
if (current.type === 'array') return null;
|
|
167
|
+
// A null default may later hold a value of any shape
|
|
168
|
+
if (current.type === 'null') return null;
|
|
159
169
|
|
|
160
170
|
// Primitives expose their wrapper's built-ins (length, toUpperCase,
|
|
161
171
|
// toFixed, etc.). Keep validating past properties of primitive type.
|
package/src/structural.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { registeredClassName } from './class-registry';
|
|
13
13
|
import { hasOwn, setOwn } from './utils/namespace';
|
|
14
14
|
import { deleteByPath, getByPath, setByPath } from './utils/object-path';
|
|
15
|
-
import { isAtomic, isBoxed, isTemporal } from './utils/value-kinds';
|
|
15
|
+
import { extraKeys, isAtomic, isBoxed, isTemporal } from './utils/value-kinds';
|
|
16
16
|
|
|
17
17
|
export { isAtomic };
|
|
18
18
|
|
|
@@ -24,6 +24,26 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
24
24
|
return proto === Object.prototype || proto === null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* `empty`, a new collection (Array, Map or Set) standing for `obj`, made
|
|
29
|
+
* like it: an instance of a registered subclass keeps its class and its
|
|
30
|
+
* extra keys (#391), with the values `value` gives for them; other
|
|
31
|
+
* collections are plain. `made` gets it before its keys are filled, so
|
|
32
|
+
* that cycles through it find it.
|
|
33
|
+
*/
|
|
34
|
+
function collectionLike<C extends object>(
|
|
35
|
+
obj: object,
|
|
36
|
+
empty: C,
|
|
37
|
+
made: (copy: C) => void,
|
|
38
|
+
value: (key: string) => unknown,
|
|
39
|
+
): C {
|
|
40
|
+
made(empty);
|
|
41
|
+
if (registeredClassName(obj) === undefined) return empty;
|
|
42
|
+
Object.setPrototypeOf(empty, Object.getPrototypeOf(obj) as object);
|
|
43
|
+
for (const key of extraKeys(obj)) setOwn(empty, key, value(key));
|
|
44
|
+
return empty;
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
/** Own keys of an error that are not enumerable but are data. */
|
|
28
48
|
const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
|
|
29
49
|
|
|
@@ -131,6 +151,15 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
131
151
|
: copy;
|
|
132
152
|
}
|
|
133
153
|
|
|
154
|
+
/** `empty`, made the copy of the collection `obj` (see collectionLike). */
|
|
155
|
+
const collection = <C extends object>(obj: object, empty: C): C =>
|
|
156
|
+
collectionLike(
|
|
157
|
+
obj,
|
|
158
|
+
empty,
|
|
159
|
+
(copy) => keep(obj, copy),
|
|
160
|
+
(key) => clone((obj as Record<string, unknown>)[key]),
|
|
161
|
+
);
|
|
162
|
+
|
|
134
163
|
function clone(val: unknown): unknown {
|
|
135
164
|
if (val === null || typeof val !== 'object') return val;
|
|
136
165
|
|
|
@@ -139,22 +168,25 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
|
|
|
139
168
|
|
|
140
169
|
if (Array.isArray(val)) {
|
|
141
170
|
// Holes stay holes
|
|
142
|
-
const arr =
|
|
171
|
+
const arr = collection(obj, new Array(val.length) as unknown[]);
|
|
143
172
|
for (let i = 0; i < val.length; i++) {
|
|
144
173
|
if (i in val) arr[i] = clone(val[i]);
|
|
145
174
|
}
|
|
146
175
|
return arr;
|
|
147
176
|
}
|
|
148
177
|
|
|
178
|
+
// Filled with the built-in methods: a subclass may override them
|
|
149
179
|
if (val instanceof Map) {
|
|
150
|
-
const copy =
|
|
151
|
-
for (const [k, v] of val)
|
|
180
|
+
const copy = collection(obj, new Map());
|
|
181
|
+
for (const [k, v] of val) {
|
|
182
|
+
Map.prototype.set.call(copy, settled(clone(k), k), clone(v));
|
|
183
|
+
}
|
|
152
184
|
return copy;
|
|
153
185
|
}
|
|
154
186
|
|
|
155
187
|
if (val instanceof Set) {
|
|
156
|
-
const copy =
|
|
157
|
-
for (const v of val)
|
|
188
|
+
const copy = collection(obj, new Set());
|
|
189
|
+
for (const v of val) Set.prototype.add.call(copy, settled(clone(v), v));
|
|
158
190
|
return copy;
|
|
159
191
|
}
|
|
160
192
|
|
|
@@ -295,15 +327,23 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
|
295
327
|
if (placed !== undefined) return placed;
|
|
296
328
|
if (!isObjectValue(p)) return fresh(c);
|
|
297
329
|
if (reuse(p, c)) return p;
|
|
330
|
+
const pr = p as Record<string, unknown>;
|
|
331
|
+
const cr = c as Record<string, unknown>;
|
|
332
|
+
/** What the result holds at `key` of `c`. */
|
|
333
|
+
const shareKey = (key: string) =>
|
|
334
|
+
share(hasOwn(pr, key) ? pr[key] : undefined, cr[key]);
|
|
335
|
+
/** `empty`, made the result for the collection `c` (see collectionLike). */
|
|
336
|
+
const collection = <C extends object>(empty: C): C =>
|
|
337
|
+
collectionLike(c, empty, (out) => done.set(c, out), shareKey);
|
|
298
338
|
if (c instanceof Map && p instanceof Map) {
|
|
299
|
-
const out = new Map();
|
|
300
|
-
|
|
301
|
-
|
|
339
|
+
const out = collection(new Map());
|
|
340
|
+
for (const [k, v] of c) {
|
|
341
|
+
Map.prototype.set.call(out, fresh(k), share(p.get(k), v));
|
|
342
|
+
}
|
|
302
343
|
return out;
|
|
303
344
|
}
|
|
304
345
|
if (Array.isArray(c) && Array.isArray(p)) {
|
|
305
|
-
const out = new Array(c.length) as unknown[];
|
|
306
|
-
done.set(c, out);
|
|
346
|
+
const out = collection(new Array(c.length) as unknown[]);
|
|
307
347
|
for (let i = 0; i < c.length; i++) {
|
|
308
348
|
if (i in c) out[i] = share(p[i], c[i]);
|
|
309
349
|
}
|
|
@@ -312,10 +352,7 @@ export function shareEqual<T>(prev: unknown, curr: T): T {
|
|
|
312
352
|
if (mergesWith(p, c, false)) {
|
|
313
353
|
const out = Object.create(Object.getPrototypeOf(c) as object | null);
|
|
314
354
|
done.set(c, out);
|
|
315
|
-
const
|
|
316
|
-
for (const key of Object.keys(cr)) {
|
|
317
|
-
setOwn(out, key, share(hasOwn(p, key) ? p[key] : undefined, cr[key]));
|
|
318
|
-
}
|
|
355
|
+
for (const key of Object.keys(cr)) setOwn(out, key, shareKey(key));
|
|
319
356
|
return out;
|
|
320
357
|
}
|
|
321
358
|
return fresh(c);
|
|
@@ -382,17 +419,40 @@ function equalBuiltin(
|
|
|
382
419
|
return undefined;
|
|
383
420
|
}
|
|
384
421
|
|
|
385
|
-
function equalKeys(
|
|
422
|
+
function equalKeys(
|
|
423
|
+
a: object,
|
|
424
|
+
b: object,
|
|
425
|
+
assumed: Pairs,
|
|
426
|
+
keysOf: (o: object) => string[] = Object.keys,
|
|
427
|
+
): boolean {
|
|
386
428
|
const ao = a as Record<string, unknown>;
|
|
387
429
|
const bo = b as Record<string, unknown>;
|
|
388
|
-
const keys =
|
|
389
|
-
if (keys.length !==
|
|
430
|
+
const keys = keysOf(ao);
|
|
431
|
+
if (keys.length !== keysOf(bo).length) return false;
|
|
390
432
|
for (const key of keys) {
|
|
391
433
|
if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
|
|
392
434
|
}
|
|
393
435
|
return true;
|
|
394
436
|
}
|
|
395
437
|
|
|
438
|
+
/** Built-in collection prototypes, whose instances hold no extra keys. */
|
|
439
|
+
const COLLECTION_PROTOS: ReadonlySet<object | null> = new Set([
|
|
440
|
+
Array.prototype,
|
|
441
|
+
Map.prototype,
|
|
442
|
+
Set.prototype,
|
|
443
|
+
]);
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Equality of the extra keys (see extraKeys) of two collections of one
|
|
447
|
+
* class: those of a subclass instance are part of its value.
|
|
448
|
+
*/
|
|
449
|
+
function equalExtraKeys(a: object, b: object, assumed: Pairs): boolean {
|
|
450
|
+
return (
|
|
451
|
+
COLLECTION_PROTOS.has(Object.getPrototypeOf(a) as object | null) ||
|
|
452
|
+
equalKeys(a, b, assumed, extraKeys)
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
396
456
|
/** `assumed` tracks the pairs of objects met (see Pairs). */
|
|
397
457
|
function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
398
458
|
if (Object.is(a, b) && !(assumed.strict && isObjectValue(a))) return true;
|
|
@@ -420,7 +480,7 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
|
420
480
|
) {
|
|
421
481
|
if (!equal(x.value, y.value, assumed)) return false;
|
|
422
482
|
}
|
|
423
|
-
return
|
|
483
|
+
return equalExtraKeys(a, b, assumed);
|
|
424
484
|
}
|
|
425
485
|
|
|
426
486
|
const builtin = equalBuiltin(a, b, assumed);
|
|
@@ -432,7 +492,7 @@ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
|
|
|
432
492
|
for (let i = 0; i < a.length; i++) {
|
|
433
493
|
if (i in a !== i in bc || !equal(a[i], bc[i], assumed)) return false;
|
|
434
494
|
}
|
|
435
|
-
return
|
|
495
|
+
return equalExtraKeys(a, b, assumed);
|
|
436
496
|
}
|
|
437
497
|
|
|
438
498
|
return equalKeys(a, b, assumed);
|
|
@@ -466,7 +526,11 @@ export function mergesWith(
|
|
|
466
526
|
arrays: boolean,
|
|
467
527
|
): a is Record<string, unknown> {
|
|
468
528
|
if (arrays && (Array.isArray(a) || Array.isArray(b))) {
|
|
469
|
-
return
|
|
529
|
+
return (
|
|
530
|
+
Array.isArray(a) &&
|
|
531
|
+
Array.isArray(b) &&
|
|
532
|
+
Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
|
|
533
|
+
);
|
|
470
534
|
}
|
|
471
535
|
return (
|
|
472
536
|
isMergeable(a) &&
|
|
@@ -1,15 +1,48 @@
|
|
|
1
|
+
import { getByPath } from './object-path';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
4
|
+
* Records the edits a reader made through a control bound to a story
|
|
3
5
|
* variable (`{textbox}`, `{checkbox}`, ...). {for} tells these apart from a
|
|
4
6
|
* list that is replaced: typing into a control inside an iteration changes
|
|
5
7
|
* its item, but must not remount the iteration and drop the control's focus.
|
|
8
|
+
* An edit is attributed to the variable path it wrote, so a replacement made
|
|
9
|
+
* elsewhere (a watcher reacting to the edit) still remounts.
|
|
6
10
|
*/
|
|
7
11
|
let version = 0;
|
|
12
|
+
const MAX_LOG = 256;
|
|
13
|
+
const log: { version: number; segments: readonly string[] }[] = [];
|
|
8
14
|
|
|
9
|
-
export function noteControlEdit(): void {
|
|
10
|
-
version
|
|
15
|
+
export function noteControlEdit(segments: readonly string[]): void {
|
|
16
|
+
log.push({ version: ++version, segments });
|
|
17
|
+
if (log.length > MAX_LOG) log.shift();
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
export function controlEditVersion(): number {
|
|
14
21
|
return version;
|
|
15
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether an edit since `since` wrote inside `item`, element `index` of
|
|
26
|
+
* `list`: the path leads through the item (compared by reference in
|
|
27
|
+
* `variables`), or, for a primitive item, ends at its slot in the list.
|
|
28
|
+
*/
|
|
29
|
+
export function editedItem(
|
|
30
|
+
since: number,
|
|
31
|
+
variables: object,
|
|
32
|
+
list: readonly unknown[],
|
|
33
|
+
item: unknown,
|
|
34
|
+
index: number,
|
|
35
|
+
): boolean {
|
|
36
|
+
return log.some(({ version: v, segments }) => {
|
|
37
|
+
if (v <= since) return false;
|
|
38
|
+
if (typeof item === 'object' && item !== null) {
|
|
39
|
+
return segments.some(
|
|
40
|
+
(_, n) => getByPath(variables, segments.slice(0, n + 1)) === item,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
segments[segments.length - 1] === String(index) &&
|
|
45
|
+
getByPath(variables, segments.slice(0, -1)) === list
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
}
|
package/src/utils/value-kinds.ts
CHANGED
|
@@ -43,3 +43,17 @@ export function atomicName(value: object): string | undefined {
|
|
|
43
43
|
const tag = value instanceof Error ? 'Error' : toStringTag(value);
|
|
44
44
|
return `${/^[AEIOU]/.test(tag) ? 'an' : 'a'} ${tag}`;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/** Whether `key` is an index of an array element. */
|
|
48
|
+
const isIndexKey = (key: string): boolean =>
|
|
49
|
+
key !== '4294967295' && String(Number(key) >>> 0) === key;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The own enumerable string keys of a collection (an Array, Map or Set) that
|
|
53
|
+
* are not its elements: what an instance of a subclass may hold besides
|
|
54
|
+
* them.
|
|
55
|
+
*/
|
|
56
|
+
export function extraKeys(collection: object): string[] {
|
|
57
|
+
const keys = Object.keys(collection);
|
|
58
|
+
return Array.isArray(collection) ? keys.filter((k) => !isIndexKey(k)) : keys;
|
|
59
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { parseMacroArgs } from '../components/macros/macro-args';
|
|
6
6
|
import type { MacroArgs } from '../registry';
|
|
7
|
+
import { tokenizeMarkupTolerant } from '../markup/parse';
|
|
7
8
|
|
|
8
9
|
/** A {widget} definition's name, then its `@` parameters. */
|
|
9
10
|
export const WIDGET_PARAMETERS = [
|
|
@@ -36,20 +37,15 @@ export function parseWidgetDef(rawArgs: string): WidgetDef {
|
|
|
36
37
|
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
-
* A definition: its arguments (read like a {widget} macro's, see
|
|
41
|
-
* parseWidgetDef) and its body. Macro names are not case-sensitive.
|
|
42
|
-
*/
|
|
43
|
-
const WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
|
|
44
|
-
|
|
45
|
-
/** `{@children}`, also with selectors such as `{.highlight @children}`. */
|
|
46
|
-
const CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
|
|
47
|
-
|
|
48
40
|
/**
|
|
49
41
|
* The names of the block widgets (those whose body renders `{@children}`)
|
|
50
42
|
* that StoryInit and the passages tagged `widget` define. They must be
|
|
51
43
|
* known as block macros before any passage is parsed, so that passages
|
|
52
44
|
* invoking them nest their content whatever the passage order.
|
|
45
|
+
*
|
|
46
|
+
* The passages are read as flat tokens, which need no block macros to be
|
|
47
|
+
* known, so a `{@children}` in an HTML comment or in a `{do}` body is text
|
|
48
|
+
* here as it is to the parser, and does not make a block widget (#387).
|
|
53
49
|
*/
|
|
54
50
|
export function blockWidgetNames(
|
|
55
51
|
passages: Iterable<{ name: string; tags?: string[]; content: string }>,
|
|
@@ -59,12 +55,28 @@ export function blockWidgetNames(
|
|
|
59
55
|
if (passage.name !== 'StoryInit' && !passage.tags?.includes('widget')) {
|
|
60
56
|
continue;
|
|
61
57
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
// The open definitions: their arguments, and whether a slot was seen
|
|
59
|
+
const open: { rawArgs: string; isBlock: boolean }[] = [];
|
|
60
|
+
for (const token of tokenizeMarkupTolerant(passage.content).tokens) {
|
|
61
|
+
if (token.type === 'macro' && token.name.toLowerCase() === 'widget') {
|
|
62
|
+
if (!token.isClose) {
|
|
63
|
+
open.push({ rawArgs: token.rawArgs, isBlock: false });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const def = open.pop();
|
|
67
|
+
if (!def?.isBlock) continue;
|
|
68
|
+
try {
|
|
69
|
+
names.push(parseWidgetDef(def.rawArgs).name);
|
|
70
|
+
} catch {
|
|
71
|
+
// The {widget} macro reports a definition it cannot read
|
|
72
|
+
}
|
|
73
|
+
} else if (
|
|
74
|
+
token.type === 'variable' &&
|
|
75
|
+
token.scope === 'local' &&
|
|
76
|
+
token.name === 'children' &&
|
|
77
|
+
open.length > 0
|
|
78
|
+
) {
|
|
79
|
+
open[open.length - 1]!.isBlock = true;
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
}
|
package/types/tooling.d.ts
CHANGED
|
@@ -100,7 +100,13 @@ export declare function defineMacro(config: MacroDefinition): void;
|
|
|
100
100
|
export declare function getMacroRegistry(): MacroMetadata[];
|
|
101
101
|
|
|
102
102
|
/** Variable type inferred from a StoryVariables/StoryTransients default value. */
|
|
103
|
-
export type VarType =
|
|
103
|
+
export type VarType =
|
|
104
|
+
| 'number'
|
|
105
|
+
| 'string'
|
|
106
|
+
| 'boolean'
|
|
107
|
+
| 'array'
|
|
108
|
+
| 'object'
|
|
109
|
+
| 'null';
|
|
104
110
|
|
|
105
111
|
/** Inferred shape of a declared variable (or one of its object fields). */
|
|
106
112
|
export interface FieldSchema {
|