@rohal12/spindle 0.59.14 → 0.59.15
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 +537 -238
- package/dist/pkg/story-variables.js +313 -149
- package/package.json +1 -1
- package/src/class-registry.ts +105 -35
- package/src/components/Passage.tsx +21 -13
- package/src/components/PassageDialog.tsx +12 -2
- package/src/components/macros/Radiobutton.tsx +4 -1
- package/src/execute-mutation.ts +3 -2
- package/src/markup/render.tsx +17 -1
- package/src/markup/spindle.peggy +16 -0
- package/src/store.ts +12 -2
- package/src/structural.ts +74 -38
- package/src/triggers.ts +6 -2
- package/src/utils/stable-key.ts +8 -4
- package/src/utils/value-kinds.ts +42 -0
- package/types/index.d.ts +3 -1
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -2,14 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
import { parse, stringify } from 'devalue';
|
|
4
4
|
import { hasOwn } from './utils/namespace';
|
|
5
|
-
import { extraKeys } from './utils/value-kinds';
|
|
5
|
+
import { extraKeys, mapEntries, setMembers } from './utils/value-kinds';
|
|
6
6
|
|
|
7
7
|
type Constructor = new (...args: any[]) => any;
|
|
8
8
|
|
|
9
9
|
const registry = new Map<string, Constructor>();
|
|
10
10
|
const ctorToName = new Map<Constructor, string>();
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Built-ins whose instances hold their value in internal slots that a save
|
|
14
|
+
* cannot rebuild: a registered class cannot extend them. (Array, Map, Set,
|
|
15
|
+
* Date, RegExp and Error can be extended, see classData.)
|
|
16
|
+
*/
|
|
17
|
+
const UNSUPPORTED_BASES: readonly Constructor[] = [
|
|
18
|
+
ArrayBuffer,
|
|
19
|
+
DataView,
|
|
20
|
+
Object.getPrototypeOf(Int8Array) as Constructor, // every typed array
|
|
21
|
+
URL,
|
|
22
|
+
URLSearchParams,
|
|
23
|
+
Number,
|
|
24
|
+
String,
|
|
25
|
+
Boolean,
|
|
26
|
+
Promise,
|
|
27
|
+
WeakMap,
|
|
28
|
+
WeakSet,
|
|
29
|
+
Function,
|
|
30
|
+
] as Constructor[];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register `ctor` as `name`, so that its instances keep their class through
|
|
34
|
+
* copies, history and saves. Throws for a class extending a built-in whose
|
|
35
|
+
* instances cannot be saved (see UNSUPPORTED_BASES).
|
|
36
|
+
*/
|
|
12
37
|
export function registerClass(name: string, ctor: Constructor): void {
|
|
38
|
+
const base =
|
|
39
|
+
typeof ctor === 'function'
|
|
40
|
+
? UNSUPPORTED_BASES.find((b) => ctor.prototype instanceof b)
|
|
41
|
+
: undefined;
|
|
42
|
+
if (base) {
|
|
43
|
+
throw new TypeError(
|
|
44
|
+
`spindle: Cannot register class "${name}": it extends ${base.name}, whose instances cannot be saved (a registered class may extend Array, Map, Set, Date, RegExp or Error)`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
13
47
|
registry.set(name, ctor);
|
|
14
48
|
ctorToName.set(ctor, name);
|
|
15
49
|
}
|
|
@@ -48,8 +82,9 @@ export function clearRegistry(): void {
|
|
|
48
82
|
//
|
|
49
83
|
// - `c:<name>`: an instance of the class registered as <name>, with its own
|
|
50
84
|
// enumerable keys (and, for Error subclasses, its message and cause); for
|
|
51
|
-
// a subclass of Array, Map or
|
|
52
|
-
//
|
|
85
|
+
// a subclass of Array, Map, Set, Date or RegExp, `[kind, contents, keys]`:
|
|
86
|
+
// the built-in it extends, what that holds (elements, Map entries as
|
|
87
|
+
// pairs, the time, or the source, flags and lastIndex) and its other own
|
|
53
88
|
// enumerable keys;
|
|
54
89
|
// - `E:<name>`: a built-in error (Error, TypeError, ..., AggregateError),
|
|
55
90
|
// with its message, cause, errors and own enumerable keys (not its stack);
|
|
@@ -155,18 +190,24 @@ function ownData(
|
|
|
155
190
|
return data;
|
|
156
191
|
}
|
|
157
192
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The built-ins a registered class may extend whose value is not in its
|
|
195
|
+
* keys, saved as their kind (see classData).
|
|
196
|
+
*/
|
|
197
|
+
const BUILTIN_KINDS = {
|
|
160
198
|
Array: Array as unknown as Constructor,
|
|
161
199
|
Map: Map as Constructor,
|
|
162
200
|
Set: Set as Constructor,
|
|
201
|
+
Date: Date as Constructor,
|
|
202
|
+
RegExp: RegExp as Constructor,
|
|
163
203
|
};
|
|
164
|
-
type
|
|
204
|
+
type BuiltinKind = keyof typeof BUILTIN_KINDS;
|
|
165
205
|
|
|
166
206
|
/**
|
|
167
207
|
* The saved form of a registered class instance: its own data (see
|
|
168
|
-
* ownData), or for a
|
|
169
|
-
* with the built-in methods, which a
|
|
208
|
+
* ownData), or for a subclass of a built-in kind (see BUILTIN_KINDS) its
|
|
209
|
+
* kind, contents and extra keys, read with the built-in methods, which a
|
|
210
|
+
* subclass may override (#391, #393).
|
|
170
211
|
*/
|
|
171
212
|
function classData(value: object): unknown {
|
|
172
213
|
const extra = () => ownData(value, extraKeys(value));
|
|
@@ -179,10 +220,18 @@ function classData(value: object): unknown {
|
|
|
179
220
|
return ['Array', items, extra()];
|
|
180
221
|
}
|
|
181
222
|
if (value instanceof Map) {
|
|
182
|
-
return ['Map', Array.from(
|
|
223
|
+
return ['Map', Array.from(mapEntries(value)), extra()];
|
|
183
224
|
}
|
|
184
225
|
if (value instanceof Set) {
|
|
185
|
-
return ['Set', Array.from(
|
|
226
|
+
return ['Set', Array.from(setMembers(value)), extra()];
|
|
227
|
+
}
|
|
228
|
+
if (value instanceof Date) {
|
|
229
|
+
return ['Date', Date.prototype.getTime.call(value), extra()];
|
|
230
|
+
}
|
|
231
|
+
if (value instanceof RegExp) {
|
|
232
|
+
const source = Reflect.get(RegExp.prototype, 'source', value) as string;
|
|
233
|
+
const flags = Reflect.get(RegExp.prototype, 'flags', value) as string;
|
|
234
|
+
return ['RegExp', [source, flags, value.lastIndex], extra()];
|
|
186
235
|
}
|
|
187
236
|
return ownData(value);
|
|
188
237
|
}
|
|
@@ -241,33 +290,64 @@ function reducers(): Record<string, (value: unknown) => unknown> {
|
|
|
241
290
|
}
|
|
242
291
|
|
|
243
292
|
/**
|
|
244
|
-
* The
|
|
245
|
-
*
|
|
246
|
-
|
|
247
|
-
|
|
293
|
+
* The RegExp of a `[source, flags, lastIndex]` list (an `R` entry, or the
|
|
294
|
+
* contents of a RegExp subclass), or undefined if the list is malformed.
|
|
295
|
+
*/
|
|
296
|
+
function newRegExp(data: unknown): RegExp | undefined {
|
|
297
|
+
if (
|
|
298
|
+
!Array.isArray(data) ||
|
|
299
|
+
typeof data[0] !== 'string' ||
|
|
300
|
+
typeof data[1] !== 'string' ||
|
|
301
|
+
!Number.isSafeInteger(data[2])
|
|
302
|
+
) {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const re = new RegExp(data[0], data[1]);
|
|
306
|
+
re.lastIndex = data[2] as number;
|
|
307
|
+
return re;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A new Date or RegExp holding `contents` (see classData), or undefined if
|
|
312
|
+
* they are malformed.
|
|
313
|
+
*/
|
|
314
|
+
function newAtomic(kind: 'Date' | 'RegExp', contents: unknown) {
|
|
315
|
+
if (kind === 'RegExp') return newRegExp(contents);
|
|
316
|
+
return typeof contents === 'number' ? new Date(contents) : undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The built-in of a registered class that a `[kind, contents, keys]` list
|
|
321
|
+
* holds (see classData). A cycle through a collection revives it before its
|
|
322
|
+
* list is complete (devalue holds unread parts as holes), so the instance
|
|
323
|
+
* is kept per list and filled with what the list holds so far. (The
|
|
324
|
+
* contents of a Date or RegExp hold no objects, so they are complete.)
|
|
248
325
|
*/
|
|
249
326
|
const collections = new WeakMap<unknown[], object>();
|
|
250
|
-
function
|
|
327
|
+
function reviveBuiltin(name: string, ctor: Constructor, list: unknown[]) {
|
|
251
328
|
const [kind, contents, keys] = list;
|
|
252
329
|
const base =
|
|
253
|
-
typeof kind === 'string' && hasOwn(
|
|
254
|
-
?
|
|
330
|
+
typeof kind === 'string' && hasOwn(BUILTIN_KINDS, kind)
|
|
331
|
+
? BUILTIN_KINDS[kind as BuiltinKind]
|
|
255
332
|
: undefined;
|
|
333
|
+
const atomic = kind === 'Date' || kind === 'RegExp';
|
|
256
334
|
if (
|
|
257
335
|
list.length !== 3 ||
|
|
258
336
|
!base ||
|
|
259
337
|
!(ctor.prototype instanceof base) ||
|
|
260
|
-
(hasOwn(list, 1) && !Array.isArray(contents)) ||
|
|
338
|
+
(!atomic && hasOwn(list, 1) && !Array.isArray(contents)) ||
|
|
261
339
|
(hasOwn(list, 2) && !isPlainData(keys))
|
|
262
340
|
) {
|
|
263
341
|
throw malformedClass(name);
|
|
264
342
|
}
|
|
265
343
|
let made = collections.get(list);
|
|
266
344
|
if (!made) {
|
|
267
|
-
|
|
345
|
+
const value = atomic ? newAtomic(kind, contents) : (new base() as object);
|
|
346
|
+
if (!value) throw malformedClass(name);
|
|
347
|
+
made = Object.setPrototypeOf(value, ctor.prototype) as object;
|
|
268
348
|
collections.set(list, made);
|
|
269
349
|
}
|
|
270
|
-
if (Array.isArray(contents)) fillCollection(made, contents);
|
|
350
|
+
if (!atomic && Array.isArray(contents)) fillCollection(made, contents);
|
|
271
351
|
if (isPlainData(keys)) {
|
|
272
352
|
for (const key of Object.keys(keys)) defineData(made, key, keys[key]);
|
|
273
353
|
}
|
|
@@ -300,7 +380,7 @@ function fillCollection(made: object, contents: unknown[]): void {
|
|
|
300
380
|
function reviveClass(name: string) {
|
|
301
381
|
return (data: Record<string, unknown> | unknown[]): unknown => {
|
|
302
382
|
if (Array.isArray(data)) {
|
|
303
|
-
return
|
|
383
|
+
return reviveBuiltin(name, registeredCtor(name), data);
|
|
304
384
|
}
|
|
305
385
|
if (!isPlainData(data)) {
|
|
306
386
|
// Already revived: a cycle through the instance revives it twice
|
|
@@ -308,10 +388,8 @@ function reviveClass(name: string) {
|
|
|
308
388
|
throw malformedClass(name);
|
|
309
389
|
}
|
|
310
390
|
const ctor = registeredCtor(name);
|
|
311
|
-
// A plain object cannot be made a
|
|
312
|
-
if (
|
|
313
|
-
Object.values(COLLECTION_KINDS).some((c) => ctor.prototype instanceof c)
|
|
314
|
-
) {
|
|
391
|
+
// A plain object cannot be made a built-in (see classData)
|
|
392
|
+
if (Object.values(BUILTIN_KINDS).some((c) => ctor.prototype instanceof c)) {
|
|
315
393
|
throw malformedClass(name);
|
|
316
394
|
}
|
|
317
395
|
Object.setPrototypeOf(data, ctor.prototype as object);
|
|
@@ -391,16 +469,8 @@ function reviverFor(key: string | symbol) {
|
|
|
391
469
|
if (key === KEYS_TAG) return reviveKeyed;
|
|
392
470
|
if (key === REGEXP_TAG) {
|
|
393
471
|
return (data: unknown) => {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
typeof data[0] !== 'string' ||
|
|
397
|
-
typeof data[1] !== 'string' ||
|
|
398
|
-
!Number.isSafeInteger(data[2])
|
|
399
|
-
) {
|
|
400
|
-
throw new TypeError('spindle: Malformed regular expression');
|
|
401
|
-
}
|
|
402
|
-
const re = new RegExp(data[0], data[1]);
|
|
403
|
-
re.lastIndex = data[2] as number;
|
|
472
|
+
const re = newRegExp(data);
|
|
473
|
+
if (!re) throw new TypeError('spindle: Malformed regular expression');
|
|
404
474
|
return re;
|
|
405
475
|
};
|
|
406
476
|
}
|
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
} from 'preact/hooks';
|
|
8
8
|
import { parseMarkup } from '../markup/parse';
|
|
9
9
|
import type { ASTNode } from '../markup/ast';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
renderNodes,
|
|
12
|
+
NobrContext,
|
|
13
|
+
ViewScopeContext,
|
|
14
|
+
useViewScope,
|
|
15
|
+
} from '../markup/render';
|
|
11
16
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
12
17
|
import type { Passage as PassageData } from '../parser';
|
|
13
18
|
import { sourceLocationOf } from '../utils/source-location';
|
|
@@ -130,20 +135,23 @@ export function Passage({
|
|
|
130
135
|
}, [passage.name, navigationId]);
|
|
131
136
|
|
|
132
137
|
const nobr = passage.tags.includes('nobr');
|
|
138
|
+
const viewScope = useViewScope();
|
|
133
139
|
|
|
134
140
|
const inner = (
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
<ViewScopeContext.Provider value={viewScope}>
|
|
142
|
+
<div
|
|
143
|
+
ref={elRef}
|
|
144
|
+
class="passage"
|
|
145
|
+
data-passage={passage.name}
|
|
146
|
+
data-tags={passage.tags.join(' ')}
|
|
147
|
+
data-transition={dataTransition}
|
|
148
|
+
>
|
|
149
|
+
{headerContent && <div class="passage-header">{headerContent}</div>}
|
|
150
|
+
{content}
|
|
151
|
+
{footerContent && <div class="passage-footer">{footerContent}</div>}
|
|
152
|
+
{doneContent && <div hidden>{doneContent}</div>}
|
|
153
|
+
</div>
|
|
154
|
+
</ViewScopeContext.Provider>
|
|
147
155
|
);
|
|
148
156
|
|
|
149
157
|
return nobr ? (
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
2
|
import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
|
|
3
3
|
import { parseMarkup } from '../markup/parse';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
renderNodes,
|
|
6
|
+
NobrContext,
|
|
7
|
+
ViewScopeContext,
|
|
8
|
+
useViewScope,
|
|
9
|
+
} from '../markup/render';
|
|
5
10
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
6
11
|
import { emitFromRender } from '../event-emitter';
|
|
7
12
|
import { useModalFocus } from '../hooks/use-modal-focus';
|
|
@@ -66,6 +71,7 @@ export function PassageDialog({
|
|
|
66
71
|
}, [markup, nobr]);
|
|
67
72
|
|
|
68
73
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const viewScope = useViewScope();
|
|
69
75
|
|
|
70
76
|
// Focus into the dialog, trap Tab, Escape to close, restore focus on close.
|
|
71
77
|
// Declared before the dialogrender effect so handlers can move focus.
|
|
@@ -113,7 +119,11 @@ export function PassageDialog({
|
|
|
113
119
|
✕
|
|
114
120
|
</button>
|
|
115
121
|
)}
|
|
116
|
-
<div class="dialog-body">
|
|
122
|
+
<div class="dialog-body">
|
|
123
|
+
<ViewScopeContext.Provider value={viewScope}>
|
|
124
|
+
{content}
|
|
125
|
+
</ViewScopeContext.Provider>
|
|
126
|
+
</div>
|
|
117
127
|
</div>
|
|
118
128
|
</div>
|
|
119
129
|
</DialogCloseContext.Provider>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import { ViewScopeContext } from '../../markup/render';
|
|
2
3
|
import { VARIABLE_PARAMETER, useVariableAction } from './input-macro';
|
|
3
4
|
|
|
4
5
|
defineMacro({
|
|
@@ -12,6 +13,8 @@ defineMacro({
|
|
|
12
13
|
render(_props, ctx) {
|
|
13
14
|
const radioValue = ctx.args.value ?? '';
|
|
14
15
|
const label = ctx.args.label ?? '';
|
|
16
|
+
// One group per variable in each view (see ViewScopeContext)
|
|
17
|
+
const view = ctx.hooks.useContext(ViewScopeContext);
|
|
15
18
|
|
|
16
19
|
useVariableAction(ctx, {
|
|
17
20
|
type: 'radiobutton',
|
|
@@ -27,7 +30,7 @@ defineMacro({
|
|
|
27
30
|
>
|
|
28
31
|
<input
|
|
29
32
|
type="radio"
|
|
30
|
-
name={`radio-${ctx.varName}`}
|
|
33
|
+
name={view ? `radio-${view}-${ctx.varName}` : `radio-${ctx.varName}`}
|
|
31
34
|
checked={ctx.value === radioValue}
|
|
32
35
|
onChange={() => ctx.setValue!(radioValue)}
|
|
33
36
|
/>
|
package/src/execute-mutation.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from './structural';
|
|
27
27
|
import { getByPath, setByPath } from './utils/object-path';
|
|
28
28
|
import { asNamespace, hasOwn } from './utils/namespace';
|
|
29
|
+
import { mapEntries, setMembers } from './utils/value-kinds';
|
|
29
30
|
|
|
30
31
|
type NamespaceName = keyof VariableNamespaces;
|
|
31
32
|
|
|
@@ -150,11 +151,11 @@ function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
|
|
|
150
151
|
seen.add(value);
|
|
151
152
|
// What a Map or Set holds is not in its properties (#388)
|
|
152
153
|
if (value instanceof Map) {
|
|
153
|
-
for (const [key, entry] of value) {
|
|
154
|
+
for (const [key, entry] of mapEntries(value)) {
|
|
154
155
|
if (!deeplyFrozen(key, seen) || !deeplyFrozen(entry, seen)) return false;
|
|
155
156
|
}
|
|
156
157
|
} else if (value instanceof Set) {
|
|
157
|
-
for (const member of value) {
|
|
158
|
+
for (const member of setMembers(value)) {
|
|
158
159
|
if (!deeplyFrozen(member, seen)) return false;
|
|
159
160
|
}
|
|
160
161
|
}
|
package/src/markup/render.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, type RefObject } from 'preact';
|
|
2
|
-
import { useContext, useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
|
+
import { useContext, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import { VarDisplay } from '../components/macros/VarDisplay';
|
|
4
4
|
import { ExprDisplay } from '../components/macros/ExprDisplay';
|
|
5
5
|
import { WidgetInvocation } from '../components/macros/WidgetInvocation';
|
|
@@ -58,6 +58,22 @@ export const InlineContext = createContext(false);
|
|
|
58
58
|
*/
|
|
59
59
|
export const RawTextContext = createContext(false);
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The view content is rendered in: '' for the story interface, and an id of
|
|
63
|
+
* its own for each passage and dialog on display (see useViewScope). The
|
|
64
|
+
* radiobuttons of a variable form a native group within their view only:
|
|
65
|
+
* one group across views would let a dialog's checked button uncheck the
|
|
66
|
+
* passage's when it mounts (#396).
|
|
67
|
+
*/
|
|
68
|
+
export const ViewScopeContext = createContext('');
|
|
69
|
+
|
|
70
|
+
let viewScopes = 0;
|
|
71
|
+
|
|
72
|
+
/** A new view scope (see ViewScopeContext), kept while mounted. */
|
|
73
|
+
export function useViewScope(): string {
|
|
74
|
+
return useState(() => `v${++viewScopes}`)[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
/**
|
|
62
78
|
* True inside an `<svg>` element. SVG attributes are case-sensitive and have
|
|
63
79
|
* no live form properties, so they are set as written (see splitAttributes).
|
package/src/markup/spindle.peggy
CHANGED
|
@@ -31,6 +31,11 @@ const BRANCH_PARENT = { elseif: 'if', else: 'if', case: 'switch', default: 'swit
|
|
|
31
31
|
const BRANCHING = new Set(['if', 'switch', 'timed']);
|
|
32
32
|
const LINK_SEPARATORS = [['|', false], ['->', false], ['<-', true]];
|
|
33
33
|
const NO_SELECTORS = {};
|
|
34
|
+
/**
|
|
35
|
+
* A CommonMark email autolink, `<` an address `>` (without braces, which
|
|
36
|
+
* are markup), as markdown reads one.
|
|
37
|
+
*/
|
|
38
|
+
const EMAIL_AUTOLINK = /^<[a-zA-Z0-9.!#$%&'*+/=?^_`|~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*>$/;
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* A link's display text and target, from its text `inner` (at `at` in the
|
|
@@ -235,6 +240,7 @@ Item
|
|
|
235
240
|
/ Escape
|
|
236
241
|
/ Comment
|
|
237
242
|
/ LinkLexeme
|
|
243
|
+
/ EmailAutolink
|
|
238
244
|
/ HtmlElement
|
|
239
245
|
/ VoidCloser
|
|
240
246
|
/ Value
|
|
@@ -294,6 +300,7 @@ TokenItem
|
|
|
294
300
|
/ Escape
|
|
295
301
|
/ Comment
|
|
296
302
|
/ LinkLexeme
|
|
303
|
+
/ EmailAutolink
|
|
297
304
|
/ &{ return !textMode; } tag:HtmlOpen { return tag; }
|
|
298
305
|
/ &{ return !textMode; } tag:HtmlClose { return VOID_TAGS.has(tag.lower) ? null : tag; }
|
|
299
306
|
/ Value
|
|
@@ -429,6 +436,15 @@ MacroName
|
|
|
429
436
|
|
|
430
437
|
// -------------------------------------------------------------------- HTML
|
|
431
438
|
|
|
439
|
+
/**
|
|
440
|
+
* An email autolink (`<support@example.com>`), text that markdown makes a
|
|
441
|
+
* link: not an element `<support>` with an attribute `@example.com` (#395).
|
|
442
|
+
*/
|
|
443
|
+
EmailAutolink
|
|
444
|
+
= value:$("<" [a-zA-Z0-9.!#$%&'*+/=?^_`|~-]+ "@" [a-zA-Z0-9.-]+ ">")
|
|
445
|
+
&{ return EMAIL_AUTOLINK.test(value); }
|
|
446
|
+
{ return lexeme('text', location(), { value }); }
|
|
447
|
+
|
|
432
448
|
/** A redundant closer of a void element (</br>), which is dropped. */
|
|
433
449
|
VoidCloser
|
|
434
450
|
= &{ return !textMode; } tag:HtmlClose &{ return VOID_TAGS.has(tag.lower); } { return null; }
|
package/src/store.ts
CHANGED
|
@@ -444,6 +444,16 @@ function startVariables(defaults: Record<string, unknown>): Namespace {
|
|
|
444
444
|
return initialVars;
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Fresh transient variables from their declared defaults (init, restart,
|
|
449
|
+
* load). Transients are never saved, so an instance of an unregistered
|
|
450
|
+
* class (an engine, a library object) is kept as it is, as mutation code
|
|
451
|
+
* keeps it (#321): a copy would lose its class and methods (#398).
|
|
452
|
+
*/
|
|
453
|
+
function freshTransients(defaults: Record<string, unknown>): Namespace {
|
|
454
|
+
return createNamespace(deepClone(defaults, { keepUnregistered: true }));
|
|
455
|
+
}
|
|
456
|
+
|
|
447
457
|
/**
|
|
448
458
|
* Enter the start passage with `variables`, as the only history moment
|
|
449
459
|
* (init, restart). Call it inside a store update.
|
|
@@ -457,7 +467,7 @@ function enterStart(
|
|
|
457
467
|
state.currentPassage = passage;
|
|
458
468
|
state.navigationId++;
|
|
459
469
|
state.variables = variables;
|
|
460
|
-
state.transient =
|
|
470
|
+
state.transient = freshTransients(transientDefaults);
|
|
461
471
|
state.temporary = createNamespace();
|
|
462
472
|
state.history = [{ passage, timestamp: Date.now() }];
|
|
463
473
|
state.historyIndex = 0;
|
|
@@ -1719,7 +1729,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
1719
1729
|
state.visitCounts = createCounts(payload.visitCounts);
|
|
1720
1730
|
state.renderCounts = createCounts(payload.renderCounts);
|
|
1721
1731
|
state.temporary = createNamespace();
|
|
1722
|
-
state.transient =
|
|
1732
|
+
state.transient = freshTransients(get().transientDefaults);
|
|
1723
1733
|
});
|
|
1724
1734
|
|
|
1725
1735
|
// The watchers of the loaded game (a payload without any, from an
|