@rohal12/spindle 0.45.1 → 0.47.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.45.1",
3
+ "version": "0.47.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -88,7 +88,7 @@ export interface MacroDefinition {
88
88
  storeVar?: boolean;
89
89
  description?: string;
90
90
  parameters?: ParameterDef[];
91
- render: (props: MacroProps, ctx: MacroContext) => VNode | null;
91
+ render: (props: MacroProps, ctx: MacroContext) => ComponentChildren;
92
92
  }
93
93
 
94
94
  const sharedHooks = {
package/src/index.tsx CHANGED
@@ -3,7 +3,11 @@ import { App } from './components/App';
3
3
  import { parseStoryData } from './parser';
4
4
  import { useStoryStore, enterRuntimePhase } from './store';
5
5
  import { emit } from './event-emitter';
6
- import { installStoryAPI, getReadyPromise } from './story-api';
6
+ import {
7
+ installStoryAPI,
8
+ getReadyPromise,
9
+ setDeclaredVariables,
10
+ } from './story-api';
7
11
  import { resetIdCounters } from './action-registry';
8
12
  import { executeStoryInit } from './story-init';
9
13
  import { checkTriggers, reinitTriggerState } from './triggers';
@@ -118,6 +122,7 @@ function boot() {
118
122
  }
119
123
 
120
124
  defaults = extractDefaults(schema);
125
+ setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
121
126
 
122
127
  useStoryStore.getState().init(storyData, defaults, transientDefaults);
123
128
 
package/src/story-api.ts CHANGED
@@ -232,11 +232,63 @@ export interface StoryAPI {
232
232
  };
233
233
  }
234
234
 
235
+ // Names declared in StoryVariables / StoryTransients, registered at boot.
236
+ // null until a schema is registered (e.g. unit tests that init the store
237
+ // directly), in which case Story.set() does not check names.
238
+ let declaredVariables: ReadonlySet<string> | null = null;
239
+ let declaredTransients: ReadonlySet<string> | null = null;
240
+ const warnedUndeclared = new Set<string>();
241
+
242
+ /** Register the declared variable names so Story.set() can flag typos. */
243
+ export function setDeclaredVariables(
244
+ variables: Iterable<string>,
245
+ transients: Iterable<string> = [],
246
+ ): void {
247
+ declaredVariables = new Set(variables);
248
+ declaredTransients = new Set(transients);
249
+ warnedUndeclared.clear();
250
+ }
251
+
252
+ /** Test-only: forget the registered declarations. */
253
+ export function _resetDeclaredVariables(): void {
254
+ declaredVariables = null;
255
+ declaredTransients = null;
256
+ warnedUndeclared.clear();
257
+ }
258
+
259
+ /**
260
+ * Split an API variable name into namespace and key. Accepts the bare name
261
+ * (`hp`), the `$` sigil authors use in passages (`$hp`), and `%` for
262
+ * transients (`%npcs`). Dot-paths are kept in the key.
263
+ */
264
+ function parseName(name: string): {
265
+ isTransient: boolean;
266
+ key: string;
267
+ } {
268
+ if (name.startsWith('%')) return { isTransient: true, key: name.slice(1) };
269
+ if (name.startsWith('$')) return { isTransient: false, key: name.slice(1) };
270
+ return { isTransient: false, key: name };
271
+ }
272
+
273
+ function warnIfUndeclared(isTransient: boolean, key: string): void {
274
+ const declared = isTransient ? declaredTransients : declaredVariables;
275
+ if (!declared) return;
276
+ const root = key.split('.')[0]!;
277
+ if (declared.has(root)) return;
278
+ const label = (isTransient ? '%' : '$') + root;
279
+ if (warnedUndeclared.has(label)) return;
280
+ warnedUndeclared.add(label);
281
+ const where = isTransient ? 'StoryTransients' : 'StoryVariables';
282
+ console.warn(
283
+ `spindle: Story.set() wrote ${label}, which is not declared in ${where}. Passages cannot reference it; check the name or declare it.`,
284
+ );
285
+ }
286
+
235
287
  /** Set a single variable, resolving dot-paths if present. */
236
288
  function setOne(name: string, value: unknown): void {
237
- const isTransient = name.startsWith('%');
238
- const key = isTransient ? name.slice(1) : name;
289
+ const { isTransient, key } = parseName(name);
239
290
  const namespace = isTransient ? 'transient' : 'variables';
291
+ warnIfUndeclared(isTransient, key);
240
292
 
241
293
  if (key.includes('.')) {
242
294
  useStoryStore.setState((state) => {
@@ -255,8 +307,7 @@ function setOne(name: string, value: unknown): void {
255
307
  function createStoryAPI(): StoryAPI {
256
308
  return {
257
309
  get(name: string): unknown {
258
- const isTransient = name.startsWith('%');
259
- const key = isTransient ? name.slice(1) : name;
310
+ const { isTransient, key } = parseName(name);
260
311
  const store = isTransient
261
312
  ? useStoryStore.getState().transient
262
313
  : useStoryStore.getState().variables;
@@ -23,3 +23,71 @@ import type { parseStoryVariables as PublishedParse } from '../types/tooling';
23
23
  const _parseSourceToPublished: typeof PublishedParse = {} as typeof SourceParse;
24
24
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
25
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
+ import type {
30
+ MacroContext as SourceMacroContext,
31
+ MacroDefinition as SourceMacroDefinition,
32
+ } from './define-macro';
33
+ import type { MacroProps as SourceMacroProps } from './registry';
34
+ import type { ASTNode as SourceASTNode } from './markup/ast';
35
+ import type {
36
+ MacroContext as PublishedMacroContext,
37
+ MacroDefinition as PublishedMacroDefinition,
38
+ MacroProps as PublishedMacroProps,
39
+ ASTNode as PublishedASTNode,
40
+ } from '../types/index';
41
+
42
+ // 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;
58
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
59
+ const _astPublishedToSource: SourceASTNode = {} as PublishedASTNode;
60
+
61
+ // A typical custom macro written against the published types must type-check,
62
+ // and misuse of the hooks must not (no `any` leaking through).
63
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
64
+ const _exampleMacro: PublishedMacroDefinition = {
65
+ name: 'counter',
66
+ block: true,
67
+ render(props, ctx) {
68
+ const [count, setCount] = ctx.hooks.useState(0);
69
+ const countIsNumber: number = count;
70
+ setCount((n) => n + 1);
71
+ // @ts-expect-error -- state setter is typed by the initial value
72
+ setCount('one');
73
+ const ref = ctx.hooks.useRef<HTMLSpanElement>(null);
74
+ ctx.hooks.useEffect(() => {
75
+ ref.current?.focus();
76
+ return () => undefined;
77
+ }, [count]);
78
+ // @ts-expect-error -- deps must be an array
79
+ ctx.hooks.useEffect(() => undefined, 'count');
80
+ const doubled: number = ctx.hooks.useMemo(() => count * 2, [count]);
81
+ const body = ctx.renderNodes(props.children ?? [], { nobr: true });
82
+ const label: string = ctx.collectText(props.children ?? []);
83
+ return ctx.wrap(
84
+ ctx.h(
85
+ 'span',
86
+ { ref, class: ctx.cls, title: label },
87
+ body,
88
+ countIsNumber,
89
+ doubled,
90
+ ),
91
+ );
92
+ },
93
+ };
package/types/index.d.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import type { ComponentChildren, VNode, h } from 'preact';
2
+ import type {
3
+ useState,
4
+ useRef,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useCallback,
8
+ useMemo,
9
+ useContext,
10
+ } from 'preact/hooks';
11
+
1
12
  // Format metadata (used by twee-ts)
2
13
  export declare const name: string;
3
14
  export declare const version: string;
@@ -242,6 +253,68 @@ export interface MacroMetadata {
242
253
  parameters?: ParameterDef[];
243
254
  }
244
255
 
256
+ /** Plain text in a parsed passage. */
257
+ export interface TextNode {
258
+ type: 'text';
259
+ value: string;
260
+ }
261
+
262
+ /** A variable display such as `{$hp}`, `{_tmp}`, `{@local}` or `{%transient}`. */
263
+ export interface VariableNode {
264
+ type: 'variable';
265
+ name: string;
266
+ scope: 'variable' | 'temporary' | 'local' | 'transient';
267
+ className?: string;
268
+ id?: string;
269
+ }
270
+
271
+ /** An expression display such as `{= $hp * 2}`. */
272
+ export interface ExpressionNode {
273
+ type: 'expression';
274
+ expression: string;
275
+ className?: string;
276
+ id?: string;
277
+ }
278
+
279
+ /** One branch of a block macro (`{elseif}`, `{else}`, `{case}`, ...). */
280
+ export interface Branch {
281
+ rawArgs: string;
282
+ className?: string;
283
+ id?: string;
284
+ children: ASTNode[];
285
+ }
286
+
287
+ /** A macro invocation, with its body and branches for block macros. */
288
+ export interface MacroNode {
289
+ type: 'macro';
290
+ name: string;
291
+ rawArgs: string;
292
+ children: ASTNode[];
293
+ branches?: Branch[];
294
+ className?: string;
295
+ id?: string;
296
+ }
297
+
298
+ /** An HTML element written in passage markup. */
299
+ export interface HtmlNode {
300
+ type: 'html';
301
+ tag: string;
302
+ attributes: Record<string, string>;
303
+ children: ASTNode[];
304
+ }
305
+
306
+ /**
307
+ * A node of a parsed passage. Macros receive their body as `props.children`
308
+ * and render it with `ctx.renderNodes()`.
309
+ * @see {@link ../../src/markup/ast.ts} for the implementation.
310
+ */
311
+ export type ASTNode =
312
+ | TextNode
313
+ | VariableNode
314
+ | ExpressionNode
315
+ | MacroNode
316
+ | HtmlNode;
317
+
245
318
  /**
246
319
  * Props passed to a macro's render function.
247
320
  * @see {@link ../../src/registry.ts} for the implementation.
@@ -250,13 +323,8 @@ export interface MacroProps {
250
323
  rawArgs: string;
251
324
  className?: string;
252
325
  id?: string;
253
- children?: any[];
254
- branches?: Array<{
255
- rawArgs: string;
256
- className?: string;
257
- id?: string;
258
- children: any[];
259
- }>;
326
+ children?: ASTNode[];
327
+ branches?: Branch[];
260
328
  }
261
329
 
262
330
  /**
@@ -278,8 +346,9 @@ export interface UseActionOptions {
278
346
 
279
347
  /**
280
348
  * Context object passed to a macro's render function alongside props.
281
- * Internal Preact/AST types are represented as `any` since consumers
282
- * may not have Preact type definitions installed.
349
+ *
350
+ * Rendering helpers and hooks are Preact's own (`preact` is a dependency of
351
+ * this package), so `ctx.hooks.useState<T>()`, `ctx.h()` etc. are fully typed.
283
352
  * @see {@link ../../src/define-macro.ts} for the implementation.
284
353
  */
285
354
  export interface MacroContext {
@@ -301,26 +370,38 @@ export interface MacroContext {
301
370
  setValue?: (value: unknown) => void;
302
371
  getValue?: () => unknown;
303
372
  evaluate?: (expr: string) => unknown;
304
- collectText: (nodes: any[]) => string;
373
+ /** Concatenate the text nodes of an AST (e.g. a macro body holding a passage name). */
374
+ collectText: (nodes: ASTNode[]) => string;
375
+ /** Source location of the current passage, for error messages. */
305
376
  sourceLocation: () => string;
306
377
  parseVarArgs: (rawArgs: string) => { varName: string; placeholder: string };
307
- extractOptions: (children: any[]) => string[];
308
- wrap: (content: any) => any;
378
+ /** Collect the labels of `{option}` sub-macros in a macro body. */
379
+ extractOptions: (children: ASTNode[]) => string[];
380
+ /** Wrap content in a `<span>` carrying the macro's class/id, or a fragment if it has neither. */
381
+ wrap: (content: ComponentChildren) => VNode<any>;
309
382
  useAction: (opts: UseActionOptions) => string;
310
- h: (type: any, props: any, ...children: any[]) => any;
383
+ /** Preact's `h` (createElement). */
384
+ h: typeof h;
385
+ /** Render AST nodes as block content (markdown, `<p>` wrapping unless nobr). */
311
386
  renderNodes: (
312
- nodes: any[],
313
- options?: { nobr?: boolean; locals?: Record<string, unknown> },
314
- ) => any;
315
- renderInlineNodes: (nodes: any[]) => any;
387
+ nodes: ASTNode[],
388
+ options?: {
389
+ nobr?: boolean;
390
+ locals?: Record<string, unknown>;
391
+ inline?: boolean;
392
+ },
393
+ ) => ComponentChildren;
394
+ /** Render AST nodes as inline content (no markdown block processing). */
395
+ renderInlineNodes: (nodes: ASTNode[]) => ComponentChildren;
396
+ /** Preact hooks, shared with Spindle's own Preact instance. */
316
397
  hooks: {
317
- useState: any;
318
- useRef: any;
319
- useEffect: any;
320
- useLayoutEffect: any;
321
- useCallback: any;
322
- useMemo: any;
323
- useContext: any;
398
+ useState: typeof useState;
399
+ useRef: typeof useRef;
400
+ useEffect: typeof useEffect;
401
+ useLayoutEffect: typeof useLayoutEffect;
402
+ useCallback: typeof useCallback;
403
+ useMemo: typeof useMemo;
404
+ useContext: typeof useContext;
324
405
  };
325
406
  }
326
407
 
@@ -330,14 +411,21 @@ export interface MacroContext {
330
411
  */
331
412
  export interface MacroDefinition {
332
413
  name: string;
414
+ /** Sub-macro names (e.g. `['option']`); a non-empty list makes the macro a block macro. */
333
415
  subMacros?: string[];
416
+ /** Accept a `{name}...{/name}` body. Inferred from `subMacros` when omitted. */
334
417
  block?: boolean;
418
+ /** Resolve `{$var}` interpolation in the macro's class/id (`ctx.resolve`). */
335
419
  interpolate?: boolean;
420
+ /** Provide `ctx.merged` and `ctx.evaluate` (variables, temporaries, locals, transients). */
336
421
  merged?: boolean;
422
+ /** Bind the first argument as a story variable (`ctx.varName`, `ctx.value`, `ctx.setValue`). */
337
423
  storeVar?: boolean;
424
+ /** Tooling hint: one-line description shown by editors. */
338
425
  description?: string;
426
+ /** Tooling hint: positional parameters. */
339
427
  parameters?: ParameterDef[];
340
- render: (props: MacroProps, ctx: MacroContext) => any;
428
+ render: (props: MacroProps, ctx: MacroContext) => ComponentChildren;
341
429
  }
342
430
 
343
431
  /**