@rohal12/spindle 0.52.7 → 0.52.9

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 (47) hide show
  1. package/README.md +26 -0
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +986 -854
  4. package/dist/pkg/macro-registry.json +18 -0
  5. package/package.json +1 -1
  6. package/src/action-registry.ts +9 -0
  7. package/src/class-registry.ts +1 -5
  8. package/src/components/Passage.tsx +35 -39
  9. package/src/components/PassageDialog.tsx +2 -2
  10. package/src/components/StoryInterface.tsx +2 -2
  11. package/src/components/macros/Checkbox.tsx +3 -8
  12. package/src/components/macros/Computed.tsx +1 -1
  13. package/src/components/macros/Cycle.tsx +3 -6
  14. package/src/components/macros/ExprDisplay.tsx +12 -29
  15. package/src/components/macros/If.tsx +35 -41
  16. package/src/components/macros/Include.tsx +2 -1
  17. package/src/components/macros/Listbox.tsx +5 -7
  18. package/src/components/macros/Print.tsx +3 -5
  19. package/src/components/macros/Radiobutton.tsx +3 -5
  20. package/src/components/macros/Repeat.tsx +2 -1
  21. package/src/components/macros/Switch.tsx +6 -13
  22. package/src/components/macros/Timed.tsx +14 -18
  23. package/src/components/macros/Type.tsx +2 -1
  24. package/src/components/macros/VarDisplay.tsx +12 -32
  25. package/src/components/macros/Widget.tsx +49 -20
  26. package/src/components/macros/branches.ts +18 -0
  27. package/src/components/macros/display.ts +19 -0
  28. package/src/components/macros/input-macro.ts +33 -11
  29. package/src/components/macros/macro-args.ts +7 -0
  30. package/src/components/macros/option-utils.ts +10 -7
  31. package/src/define-macro.ts +4 -6
  32. package/src/expression.ts +58 -40
  33. package/src/hooks/use-action.ts +7 -15
  34. package/src/index.tsx +5 -15
  35. package/src/interpolation.ts +34 -41
  36. package/src/markup/ast.ts +4 -3
  37. package/src/markup/render.tsx +121 -65
  38. package/src/parser.ts +119 -13
  39. package/src/registry.ts +13 -12
  40. package/src/saves/save-manager.ts +18 -13
  41. package/src/saves/storage.ts +19 -20
  42. package/src/story-api.ts +30 -73
  43. package/src/structural.ts +1 -18
  44. package/src/types-drift-check.ts +37 -50
  45. package/src/utils/macro-names.ts +40 -0
  46. package/src/utils/namespace.ts +17 -0
  47. package/src/widgets/widget-registry.ts +5 -4
package/src/registry.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType } from 'preact';
2
2
  import type { ASTNode, Branch } from './markup/ast';
3
+ import { NameMap, NameSet } from './utils/macro-names';
3
4
 
4
5
  export interface MacroProps {
5
6
  rawArgs: string;
@@ -9,17 +10,17 @@ export interface MacroProps {
9
10
  branches?: Branch[];
10
11
  }
11
12
 
12
- const registry = new Map<string, ComponentType<MacroProps>>();
13
+ const registry = new NameMap<ComponentType<MacroProps>>();
13
14
 
14
15
  export function registerMacro(
15
16
  name: string,
16
17
  component: ComponentType<MacroProps>,
17
18
  ): void {
18
- registry.set(name.toLowerCase(), component);
19
+ registry.set(name, component);
19
20
  }
20
21
 
21
22
  export function getMacro(name: string): ComponentType<MacroProps> | undefined {
22
- return registry.get(name.toLowerCase());
23
+ return registry.get(name);
23
24
  }
24
25
 
25
26
  /**
@@ -42,29 +43,29 @@ export type MacroTextRenderer = (
42
43
  ctx: MacroTextContext,
43
44
  ) => string;
44
45
 
45
- const textRegistry = new Map<string, MacroTextRenderer>();
46
+ const textRegistry = new NameMap<MacroTextRenderer>();
46
47
 
47
48
  /** Register (or, with undefined, remove) the text form of a macro. */
48
49
  export function registerMacroText(
49
50
  name: string,
50
51
  text: MacroTextRenderer | undefined,
51
52
  ): void {
52
- if (text) textRegistry.set(name.toLowerCase(), text);
53
- else textRegistry.delete(name.toLowerCase());
53
+ if (text) textRegistry.set(name, text);
54
+ else textRegistry.delete(name);
54
55
  }
55
56
 
56
57
  export function getMacroText(name: string): MacroTextRenderer | undefined {
57
- return textRegistry.get(name.toLowerCase());
58
+ return textRegistry.get(name);
58
59
  }
59
60
 
60
- const subMacros = new Set<string>();
61
+ const subMacros = new NameSet();
61
62
 
62
63
  export function registerSubMacro(name: string): void {
63
- subMacros.add(name.toLowerCase());
64
+ subMacros.add(name);
64
65
  }
65
66
 
66
67
  export function isSubMacro(name: string): boolean {
67
- return subMacros.has(name.toLowerCase());
68
+ return subMacros.has(name);
68
69
  }
69
70
 
70
71
  /**
@@ -133,13 +134,13 @@ export interface MacroMetadata {
133
134
  parameters?: ParameterDef[];
134
135
  }
135
136
 
136
- const metadataRegistry = new Map<string, MacroMetadata>();
137
+ const metadataRegistry = new NameMap<MacroMetadata>();
137
138
 
138
139
  export function registerMacroMetadata(
139
140
  name: string,
140
141
  metadata: MacroMetadata,
141
142
  ): void {
142
- metadataRegistry.set(name.toLowerCase(), metadata);
143
+ metadataRegistry.set(name, metadata);
143
144
  }
144
145
 
145
146
  export function getMacroRegistry(): MacroMetadata[] {
@@ -9,7 +9,8 @@ import type {
9
9
  } from './types';
10
10
  import { estimatePayloadBytes, isSaveExport, isSavePayload } from './types';
11
11
  import { getBackend, resetBackend } from './storage';
12
- import { deepClone, serialize, deserialize } from '../class-registry';
12
+ import { serialize, deserialize } from '../class-registry';
13
+ import { deepClone } from '../structural';
13
14
  import { emit } from '../event-emitter';
14
15
  import { withoutDraws } from '../prng';
15
16
 
@@ -101,6 +102,18 @@ function queued<A extends unknown[], R>(
101
102
  return (...args) => inOrder(() => op(...args));
102
103
  }
103
104
 
105
+ /** An operation passing the save `saveId`, if it is stored, to `read`. */
106
+ const readingSave = <R>(read: (record: SaveRecord | undefined) => R) =>
107
+ queued(async (saveId: string) =>
108
+ read(await (await getBackend()).getSave(saveId)),
109
+ );
110
+
111
+ /** An operation passing the save a slot holds, if any, to `read`. */
112
+ const readingSlot = <R>(read: (record: SaveRecord | undefined) => R) =>
113
+ queued(async (ifid: string, slot?: string) =>
114
+ read(await slotRecord(ifid, slot)),
115
+ );
116
+
104
117
  // --- Playthroughs ---
105
118
 
106
119
  /**
@@ -371,9 +384,7 @@ export function deserializePayload(payload: SavePayload): SavePayload {
371
384
  const livePayload = (record: SaveRecord | undefined) =>
372
385
  record && deserializePayload(record.payload);
373
386
 
374
- export const loadSave = queued(async (saveId: string) =>
375
- livePayload(await (await getBackend()).getSave(saveId)),
376
- );
387
+ export const loadSave = readingSave(livePayload);
377
388
 
378
389
  /**
379
390
  * Delete a save record. If the default slot or a named slot holds it, that
@@ -623,9 +634,7 @@ async function hasQuickSaveNow(ifid: string, slot?: string): Promise<boolean> {
623
634
  return (await slotRecord(ifid, slot)) !== undefined;
624
635
  }
625
636
 
626
- export const loadQuickSave = queued(async (ifid: string, slot?: string) =>
627
- livePayload(await slotRecord(ifid, slot)),
628
- );
637
+ export const loadQuickSave = readingSlot(livePayload);
629
638
 
630
639
  /**
631
640
  * Read the save held in a slot (the default slot when `slot` is omitted) for
@@ -753,9 +762,7 @@ function toExport(record: SaveRecord | undefined): SaveExport | undefined {
753
762
  };
754
763
  }
755
764
 
756
- export const exportSave = queued(async (saveId: string) =>
757
- toExport(await (await getBackend()).getSave(saveId)),
758
- );
765
+ export const exportSave = readingSave(toExport);
759
766
 
760
767
  /**
761
768
  * Validate an export against the running story and build the record to store:
@@ -812,9 +819,7 @@ export function importSave(
812
819
  * Export the save held in a slot (default autosave slot when `slot` is omitted).
813
820
  * Returns undefined if the slot is empty.
814
821
  */
815
- export const exportSlotSave = queued(async (ifid: string, slot?: string) =>
816
- toExport(await slotRecord(ifid, slot)),
817
- );
822
+ export const exportSlotSave = readingSlot(toExport);
818
823
 
819
824
  /**
820
825
  * Import an exported save into a slot (default autosave slot when `slot` is
@@ -256,6 +256,19 @@ export function createLocalStorageBackend(): StorageBackend {
256
256
  const IDB_DB_NAME = 'spindle';
257
257
  const IDB_DB_VERSION = 1;
258
258
 
259
+ /** Each object store's key path, and the key path of each of its indexes. */
260
+ const IDB_STORES: Record<
261
+ TableName,
262
+ { keyPath: string; indexes: Record<string, string> }
263
+ > = {
264
+ saves: {
265
+ keyPath: 'meta.id',
266
+ indexes: { ifid: 'meta.ifid', playthroughId: 'meta.playthroughId' },
267
+ },
268
+ playthroughs: { keyPath: 'id', indexes: { ifid: 'ifid' } },
269
+ meta: { keyPath: 'key', indexes: {} },
270
+ };
271
+
259
272
  function createIDBBackend(): StorageBackend {
260
273
  let dbPromise: Promise<IDBDatabase> | null = null;
261
274
 
@@ -267,26 +280,12 @@ function createIDBBackend(): StorageBackend {
267
280
 
268
281
  req.onupgradeneeded = () => {
269
282
  const db = req.result;
270
-
271
- if (!db.objectStoreNames.contains('saves')) {
272
- const savesStore = db.createObjectStore('saves', {
273
- keyPath: 'meta.id',
274
- });
275
- savesStore.createIndex('ifid', 'meta.ifid', { unique: false });
276
- savesStore.createIndex('playthroughId', 'meta.playthroughId', {
277
- unique: false,
278
- });
279
- }
280
-
281
- if (!db.objectStoreNames.contains('playthroughs')) {
282
- const ptStore = db.createObjectStore('playthroughs', {
283
- keyPath: 'id',
284
- });
285
- ptStore.createIndex('ifid', 'ifid', { unique: false });
286
- }
287
-
288
- if (!db.objectStoreNames.contains('meta')) {
289
- db.createObjectStore('meta', { keyPath: 'key' });
283
+ for (const [name, { keyPath, indexes }] of Object.entries(IDB_STORES)) {
284
+ if (db.objectStoreNames.contains(name)) continue;
285
+ const store = db.createObjectStore(name, { keyPath });
286
+ for (const [index, path] of Object.entries(indexes)) {
287
+ store.createIndex(index, path, { unique: false });
288
+ }
290
289
  }
291
290
  };
292
291
 
package/src/story-api.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useStoryStore, trackRuntimeUnsub } from './store';
2
- import type { VariableNamespaces } from './store';
2
+ import type { StoryState, VariableNamespaces } from './store';
3
3
  import {
4
4
  on as emitterOn,
5
5
  emit,
@@ -24,7 +24,7 @@ import { registerClass } from './class-registry';
24
24
  import { frozenCopy, getActiveMutationScope } from './execute-mutation';
25
25
  import { getByPath, setByPath } from './utils/object-path';
26
26
  import { changedNames, checkVariableName, ownValue } from './utils/namespace';
27
- import { countQueries, currentPassage, previousPassage } from './expression';
27
+ import { historyQueries } from './expression';
28
28
  import { defineMacro } from './define-macro';
29
29
  import type { MacroDefinition } from './define-macro';
30
30
  import { getMacroRegistry as _getMacroRegistry } from './registry';
@@ -259,9 +259,15 @@ function setOne(draft: VariableNamespaces, name: string, value: unknown): void {
259
259
  }
260
260
  }
261
261
 
262
+ /** A method calling the store action `name`, as the store holds it then. */
263
+ function storeAction<K extends keyof StoryState>(name: K): StoryState[K] {
264
+ return ((...args: unknown[]) =>
265
+ (useStoryStore.getState()[name] as (...args: unknown[]) => unknown)(
266
+ ...args,
267
+ )) as StoryState[K];
268
+ }
269
+
262
270
  function createStoryAPI(): StoryAPI {
263
- const visits = countQueries(() => useStoryStore.getState().visitCounts);
264
- const renders = countQueries(() => useStoryStore.getState().renderCounts);
265
271
  return {
266
272
  get(name: string): unknown {
267
273
  const { isTransient, key } = parseName(name);
@@ -300,64 +306,23 @@ function createStoryAPI(): StoryAPI {
300
306
  // Called from running mutation code, these store actions commit the
301
307
  // code's writes so far before they record, replace or save state, and
302
308
  // the code goes on from the state they leave (see storyStateGuard).
303
- goto(passageName: string): void {
304
- useStoryStore.getState().navigate(passageName);
305
- },
306
-
307
- back(): void {
308
- useStoryStore.getState().goBack();
309
- },
310
-
311
- forward(): void {
312
- useStoryStore.getState().goForward();
313
- },
314
-
315
- restart(): void {
316
- useStoryStore.getState().restart();
317
- },
318
-
319
- save(slot?: string, custom?: Record<string, unknown>): Promise<void> {
320
- return useStoryStore.getState().save(slot, custom);
321
- },
322
-
323
- load(slot?: string): Promise<void> {
324
- return useStoryStore.getState().load(slot);
325
- },
326
-
327
- hasSave(slot?: string): boolean {
328
- return useStoryStore.getState().hasSave(slot);
329
- },
330
-
331
- getSaveInfo(slot?: string): Promise<SaveInfo | null> {
332
- return useStoryStore.getState().getSaveInfo(slot);
333
- },
334
-
335
- listSaves(): Promise<SaveInfo[]> {
336
- return useStoryStore.getState().listSaves();
337
- },
338
-
339
- deleteSave(slot?: string): Promise<void> {
340
- return useStoryStore.getState().deleteSave(slot);
341
- },
342
-
343
- exportSave(slot?: string): Promise<SaveExport | null> {
344
- return useStoryStore.getState().exportSave(slot);
345
- },
346
-
347
- importSave(data: unknown, slot?: string): Promise<SaveInfo> {
348
- return useStoryStore.getState().importSave(data, slot);
349
- },
350
-
351
- visited: visits.count,
352
- hasVisited: visits.has,
353
- hasVisitedAny: visits.any,
354
- hasVisitedAll: visits.all,
355
- rendered: renders.count,
356
- hasRendered: renders.has,
357
- hasRenderedAny: renders.any,
358
- hasRenderedAll: renders.all,
359
- currentPassage,
360
- previousPassage,
309
+ goto: storeAction('navigate'),
310
+ back: storeAction('goBack'),
311
+ forward: storeAction('goForward'),
312
+ restart: storeAction('restart'),
313
+ save: storeAction('save'),
314
+ load: storeAction('load'),
315
+ hasSave: storeAction('hasSave'),
316
+ getSaveInfo: storeAction('getSaveInfo'),
317
+ listSaves: storeAction('listSaves'),
318
+ deleteSave: storeAction('deleteSave'),
319
+ exportSave: storeAction('exportSave'),
320
+ importSave: storeAction('importSave'),
321
+
322
+ ...historyQueries(
323
+ () => useStoryStore.getState().visitCounts,
324
+ () => useStoryStore.getState().renderCounts,
325
+ ),
361
326
 
362
327
  get title(): string {
363
328
  return useStoryStore.getState().storyData?.name || '';
@@ -416,17 +381,9 @@ function createStoryAPI(): StoryAPI {
416
381
  return { usage: 0, quota: 0, estimateSupported: false };
417
382
  },
418
383
 
419
- clearGameData(): Promise<void> {
420
- return useStoryStore.getState().clearGameData();
421
- },
422
-
423
- clearAllData(): Promise<void> {
424
- return useStoryStore.getState().clearAllData();
425
- },
426
-
427
- deletePlaythrough(playthroughId: string): Promise<void> {
428
- return useStoryStore.getState().deletePlaythrough(playthroughId);
429
- },
384
+ clearGameData: storeAction('clearGameData'),
385
+ clearAllData: storeAction('clearAllData'),
386
+ deletePlaythrough: storeAction('deletePlaythrough'),
430
387
 
431
388
  get backend() {
432
389
  return getBackendType();
package/src/structural.ts CHANGED
@@ -4,26 +4,9 @@
4
4
  // what a change is.
5
5
 
6
6
  import { registeredClassName } from './class-registry';
7
- import { hasOwn } from './utils/namespace';
7
+ import { hasOwn, setOwn } from './utils/namespace';
8
8
  import { deleteByPath, getByPath, setByPath } from './utils/object-path';
9
9
 
10
- /**
11
- * Set an own enumerable property. A "__proto__" key is defined as an own
12
- * property (as JSON.parse does) instead of replacing the prototype.
13
- */
14
- export function setOwn(target: object, key: string, value: unknown): void {
15
- if (key === '__proto__') {
16
- Object.defineProperty(target, key, {
17
- value,
18
- enumerable: true,
19
- writable: true,
20
- configurable: true,
21
- });
22
- } else {
23
- (target as Record<string, unknown>)[key] = value;
24
- }
25
- }
26
-
27
10
  // --- Deep Clone ---
28
11
 
29
12
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -1,62 +1,61 @@
1
1
  /**
2
- * Compile-time check: the hand-written types/index.d.ts must stay in sync
3
- * with the source StoryAPI interface. If this file fails to compile,
4
- * the published types have drifted from the implementation.
2
+ * Compile-time check: the hand-written published types (types/*.d.ts) must
3
+ * stay in sync with the source they describe. If this file fails to
4
+ * compile, the published types have drifted from the implementation.
5
5
  *
6
6
  * Run: npx tsc --noEmit
7
7
  */
8
8
  import type { StoryAPI as SourceAPI } from './story-api';
9
- import type { StoryAPI as PublishedAPI } from '../types/index';
10
-
11
- // Both directions — if either fails, the types have drifted.
12
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
13
- const _sourceToPublished: PublishedAPI = {} as SourceAPI;
14
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
- const _publishedToSource: SourceAPI = {} as PublishedAPI;
16
-
17
- // Tooling entry point (`@rohal12/spindle/tooling`): types/tooling.d.ts must
18
- // match the parser that dist/pkg/tooling.js re-exports.
19
9
  import type { parseStoryVariables as SourceParse } from './story-variables';
20
- import type { parseStoryVariables as PublishedParse } from '../types/tooling';
21
-
22
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
23
- const _parseSourceToPublished: typeof PublishedParse = {} as typeof SourceParse;
24
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
25
- const _parsePublishedToSource: typeof SourceParse = {} as typeof PublishedParse;
26
-
27
- // Custom macro API: the published MacroContext/MacroDefinition must match the
28
- // ones defineMacro() actually passes and accepts.
29
10
  import type {
30
11
  MacroContext as SourceMacroContext,
31
12
  MacroDefinition as SourceMacroDefinition,
32
13
  } from './define-macro';
33
14
  import type { MacroProps as SourceMacroProps } from './registry';
34
15
  import type { ASTNode as SourceASTNode } from './markup/ast';
16
+ import type { bootStory as SourceBootStory } from './headless';
35
17
  import type {
18
+ StoryAPI as PublishedAPI,
36
19
  MacroContext as PublishedMacroContext,
37
20
  MacroDefinition as PublishedMacroDefinition,
38
21
  MacroProps as PublishedMacroProps,
39
22
  ASTNode as PublishedASTNode,
40
23
  } from '../types/index';
24
+ import type { parseStoryVariables as PublishedParse } from '../types/tooling';
25
+ import type { bootStory as PublishedBootStory } from '../types/headless';
41
26
 
27
+ /** What the source declares, by published name. */
28
+ interface Source {
29
+ StoryAPI: SourceAPI;
30
+ // Tooling entry point (`@rohal12/spindle/tooling`): the parser that
31
+ // dist/pkg/tooling.js re-exports.
32
+ parseStoryVariables: typeof SourceParse;
33
+ // Custom macro API: the MacroContext/MacroDefinition defineMacro()
34
+ // actually passes and accepts.
35
+ MacroContext: SourceMacroContext;
36
+ MacroDefinition: SourceMacroDefinition;
37
+ MacroProps: SourceMacroProps;
38
+ ASTNode: SourceASTNode;
39
+ // Headless entry point (`@rohal12/spindle/headless`).
40
+ bootStory: typeof SourceBootStory;
41
+ }
42
+
43
+ /** What types/index.d.ts, tooling.d.ts and headless.d.ts publish. */
44
+ interface Published {
45
+ StoryAPI: PublishedAPI;
46
+ parseStoryVariables: typeof PublishedParse;
47
+ MacroContext: PublishedMacroContext;
48
+ MacroDefinition: PublishedMacroDefinition;
49
+ MacroProps: PublishedMacroProps;
50
+ ASTNode: PublishedASTNode;
51
+ bootStory: typeof PublishedBootStory;
52
+ }
53
+
54
+ // Both directions — if either fails, the types have drifted.
42
55
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
43
- const _ctxSourceToPublished: PublishedMacroContext = {} as SourceMacroContext;
44
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
45
- const _ctxPublishedToSource: SourceMacroContext = {} as PublishedMacroContext;
46
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
47
- const _defSourceToPublished: PublishedMacroDefinition =
48
- {} as SourceMacroDefinition;
49
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
50
- const _defPublishedToSource: SourceMacroDefinition =
51
- {} as PublishedMacroDefinition;
52
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
53
- const _propsSourceToPublished: PublishedMacroProps = {} as SourceMacroProps;
54
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
55
- const _propsPublishedToSource: SourceMacroProps = {} as PublishedMacroProps;
56
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
57
- const _astSourceToPublished: PublishedASTNode = {} as SourceASTNode;
56
+ const _sourceToPublished: Published = {} as Source;
58
57
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
59
- const _astPublishedToSource: SourceASTNode = {} as PublishedASTNode;
58
+ const _publishedToSource: Source = {} as Published;
60
59
 
61
60
  // A typical custom macro written against the published types must type-check,
62
61
  // and misuse of the hooks must not (no `any` leaking through).
@@ -91,15 +90,3 @@ const _exampleMacro: PublishedMacroDefinition = {
91
90
  );
92
91
  },
93
92
  };
94
-
95
- // Headless entry point (`@rohal12/spindle/headless`): types/headless.d.ts must
96
- // match src/headless.ts.
97
- import type { bootStory as SourceBootStory } from './headless';
98
- import type { bootStory as PublishedBootStory } from '../types/headless';
99
-
100
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
101
- const _bootSourceToPublished: typeof PublishedBootStory =
102
- {} as typeof SourceBootStory;
103
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
104
- const _bootPublishedToSource: typeof SourceBootStory =
105
- {} as typeof PublishedBootStory;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Macro and widget names are case-insensitive: `{StatusBar}` invokes the
3
+ * widget defined as `statusbar`. A NameMap keys its entries by the lowercased
4
+ * name.
5
+ */
6
+ export class NameMap<V> extends Map<string, V> {
7
+ override get(name: string): V | undefined {
8
+ return super.get(name.toLowerCase());
9
+ }
10
+ override set(name: string, value: V): this {
11
+ return super.set(name.toLowerCase(), value);
12
+ }
13
+ override has(name: string): boolean {
14
+ return super.has(name.toLowerCase());
15
+ }
16
+ override delete(name: string): boolean {
17
+ return super.delete(name.toLowerCase());
18
+ }
19
+ }
20
+
21
+ /** A set of macro or widget names (see NameMap). */
22
+ export class NameSet {
23
+ private readonly names = new NameMap<true>();
24
+
25
+ constructor(names: Iterable<string> = []) {
26
+ for (const name of names) this.add(name);
27
+ }
28
+
29
+ add(name: string): void {
30
+ this.names.set(name, true);
31
+ }
32
+
33
+ has(name: string): boolean {
34
+ return this.names.has(name);
35
+ }
36
+
37
+ delete(name: string): void {
38
+ this.names.delete(name);
39
+ }
40
+ }
@@ -48,6 +48,23 @@ export const ownValue = (
48
48
  key: string,
49
49
  ): unknown => (ns && hasOwn(ns, key) ? (ns as Namespace)[key] : undefined);
50
50
 
51
+ /**
52
+ * Set an own enumerable property. A "__proto__" key is defined as an own
53
+ * property (as JSON.parse does) instead of replacing the prototype.
54
+ */
55
+ export function setOwn(target: object, key: string, value: unknown): void {
56
+ if (key === '__proto__') {
57
+ Object.defineProperty(target, key, {
58
+ value,
59
+ enumerable: true,
60
+ writable: true,
61
+ configurable: true,
62
+ });
63
+ } else {
64
+ (target as Record<string, unknown>)[key] = value;
65
+ }
66
+ }
67
+
51
68
  /**
52
69
  * A record with no prototype holding the own entries of `sources`, or only
53
70
  * those whose value passes `keep`.
@@ -1,5 +1,6 @@
1
1
  import type { ASTNode } from '../markup/ast';
2
2
  import { checkVariableName } from '../utils/namespace';
3
+ import { NameMap } from '../utils/macro-names';
3
4
 
4
5
  interface WidgetEntry {
5
6
  body: ASTNode[];
@@ -7,7 +8,7 @@ interface WidgetEntry {
7
8
  isBlock: boolean;
8
9
  }
9
10
 
10
- const widgets = new Map<string, WidgetEntry>();
11
+ const widgets = new NameMap<WidgetEntry>();
11
12
 
12
13
  /**
13
14
  * Register a widget. Its `@` parameters become locals of its body, so one
@@ -24,7 +25,7 @@ export function registerWidget(
24
25
  for (const param of filteredParams) {
25
26
  if (param.startsWith('@')) checkVariableName(param.slice(1), param);
26
27
  }
27
- widgets.set(name.toLowerCase(), {
28
+ widgets.set(name, {
28
29
  body: bodyAST,
29
30
  params: filteredParams,
30
31
  isBlock,
@@ -32,11 +33,11 @@ export function registerWidget(
32
33
  }
33
34
 
34
35
  export function getWidget(name: string): WidgetEntry | undefined {
35
- return widgets.get(name.toLowerCase());
36
+ return widgets.get(name);
36
37
  }
37
38
 
38
39
  export function isBlockWidget(name: string): boolean {
39
- return widgets.get(name.toLowerCase())?.isBlock ?? false;
40
+ return widgets.get(name)?.isBlock ?? false;
40
41
  }
41
42
 
42
43
  export function clearWidgets(): void {