Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Transient variables (`%var`): reactive Zustand-backed variables that are excluded from all persistence (history snapshots, save payloads, session storage). Declared in a `StoryTransients` passage with `%name = value` syntax. Ideal for large derived state projected from external engines. Accessible via `{%var}` in passages, `{set %var = expr}`, and `Story.set('%var', value)` / `Story.get('%var')` in the API. ([#137](https://github.com/rohal12/spindle/issues/137))
- `Story.on('storyinit', callback)` event that fires after `StoryInit` completes — on initial boot and after every `restart()` call (including `Story.storage.clearGameData()` and `Story.storage.clearAllData()`). Allows external state engines to reliably re-sync after a restart. ([#115](https://github.com/rohal12/spindle/issues/115))
- Tooling API: `Story.getMacroRegistry()` returns metadata for all registered macros (built-in and user-defined) — name, block status, sub-macros, feature flags, source origin, and optional description/parameters
- `@rohal12/spindle/tooling` entry point for Node.js/LSP use — lightweight `defineMacro()` shim that captures metadata without Preact, pre-loaded with builtin metadata from build-time JSON
Expand Down
5 changes: 3 additions & 2 deletions docs/markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ All four forms navigate to `Target` when clicked. The first form uses the passag

## Variable Display

Inline a variable's value using `{$name}` or `{_name}`:
Inline a variable's value using `{$name}`, `{_name}`, or `{%name}`:

```
Your health is {$health}.
Temporary result: {_result}.
NPC count: {%npcList.length}.
```

Dot notation accesses nested fields:
Expand Down Expand Up @@ -113,7 +114,7 @@ Void tags (`br`, `col`, `hr`, `img`, `wbr`) are self-closing. All other tags req
</div>
```

Variable references (`{$var}`, `{_var}`, `{@var}`) inside HTML attributes are interpolated at render time:
Variable references (`{$var}`, `{_var}`, `{@var}`, `{%var}`) inside HTML attributes are interpolated at render time:

```
{set $color = "red"}
Expand Down
15 changes: 15 additions & 0 deletions docs/special-passages.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ When this passage exists, Spindle validates every `$variable` reference in your

See [Variables](variables.md) for details.

## `StoryTransients`

Declares transient variables with their default values. Each line must follow `%name = expression`:

```
:: StoryTransients
%npcList = []
%agents = {}
%economy_summary = {}
```

Transient variables are reactive but excluded from all persistence (history, saves, session storage). They reset to defaults on restart and load.

Variable names must be unique across `StoryVariables` and `StoryTransients`. See [Variables](variables.md) for details.

## `StoryInterface`

Controls the entire page layout. When this passage exists, its content replaces the default UI — including the menubar and passage display area. Use the `{passage}` macro to place the current passage within your custom layout.
Expand Down
20 changes: 20 additions & 0 deletions docs/story-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ Set one or more story variables.
{/do}
```

#### Transient variables

Prefix variable names with `%` to read/write transient variables:

```
{do}
Story.set("%npcList", [...]);
Story.set({ "%agents": {...}, health: 100 });
var agents = Story.get("%agents");
{/do}
```

Transient variables fire `variableChanged` events with `%`-prefixed keys:

```
Story.on("variableChanged", function(changed) {
// changed = { "%npcList": { from: [...], to: [...] }, health: { from: 90, to: 100 } }
});
```

### `Story.goto(passageName)`

Navigate to a passage.
Expand Down
52 changes: 49 additions & 3 deletions docs/variables.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Variables

Spindle has three kinds of variables: **story variables** that persist across passages, **temporary variables** that reset on each navigation, and **local variables** that are scoped to a block (for-loop or widget body).
Spindle has four kinds of variables: **story variables** that persist across passages, **temporary variables** that reset on each navigation, **local variables** that are scoped to a block (for-loop or widget body), and **transient variables** that are reactive but excluded from persistence.

## Story Variables

Expand All @@ -27,6 +27,51 @@ Display them with `{_temp}` or `{print _temp}`.

Use temporary variables for intermediate calculations that don't need to persist.

## Transient Variables

Transient variables start with `%` and persist across passage navigation like story variables, but are **excluded from all persistence** — history snapshots, save payloads, and session storage. They are ideal for large derived state that is fully re-derivable from an external engine.

```
{set %npcList = [...]}
{set %dashboardData = { revenue: 1000 }}
```

Display them with `{%npcList}` or `{print %npcList}`.

Transient variables are reactive — changes trigger Preact rerenders just like `$` variables. But unlike `$` variables, they don't bloat history snapshots or save files.

### When to use transient variables

- **Derived display state** projected from an external engine (NPC lists, stat sheets, economy dashboards)
- **UI state** that doesn't need to survive a save/load cycle (panel open/closed, scroll position)
- **Large data** that would cause excessive history growth if stored as `$` variables

### The `StoryTransients` Passage

Declare transient variables and their defaults in a special passage named `StoryTransients`:

```
:: StoryTransients
%npcList = []
%agents = {}
%economy_summary = {}
```

These defaults are applied on `init()` and `restart()`, and after loading a save (since transient data is not saved).

The `StoryTransients` passage is optional. Variable names must be unique across `$` and `%` scopes.

### Lifecycle

| Event | Behavior |
| ----------------- | -------------------------------------------------- |
| Navigation | Persists (unlike `_temporary`) |
| Back / Forward | Stays at current value (not restored from history) |
| Restart | Reset to defaults |
| Save | Excluded |
| Load | Reset to defaults |
| Page refresh (F5) | Reset to defaults |

## The `StoryVariables` Passage

Declare all story variables and their default values in a special passage named `StoryVariables`. Each line follows the format `$name = value`:
Expand Down Expand Up @@ -106,7 +151,7 @@ Then use methods and getters in your passages:

## Expressions

Anywhere Spindle expects a value (conditions in `{if}`, values in `{set}`, arguments to `{print}`), you write JavaScript expressions with `$var`, `_var`, and `@var` placeholders:
Anywhere Spindle expects a value (conditions in `{if}`, values in `{set}`, arguments to `{print}`), you write JavaScript expressions with `$var`, `_var`, `@var`, and `%var` placeholders:

```
{if $health > 0 && $character.alive}
Expand All @@ -121,10 +166,11 @@ Before evaluation, the expression system transforms:
- `$varName` into a reference to the story variable `varName`
- `_tempName` into a reference to the temporary variable `tempName`
- `@localName` into a reference to the block-scoped local `localName`
- `%transName` into a reference to the transient variable `transName`

Standard JavaScript operators and built-in functions (`Math`, `Array` methods, string methods) all work.

Variable sigils inside string literals are preserved as-is. This means `$`, `_`, and `@` inside quoted strings won't be transformed:
Variable sigils inside string literals are preserved as-is. This means `$`, `_`, `@`, and `%` inside quoted strings won't be transformed:

```
{set $greeting = "Hello, " + $name}
Expand Down
9 changes: 6 additions & 3 deletions src/components/macros/Computed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ function computeAndApply(
variables: Record<string, unknown>,
temporary: Record<string, unknown>,
locals: Record<string, unknown>,
transient: Record<string, unknown>,
rawArgs: string,
): void {
let newValue: unknown;
try {
newValue = evaluate(expr, variables, temporary, locals);
newValue = evaluate(expr, variables, temporary, locals, transient);
} catch (err) {
console.error(
`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
Expand All @@ -86,7 +87,7 @@ defineMacro({
name: 'computed',
merged: true,
render({ rawArgs }, ctx) {
const [mergedVars, mergedTemps, mergedLocals] = ctx.merged!;
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;

let target: string;
let expr: string;
Expand All @@ -113,6 +114,7 @@ defineMacro({
mergedVars,
mergedTemps,
mergedLocals,
mergedTrans,
rawArgs,
);
}
Expand All @@ -125,9 +127,10 @@ defineMacro({
mergedVars,
mergedTemps,
mergedLocals,
mergedTrans,
rawArgs,
);
}, [mergedVars, mergedTemps, mergedLocals]);
}, [mergedVars, mergedTemps, mergedLocals, mergedTrans]);

return null;
},
Expand Down
9 changes: 8 additions & 1 deletion src/components/macros/ExprDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ export function ExprDisplay({ expression, className, id }: ExprDisplayProps) {
const localsValues = useContext(LocalsValuesContext);
const variables = useStoryStore((s) => s.variables);
const temporary = useStoryStore((s) => s.temporary);
const transient = useStoryStore((s) => s.transient);

let display: string;
try {
const value = evaluate(expression, variables, temporary, localsValues);
const value = evaluate(
expression,
variables,
temporary,
localsValues,
transient,
);
display = value == null ? '' : String(value);
} catch {
display = `{error: ${expression}}`;
Expand Down
4 changes: 3 additions & 1 deletion src/components/macros/Unset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ defineMacro({
state.deleteVariable(name.slice(1));
} else if (name.startsWith('_')) {
state.deleteTemporary(name.slice(1));
} else if (name.startsWith('%')) {
state.deleteTransient(name.slice(1));
} else if (name.startsWith('@')) {
ctx.update(name.slice(1), undefined);
} else {
console.error(
`spindle: {unset} expects a variable ($name, _name, or @name), got "${name}"`,
`spindle: {unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
);
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/components/macros/VarDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useInterpolate } from '../../hooks/use-interpolate';

interface VarDisplayProps {
name: string;
scope: 'variable' | 'temporary' | 'local';
scope: 'variable' | 'temporary' | 'local' | 'transient';
className?: string;
id?: string;
}
Expand All @@ -22,7 +22,9 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
? s.variables[root]
: scope === 'temporary'
? s.temporary[root]
: undefined,
: scope === 'transient'
? s.transient[root]
: undefined,
);

let value: unknown;
Expand Down
14 changes: 11 additions & 3 deletions src/components/macros/WidgetInvocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ function isStandaloneValue(token: string): boolean {
// Quoted string
if (first === '"' || first === "'" || first === '`') return true;
// Variable ($var, _var, @var)
if (first === '$' || first === '_' || first === '@') return true;
if (first === '$' || first === '_' || first === '@' || first === '%')
return true;
// Number literal
if (/\d/.test(first)) return true;
// Signed number (-1, +2)
Expand Down Expand Up @@ -218,7 +219,8 @@ export function WidgetInvocation({
}: WidgetInvocationProps) {
const parentValues = useContext(LocalsValuesContext);
const nobr = useContext(NobrContext);
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
useMergedLocals();

const childrenValue = invocationChildren?.length ? invocationChildren : null;

Expand All @@ -239,7 +241,13 @@ export function WidgetInvocation({
let value: unknown;
if (expr !== undefined) {
try {
value = evaluate(expr, mergedVars, mergedTemps, mergedLocals);
value = evaluate(
expr,
mergedVars,
mergedTemps,
mergedLocals,
mergedTrans,
);
} catch {
value = undefined;
}
Expand Down
12 changes: 11 additions & 1 deletion src/define-macro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface MacroContext {
Record<string, unknown>,
Record<string, unknown>,
Record<string, unknown>,
Record<string, unknown>,
];
varName?: string;
value?: unknown;
Expand Down Expand Up @@ -178,12 +179,21 @@ export function defineMacro(
ctx.merged = useMergedLocals();
const merged = ctx.merged;
ctx.evaluate = (expr: string) =>
evaluate(expr, merged[0], merged[1], merged[2]);
evaluate(expr, merged[0], merged[1], merged[2], merged[3]);
}

if (config.storeVar) {
const firstToken =
props.rawArgs.trim().split(/\s+/)[0]?.replace(/["']/g, '') ?? '';

if (firstToken.startsWith('%')) {
return h(
'span',
{ class: 'error' },
`{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`,
);
}

const varExpr = firstToken.replace(/["']/g, '').replace(/^\$/, '');
const segments = varExpr.split('.');
ctx.varName = varExpr;
Expand Down
13 changes: 12 additions & 1 deletion src/execute-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export function executeMutation(
const state = useStoryStore.getState();
const vars = deepClone(state.variables);
const temps = deepClone(state.temporary);
const trans = deepClone(state.transient);
const localsClone = { ...mergedLocals };

execute(code, vars, temps, localsClone);
execute(code, vars, temps, localsClone, trans);

for (const key of Object.keys(vars)) {
if (vars[key] !== state.variables[key]) {
Expand All @@ -24,6 +25,11 @@ export function executeMutation(
state.setTemporary(key, temps[key]);
}
}
for (const key of Object.keys(trans)) {
if (trans[key] !== state.transient[key]) {
state.setTransient(key, trans[key]);
}
}
for (const key of Object.keys(localsClone)) {
if (localsClone[key] !== mergedLocals[key]) {
scopeUpdate(key, localsClone[key]);
Expand All @@ -41,6 +47,11 @@ export function executeMutation(
state.deleteTemporary(key);
}
}
for (const key of Object.keys(state.transient)) {
if (!(key in trans)) {
state.deleteTransient(key);
}
}
for (const key of Object.keys(mergedLocals)) {
if (!(key in localsClone)) {
scopeUpdate(key, undefined);
Expand Down
Loading
Loading