diff --git a/.worktrees/feat-storage-management b/.worktrees/feat-storage-management new file mode 160000 index 0000000..cee58cb --- /dev/null +++ b/.worktrees/feat-storage-management @@ -0,0 +1 @@ +Subproject commit cee58cbbd32392f2010fe0416af9405bc7bcb6d8 diff --git a/dev/story.twee b/dev/story.twee index 35f43e1..5ea733c 100644 --- a/dev/story.twee +++ b/dev/story.twee @@ -104,6 +104,8 @@ A wooden door stands to the north. Through a crack beneath it, you see flickerin [[Code passages->Code Passages Test]] | [[Watch triggers->Watch Tests]] | [[Dialog API->Dialog API Tests]] | [[SVG test->SVG Interop]] +[[Consecutive set->Consecutive Set Test]] + [[Nested HTML include->Nested HTML Include Test]] | [[Button HTML test->Button HTML Test]] :: Hallway @@ -1259,6 +1261,16 @@ This is a programmatic dialog. It was opened via Story.openDialog(). :: Dialog API Second This is the second queued dialog. +:: Consecutive Set Test +{include "Consecutive Set Sub"} + +[[Start]] + +:: Consecutive Set Sub [nobr] +{set _x = [3, 1, 2]} +{set _y = _x.slice().sort()} +Result: {_y} + :: Button HTML Test **Button with HTML block element inside:** diff --git a/docs/superpowers/plans/2026-03-22-tooling-api-macro-registry.md b/docs/superpowers/plans/2026-03-22-tooling-api-macro-registry.md new file mode 100644 index 0000000..13e9d3b --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-tooling-api-macro-registry.md @@ -0,0 +1,1011 @@ +# Tooling API: Macro Registry Metadata — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expose macro registry metadata for external tooling (LSP servers, linters, CLI checkers) so they can enumerate all registered macros and their metadata without hardcoded definitions. + +**Architecture:** Two access paths share the same `MacroMetadata` type. Runtime path: `Story.getMacroRegistry()` reads live metadata from a parallel Map in `src/registry.ts`, populated by `defineMacro()`. Tooling path: `@rohal12/spindle/tooling` is a lightweight Node.js module with its own metadata-only `defineMacro()` shim (no Preact dependency) that pre-loads builtin metadata from a build-time JSON snapshot. The build script generates this JSON by importing builtins and serializing the registry. + +**Tech Stack:** TypeScript, Vitest, Bun (build scripts) + +--- + +## File Map + +| File | Action | Responsibility | +| ---------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/registry.ts` | Modify | Add `MacroMetadata`, `ParameterDef` types; parallel metadata Map; `registerMacroMetadata()`, `getMacroRegistry()`, `clearMetadataRegistry()` | +| `src/define-macro.ts` | Modify | Add `description`, `parameters` to `MacroDefinition`; store metadata on registration; accept `source` param | +| `src/components/macros/*.tsx` | Modify | Add `block: true` to 11 builtin macros that are only block via hardcoded `BLOCK_MACROS` set | +| `src/story-api.ts` | Modify | Add `getMacroRegistry()` to interface and implementation; pass `source: 'user'` | +| `pkg/types/index.d.ts` | Modify | Add `MacroMetadata`, `ParameterDef` types; add `getMacroRegistry()` to `StoryAPI` | +| `pkg/tooling.js` | Create | Metadata-only `defineMacro` shim + `getMacroRegistry` for Node.js | +| `pkg/types/tooling.d.ts` | Create | Type declarations for `@rohal12/spindle/tooling` | +| `scripts/dump-macro-registry.ts` | Create | Build-time script to serialize builtin metadata to JSON | +| `scripts/build-format.ts` | Modify | Copy tooling files to `dist/pkg/`; run dump script | +| `package.json` | Modify | Add `./tooling` export; add files | +| `test/unit/macro-registry.test.ts` | Create | Tests for metadata storage, retrieval, source tracking | +| `test/unit/tooling-entry.test.ts` | Create | Tests for the tooling entry point | + +--- + +### Task 1: Add MacroMetadata types and metadata storage to registry + +**Files:** + +- Modify: `src/registry.ts` +- Test: `test/unit/macro-registry.test.ts` + +- [ ] **Step 1: Write failing tests for metadata storage and retrieval** + +Create `test/unit/macro-registry.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { + registerMacroMetadata, + getMacroRegistry, + clearMetadataRegistry, +} from '../../src/registry'; +import type { MacroMetadata } from '../../src/registry'; + +describe('macro metadata registry', () => { + beforeEach(() => { + clearMetadataRegistry(); + }); + + it('stores and retrieves metadata', () => { + const meta: MacroMetadata = { + name: 'test', + block: false, + subMacros: [], + source: 'builtin', + }; + registerMacroMetadata('test', meta); + const all = getMacroRegistry(); + expect(all).toHaveLength(1); + expect(all[0]).toEqual(meta); + }); + + it('normalizes name to lowercase', () => { + registerMacroMetadata('MyMacro', { + name: 'MyMacro', + block: false, + subMacros: [], + source: 'builtin', + }); + const all = getMacroRegistry(); + expect(all).toHaveLength(1); + expect(all[0]!.name).toBe('MyMacro'); + }); + + it('overwrites metadata for the same name', () => { + registerMacroMetadata('test', { + name: 'test', + block: false, + subMacros: [], + source: 'builtin', + }); + registerMacroMetadata('test', { + name: 'test', + block: true, + subMacros: ['child'], + source: 'user', + }); + const all = getMacroRegistry(); + expect(all).toHaveLength(1); + expect(all[0]!.block).toBe(true); + expect(all[0]!.source).toBe('user'); + }); + + it('includes optional fields when present', () => { + registerMacroMetadata('rich', { + name: 'rich', + block: true, + subMacros: ['sub1'], + storeVar: true, + interpolate: true, + merged: true, + description: 'A rich macro', + parameters: [ + { name: 'target', required: true, description: 'The target variable' }, + ], + source: 'user', + }); + const meta = getMacroRegistry()[0]!; + expect(meta.storeVar).toBe(true); + expect(meta.interpolate).toBe(true); + expect(meta.merged).toBe(true); + expect(meta.description).toBe('A rich macro'); + expect(meta.parameters).toHaveLength(1); + expect(meta.parameters![0]!.name).toBe('target'); + }); + + it('clearMetadataRegistry empties the registry', () => { + registerMacroMetadata('a', { + name: 'a', + block: false, + subMacros: [], + source: 'builtin', + }); + registerMacroMetadata('b', { + name: 'b', + block: false, + subMacros: [], + source: 'builtin', + }); + expect(getMacroRegistry()).toHaveLength(2); + clearMetadataRegistry(); + expect(getMacroRegistry()).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/macro-registry.test.ts` +Expected: FAIL — `registerMacroMetadata`, `getMacroRegistry`, `clearMetadataRegistry` not exported + +- [ ] **Step 3: Implement metadata types and storage in registry.ts** + +Add to `src/registry.ts` (after existing code): + +```typescript +export interface ParameterDef { + name: string; + required?: boolean; + description?: string; +} + +export interface MacroMetadata { + name: string; + block: boolean; + subMacros: string[]; + storeVar?: boolean; + interpolate?: boolean; + merged?: boolean; + source: 'builtin' | 'user'; + description?: string; + parameters?: ParameterDef[]; +} + +const metadataRegistry = new Map(); + +export function registerMacroMetadata( + name: string, + metadata: MacroMetadata, +): void { + metadataRegistry.set(name.toLowerCase(), metadata); +} + +export function getMacroRegistry(): MacroMetadata[] { + return Array.from(metadataRegistry.values()); +} + +export function clearMetadataRegistry(): void { + metadataRegistry.clear(); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/macro-registry.test.ts` +Expected: PASS (all 5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/registry.ts test/unit/macro-registry.test.ts +git commit -m "feat: add MacroMetadata types and metadata storage to registry" +``` + +--- + +### Task 2: Add `block: true` to builtin macro configs + +Currently, 11 builtin macros are block macros only because they appear in the hardcoded `BLOCK_MACROS` set in `src/markup/ast.ts`. Their `defineMacro()` configs don't include `block: true`. Since the metadata API derives block status from the config, we need to make the config the source of truth. + +**Files:** + +- Modify: `src/components/macros/If.tsx` +- Modify: `src/components/macros/For.tsx` +- Modify: `src/components/macros/Do.tsx` +- Modify: `src/components/macros/Button.tsx` +- Modify: `src/components/macros/MacroLink.tsx` +- Modify: `src/components/macros/Cycle.tsx` +- Modify: `src/components/macros/Repeat.tsx` +- Modify: `src/components/macros/Type.tsx` +- Modify: `src/components/macros/Widget.tsx` +- Modify: `src/components/macros/Span.tsx` +- Modify: `src/components/macros/Nobr.tsx` + +Macros that already have correct configs (no changes needed): + +- `switch` — has `subMacros: ['case', 'default']` (implies block) +- `timed` — has `subMacros: ['next']` (implies block) +- `listbox` — has `subMacros: ['option']` (implies block) +- `dialog` — already has `block: true` + +- [ ] **Step 1: Add `block: true` to each macro config** + +For each file, add `block: true` to the `defineMacro({...})` config object: + +- `src/components/macros/If.tsx`: `defineMacro({ name: 'if', block: true, interpolate: true, merged: true, ...` +- `src/components/macros/For.tsx`: `defineMacro({ name: 'for', block: true, interpolate: true, merged: true, ...` +- `src/components/macros/Do.tsx`: `defineMacro({ name: 'do', block: true, ...` +- `src/components/macros/Button.tsx`: `defineMacro({ name: 'button', block: true, interpolate: true, ...` +- `src/components/macros/MacroLink.tsx`: `defineMacro({ name: 'link', block: true, interpolate: true, ...` +- `src/components/macros/Cycle.tsx`: `defineMacro({ name: 'cycle', block: true, storeVar: true, ...` +- `src/components/macros/Repeat.tsx`: `defineMacro({ name: 'repeat', block: true, interpolate: true, ...` +- `src/components/macros/Type.tsx`: `defineMacro({ name: 'type', block: true, interpolate: true, ...` +- `src/components/macros/Widget.tsx`: `defineMacro({ name: 'widget', block: true, ...` +- `src/components/macros/Span.tsx`: `defineMacro({ name: 'span', block: true, ...` +- `src/components/macros/Nobr.tsx`: `defineMacro({ name: 'nobr', block: true, ...` + +- [ ] **Step 2: Run full test suite to verify no regressions** + +Run: `npx vitest run` +Expected: All existing tests pass. The `block: true` flag causes `defineMacro()` to call `registerBlockMacro()`, which adds to the same `BLOCK_MACROS` set — redundant with the hardcoded entries but harmless. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/macros/If.tsx src/components/macros/For.tsx src/components/macros/Do.tsx src/components/macros/Button.tsx src/components/macros/MacroLink.tsx src/components/macros/Cycle.tsx src/components/macros/Repeat.tsx src/components/macros/Type.tsx src/components/macros/Widget.tsx src/components/macros/Span.tsx src/components/macros/Nobr.tsx +git commit -m "refactor: add block: true to builtin macro configs for metadata accuracy" +``` + +--- + +### Task 3: Store metadata during defineMacro registration + +**Files:** + +- Modify: `src/define-macro.ts` +- Test: `test/unit/macro-registry.test.ts` (extend) + +- [ ] **Step 1: Write failing tests for defineMacro metadata storage** + +Append to `test/unit/macro-registry.test.ts`: + +```typescript +import { defineMacro } from '../../src/define-macro'; + +describe('defineMacro stores metadata', () => { + beforeEach(() => { + clearMetadataRegistry(); + }); + + it('stores basic metadata from defineMacro config', () => { + defineMacro({ + name: 'test-basic', + render: () => null, + }); + const all = getMacroRegistry(); + const meta = all.find((m) => m.name === 'test-basic'); + expect(meta).toBeDefined(); + expect(meta!.block).toBe(false); + expect(meta!.subMacros).toEqual([]); + expect(meta!.source).toBe('builtin'); + }); + + it('stores feature flags from config', () => { + defineMacro({ + name: 'test-flags', + block: true, + subMacros: ['child-a', 'child-b'], + interpolate: true, + merged: true, + storeVar: true, + render: () => null, + }); + const meta = getMacroRegistry().find((m) => m.name === 'test-flags'); + expect(meta).toBeDefined(); + expect(meta!.block).toBe(true); + expect(meta!.subMacros).toEqual(['child-a', 'child-b']); + expect(meta!.interpolate).toBe(true); + expect(meta!.merged).toBe(true); + expect(meta!.storeVar).toBe(true); + }); + + it('stores description and parameters', () => { + defineMacro({ + name: 'test-docs', + description: 'A documented macro', + parameters: [{ name: 'value', required: true, description: 'The value' }], + render: () => null, + }); + const meta = getMacroRegistry().find((m) => m.name === 'test-docs'); + expect(meta!.description).toBe('A documented macro'); + expect(meta!.parameters).toEqual([ + { name: 'value', required: true, description: 'The value' }, + ]); + }); + + it('defaults source to builtin', () => { + defineMacro({ name: 'test-src', render: () => null }); + const meta = getMacroRegistry().find((m) => m.name === 'test-src'); + expect(meta!.source).toBe('builtin'); + }); + + it('accepts explicit source parameter', () => { + defineMacro({ name: 'test-user', render: () => null }, 'user'); + const meta = getMacroRegistry().find((m) => m.name === 'test-user'); + expect(meta!.source).toBe('user'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/macro-registry.test.ts` +Expected: FAIL — `defineMacro` does not yet store metadata; second parameter not accepted + +- [ ] **Step 3: Extend MacroDefinition and defineMacro to store metadata** + +In `src/define-macro.ts`: + +1. Add `description` and `parameters` to `MacroDefinition`: + +```typescript +export interface MacroDefinition { + name: string; + subMacros?: string[]; + block?: boolean; + interpolate?: boolean; + merged?: boolean; + storeVar?: boolean; + description?: string; + parameters?: ParameterDef[]; + render: (props: MacroProps, ctx: MacroContext) => VNode | null; +} +``` + +2. Add import for `ParameterDef` and `registerMacroMetadata`: + +```typescript +import { + registerMacro, + registerSubMacro, + registerMacroMetadata, +} from './registry'; +import type { MacroProps, ParameterDef } from './registry'; +``` + +3. Update `defineMacro` signature and add metadata registration at the end: + +```typescript +export function defineMacro( + config: MacroDefinition, + source: 'builtin' | 'user' = 'builtin', +): void { + // ... existing Wrapper function unchanged ... + + registerMacro(config.name, Wrapper); + + // Store metadata for tooling API + const isBlock = + config.block === true || + (config.block !== false && (config.subMacros?.length ?? 0) > 0); + registerMacroMetadata(config.name, { + name: config.name, + block: isBlock, + subMacros: config.subMacros ?? [], + storeVar: config.storeVar, + interpolate: config.interpolate, + merged: config.merged, + description: config.description, + parameters: config.parameters, + source, + }); + + if (config.subMacros) { + for (const sub of config.subMacros) registerSubMacro(sub); + } + if (isBlock) { + registerBlockMacro(config.name); + } +} +``` + +Note: the `isBlock` logic is extracted to avoid duplicating the condition. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/macro-registry.test.ts` +Expected: PASS (all tests) + +- [ ] **Step 5: Run full test suite to verify no regressions** + +Run: `npx vitest run` +Expected: All existing tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/define-macro.ts src/registry.ts test/unit/macro-registry.test.ts +git commit -m "feat: store macro metadata during defineMacro registration" +``` + +--- + +### Task 4: Add getMacroRegistry to Story API + +**Files:** + +- Modify: `src/story-api.ts` +- Test: `test/unit/story-api.test.ts` (extend) + +- [ ] **Step 1: Write failing test for Story.getMacroRegistry** + +Append to the `describe('StoryAPI')` block in `test/unit/story-api.test.ts`: + +```typescript +describe('getMacroRegistry', () => { + it('is available on the Story API', () => { + expect(typeof Story.getMacroRegistry).toBe('function'); + }); + + it('returns metadata for registered macros', () => { + const registry = Story.getMacroRegistry(); + expect(Array.isArray(registry)).toBe(true); + // Builtins are registered via vitest setupFiles + expect(registry.length).toBeGreaterThan(0); + const setMacro = registry.find((m: any) => m.name === 'set'); + expect(setMacro).toBeDefined(); + expect(setMacro.source).toBe('builtin'); + }); + + it('marks user-defined macros with source user', () => { + Story.defineMacro({ name: 'user-test-macro', render: () => null }); + const registry = Story.getMacroRegistry(); + const userMacro = registry.find((m: any) => m.name === 'user-test-macro'); + expect(userMacro).toBeDefined(); + expect(userMacro.source).toBe('user'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/unit/story-api.test.ts` +Expected: FAIL — `Story.getMacroRegistry` is not a function + +- [ ] **Step 3: Add getMacroRegistry to StoryAPI interface and implementation** + +In `src/story-api.ts`: + +1. Add aliased imports to avoid name shadowing with object methods: + +```typescript +import { getMacroRegistry as _getMacroRegistry } from './registry'; +import type { MacroMetadata } from './registry'; +``` + +2. Add to `StoryAPI` interface (after `defineMacro`): + +```typescript +getMacroRegistry(): MacroMetadata[]; +``` + +3. Update `defineMacro` in `createStoryAPI()` to pass `'user'` source, and add `getMacroRegistry`: + +```typescript +defineMacro(config: MacroDefinition): void { + defineMacro(config, 'user'); +}, + +getMacroRegistry(): MacroMetadata[] { + return _getMacroRegistry(); +}, +``` + +Note: The imported function is aliased as `_getMacroRegistry` to avoid shadowing with the object method name. The `defineMacro` call now passes `'user'` as the source (the import `defineMacro` from `./define-macro` resolves via closure scope, not `this`). + +4. Re-export `MacroMetadata` from story-api.ts: + +```typescript +export type { MacroMetadata }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/story-api.test.ts` +Expected: PASS + +- [ ] **Step 5: Run full test suite** + +Run: `npx vitest run` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/story-api.ts test/unit/story-api.test.ts +git commit -m "feat: add getMacroRegistry to Story API" +``` + +--- + +### Task 5: Update public type declarations + +**Files:** + +- Modify: `pkg/types/index.d.ts` + +- [ ] **Step 1: Add MacroMetadata and ParameterDef types** + +Add before the `StoryAPI` interface in `pkg/types/index.d.ts`: + +```typescript +/** + * Typed parameter definition for macro tooling metadata. + * Macro authors can provide these to help LSP servers, linters, and documentation generators. + */ +export interface ParameterDef { + name: string; + required?: boolean; + description?: string; +} + +/** + * Metadata about a registered macro, used by tooling (LSP, linters, doc generators). + * Available at runtime via `Story.getMacroRegistry()` or via the `@rohal12/spindle/tooling` entry point. + */ +export interface MacroMetadata { + name: string; + block: boolean; + subMacros: string[]; + storeVar?: boolean; + interpolate?: boolean; + merged?: boolean; + source: 'builtin' | 'user'; + description?: string; + parameters?: ParameterDef[]; +} +``` + +- [ ] **Step 2: Add getMacroRegistry to StoryAPI interface** + +Add to the `StoryAPI` interface (after the `saves` property): + +```typescript +/** Return metadata for all registered macros (built-in and user-defined). */ +getMacroRegistry(): MacroMetadata[]; +``` + +- [ ] **Step 3: Typecheck** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add pkg/types/index.d.ts +git commit -m "feat: add MacroMetadata types to public type declarations" +``` + +--- + +### Task 6: Create tooling entry point + +**Files:** + +- Create: `pkg/tooling.js` +- Create: `pkg/types/tooling.d.ts` +- Modify: `package.json` + +- [ ] **Step 1: Write test for tooling entry point** + +Create `test/unit/tooling-entry.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +// We test the tooling module's logic by simulating what pkg/tooling.js does. +// The actual pkg/tooling.js is a plain JS file read at runtime from dist/pkg/macro-registry.json. +// Here we test the contract: defineMacro captures metadata, getMacroRegistry returns it. + +describe('tooling entry point contract', () => { + // Simulate the tooling module's metadata-only registry + let metadata: Map; + + function defineMacro(config: any) { + const name = config.name.toLowerCase(); + metadata.set(name, { + name: config.name, + block: config.block ?? (config.subMacros?.length > 0 ? true : false), + subMacros: config.subMacros ?? [], + storeVar: config.storeVar, + interpolate: config.interpolate, + merged: config.merged, + description: config.description, + parameters: config.parameters, + source: 'user', + }); + } + + function getMacroRegistry() { + return Array.from(metadata.values()); + } + + beforeEach(() => { + metadata = new Map(); + }); + + it('defineMacro captures metadata without render function', () => { + defineMacro({ + name: 'custom', + block: true, + subMacros: ['option'], + description: 'A custom macro', + render: () => null, // ignored by tooling shim + }); + const all = getMacroRegistry(); + expect(all).toHaveLength(1); + expect(all[0].name).toBe('custom'); + expect(all[0].block).toBe(true); + expect(all[0].subMacros).toEqual(['option']); + expect(all[0].description).toBe('A custom macro'); + expect(all[0].source).toBe('user'); + }); + + it('block defaults to true when subMacros are present', () => { + defineMacro({ + name: 'branching', + subMacros: ['branch'], + render: () => null, + }); + expect(getMacroRegistry()[0].block).toBe(true); + }); + + it('block defaults to false when no subMacros', () => { + defineMacro({ name: 'simple', render: () => null }); + expect(getMacroRegistry()[0].block).toBe(false); + }); + + it('multiple macros accumulate', () => { + defineMacro({ name: 'a', render: () => null }); + defineMacro({ name: 'b', block: true, render: () => null }); + expect(getMacroRegistry()).toHaveLength(2); + }); +}); + +describe('pkg/tooling.js exists', () => { + it('tooling.js file exists in pkg/', () => { + const toolingPath = resolve(__dirname, '../../pkg/tooling.js'); + expect(existsSync(toolingPath)).toBe(true); + }); + + it('tooling type declaration exists', () => { + const typesPath = resolve(__dirname, '../../pkg/types/tooling.d.ts'); + expect(existsSync(typesPath)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/tooling-entry.test.ts` +Expected: Contract tests pass (they're self-contained), but file existence tests fail + +- [ ] **Step 3: Create pkg/tooling.js** + +```javascript +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Load built-in macro metadata generated at build time +const registryPath = join(__dirname, 'macro-registry.json'); +let builtins = []; +try { + builtins = JSON.parse(readFileSync(registryPath, 'utf-8')); +} catch { + // macro-registry.json not yet built — empty builtins +} + +const metadata = new Map(); +for (const m of builtins) metadata.set(m.name.toLowerCase(), m); + +/** + * Metadata-only defineMacro for tooling. + * Captures macro metadata without creating Preact components. + * LSP servers call this to register user-defined macros discovered in story scripts. + */ +export function defineMacro(config) { + const name = config.name.toLowerCase(); + metadata.set(name, { + name: config.name, + block: config.block ?? (config.subMacros?.length > 0 ? true : false), + subMacros: config.subMacros ?? [], + storeVar: config.storeVar, + interpolate: config.interpolate, + merged: config.merged, + description: config.description, + parameters: config.parameters, + source: 'user', + }); +} + +/** + * Return metadata for all registered macros (built-in + user-defined). + */ +export function getMacroRegistry() { + return Array.from(metadata.values()); +} +``` + +- [ ] **Step 4: Create pkg/types/tooling.d.ts** + +```typescript +export interface ParameterDef { + name: string; + required?: boolean; + description?: string; +} + +export interface MacroMetadata { + name: string; + block: boolean; + subMacros: string[]; + storeVar?: boolean; + interpolate?: boolean; + merged?: boolean; + source: 'builtin' | 'user'; + description?: string; + parameters?: ParameterDef[]; +} + +export interface MacroDefinition { + name: string; + subMacros?: string[]; + block?: boolean; + interpolate?: boolean; + merged?: boolean; + storeVar?: boolean; + description?: string; + parameters?: ParameterDef[]; + render: (...args: any[]) => any; +} + +/** + * Metadata-only defineMacro for tooling. + * Captures macro metadata without creating Preact components. + * LSP servers call this to register user-defined macros discovered in story scripts. + */ +export declare function defineMacro(config: MacroDefinition): void; + +/** + * Return metadata for all registered macros (built-in + user-defined). + */ +export declare function getMacroRegistry(): MacroMetadata[]; +``` + +- [ ] **Step 5: Update package.json** + +Add `"./tooling"` export and update `files`: + +In `exports`: + +```json +"exports": { + ".": { + "types": "./types/index.d.ts", + "import": "./dist/pkg/index.js" + }, + "./tooling": { + "types": "./types/tooling.d.ts", + "import": "./dist/pkg/tooling.js" + } +} +``` + +In `files`, add `"dist/pkg/tooling.js"` and `"dist/pkg/macro-registry.json"`. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npx vitest run test/unit/tooling-entry.test.ts` +Expected: All tests pass (including file existence checks) + +- [ ] **Step 7: Typecheck** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 8: Commit** + +```bash +git add pkg/tooling.js pkg/types/tooling.d.ts package.json test/unit/tooling-entry.test.ts +git commit -m "feat: create tooling entry point for Node.js LSP use" +``` + +--- + +### Task 7: Build-time macro registry dump + +**Files:** + +- Create: `scripts/dump-macro-registry.ts` +- Modify: `scripts/build-format.ts` + +- [ ] **Step 1: Create dump-macro-registry.ts script** + +Create `scripts/dump-macro-registry.ts`: + +```typescript +/** + * Dump the built-in macro registry metadata to JSON. + * Run after register-builtins has been imported (side-effect registration). + * + * Usage: bun run scripts/dump-macro-registry.ts + * Output: dist/pkg/macro-registry.json + */ +import '../src/macros/register-builtins'; +import { getMacroRegistry } from '../src/registry'; +import { writeFileSync, mkdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outputDir = resolve(__dirname, '..', 'dist', 'pkg'); +mkdirSync(outputDir, { recursive: true }); + +const registry = getMacroRegistry(); +const outputPath = resolve(outputDir, 'macro-registry.json'); +writeFileSync(outputPath, JSON.stringify(registry, null, 2), 'utf-8'); + +console.log( + `Dumped ${registry.length} macro metadata entries to dist/pkg/macro-registry.json`, +); +``` + +- [ ] **Step 2: Update build-format.ts to copy tooling files and run dump** + +Add to `scripts/build-format.ts`, after the existing `console.log('Built dist/pkg/ (npm package)')` line: + +```typescript +// Copy tooling entry point +copyFileSync( + resolve(projectRoot, 'pkg/tooling.js'), + resolve(pkgDir, 'tooling.js'), +); + +// Copy tooling type declarations +copyFileSync( + resolve(projectRoot, 'pkg/types/tooling.d.ts'), + resolve(pkgTypesDir, 'tooling.d.ts'), +); + +console.log('Copied tooling entry point to dist/pkg/'); +``` + +Note: The `macro-registry.json` is generated by a separate `bun run scripts/dump-macro-registry.ts` call. Update the `build` script in `package.json`: + +```json +"build": "vite build && bun run scripts/build-format.ts && bun run scripts/dump-macro-registry.ts" +``` + +- [ ] **Step 3: Test the build** + +Run: `npm run build` +Expected: Build completes successfully. `dist/pkg/macro-registry.json` exists and contains an array of macro metadata objects. `dist/pkg/tooling.js` exists. + +- [ ] **Step 4: Verify dump output** + +Run: `cat dist/pkg/macro-registry.json | head -30` +Expected: JSON array with entries like `{"name": "set", "block": false, "subMacros": [], "source": "builtin", ...}` + +- [ ] **Step 5: Commit** + +```bash +git add scripts/dump-macro-registry.ts scripts/build-format.ts package.json +git commit -m "feat: generate macro-registry.json at build time for tooling entry point" +``` + +--- + +### Task 8: Verify builtins have metadata after registration + +**Files:** + +- Test: `test/unit/macro-registry.test.ts` (extend) + +- [ ] **Step 1: Write test that builtins are registered with metadata** + +Builtins are auto-registered via vitest `setupFiles`. Add to `test/unit/macro-registry.test.ts`: + +```typescript +describe('builtin macros have metadata', () => { + // NOTE: do NOT call clearMetadataRegistry() here — we want builtins + + it('all expected builtins are present', () => { + const registry = getMacroRegistry(); + const names = registry.map((m) => m.name); + // Spot-check a selection of builtins + expect(names).toContain('set'); + expect(names).toContain('if'); + expect(names).toContain('for'); + expect(names).toContain('button'); + expect(names).toContain('switch'); + expect(names).toContain('textbox'); + expect(names).toContain('widget'); + }); + + it('block macros are marked as block', () => { + const registry = getMacroRegistry(); + const ifMacro = registry.find((m) => m.name === 'if'); + expect(ifMacro!.block).toBe(true); + const forMacro = registry.find((m) => m.name === 'for'); + expect(forMacro!.block).toBe(true); + }); + + it('non-block macros are not marked as block', () => { + const registry = getMacroRegistry(); + const setMacro = registry.find((m) => m.name === 'set'); + expect(setMacro!.block).toBe(false); + }); + + it('switch has correct subMacros', () => { + const registry = getMacroRegistry(); + const switchMacro = registry.find((m) => m.name === 'switch'); + expect(switchMacro!.subMacros).toEqual(['case', 'default']); + }); + + it('all builtins have source builtin', () => { + const registry = getMacroRegistry(); + // Filter to known builtins (registry may have test macros from other describe blocks) + const builtinNames = [ + 'set', + 'if', + 'for', + 'button', + 'switch', + 'textbox', + 'widget', + ]; + for (const name of builtinNames) { + const macro = registry.find((m) => m.name === name); + expect(macro!.source, `${name} should have source 'builtin'`).toBe( + 'builtin', + ); + } + }); + + it('feature flags are preserved', () => { + const registry = getMacroRegistry(); + const ifMacro = registry.find((m) => m.name === 'if'); + expect(ifMacro!.interpolate).toBe(true); + expect(ifMacro!.merged).toBe(true); + + const textboxMacro = registry.find((m) => m.name === 'textbox'); + expect(textboxMacro!.storeVar).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run test/unit/macro-registry.test.ts` +Expected: All tests pass + +- [ ] **Step 3: Run full test suite** + +Run: `npx vitest run` +Expected: All tests pass + +- [ ] **Step 4: Typecheck** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add test/unit/macro-registry.test.ts +git commit -m "test: verify builtin macros have correct metadata" +``` diff --git a/docs/superpowers/plans/2026-03-25-deferred-render.md b/docs/superpowers/plans/2026-03-25-deferred-render.md new file mode 100644 index 0000000..6ed3c43 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-deferred-render.md @@ -0,0 +1,676 @@ +# Deferred Render Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow games with async initialization to defer passage rendering until `Story.ready()` is called, preventing crashes from undefined globals on page reload. + +**Architecture:** Module-level promise/resolver in `story-api.ts` coordinates the deferred state. Store holds `renderDeferred` boolean. `PassageDisplay.tsx` gates on it to show `StoryLoading` passage or blank. `index.tsx` chains `:storyready` dispatch on the promise. + +**Tech Stack:** Preact, TypeScript, Zustand, Vitest (happy-dom) + +**Spec:** `docs/superpowers/specs/2026-03-25-deferred-render-design.md` + +--- + +### Task 1: Add `renderDeferred` to the Zustand store + +**Files:** + +- Modify: `src/store.ts:40-49` (SPECIAL_PASSAGES), `src/store.ts:218-266` (StoryState interface), `src/store.ts:268-286` (store defaults), `src/store.ts:488-530` (restart method) +- Test: `test/unit/store.test.ts` + +- [ ] **Step 1: Write failing tests for store deferred render state** + +Add to `test/unit/store.test.ts` at the end of the file, inside the outer `describe('useStoryStore', ...)`: + +```ts +describe('renderDeferred', () => { + it('defaults to false', () => { + expect(useStoryStore.getState().renderDeferred).toBe(false); + }); + + it('deferRender() sets renderDeferred to true', () => { + useStoryStore.getState().deferRender(); + expect(useStoryStore.getState().renderDeferred).toBe(true); + }); + + it('clearDeferredRender() sets renderDeferred to false', () => { + useStoryStore.getState().deferRender(); + useStoryStore.getState().clearDeferredRender(); + expect(useStoryStore.getState().renderDeferred).toBe(false); + }); + + it('deferRender() is idempotent', () => { + useStoryStore.getState().deferRender(); + useStoryStore.getState().deferRender(); + expect(useStoryStore.getState().renderDeferred).toBe(true); + }); + + it('restart() resets renderDeferred to false', () => { + const story = makeStoryData([makePassage(1, 'Start', '')]); + useStoryStore.getState().init(story); + useStoryStore.getState().deferRender(); + expect(useStoryStore.getState().renderDeferred).toBe(true); + useStoryStore.getState().restart(); + expect(useStoryStore.getState().renderDeferred).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/store.test.ts` +Expected: FAIL — `renderDeferred` not in state, `deferRender`/`clearDeferredRender` not functions. + +- [ ] **Step 3: Add `StoryLoading` to `SPECIAL_PASSAGES`** + +In `src/store.ts`, add `'StoryLoading'` to the `SPECIAL_PASSAGES` set at line 40-49: + +```ts +const SPECIAL_PASSAGES = new Set([ + 'StoryInit', + 'StoryInterface', + 'StoryVariables', + 'StoryLoading', + 'SaveTitle', + 'PassageReady', + 'PassageHeader', + 'PassageFooter', + 'PassageDone', +]); +``` + +- [ ] **Step 4: Add `renderDeferred` to `StoryState` interface** + +In `src/store.ts`, add to the `StoryState` interface (after `nobr: boolean;` around line 236): + +```ts +renderDeferred: boolean; +``` + +And add the method signatures (after `consumeNextTransition` around line 265): + +```ts +deferRender: () => void; +clearDeferredRender: () => void; +``` + +- [ ] **Step 5: Add `renderDeferred` default and methods to the store** + +In `src/store.ts`, add to the store initializer (after `nobr: false,` around line 286): + +```ts +renderDeferred: false, +``` + +And add the method implementations (after the `consumeNextTransition` method, before the closing `}))`: + +```ts +deferRender: () => { + set((state) => { + state.renderDeferred = true; + }); +}, + +clearDeferredRender: () => { + set((state) => { + state.renderDeferred = false; + }); +}, +``` + +- [ ] **Step 6: Reset `renderDeferred` in `restart()` method** + +In `src/store.ts`, in the `restart()` method's `set()` call (around line 500-513), add `state.renderDeferred = false;` after `state.renderCounts = { [startPassage.name]: 1 };`: + +```ts +state.renderCounts = { [startPassage.name]: 1 }; +state.renderDeferred = false; +``` + +This ensures a clean slate before `fireStoryInit()` runs, so storyinit listeners can call `deferRender()` again if needed. + +- [ ] **Step 7: Add `renderDeferred: false` to the `beforeEach` reset in `test/unit/store.test.ts`** + +In the `beforeEach` block that calls `useStoryStore.setState(...)`, add `renderDeferred: false,` to the reset object (after `nextTransition: null,`). + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npx vitest run test/unit/store.test.ts` +Expected: ALL PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/store.ts test/unit/store.test.ts +git commit -m "feat: add renderDeferred state to store (#119)" +``` + +--- + +### Task 2: Add `Story.deferRender()` and `Story.ready()` to the Story API + +**Files:** + +- Modify: `src/story-api.ts` +- Test: `test/unit/story-api.test.ts` + +- [ ] **Step 1: Write failing tests** + +Add to `test/unit/story-api.test.ts`, inside the outer `describe('StoryAPI', ...)`. Also add `_resetReadyState` to the dynamic import pattern and call it in `beforeEach` to prevent promise leakage between tests: + +```ts +describe('deferRender / ready', () => { + beforeEach(async () => { + // Reset module-level promise state between tests + const mod = await import('../../src/story-api'); + (mod as any)._resetReadyState?.(); + }); + + it('deferRender() sets store.renderDeferred to true', () => { + Story.deferRender(); + expect(useStoryStore.getState().renderDeferred).toBe(true); + }); + + it('ready() clears store.renderDeferred', () => { + Story.deferRender(); + Story.ready(); + expect(useStoryStore.getState().renderDeferred).toBe(false); + }); + + it('ready() without prior deferRender() is a no-op', () => { + Story.ready(); // should not throw + expect(useStoryStore.getState().renderDeferred).toBe(false); + }); + + it('deferRender() creates a promise that ready() resolves', async () => { + Story.deferRender(); + const { getReadyPromise } = await import('../../src/story-api'); + const promise = getReadyPromise(); + expect(promise).toBeInstanceOf(Promise); + + let resolved = false; + promise!.then(() => { + resolved = true; + }); + + Story.ready(); + await promise; + expect(resolved).toBe(true); + }); + + it('getReadyPromise() returns null when not deferred', async () => { + const { getReadyPromise } = await import('../../src/story-api'); + expect(getReadyPromise()).toBeNull(); + }); + + it('deferRender() replaces previous promise on repeated calls', async () => { + const { getReadyPromise } = await import('../../src/story-api'); + Story.deferRender(); + const first = getReadyPromise(); + Story.deferRender(); + const second = getReadyPromise(); + expect(first).not.toBe(second); + + // Resolve the current one; first is orphaned (harmless — no refs held) + Story.ready(); + await second; + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/story-api.test.ts` +Expected: FAIL — `deferRender` and `ready` not on Story, `getReadyPromise` not exported. + +- [ ] **Step 3: Add promise lifecycle and API methods to `story-api.ts`** + +At module level, after the imports (around line 48), add: + +```ts +// Deferred-render promise lifecycle. +// deferRender() creates a promise; ready() resolves it. +// index.tsx reads getReadyPromise() AFTER render(), which is after both +// author JS and storyinit have run, so it always gets the final promise. +let readyResolve: (() => void) | null = null; +let readyPromise: Promise | null = null; + +/** Returns the current deferred-render promise, or null if not deferred. */ +export function getReadyPromise(): Promise | null { + return readyPromise; +} + +/** Test-only: reset module-level promise state. */ +export function _resetReadyState(): void { + readyResolve = null; + readyPromise = null; +} +``` + +Add `deferRender` and `ready` to the `StoryAPI` interface (after `setNextTransition`, around line 123): + +```ts +deferRender(): void; +ready(): void; +``` + +Add implementations inside `createStoryAPI()` (after `setNextTransition` method, around line 453): + +```ts +deferRender(): void { + useStoryStore.getState().deferRender(); + readyPromise = new Promise((resolve) => { + readyResolve = resolve; + }); +}, + +ready(): void { + if (!readyResolve) return; + useStoryStore.getState().clearDeferredRender(); + readyResolve(); + readyResolve = null; + readyPromise = null; +}, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/story-api.test.ts` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/story-api.ts test/unit/story-api.test.ts +git commit -m "feat: add Story.deferRender() and Story.ready() API (#119)" +``` + +--- + +### Task 3: Gate passage rendering on `renderDeferred` in PassageDisplay + +**Files:** + +- Modify: `src/components/macros/PassageDisplay.tsx:68-291` +- Test: `test/dom/passage-display-transition.test.tsx` (add new tests) + +- [ ] **Step 1: Read the existing test file** + +Read `test/dom/passage-display-transition.test.tsx` to understand the full test setup. Tests use `renderPassageMacro(container)` which tokenizes `{passage}` and renders via the macro system. Use the same pattern for new tests. + +- [ ] **Step 2: Write failing tests** + +Add a new `describe('deferred render', ...)` block inside the outer `describe('PassageDisplay transition state machine', ...)` in `test/dom/passage-display-transition.test.tsx`: + +```ts +describe('deferred render', () => { + it('shows StoryLoading passage content when renderDeferred is true', () => { + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Start content'), + makePassage(2, 'StoryLoading', 'Loading, please wait...'), + ]); + useStoryStore.getState().init(storyData); + useStoryStore.getState().deferRender(); + + renderPassageMacro(container); + + expect(container.textContent).toContain('Loading, please wait...'); + expect(container.textContent).not.toContain('Start content'); + }); + + it('shows empty content when renderDeferred is true and no StoryLoading passage', () => { + const storyData = makeStoryData([makePassage(1, 'Start', 'Start content')]); + useStoryStore.getState().init(storyData); + useStoryStore.getState().deferRender(); + + renderPassageMacro(container); + + expect(container.textContent).not.toContain('Start content'); + }); + + it('shows current passage after clearDeferredRender', () => { + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Start content'), + makePassage(2, 'StoryLoading', 'Loading...'), + ]); + useStoryStore.getState().init(storyData); + useStoryStore.getState().deferRender(); + + renderPassageMacro(container); + expect(container.textContent).toContain('Loading...'); + + act(() => { + useStoryStore.getState().clearDeferredRender(); + }); + + expect(container.textContent).toContain('Start content'); + expect(container.textContent).not.toContain('Loading...'); + }); + + it('navigation during deferred render updates store but display stays on StoryLoading', () => { + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Start content'), + makePassage(2, 'Room', 'Room content'), + makePassage(3, 'StoryLoading', 'Loading...'), + ]); + useStoryStore.getState().init(storyData); + useStoryStore.getState().deferRender(); + + renderPassageMacro(container); + expect(container.textContent).toContain('Loading...'); + + // Navigate while deferred + act(() => { + useStoryStore.getState().navigate('Room'); + }); + + // Store updated but display still shows loading + expect(useStoryStore.getState().currentPassage).toBe('Room'); + expect(container.textContent).toContain('Loading...'); + expect(container.textContent).not.toContain('Room content'); + + // Clear deferred → shows the navigated-to passage + act(() => { + useStoryStore.getState().clearDeferredRender(); + }); + + expect(container.textContent).toContain('Room content'); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npx vitest run test/dom/passage-display-transition.test.tsx` +Expected: FAIL — PassageDisplay does not gate on `renderDeferred`. + +- [ ] **Step 4: Implement the deferred render gate in `PassageDisplay.tsx`** + +In `src/components/macros/PassageDisplay.tsx`, make these changes inside the `render` function: + +**4a.** After `const storyData = useStoryStore((s) => s.storyData);` (line 73), add: + +```ts +const renderDeferred = useStoryStore((s) => s.renderDeferred); +``` + +**4b.** Replace the passage resolution block (lines 248-259). Change this: + +```ts +// Resolve the passage to display (or show nothing during outgoing phase) +const passage = displayedPassage + ? storyData?.passages.get(displayedPassage) + : null; + +if (!passage && displayedPassage) { + return ( +
+ Error: Passage “{displayedPassage}” not found. +
+ ); +} +``` + +To this: + +```ts +// When render is deferred, show StoryLoading passage or nothing +const effectivePassage = renderDeferred + ? (storyData?.passages.get('StoryLoading') ?? null) + : displayedPassage + ? storyData?.passages.get(displayedPassage) + : null; + +if (!effectivePassage && displayedPassage && !renderDeferred) { + return ( +
+ Error: Passage “{displayedPassage}” not found. +
+ ); +} +``` + +**4c.** Update the PassageReady rendering (around line 268). Change: + +```tsx +{ + readyPassage && ( + + ); +} +``` + +To: + +```tsx +{ + !renderDeferred && readyPassage && ( + + ); +} +``` + +**4d.** Update the Passage component rendering (around line 280-286). Change: + +```tsx +{ + passage && ( + + ); +} +``` + +To: + +```tsx +{ + effectivePassage && ( + + ); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run test/dom/passage-display-transition.test.tsx` +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/components/macros/PassageDisplay.tsx test/dom/passage-display-transition.test.tsx +git commit -m "feat: gate PassageDisplay on renderDeferred, show StoryLoading (#119)" +``` + +--- + +### Task 4: Defer `:storyready` dispatch in index.tsx + +**Files:** + +- Modify: `src/index.tsx:1-5` (imports), `src/index.tsx:203` (:storyready dispatch) +- Test: `test/unit/deferred-storyready.test.ts` (new file) + +- [ ] **Step 1: Write the test file** + +Create `test/unit/deferred-storyready.test.ts`: + +```ts +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useStoryStore } from '../../src/store'; +import { + installStoryAPI, + getReadyPromise, + _resetReadyState, +} from '../../src/story-api'; + +describe('deferred :storyready', () => { + beforeEach(() => { + _resetReadyState(); + useStoryStore.setState({ + storyData: null, + currentPassage: '', + variables: {}, + variableDefaults: {}, + temporary: {}, + history: [], + historyIndex: -1, + visitCounts: {}, + renderCounts: {}, + renderDeferred: false, + transitionConfig: null, + nextTransition: null, + }); + installStoryAPI(); + }); + + afterEach(() => { + _resetReadyState(); + }); + + it('getReadyPromise() is null when deferRender was not called', () => { + expect(getReadyPromise()).toBeNull(); + }); + + it('getReadyPromise() returns a promise after deferRender()', () => { + window.Story.deferRender(); + expect(getReadyPromise()).toBeInstanceOf(Promise); + }); + + it(':storyready fires after ready() resolves the promise', async () => { + window.Story.deferRender(); + + const handler = vi.fn(); + document.addEventListener(':storyready', handler); + + const promise = getReadyPromise()!; + + // Simulate what index.tsx does: chain :storyready on the promise + promise.then(() => { + document.dispatchEvent(new CustomEvent(':storyready')); + }); + + expect(handler).not.toHaveBeenCalled(); + + window.Story.ready(); + await promise; + // Allow microtask to flush + await new Promise((r) => setTimeout(r, 0)); + + expect(handler).toHaveBeenCalledTimes(1); + + document.removeEventListener(':storyready', handler); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `npx vitest run test/unit/deferred-storyready.test.ts` +Expected: ALL PASS (tests the promise mechanism already implemented in Task 2). + +- [ ] **Step 3: Modify `index.tsx` to defer `:storyready`** + +In `src/index.tsx`, change the import on line 5: + +```ts +// Before: +import { installStoryAPI } from './story-api'; + +// After: +import { installStoryAPI, getReadyPromise } from './story-api'; +``` + +Then replace the `:storyready` dispatch at line 203. Change: + +```ts +document.dispatchEvent(new CustomEvent(':storyready')); +``` + +To: + +```ts +const pending = getReadyPromise(); +if (pending) { + pending.then(() => { + document.dispatchEvent(new CustomEvent(':storyready')); + }); +} else { + document.dispatchEvent(new CustomEvent(':storyready')); +} +``` + +- [ ] **Step 4: Run full test suite** + +Run: `npx vitest run` +Expected: ALL PASS + +- [ ] **Step 5: Run type check** + +Run: `npx tsc --noEmit` +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/index.tsx test/unit/deferred-storyready.test.ts +git commit -m "feat: defer :storyready dispatch when render is deferred (#119)" +``` + +--- + +### Task 5: Final verification + +**Files:** + +- All modified files from Tasks 1-4 + +- [ ] **Step 1: Run full test suite** + +Run: `npx vitest run` +Expected: ALL PASS + +- [ ] **Step 2: Run type check** + +Run: `npx tsc --noEmit` +Expected: No errors. + +- [ ] **Step 3: Verify the complete boot flow by reading the code path** + +Trace through the code to confirm: + +1. `Story.deferRender()` in author JS → sets `renderDeferred = true`, creates promise +2. Store init → StoryInit → session restore → `:storyinit` (unchanged) +3. `render(, root)` → App renders → StoryInterface renders → `{passage}` macro sees `renderDeferred = true` → shows `StoryLoading` passage +4. `getReadyPromise()` returns the promise → `.then()` chains `:storyready` +5. Game's async boot completes → `Story.ready()` → clears `renderDeferred`, resolves promise +6. `{passage}` re-renders with actual passage → `:storyready` fires + +- [ ] **Step 4: Verify the restart flow by reading the code path** + +Confirm that: + +1. `Story.restart()` resets store including `renderDeferred = false` +2. `fireStoryInit()` calls listeners → game can call `Story.deferRender()` again +3. New promise is created → `{passage}` shows `StoryLoading` again +4. Game calls `Story.ready()` → passage renders (no `:storyready` — it only fires once per page load) diff --git a/docs/superpowers/plans/2026-03-27-published-types-sync.md b/docs/superpowers/plans/2026-03-27-published-types-sync.md new file mode 100644 index 0000000..c98c53c --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-published-types-sync.md @@ -0,0 +1,585 @@ +# Published Types Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring `types/index.d.ts` in sync with the source `StoryAPI` interface and add a compile-time drift check so they never diverge again. + +**Architecture:** Hand-update the published `.d.ts` with all missing types and members, then add a `.ts` file that asserts bidirectional assignability between the source and published `StoryAPI` types. The drift check runs as part of `npx tsc --noEmit`. + +**Tech Stack:** TypeScript type declarations, `tsc --noEmit` for verification. + +--- + +### Task 1: Create drift-detection check (will fail until types are updated) + +**Files:** + +- Create: `src/types-drift-check.ts` + +- [ ] **Step 1: Write the drift-check file** + +```ts +/** + * Compile-time check: the hand-written types/index.d.ts must stay in sync + * with the source StoryAPI interface. If this file fails to compile, + * the published types have drifted from the implementation. + * + * Run: npx tsc --noEmit + */ +import type { StoryAPI as SourceAPI } from './story-api'; +import type { StoryAPI as PublishedAPI } from '../types/index'; + +// Both directions — if either fails, the types have drifted. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _sourceToPublished: PublishedAPI = {} as SourceAPI; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _publishedToSource: SourceAPI = {} as PublishedAPI; +``` + +- [ ] **Step 2: Run typecheck to verify it fails** + +Run: `npx tsc --noEmit 2>&1 | head -40` +Expected: Multiple errors about missing properties on `PublishedAPI` (the whole point — types haven't been updated yet). + +- [ ] **Step 3: Commit the failing check** + +```bash +git add src/types-drift-check.ts +git commit -m "test: add compile-time drift check for published types + +Refs #134" +``` + +--- + +### Task 2: Update `types/index.d.ts` — add supporting types + +**Files:** + +- Modify: `types/index.d.ts` + +These types are referenced by the new `StoryAPI` members and must be added before the interface itself. + +- [ ] **Step 1: Add event types after the `Passage` interface (line 103)** + +Insert after the `Passage` interface closing brace: + +```ts +/** + * Map of story event names to their callback signatures. + * @see {@link ../../src/event-emitter.ts} for the implementation. + */ +export interface StoryEventMap { + storyinit: () => void; + beforerestart: () => void; + actionsChanged: () => void; + variableChanged: ( + changed: Record, + ) => void; + beforesave: ( + slot: string | undefined, + custom: Record | undefined, + ) => void; + aftersave: (slot: string | undefined) => void; + beforeload: (slot: string | undefined) => void; + afterload: (slot: string | undefined) => void; + beforenavigate: (passageName: string) => void; + afternavigate: (to: string, from: string) => void; +} + +/** Event name that can be passed to `Story.on()`. */ +export type StoryEvent = keyof StoryEventMap; + +/** Callback type for a given story event. */ +export type StoryEventCallback = StoryEventMap[E]; +``` + +- [ ] **Step 2: Add transition types** + +Insert after the event types: + +```ts +/** Transition animation type. */ +export type TransitionType = 'none' | 'fade' | 'fade-through' | 'crossfade'; + +/** + * Configuration for passage transitions. + * @see {@link ../../src/transition.ts} for the implementation. + */ +export interface TransitionConfig { + type: TransitionType; + duration?: number; + pause?: number; +} +``` + +- [ ] **Step 3: Add watch options** + +```ts +/** + * Options for `Story.watch()` trigger registration. + * @see {@link ../../src/triggers.ts} for the implementation. + */ +export interface WatchOptions { + goto?: string; + dialog?: string; + run?: string; + once?: boolean; + name?: string; + priority?: number; +} +``` + +- [ ] **Step 4: Add action types** + +```ts +/** Type of interactive action registered by a macro. */ +export type ActionType = + | 'link' + | 'button' + | 'cycle' + | 'textbox' + | 'numberbox' + | 'textarea' + | 'checkbox' + | 'radiobutton' + | 'listbox' + | 'back' + | 'forward' + | 'restart' + | 'save' + | 'load' + | 'dialog'; + +/** + * A registered interactive action (link, button, input, etc.). + * @see {@link ../../src/action-registry.ts} for the implementation. + */ +export interface StoryAction { + id: string; + type: ActionType; + label: string; + target?: string; + variable?: string; + options?: string[]; + value?: unknown; + disabled?: boolean; + perform: (value?: unknown) => void; +} +``` + +- [ ] **Step 5: Add storage types** + +```ts +/** + * Storage usage information returned by `Story.storage.getInfo()`. + * @see {@link ../../src/saves/types.ts} for the implementation. + */ +export interface StorageInfo { + saveCount: number; + playthroughCount: number; + totalBytes: number; + backend: 'indexeddb' | 'localstorage' | 'memory'; +} + +/** + * Browser storage quota estimate returned by `Story.storage.getQuota()`. + * @see {@link ../../src/saves/types.ts} for the implementation. + */ +export interface StorageQuota { + usage: number; + quota: number; + estimateSupported: boolean; +} +``` + +- [ ] **Step 6: Add macro definition types** + +```ts +/** + * Parameter metadata for a macro definition. + * @see {@link ../../src/registry.ts} for the implementation. + */ +export interface ParameterDef { + name: string; + required?: boolean; + description?: string; +} + +/** + * Metadata about a registered macro, returned by `Story.getMacroRegistry()`. + * @see {@link ../../src/registry.ts} for the implementation. + */ +export interface MacroMetadata { + name: string; + block: boolean; + subMacros: string[]; + storeVar?: boolean; + interpolate?: boolean; + merged?: boolean; + source: 'builtin' | 'user'; + description?: string; + parameters?: ParameterDef[]; +} + +/** + * Props passed to a macro's render function. + * @see {@link ../../src/registry.ts} for the implementation. + */ +export interface MacroProps { + rawArgs: string; + className?: string; + id?: string; + children?: any[]; + branches?: Array<{ + rawArgs: string; + className?: string; + id?: string; + children: any[]; + }>; +} + +/** + * Options for registering an interactive action via `ctx.useAction`. + * @see {@link ../../src/hooks/use-action.ts} for the implementation. + */ +export interface UseActionOptions { + type: ActionType; + key: string; + authorId?: string; + label: string; + target?: string; + variable?: string; + options?: string[]; + value?: unknown; + disabled?: boolean; + perform: (value?: unknown) => void; +} + +/** + * Context object passed to a macro's render function alongside props. + * Internal Preact/AST types are represented as `any` since consumers + * may not have Preact type definitions installed. + * @see {@link ../../src/define-macro.ts} for the implementation. + */ +export interface MacroContext { + className?: string; + id?: string; + resolve?: (s: string | undefined) => string | undefined; + cls: string; + mutate: (code: string) => void; + update: (key: string, value: unknown) => void; + getValues: () => Record; + merged?: readonly [ + Record, + Record, + Record, + ]; + varName?: string; + value?: unknown; + setValue?: (value: unknown) => void; + getValue?: () => unknown; + evaluate?: (expr: string) => unknown; + collectText: (nodes: any[]) => string; + sourceLocation: () => string; + parseVarArgs: (rawArgs: string) => { varName: string; placeholder: string }; + extractOptions: (children: any[]) => string[]; + wrap: (content: any) => any; + useAction: (opts: UseActionOptions) => string; + h: (type: any, props: any, ...children: any[]) => any; + renderNodes: ( + nodes: any[], + options?: { nobr?: boolean; locals?: Record }, + ) => any; + renderInlineNodes: (nodes: any[]) => any; + hooks: { + useState: any; + useRef: any; + useEffect: any; + useLayoutEffect: any; + useCallback: any; + useMemo: any; + useContext: any; + }; +} + +/** + * Configuration object for `Story.defineMacro()`. + * @see {@link ../../src/define-macro.ts} for the implementation. + */ +export interface MacroDefinition { + name: string; + subMacros?: string[]; + block?: boolean; + interpolate?: boolean; + merged?: boolean; + storeVar?: boolean; + description?: string; + parameters?: ParameterDef[]; + render: (props: MacroProps, ctx: MacroContext) => any; +} +``` + +- [ ] **Step 7: Commit supporting types** + +```bash +git add types/index.d.ts +git commit -m "feat(types): add supporting types for full StoryAPI surface + +Adds StoryEventMap, TransitionConfig, WatchOptions, StoryAction, +StorageInfo, StorageQuota, MacroDefinition, MacroContext, and related +types to the published type declarations. + +Refs #134" +``` + +--- + +### Task 3: Update `types/index.d.ts` — fix existing `StoryAPI` members and add missing ones + +**Files:** + +- Modify: `types/index.d.ts` + +- [ ] **Step 1: Fix `HistoryMoment` to include optional `prng` field** + +Change the existing `HistoryMoment` interface from: + +```ts +export interface HistoryMoment { + passage: string; + variables: Record; + timestamp: number; +} +``` + +To: + +```ts +export interface HistoryMoment { + passage: string; + variables: Record; + timestamp: number; + prng?: { seed: string; pull: number } | null; +} +``` + +- [ ] **Step 2: Add `prng` field to `SavePayload`** + +The source `SavePayload` in `src/saves/types.ts` has `prng?: PRNGSnapshot | null` which is missing from the published type. Add it: + +Change: + +```ts +export interface SavePayload { + passage: string; + variables: Record; + history: HistoryMoment[]; + historyIndex: number; + visitCounts?: Record; + renderCounts?: Record; +} +``` + +To: + +```ts +export interface SavePayload { + passage: string; + variables: Record; + history: HistoryMoment[]; + historyIndex: number; + visitCounts?: Record; + renderCounts?: Record; + prng?: { seed: string; pull: number } | null; +} +``` + +- [ ] **Step 3: Fix `visited`/`hasVisited`/`rendered`/`hasRendered` to accept optional name** + +The source signatures use `name?: string` (optional, defaults to current passage). Change: + +```ts + visited(name: string): number; + hasVisited(name: string): boolean; +``` + +To: + +```ts + visited(name?: string): number; + hasVisited(name?: string): boolean; +``` + +And change: + +```ts + rendered(name: string): number; + hasRendered(name: string): boolean; +``` + +To: + +```ts + rendered(name?: string): number; + hasRendered(name?: string): boolean; +``` + +- [ ] **Step 4: Add all missing `StoryAPI` members** + +Add these members to the `StoryAPI` interface, after `isDialogOpen()` and before the closing brace. Group by domain: + +```ts + /** Register a class constructor for use in story expressions. */ + registerClass(name: string, ctor: new (...args: any[]) => any): void; + + /** Register a custom macro. */ + defineMacro(config: MacroDefinition): void; + + /** Return metadata for all registered macros. */ + getMacroRegistry(): MacroMetadata[]; + + /** Storage management API. */ + readonly storage: { + /** Get storage usage information (save count, byte size, backend type). */ + getInfo(): Promise; + /** Get browser storage quota estimate. */ + getQuota(): Promise; + /** Delete all saves for the current game. */ + clearGameData(): Promise; + /** Delete all Spindle data across all games. */ + clearAllData(): Promise; + /** Delete a specific playthrough and its saves. */ + deletePlaythrough(playthroughId: string): Promise; + /** The active storage backend. */ + readonly backend: 'indexeddb' | 'localstorage' | 'memory'; + }; + + /** Return all registered interactive actions. */ + getActions(): StoryAction[]; + + /** Perform a registered action by ID. */ + performAction(id: string, value?: unknown): void; + + /** Subscribe to a story event. Returns an unsubscribe function. */ + on( + event: E, + callback: StoryEventCallback, + ): () => void; + + /** Wait for the next frame's actions to be registered, then return them. */ + waitForActions(): Promise; + + /** Register a trigger that fires when a condition expression becomes truthy. Returns an unsubscribe function. */ + watch( + condition: string, + callbackOrOptions: (() => void) | WatchOptions, + ): () => void; + + /** Remove a named trigger registered with `watch()`. */ + unwatch(name: string): void; + + /** Enable or disable the `{nobr}` (no line breaks) rendering mode globally. */ + setNobr(enabled: boolean): void; + + /** Enable or disable the story stylesheet. */ + setCSS(enabled: boolean): void; + + /** Set the default passage transition. Pass `null` to clear. */ + setTransition(config: TransitionConfig | null): void; + + /** Set a one-time transition for the next navigation only. Pass `null` to clear. */ + setNextTransition(config: TransitionConfig | null): void; + + /** Defer initial passage rendering until `ready()` is called. */ + deferRender(): void; + + /** Unblock deferred rendering (call after `deferRender()`). */ + ready(): void; + + /** Return a random float in [0, 1). Uses the seeded PRNG if enabled, otherwise Math.random(). */ + random(): number; + + /** Return a random integer in [min, max] (inclusive). */ + randomInt(min: number, max: number): number; + + /** Story configuration. */ + readonly config: { + /** Maximum number of history moments to retain. */ + maxHistory: number; + }; + + /** Seedable pseudo-random number generator. */ + readonly prng: { + /** Initialize the PRNG with an optional seed. */ + init(seed?: string, useEntropy?: boolean): void; + /** Check whether the seeded PRNG is active. */ + isEnabled(): boolean; + /** The current PRNG seed. */ + readonly seed: string; + /** The number of values pulled from the current seed. */ + readonly pull: number; + }; +``` + +- [ ] **Step 5: Commit StoryAPI updates** + +```bash +git add types/index.d.ts +git commit -m "feat(types): add all missing StoryAPI members to published types + +Adds on(), deferRender(), ready(), setNobr(), setCSS(), setTransition(), +setNextTransition(), defineMacro(), getMacroRegistry(), registerClass(), +getActions(), performAction(), waitForActions(), watch(), unwatch(), +random(), randomInt(), config, prng, and storage namespaces. + +Fixes visited/hasVisited/rendered/hasRendered to accept optional name. +Fixes HistoryMoment to include optional prng field. + +Fixes #134" +``` + +--- + +### Task 4: Verify typecheck passes and run tests + +**Files:** + +- None (verification only) + +- [ ] **Step 1: Run typecheck** + +Run: `npx tsc --noEmit` +Expected: Clean exit (0 errors). The drift-check file now compiles because the published types match the source. + +- [ ] **Step 2: Run existing tests** + +Run: `npx vitest run` +Expected: All existing tests pass (no runtime changes were made). + +- [ ] **Step 3: Squash the two intermediate commits into one clean commit** + +The two intermediate commits (Task 1 step 3 and Task 2 step 7) are scaffolding. Squash the last 3 commits (drift check, supporting types, StoryAPI members) into one: + +```bash +git reset --soft HEAD~3 +git commit -m "feat(types): sync published types with source StoryAPI + +Add all missing StoryAPI members to types/index.d.ts: on(), deferRender(), +ready(), setNobr(), setCSS(), setTransition(), setNextTransition(), +defineMacro(), getMacroRegistry(), registerClass(), getActions(), +performAction(), waitForActions(), watch(), unwatch(), random(), +randomInt(), config, prng, and storage namespaces. + +Add supporting types: StoryEventMap, StoryEvent, StoryEventCallback, +TransitionConfig, WatchOptions, StoryAction, StorageInfo, StorageQuota, +MacroDefinition, MacroContext, MacroProps, MacroMetadata. + +Fix visited/hasVisited/rendered/hasRendered to accept optional name arg. +Fix HistoryMoment to include optional prng field. + +Add src/types-drift-check.ts — a compile-time check that fails typecheck +if the published and source StoryAPI interfaces diverge. + +Fixes #134" +``` diff --git a/docs/superpowers/plans/2026-03-28-plain-text-fast-path.md b/docs/superpowers/plans/2026-03-28-plain-text-fast-path.md new file mode 100644 index 0000000..7719a0e --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-plain-text-fast-path.md @@ -0,0 +1,522 @@ +# Plain Text Fast Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Skip micromark + innerHTML for plain text without markdown syntax in `renderNodes`, eliminating ~54% of remaining click time (288ms → ~80-100ms). + +**Architecture:** Add a detection regex and `buildPlainTextVnodes` helper in `src/markup/render.tsx`. After the combined string is built, strip placeholders, test for markdown syntax characters. If none found, split on placeholders and build vnodes directly. Falls through to existing pipeline on any match. + +**Tech Stack:** Preact, TypeScript, Vitest (happy-dom) + +--- + +### Task 1: Write failing tests for plain text fast path correctness + +**Files:** + +- Create: `test/dom/render-plain-text.test.tsx` + +These tests exercise `renderNodes` directly through tokenize → buildAST → renderNodes. They should pass both before and after the implementation (the fast path must produce identical output to the existing pipeline). + +- [ ] **Step 1: Create the test file with correctness tests** + +```tsx +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from 'preact'; +import { tokenize } from '../../src/markup/tokenizer'; +import { buildAST } from '../../src/markup/ast'; +import { renderNodes } from '../../src/markup/render'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage } from '../../src/parser'; + +function makePassage(pid: number, name: string, content: string): Passage { + return { pid, name, tags: [], metadata: {}, content }; +} + +function makeStoryData(passages: Passage[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +function renderMarkup( + markup: string, + options?: { nobr?: boolean }, +): HTMLElement { + const tokens = tokenize(markup); + const ast = buildAST(tokens); + const container = document.createElement('div'); + render(<>{renderNodes(ast, options)}, container); + return container; +} + +describe('plain text fast path (issue #145)', () => { + beforeEach(() => { + const store = useStoryStore.getState(); + store.init(makeStoryData([makePassage(1, 'Start', 'Start')])); + }); + + describe('correctly renders plain text without markdown pipeline', () => { + it('plain text in nobr mode', () => { + const el = renderMarkup('Hello World', { nobr: true }); + expect(el.textContent).toBe('Hello World'); + }); + + it('plain text with variable placeholder', () => { + useStoryStore.getState().setVariable('name', 'ALMA'); + const el = renderMarkup('Name: {$name}', { nobr: true }); + expect(el.textContent).toBe('Name: ALMA'); + }); + + it('Unicode symbols (not markdown syntax)', () => { + const el = renderMarkup('▸ Menu ✕ Close', { nobr: true }); + expect(el.textContent).toBe('▸ Menu ✕ Close'); + }); + + it('emoji content', () => { + const el = renderMarkup('🔒 Locked', { nobr: true }); + expect(el.textContent).toBe('🔒 Locked'); + }); + + it('text with colons, commas, slashes (non-markdown punctuation)', () => { + const el = renderMarkup('Requires: Level 2, Policy / Tier 3', { + nobr: true, + }); + expect(el.textContent).toBe('Requires: Level 2, Policy / Tier 3'); + }); + + it('text with parentheses and equals', () => { + const el = renderMarkup('Speed (km/h) = 100', { nobr: true }); + expect(el.textContent).toBe('Speed (km/h) = 100'); + }); + + it('bare exclamation mark is not markdown', () => { + const el = renderMarkup('Activate!', { nobr: true }); + expect(el.textContent).toBe('Activate!'); + }); + + it('multiple variables interleaved with text', () => { + useStoryStore.getState().setVariable('a', 'Alpha'); + useStoryStore.getState().setVariable('b', 'Beta'); + const el = renderMarkup('{$a} and {$b}', { nobr: true }); + expect(el.textContent).toBe('Alpha and Beta'); + }); + + it('HTML element children with plain text', () => { + const el = renderMarkup( + '
Hello
', + { nobr: true }, + ); + expect(el.querySelector('.label')!.textContent).toBe('Hello'); + }); + }); + + describe('non-nobr wraps in

', () => { + it('plain text without nobr gets

wrapper', () => { + const el = renderMarkup('Just some text'); + const p = el.querySelector('p'); + expect(p).not.toBeNull(); + expect(p!.textContent).toBe('Just some text'); + }); + + it('plain text with variable without nobr', () => { + useStoryStore.getState().setVariable('name', 'ALMA'); + const el = renderMarkup('Hello {$name}'); + const p = el.querySelector('p'); + expect(p).not.toBeNull(); + expect(p!.textContent).toBe('Hello ALMA'); + }); + }); + + describe('falls through to markdown for syntax-bearing text', () => { + it('emphasis with asterisks', () => { + const el = renderMarkup('This is *important* text'); + expect(el.querySelector('em')!.textContent).toBe('important'); + }); + + it('emphasis with underscores', () => { + const el = renderMarkup('This is _emphasized_ text'); + expect(el.querySelector('em')!.textContent).toBe('emphasized'); + }); + + it('inline code', () => { + const el = renderMarkup('Use `console.log` here'); + expect(el.querySelector('code')!.textContent).toBe('console.log'); + }); + + it('heading', () => { + const el = renderMarkup('# Title'); + expect(el.querySelector('h1')!.textContent).toBe('Title'); + }); + + it('link syntax', () => { + const el = renderMarkup('See [docs](http://example.com)'); + const link = el.querySelector('a'); + expect(link).not.toBeNull(); + expect(link!.textContent).toBe('docs'); + }); + + it('GFM strikethrough', () => { + const el = renderMarkup('This is ~~deleted~~ text'); + expect(el.querySelector('del')!.textContent).toBe('deleted'); + }); + + it('backslash escape', () => { + const el = renderMarkup('Price\\: free'); + expect(el.textContent).toContain('Price'); + }); + + it('unordered list item', () => { + const el = renderMarkup('- Item 1\n- Item 2'); + expect(el.querySelector('ul')).not.toBeNull(); + }); + + it('ordered list item', () => { + const el = renderMarkup('1. First\n2. Second'); + expect(el.querySelector('ol')).not.toBeNull(); + }); + + it('image syntax', () => { + const el = renderMarkup('![alt](http://example.com/img.png)'); + expect(el.querySelector('img')).not.toBeNull(); + }); + + it('blockquote', () => { + const el = renderMarkup('> quoted text'); + expect(el.querySelector('blockquote')).not.toBeNull(); + }); + + it('GFM table', () => { + const el = renderMarkup('| A | B |\n| --- | --- |\n| 1 | 2 |'); + expect(el.querySelector('table')).not.toBeNull(); + }); + + it('blank lines create paragraphs', () => { + const el = renderMarkup('Para 1\n\nPara 2'); + expect(el.querySelectorAll('p').length).toBeGreaterThanOrEqual(2); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they pass (baseline)** + +These tests verify existing behavior — they should all pass before any implementation changes. + +Run: `npx vitest run test/dom/render-plain-text.test.tsx` +Expected: All tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add test/dom/render-plain-text.test.tsx +git commit -m "test: add correctness baseline tests for renderNodes plain text fast path (#145)" +``` + +--- + +### Task 2: Implement the plain text fast path + +**Files:** + +- Modify: `src/markup/render.tsx:268-315` + +- [ ] **Step 1: Add the detection regex constants and `buildPlainTextVnodes` helper** + +Add these above the `renderNodes` function (before line 268), after the `getVariableTextValue` function: + +```tsx +/** + * Characters/patterns that trigger CommonMark or GFM transformations. + * Any match → fall through to the full micromark pipeline. + * False positives (e.g. `-` used as text, not list) just use the slower path. + */ +const MARKDOWN_SYNTAX_RE = /[*_`#|~\[>\\\-]|!\[|\d+\./; +const BLANK_LINE_RE = /\n\s*\n/; +const PLACEHOLDER_SPLIT_RE = /(<\/span>)/; +const PLACEHOLDER_IDX_RE = /^<\/span>$/; +const PLACEHOLDER_STRIP_RE = /<\/span>/g; + +/** + * Build Preact vnodes from a combined string that contains only plain text + * and placeholders. No micromark, no innerHTML. + */ +function buildPlainTextVnodes( + combined: string, + components: preact.ComponentChildren[], + nobr?: boolean, +): preact.ComponentChildren { + const parts = combined.split(PLACEHOLDER_SPLIT_RE); + const children: preact.ComponentChildren[] = []; + for (const part of parts) { + const m = PLACEHOLDER_IDX_RE.exec(part); + if (m) { + children.push(components[parseInt(m[1]!, 10)]); + } else if (part) { + children.push(part); + } + } + return nobr ? <>{children} : h('p', null, ...children); +} +``` + +- [ ] **Step 2: Insert the fast path check in `renderNodes`** + +In `renderNodes`, after the combined string loop (after the `for` loop ending at line 308), and before the `markdownToHtml` call (line 311), add: + +Replace this block in `renderNodes`: + +```tsx +// Run combined text through markdown +const html = markdownToHtml(combined, { inline: options?.inline }); + +// Convert HTML to Preact VNodes, replacing placeholders with components +return htmlToPreact(html, components, options?.nobr); +``` + +With: + +```tsx +// Fast path: skip micromark + innerHTML when text has no markdown syntax. +// This eliminates ~655 innerHTML calls on plain UI text like "ALMA", +// "▸ Crew", "Activate" that pass through the full pipeline only to +// produce the same text they started with (issue #145). +const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, ''); +if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) { + return buildPlainTextVnodes(combined, components, options?.nobr); +} + +// Run combined text through markdown +const html = markdownToHtml(combined, { inline: options?.inline }); + +// Convert HTML to Preact VNodes, replacing placeholders with components +return htmlToPreact(html, components, options?.nobr); +``` + +- [ ] **Step 3: Run the type checker** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Run all tests** + +Run: `npx vitest run` +Expected: All tests PASS (both new and existing) + +- [ ] **Step 5: Commit** + +```bash +git add src/markup/render.tsx +git commit -m "fix: skip micromark + innerHTML for plain text without markdown syntax (#145)" +``` + +--- + +### Task 3: Add benchmark test + +**Files:** + +- Create: `test/dom/render-plain-text-bench.test.tsx` + +This is a separate file so it can be run independently. It measures the performance impact of the fast path with an ALMA-like UI structure. + +- [ ] **Step 1: Create the benchmark test** + +```tsx +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from 'preact'; +import { Passage } from '../../src/components/Passage'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage as PassageData } from '../../src/parser'; + +function makePassage( + pid: number, + name: string, + content: string, + tags: string[] = [], +): PassageData { + return { pid, name, tags, metadata: {}, content }; +} + +function makeStoryData(passages: PassageData[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +describe('renderNodes plain-text fast path benchmark', () => { + beforeEach(() => { + const store = useStoryStore.getState(); + store.init(makeStoryData([makePassage(1, 'Start', 'Start')])); + }); + + it('ALMA-like UI passage with nested HTML + plain text labels', () => { + useStoryStore.getState().setVariable( + 'items', + Array.from({ length: 20 }, (_, i) => ({ + id: `item-${i}`, + name: `Research ${i}`, + status: i % 3 === 0 ? 'active' : 'locked', + effects: `+${i} bonus`, + })), + ); + + const content = [ + '

', + '
', + ' ALMA', + ' ', + '
', + ' ', + '
', + ' Research Tree', + ' {for @node of $items}', + '
', + '
', + ' ', + ' {@node.name}', + '
', + '
', + ' {@node.effects}', + ' {if @node.status == "active"}', + ' ', + ' {/if}', + '
', + '
', + ' {/for}', + '
', + '
', + ].join('\n'); + + const passage = makePassage(1, 'Test', content, ['nobr']); + const storyData = makeStoryData([passage]); + useStoryStore.getState().init(storyData); + useStoryStore.getState().show(1); + + const container = document.createElement('div'); + + // Warm up + render(, container); + container.innerHTML = ''; + + // Benchmark + const iterations = 50; + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + render(, container); + container.innerHTML = ''; + } + const elapsed = performance.now() - start; + const perRender = elapsed / iterations; + + // Verify correctness + render(, container); + expect(container.querySelectorAll('.card').length).toBe(20); + expect(container.querySelector('.header-id')!.textContent).toBe('ALMA'); + expect(container.querySelectorAll('.nav-item').length).toBe(7); + + console.log( + `ALMA-like UI: ${perRender.toFixed(2)}ms/render (${iterations} iterations, ${elapsed.toFixed(0)}ms total)`, + ); + }); + + it('worst case: many small HTML elements with short text labels', () => { + const items = Array.from({ length: 100 }, (_, i) => `Label ${i}`); + useStoryStore.getState().setVariable('labels', items); + + const content = + '{for @label of $labels}
{@label}
{/for}'; + + const passage = makePassage(1, 'Test', content, ['nobr']); + const storyData = makeStoryData([passage]); + useStoryStore.getState().init(storyData); + useStoryStore.getState().show(1); + + const container = document.createElement('div'); + + // Warm up + render(, container); + container.innerHTML = ''; + + const iterations = 20; + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + render(, container); + container.innerHTML = ''; + } + const elapsed = performance.now() - start; + const perRender = elapsed / iterations; + + render(, container); + expect(container.querySelectorAll('.cell').length).toBe(100); + + console.log( + `100 HTML elements with text: ${perRender.toFixed(2)}ms/render (${iterations} iterations, ${elapsed.toFixed(0)}ms total)`, + ); + }); +}); +``` + +- [ ] **Step 2: Run the benchmark** + +Run: `npx vitest run test/dom/render-plain-text-bench.test.tsx` +Expected: PASS, console output shows ms/render timings + +- [ ] **Step 3: Commit** + +```bash +git add test/dom/render-plain-text-bench.test.tsx +git commit -m "test: add benchmark for renderNodes plain text fast path (#145)" +``` + +--- + +### Task 4: Final verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run full test suite** + +Run: `npx vitest run` +Expected: All tests PASS + +- [ ] **Step 2: Run type checker** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Verify the fast path is actually being hit** + +Add a temporary `console.log` in `buildPlainTextVnodes` and run the ALMA benchmark to confirm the fast path fires. Then remove the console.log. + +Alternatively, check that the benchmark shows measurable improvement compared to a baseline (comment out the fast path check, run benchmark, restore, run again, compare). diff --git a/docs/superpowers/specs/2026-03-25-deferred-render-design.md b/docs/superpowers/specs/2026-03-25-deferred-render-design.md new file mode 100644 index 0000000..894f4f7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-deferred-render-design.md @@ -0,0 +1,117 @@ +# Deferred Render for Async Engine Boot + +**Issue:** #119 +**Date:** 2026-03-25 + +## Problem + +On page reload, Spindle restores the last-visited passage from session storage and renders it immediately during startup. Games with async initialization (e.g., building a game engine from YAML configs) have no way to defer the initial passage render until their boot sequence completes. + +This causes `ReferenceError` crashes when the restored passage uses globals set up during boot (e.g., `window.rr` for a SpindleBridge API). + +## Design + +### API + +Two new methods on `window.Story`: + +```ts +Story.deferRender(): void // Suppress passage rendering until ready() is called +Story.ready(): void // Unblock rendering +``` + +- `deferRender()` can be called in author JS (initial boot) or inside a `storyinit` handler (restart). +- `ready()` clears the deferred state. +- Calling `ready()` without a prior `deferRender()` is a no-op. +- Calling `deferRender()` after render is already active is a no-op (guard against misuse). +- `deferRender()` is idempotent — calling it twice in the same deferred window is harmless. + +### Promise Lifecycle + +A module-level promise/resolver pair lives in `story-api.ts`: + +```ts +let readyResolve: (() => void) | null = null; +let readyPromise: Promise | null = null; +``` + +- `Story.deferRender()` creates a fresh `Promise` and stores the resolver. Each call replaces the previous pair (supports restart). +- `Story.ready()` calls `readyResolve()`, then nulls both references. +- `getReadyPromise(): Promise | null` — exported for `index.tsx` to consume. + +On initial boot, `index.tsx` calls `render(, root)`, then checks `getReadyPromise()`. If non-null, it chains `.then(() => dispatch(':storyready'))`. If null, it dispatches `:storyready` immediately. + +On restart, there is no `:storyready` dispatch (`:storyready` fires once per page load, not per restart). The game knows rendering resumed because it called `Story.ready()` itself. + +### Store Changes + +Add to the Zustand store: + +- `renderDeferred: boolean` — default `false` +- `deferRender(): void` — sets `renderDeferred = true` +- `clearDeferredRender(): void` — sets `renderDeferred = false` + +### Rendering While Deferred + +The deferred gate lives in the `{passage}` macro (`PassageDisplay.tsx`), not at the App level. `StoryInterface` still renders normally (menubar, chrome), but `{passage}` shows the `StoryLoading` passage content instead of the current passage while `renderDeferred` is true. + +- If a `StoryLoading` passage exists, render it through the normal markup pipeline (supports macros, HTML, styling — but should avoid referencing globals that depend on async boot). +- If the passage does not exist, render nothing (empty content area). + +`StoryLoading` is added to `SPECIAL_PASSAGES` in `store.ts` to prevent accidental navigation to it. + +### Navigation During Deferred Render + +Navigation (`Story.goto()`, links) works normally during the deferred window — store state updates, history entries are pushed, visit counts increment. The passage is simply not displayed until `ready()` is called, at which point the last-navigated-to passage renders. This matches the expected use case: the game's async boot may involve state restoration that triggers navigation. + +### Boot Sequence (index.tsx) + +All steps before render are unchanged (parse story, inject styles, install API, author CSS/JS, `:storystartup`, validate, init store, StoryInit, session restore, `:storyinit`, widgets, subscriptions). + +``` +render(, root) +if getReadyPromise() !== null: + readyPromise.then(() => dispatch(':storyready')) +else: + dispatch(':storyready') // current behavior +``` + +### Restart Flow + +On `Story.restart()`: + +1. Store resets state (existing behavior). +2. `executeStoryInit()` + `fireStoryInit()` (existing behavior). +3. If a `storyinit` listener called `Story.deferRender()`, a fresh promise is created and `renderDeferred` becomes true — passage rendering is suppressed. +4. Game does async work, calls `Story.ready()` to unblock. No `:storyready` is dispatched. + +## Usage Example + +```js +// Author JS (runs before :storystartup) +Story.deferRender(); + +// storyinit listener (fires on both initial boot and restart) +Story.on('storyinit', async () => { + await buildGameStateSim(); // async engine construction + mountSpindleBridge(); // sets window.rr + Story.ready(); // unblocks render +}); +``` + +## Scope + +### Changed + +- `src/store.ts` — add `renderDeferred`, `deferRender()`, `clearDeferredRender()`; add `StoryLoading` to `SPECIAL_PASSAGES` +- `src/story-api.ts` — expose `Story.deferRender()` and `Story.ready()`; hold promise/resolver pair +- `src/index.tsx` — defer `:storyready` dispatch when render is deferred +- `src/components/macros/PassageDisplay.tsx` — gate on `renderDeferred`, render `StoryLoading` passage content + +### Not Changed + +- `src/components/App.tsx` — no changes; StoryInterface renders normally during deferred window +- `:storystartup` semantics +- `:storyinit` semantics (still sync fire-and-forget) +- `:storyready` still fires once per page load (just delayed when deferred) +- No timeout — if `ready()` is never called, that's a game bug diff --git a/docs/superpowers/specs/2026-03-27-published-types-sync-design.md b/docs/superpowers/specs/2026-03-27-published-types-sync-design.md new file mode 100644 index 0000000..111cf3a --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-published-types-sync-design.md @@ -0,0 +1,137 @@ +# Published Types Sync — Design Spec + +**Issue:** #134 — `types/index.d.ts` missing `on()`, `deferRender()`, `ready()`, and ~17 other members +**Date:** 2026-03-27 +**Approach:** Hand-update + drift-detection test (Option A) + +## Problem + +The hand-written `types/index.d.ts` (npm-published type declarations) is a subset of the source `StoryAPI` interface in `src/story-api.ts`. Host applications must augment `StoryAPI` to use missing methods, which is fragile and duplicates type information. + +## Changes + +### 1. Add supporting types to `types/index.d.ts` + +New type/interface declarations needed (all derived from source): + +| Type | Source file | Purpose | +| ----------------------- | ------------------------ | ---------------------------------------------------------------- | +| `StoryEvent` | `src/event-emitter.ts` | Union of event name literals | +| `StoryEventCallback` | `src/event-emitter.ts` | Per-event callback signatures (mapped type) | +| `TransitionType` | `src/transition.ts` | `'none' \| 'fade' \| 'fade-through' \| 'crossfade'` | +| `TransitionConfig` | `src/transition.ts` | `{ type, duration?, pause? }` | +| `WatchOptions` | `src/triggers.ts` | `{ goto?, dialog?, run?, once?, name?, priority? }` | +| `MacroDefinition` | `src/define-macro.ts` | Config object for `defineMacro()` — see §4 for public-safe shape | +| `MacroProps` | `src/registry.ts` | Props passed to macro render function | +| `MacroContext` | `src/define-macro.ts` | Context object passed to macro render function | +| `MacroMetadata` | `src/registry.ts` | Returned by `getMacroRegistry()` | +| `ParameterDef` | `src/registry.ts` | Used within `MacroMetadata` | +| `ActionType` | `src/action-registry.ts` | Union of action type literals | +| `StoryAction` | `src/action-registry.ts` | Returned by `getActions()` | +| `StorageInfo` | `src/saves/types.ts` | Returned by `storage.getInfo()` | +| `StorageQuota` | `src/saves/types.ts` | Returned by `storage.getQuota()` | + +### 2. Add missing `StoryAPI` members + +Members present in `src/story-api.ts:99-176` but absent from `types/index.d.ts`: + +**Event system:** + +- `on(event: E, callback: StoryEventCallback): () => void` + +**Deferred rendering:** + +- `deferRender(): void` +- `ready(): void` + +**Render options:** + +- `setNobr(enabled: boolean): void` +- `setCSS(enabled: boolean): void` +- `setTransition(config: TransitionConfig | null): void` +- `setNextTransition(config: TransitionConfig | null): void` + +**Macro system:** + +- `registerClass(name: string, ctor: new (...args: any[]) => any): void` +- `defineMacro(config: MacroDefinition): void` +- `getMacroRegistry(): MacroMetadata[]` + +**Action system:** + +- `getActions(): StoryAction[]` +- `performAction(id: string, value?: unknown): void` +- `waitForActions(): Promise` + +**Watch/triggers:** + +- `watch(condition: string, callbackOrOptions: (() => void) | WatchOptions): () => void` +- `unwatch(name: string): void` + +**Randomness:** + +- `random(): number` +- `randomInt(min: number, max: number): number` + +**Namespaces:** + +- `readonly storage: { getInfo(), getQuota(), clearGameData(), clearAllData(), deletePlaythrough(), readonly backend }` +- `readonly config: { maxHistory: number }` +- `readonly prng: { init(), isEnabled(), readonly seed, readonly pull }` + +### 3. Drift-detection test + +New file: `src/types-drift-check.ts` + +A plain `.ts` file (not a vitest test) included by the existing tsconfig `src/**/*` glob. It uses TypeScript assignability checks to verify the hand-written `StoryAPI` from `types/index.d.ts` matches the source `StoryAPI` from `src/story-api.ts`. Both directions are tested: + +```ts +import type { StoryAPI as SourceAPI } from './story-api'; +import type { StoryAPI as PublishedAPI } from '../types/index'; + +// If either assignment fails to compile, the types have drifted +const _sourceToPublished: PublishedAPI = {} as SourceAPI; +const _publishedToSource: SourceAPI = {} as PublishedAPI; +``` + +This runs as part of `npx tsc --noEmit` (typecheck). If someone adds a method to the source but not the `.d.ts` (or vice versa), the typecheck fails. + +Note: the `settings` property uses `typeof settings` in source but a hand-written `SettingsAPI` interface in the published types. These are structurally equivalent, so TypeScript's structural typing handles this without special casing. + +### 4. `MacroDefinition` and supporting types for public API + +The source `MacroDefinition` in `src/define-macro.ts` references internal types via its `render` callback. For the published `.d.ts`, internal types are replaced with standalone representations: + +- **`ASTNode`** → `any` (opaque AST nodes — authors receive these, don't construct them) +- **`VNode`** → `any` (Preact VNode — consumers may not have Preact types installed) +- **`Branch`** → `{ name: string; args: string; children: any[] }` (simplified) +- **`ComponentChildren`** → `any` (Preact type) + +The published `MacroProps` interface: + +```ts +interface MacroProps { + rawArgs: string; + className?: string; + id?: string; + children?: any[]; + branches?: Array<{ name: string; args: string; children: any[] }>; +} +``` + +The published `MacroContext` interface includes all fields from `src/define-macro.ts:43-79` with internal types replaced by `any` or `(...args: any[]) => any` as appropriate. + +### 5. `SavePayload` / `HistoryMoment` alignment + +The published `types/index.d.ts` defines `HistoryMoment` (used in `SavePayload.history`) without the optional `prng` field that exists in the source `SaveHistoryMoment`. Update the published `HistoryMoment` to include `prng?: { seed: string; state: string } | null` to match. + +## Out of scope + +- Auto-generating types via `tsc --declaration` (rejected — leaks internals) +- Re-exporting source `.ts` types directly (rejected — `.d.ts` consumers can't import `.ts`) +- Changes to the `exports` field in `package.json` + +## Risks + +- `MacroDefinition` has deep internal dependencies — the published type may need to use `any` or opaque types for some callback parameters +- The `settings` property type in source is `typeof settings` (the actual module), while the published type uses a hand-written `SettingsAPI` interface — the drift test must account for this structural equivalence diff --git a/docs/superpowers/specs/2026-03-28-plain-text-fast-path-design.md b/docs/superpowers/specs/2026-03-28-plain-text-fast-path-design.md new file mode 100644 index 0000000..7a27c43 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-plain-text-fast-path-design.md @@ -0,0 +1,146 @@ +# Plain Text Fast Path in renderNodes + +**Issue:** [#145](https://github.com/rohal12/spindle/issues/145) +**Date:** 2026-03-28 +**Status:** Approved + +## Problem + +After the #143 whitespace-only fast path (1,673ms → 288ms), 54% of remaining click time is spent in `innerHTML` parsing plain UI text that contains no markdown syntax. Text like `"ALMA"`, `"▸ Crew"`, `"Activate"` passes through `micromark → innerHTML → DOM walk → Preact vnodes` and produces the same text it started with. + +The bottleneck is `HtmlNodeRenderer` (render.tsx:106) calling `renderNodes` for every HTML element's children. In a `[nobr]` passage with 23 `{for}` cards, this triggers ~655 `innerHTML` calls on plain text. + +## Solution + +Add a plain text fast path in `renderNodes` between the combined-string construction and the `markdownToHtml` call. When the text portions of the combined string contain no markdown-triggering characters, split on `` placeholders and build vnodes directly — bypassing both micromark and `innerHTML`. + +## Detection + +After building the combined string (render.tsx line 304), strip placeholder spans and test the remaining text: + +```typescript +const MARKDOWN_SYNTAX_RE = /[*_`#|~\[>\\\-+=]|!\[|\d+\./; +const BLANK_LINE_RE = /\n\s*\n/; +``` + +Characters covered: + +- `*_` — emphasis/strong +- `` ` `` — code spans +- `#` — ATX headings +- `|` — GFM tables +- `~` — GFM strikethrough +- `[` — links/images +- `>` — blockquotes +- `\` — escape sequences +- `-` — list items, thematic breaks (`---`) +- `![` — images (`![alt](url)`) — bare `!` excluded to avoid false positives on UI text like `"Activate!"` +- `+` — list items (`+ item`) +- `=` — setext heading underlines (`Title\n===`) +- `\d+\.` — ordered list items (`1.`) — note: also matches version-like strings e.g. `"v2.3"`, a harmless false positive + +### Known limitations + +- **HTML entities** (`©`, `©`) are not detected. Micromark decodes these; the fast path leaves them as literal text. In practice, Twine authors use Unicode characters directly. +- **Trailing-space hard breaks** (two+ spaces before newline → `
`) are not detected. The backslash hard break (`\`) IS caught. Trailing-space breaks are extremely rare in Twine content. +- `\n\s*\n` — paragraph breaks (blank lines) + +This is broadly correct for all `renderNodes` callers. Any false positive (text contains `-` but not as a list item) harmlessly falls through to the existing micromark path. + +## Direct Vnode Construction + +New helper function `buildPlainTextVnodes`: + +1. Split combined string on `` placeholders using a regex +2. Interleave text strings with pre-rendered components from the `components` array +3. With `nobr` (the hot path): return `<>{children}` +4. Without `nobr`: wrap in `

` to match what micromark would produce + +~15 lines, no DOM allocation. + +## Insertion Point + +In `renderNodes`, after line 304 (combined string built), before line 307 (`markdownToHtml` call): + +```typescript +// ... existing combined string construction (lines 287-304) ... + +// Fast path: skip micromark + innerHTML when text has no markdown syntax. +const textOnly = combined.replace(/<\/span>/g, ''); +if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) { + return buildPlainTextVnodes(combined, components, options?.nobr); +} + +// Run combined text through markdown (existing code) +const html = markdownToHtml(combined); +return htmlToPreact(html, components, options?.nobr); +``` + +The existing whitespace-only fast path (lines 279-285) stays as-is. It handles the case where there's no text at all (pure macro/HTML content). This new path handles the next tier: text exists but contains no markdown syntax. + +## What Doesn't Change + +- `htmlToPreact`, `convertDomNode`, `HtmlNodeRenderer`, `markdownToHtml` — untouched +- The whitespace-only fast path from #143 — stays as-is +- Any text with markdown syntax characters — uses the existing pipeline +- Public API — no signature changes + +## Test Strategy + +### Correctness tests + +Verify the fast path produces identical output to the existing pipeline: + +- Plain text in HTML element children (nobr) +- Plain text with variable placeholders +- Unicode symbols (not markdown syntax) +- Multiple HTML elements with plain text children +- Text with colons, commas, slashes (non-markdown punctuation) +- Numbers and version strings +- `{for}` loops with HTML + plain text bodies + +### Fallthrough tests + +Verify text with markdown syntax still uses the full pipeline: + +- Emphasis (`*`, `_`) +- Code spans (`` ` ``) +- Headings (`#`) +- Links (`[`) +- GFM strikethrough (`~`) +- Backslash escapes (`\`) +- List items (`-`, `1.`) +- Images (`![`) +- Blockquotes (`>`) +- GFM tables (`|`) +- Blank lines (paragraph breaks) + +Note: bare `!` (e.g. `"Activate!"`) should NOT trigger fallthrough — only `![` does. + +### Non-nobr tests + +Verify `

` wrapping when nobr is false. + +### Benchmark test + +ALMA-like UI passage with nested HTML + 20 `{for}` cards, measuring ms/render. + +## Expected Impact + +| Version | Click duration | Bottleneck | +| -------------------------- | -------------- | ------------------------------------------ | +| 0.43.2 (before #143) | 1,673 ms | `htmlToPreact` in `{for}` loops (89%) | +| 0.43.3 (after #143) | 297 ms | `htmlToPreact` from HtmlNodeRenderer (61%) | +| 0.43.3 + game-side CSS fix | 288 ms | `set innerHTML` from plain text (54%) | +| **This fix (projected)** | **~80-100 ms** | Preact diffing + layout | + +## Source Locations + +All in `src/markup/render.tsx`: + +| Function | Line | Role | +| ------------------ | -------------- | ----------------------------------------------------------------------------- | +| `htmlToPreact` | 38 | Creates temp div, sets innerHTML, strips `

`, walks DOM → vnodes | +| `HtmlNodeRenderer` | 90 | Renders `

`, ``, etc. — calls `renderNodes` for children (line 106) | +| `renderNodes` | 268 | Entry point — builds combined string, calls `markdownToHtml` + `htmlToPreact` | +| `markdownToHtml` | markdown.ts:12 | Runs micromark with CommonMark + GFM tables + strikethrough | diff --git a/src/markup/markdown.ts b/src/markup/markdown.ts index ec3690e..862693f 100644 --- a/src/markup/markdown.ts +++ b/src/markup/markdown.ts @@ -8,14 +8,31 @@ import { /** * Parse a text string as CommonMark markdown and return an HTML string. * Includes GFM table and strikethrough extensions. + * + * When `inline` is true, block-level constructs (lists, headings, blockquotes, + * thematic breaks) are disabled. This is used when rendering content inside + * inline HTML elements like `` where block-level output is invalid. */ -export function markdownToHtml(text: string): string { +export function markdownToHtml( + text: string, + options?: { inline?: boolean }, +): string { + const disabled: string[] = ['codeIndented']; + if (options?.inline) { + disabled.push( + 'list', + 'headingAtx', + 'setextUnderline', + 'thematicBreak', + 'blockQuote', + ); + } return micromark(text, { allowDangerousHtml: true, extensions: [ gfmTable(), gfmStrikethrough(), - { disable: { null: ['codeIndented'] } }, + { disable: { null: disabled } }, ], htmlExtensions: [gfmTableHtml(), gfmStrikethroughHtml()], }); diff --git a/src/markup/render.tsx b/src/markup/render.tsx index 6078490..9e4451a 100644 --- a/src/markup/render.tsx +++ b/src/markup/render.tsx @@ -87,6 +87,43 @@ function convertDomNode( return null; } +/** Inline elements where block-level markdown (lists, headings) is invalid. */ +const INLINE_ELEMENTS = new Set([ + 'a', + 'abbr', + 'b', + 'bdi', + 'bdo', + 'br', + 'cite', + 'code', + 'data', + 'dfn', + 'em', + 'i', + 'kbd', + 'label', + 'mark', + 'meter', + 'output', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', + 'var', + 'wbr', +]); + function HtmlNodeRenderer({ node }: { node: HtmlNode }) { const resolve = useInterpolate(); const nobr = useContext(NobrContext); @@ -97,13 +134,16 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) { attrs[k] = resolve(v) ?? v; } const isSvgRoot = node.tag.toLowerCase() === 'svg'; - // Inside SVG, skip markdown processing — markdown wraps content in

tags - // which break the SVG namespace and produce zero-dimension elements. + const isInline = INLINE_ELEMENTS.has(node.tag.toLowerCase()); + // Inside SVG, skip markdown processing entirely — markdown wraps content + // in

tags which break the SVG namespace. + // Inside inline elements, disable block-level markdown (lists, headings, + // blockquotes) since those produce invalid HTML inside inline containers. const children = node.children.length > 0 ? inSvg || isSvgRoot ? renderInlineNodes(node.children) - : renderNodes(node.children, { nobr, locals }) + : renderNodes(node.children, { nobr, locals, inline: isInline }) : undefined; const element = h(node.tag, attrs, children); return isSvgRoot ? ( @@ -254,6 +294,39 @@ function getVariableTextValue( return value == null ? '' : String(value); } +/** + * Characters/patterns that trigger CommonMark or GFM transformations. + * Any match → fall through to the full micromark pipeline. + * False positives (e.g. `-` used as text, not list) just use the slower path. + */ +const MARKDOWN_SYNTAX_RE = /[*_`#|~\[>\\\-+=]|!\[|\d+\./; +const BLANK_LINE_RE = /\n\s*\n/; +const PLACEHOLDER_SPLIT_RE = /(<\/span>)/; +const PLACEHOLDER_IDX_RE = /^<\/span>$/; +const PLACEHOLDER_STRIP_RE = /<\/span>/g; + +/** + * Build Preact vnodes from a combined string that contains only plain text + * and placeholders. No micromark, no innerHTML. + */ +function buildPlainTextVnodes( + combined: string, + components: preact.ComponentChildren[], + nobr?: boolean, +): preact.ComponentChildren { + const parts = combined.split(PLACEHOLDER_SPLIT_RE); + const children: preact.ComponentChildren[] = []; + for (const part of parts) { + const m = PLACEHOLDER_IDX_RE.exec(part); + if (m) { + children.push(components[parseInt(m[1]!, 10)]); + } else if (part) { + children.push(part); + } + } + return nobr ? <>{children} : h('p', null, ...children); +} + /** * Render AST nodes with full CommonMark markdown support. * @@ -267,7 +340,11 @@ function getVariableTextValue( */ export function renderNodes( nodes: ASTNode[], - options?: { nobr?: boolean; locals?: Record }, + options?: { + nobr?: boolean; + locals?: Record; + inline?: boolean; + }, ): preact.ComponentChildren { if (nodes.length === 0) return null; @@ -303,8 +380,17 @@ export function renderNodes( } } + // Fast path: skip micromark + innerHTML when text has no markdown syntax. + // This eliminates ~655 innerHTML calls on plain UI text like "ALMA", + // "▸ Crew", "Activate" that pass through the full pipeline only to + // produce the same text they started with (issue #145). + const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, ''); + if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) { + return buildPlainTextVnodes(combined, components, options?.nobr); + } + // Run combined text through markdown - const html = markdownToHtml(combined); + const html = markdownToHtml(combined, { inline: options?.inline }); // Convert HTML to Preact VNodes, replacing placeholders with components return htmlToPreact(html, components, options?.nobr); diff --git a/test/dom/render-plain-text-bench.test.tsx b/test/dom/render-plain-text-bench.test.tsx new file mode 100644 index 0000000..c866499 --- /dev/null +++ b/test/dom/render-plain-text-bench.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from 'preact'; +import { Passage } from '../../src/components/Passage'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage as PassageData } from '../../src/parser'; + +function makePassage( + pid: number, + name: string, + content: string, + tags: string[] = [], +): PassageData { + return { pid, name, tags, metadata: {}, content }; +} + +function makeStoryData(passages: PassageData[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +describe('renderNodes plain-text fast path benchmark', () => { + beforeEach(() => { + const store = useStoryStore.getState(); + store.init(makeStoryData([makePassage(1, 'Start', 'Start')])); + }); + + it('ALMA-like UI passage with nested HTML + plain text labels', () => { + const content = [ + '

', + '
', + ' ALMA', + ' ', + '
', + ' ', + '
', + ' Research Tree', + ' {for @node of $items}', + '
', + '
', + ' ', + ' {@node.name}', + '
', + '
', + ' {@node.effects}', + ' {if @node.status == "active"}', + ' ', + ' {/if}', + '
', + '
', + ' {/for}', + '
', + '
', + ].join('\n'); + + const passage = makePassage(2, 'Test', content, ['nobr']); + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Start'), + passage, + ]); + useStoryStore.getState().init(storyData); + useStoryStore.getState().setVariable( + 'items', + Array.from({ length: 20 }, (_, i) => ({ + id: `item-${i}`, + name: `Research ${i}`, + status: i % 3 === 0 ? 'active' : 'locked', + effects: `+${i} bonus`, + })), + ); + useStoryStore.getState().navigate('Test'); + + // Warm up + const warmupContainer = document.createElement('div'); + render(, warmupContainer); + + // Benchmark + const iterations = 50; + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + const c = document.createElement('div'); + render(, c); + } + const elapsed = performance.now() - start; + const perRender = elapsed / iterations; + + // Verify correctness + const container = document.createElement('div'); + render(, container); + expect(container.querySelectorAll('.card').length).toBe(20); + expect(container.querySelector('.header-id')!.textContent).toBe('ALMA'); + expect(container.querySelectorAll('.nav-item').length).toBe(7); + + console.log( + `ALMA-like UI: ${perRender.toFixed(2)}ms/render (${iterations} iterations, ${elapsed.toFixed(0)}ms total)`, + ); + }); + + it('worst case: many small HTML elements with short text labels', () => { + const content = + '{for @label of $labels}
{@label}
{/for}'; + + const passage = makePassage(2, 'Test', content, ['nobr']); + const storyData = makeStoryData([ + makePassage(1, 'Start', 'Start'), + passage, + ]); + useStoryStore.getState().init(storyData); + useStoryStore.getState().setVariable( + 'labels', + Array.from({ length: 100 }, (_, i) => `Label ${i}`), + ); + useStoryStore.getState().navigate('Test'); + + // Warm up + const warmupContainer = document.createElement('div'); + render(, warmupContainer); + + const iterations = 20; + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + const c = document.createElement('div'); + render(, c); + } + const elapsed = performance.now() - start; + const perRender = elapsed / iterations; + + const container = document.createElement('div'); + render(, container); + expect(container.querySelectorAll('.cell').length).toBe(100); + + console.log( + `100 HTML elements with text: ${perRender.toFixed(2)}ms/render (${iterations} iterations, ${elapsed.toFixed(0)}ms total)`, + ); + }); +}); diff --git a/test/dom/render-plain-text.test.tsx b/test/dom/render-plain-text.test.tsx new file mode 100644 index 0000000..04b7db2 --- /dev/null +++ b/test/dom/render-plain-text.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from 'preact'; +import { tokenize } from '../../src/markup/tokenizer'; +import { buildAST } from '../../src/markup/ast'; +import { renderNodes } from '../../src/markup/render'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage } from '../../src/parser'; + +function makePassage(pid: number, name: string, content: string): Passage { + return { pid, name, tags: [], metadata: {}, content }; +} + +function makeStoryData(passages: Passage[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +function renderMarkup( + markup: string, + options?: { nobr?: boolean }, +): HTMLElement { + const tokens = tokenize(markup); + const ast = buildAST(tokens); + const container = document.createElement('div'); + render(<>{renderNodes(ast, options)}, container); + return container; +} + +describe('plain text fast path (issue #145)', () => { + beforeEach(() => { + const store = useStoryStore.getState(); + store.init(makeStoryData([makePassage(1, 'Start', 'Start')])); + }); + + describe('correctly renders plain text without markdown pipeline', () => { + it('plain text in nobr mode', () => { + const el = renderMarkup('Hello World', { nobr: true }); + expect(el.textContent).toBe('Hello World'); + }); + + it('plain text with variable placeholder', () => { + useStoryStore.getState().setVariable('name', 'ALMA'); + const el = renderMarkup('Name: {$name}', { nobr: true }); + expect(el.textContent).toBe('Name: ALMA'); + }); + + it('Unicode symbols (not markdown syntax)', () => { + const el = renderMarkup('▸ Menu ✕ Close', { nobr: true }); + expect(el.textContent).toBe('▸ Menu ✕ Close'); + }); + + it('emoji content', () => { + const el = renderMarkup('🔒 Locked', { nobr: true }); + expect(el.textContent).toBe('🔒 Locked'); + }); + + it('text with colons, commas, slashes (non-markdown punctuation)', () => { + const el = renderMarkup('Requires: Level 2, Policy / Tier 3', { + nobr: true, + }); + expect(el.textContent).toBe('Requires: Level 2, Policy / Tier 3'); + }); + + it('text with parentheses and equals', () => { + const el = renderMarkup('Speed (km/h) = 100', { nobr: true }); + expect(el.textContent).toBe('Speed (km/h) = 100'); + }); + + it('bare exclamation mark is not markdown', () => { + const el = renderMarkup('Activate!', { nobr: true }); + expect(el.textContent).toBe('Activate!'); + }); + + it('multiple variables interleaved with text', () => { + useStoryStore.getState().setVariable('a', 'Alpha'); + useStoryStore.getState().setVariable('b', 'Beta'); + const el = renderMarkup('{$a} and {$b}', { nobr: true }); + expect(el.textContent).toBe('Alpha and Beta'); + }); + + it('HTML element children with plain text', () => { + const el = renderMarkup( + '
Hello
', + { nobr: true }, + ); + expect(el.querySelector('.label')!.textContent).toBe('Hello'); + }); + }); + + describe('non-nobr wraps in

', () => { + it('plain text without nobr gets

wrapper', () => { + const el = renderMarkup('Just some text'); + const p = el.querySelector('p'); + expect(p).not.toBeNull(); + expect(p!.textContent).toBe('Just some text'); + }); + + it('plain text with variable without nobr', () => { + useStoryStore.getState().setVariable('name', 'ALMA'); + const el = renderMarkup('Hello {$name}'); + const p = el.querySelector('p'); + expect(p).not.toBeNull(); + expect(p!.textContent).toBe('Hello ALMA'); + }); + }); + + describe('falls through to markdown for syntax-bearing text', () => { + it('emphasis with asterisks', () => { + const el = renderMarkup('This is *important* text'); + expect(el.querySelector('em')!.textContent).toBe('important'); + }); + + it('emphasis with underscores', () => { + const el = renderMarkup('This is _emphasized_ text'); + expect(el.querySelector('em')!.textContent).toBe('emphasized'); + }); + + it('inline code', () => { + const el = renderMarkup('Use `console.log` here'); + expect(el.querySelector('code')!.textContent).toBe('console.log'); + }); + + it('heading', () => { + const el = renderMarkup('# Title'); + expect(el.querySelector('h1')!.textContent).toBe('Title'); + }); + + it('link syntax', () => { + const el = renderMarkup('See [docs](http://example.com)'); + const link = el.querySelector('a'); + expect(link).not.toBeNull(); + expect(link!.textContent).toBe('docs'); + }); + + it('GFM strikethrough', () => { + const el = renderMarkup('This is ~~deleted~~ text'); + expect(el.querySelector('del')!.textContent).toBe('deleted'); + }); + + it('backslash escape', () => { + const el = renderMarkup('Price\\: free'); + expect(el.textContent).toContain('Price'); + }); + + it('unordered list item', () => { + const el = renderMarkup('- Item 1\n- Item 2'); + expect(el.querySelector('ul')).not.toBeNull(); + }); + + it('ordered list item', () => { + const el = renderMarkup('1. First\n2. Second'); + expect(el.querySelector('ol')).not.toBeNull(); + }); + + it('image syntax', () => { + const el = renderMarkup('![alt](http://example.com/img.png)'); + expect(el.querySelector('img')).not.toBeNull(); + }); + + it('blockquote', () => { + const el = renderMarkup('> quoted text'); + expect(el.querySelector('blockquote')).not.toBeNull(); + }); + + it('GFM table', () => { + const el = renderMarkup('| A | B |\n| --- | --- |\n| 1 | 2 |'); + expect(el.querySelector('table')).not.toBeNull(); + }); + + it('blank lines create paragraphs', () => { + const el = renderMarkup('Para 1\n\nPara 2'); + expect(el.querySelectorAll('p').length).toBeGreaterThanOrEqual(2); + }); + }); +}); diff --git a/test/e2e/issue136.test.ts b/test/e2e/issue136.test.ts new file mode 100644 index 0000000..32e5024 --- /dev/null +++ b/test/e2e/issue136.test.ts @@ -0,0 +1,92 @@ +/** + * E2E reproduction test for issue #136: + * Consecutive {set} macros can't see each other's variable mutations. + * + * Requires dist/story.html to exist (run `npm run preview` first). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { createServer } from 'http'; +import { readFileSync, existsSync } from 'fs'; +import { resolve, extname } from 'path'; + +const projectRoot = resolve(import.meta.dirname!, '../..'); +const distDir = resolve(projectRoot, 'dist'); +const storyPath = resolve(distDir, 'story.html'); + +const MIME: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', +}; + +let server: ReturnType; +let browser: Browser; +let page: Page; +let baseUrl: string; + +beforeAll(async () => { + if (!existsSync(storyPath)) { + throw new Error('dist/story.html not found. Run `npm run preview` first.'); + } + + server = createServer((req, res) => { + const filePath = resolve( + distDir, + (req.url || '/').replace(/^\//, '') || 'story.html', + ); + if (!existsSync(filePath)) { + res.writeHead(404); + res.end(); + return; + } + const ext = extname(filePath); + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + }); + res.end(readFileSync(filePath)); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number }; + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); + + browser = await chromium.launch(); + page = await browser.newPage(); +}, 30_000); + +afterAll(async () => { + await browser?.close(); + server?.close(); +}); + +describe('issue #136: consecutive {set} mutations', () => { + beforeAll(async () => { + // Collect page errors + page.on('pageerror', (err) => { + console.error('Page error:', err.message); + }); + + await page.goto(`${baseUrl}/story.html`); + await page.waitForSelector('[data-passage="Start"]'); + await page.click('a.macro-link:has-text("Consecutive set")'); + await page.waitForSelector('[data-passage="Consecutive Set Test"]'); + }); + + it('second {set} sees _temp from first {set} — renders Result: 1,2,3', async () => { + const text = await page.textContent( + '[data-passage="Consecutive Set Test"]', + ); + console.log('PAGE TEXT:', JSON.stringify(text)); + const errors = await page.$$eval('.error', (els) => + els.map((e) => e.textContent), + ); + console.log('ERRORS:', JSON.stringify(errors)); + expect(errors).toHaveLength(0); + expect(text).toContain('Result: 1,2,3'); + }); +}); diff --git a/test/unit/expression.test.ts b/test/unit/expression.test.ts index 373da49..a4178d8 100644 --- a/test/unit/expression.test.ts +++ b/test/unit/expression.test.ts @@ -440,4 +440,23 @@ describe('executeMutation', () => { expect(useStoryStore.getState().temporary.t).toBeUndefined(); expect('t' in useStoryStore.getState().temporary).toBe(false); }); + + // Reproduction of issue #136: consecutive {set} macros can't see each other + it('consecutive _temp mutations see each other (issue #136)', () => { + // Simulates: {set _x = [3, 1, 2]} then {set _y = _x.slice().sort()} + executeMutation('_x = [3, 1, 2]', {}, () => {}); + executeMutation('_y = _x.slice().sort()', {}, () => {}); + + expect(useStoryStore.getState().temporary.x).toEqual([3, 1, 2]); + expect(useStoryStore.getState().temporary.y).toEqual([1, 2, 3]); + }); + + it('consecutive $var mutations see each other (issue #136)', () => { + // Simulates: {set $x = 10} then {set $y = $x + 5} + executeMutation('$x = 10', {}, () => {}); + executeMutation('$y = $x + 5', {}, () => {}); + + expect(useStoryStore.getState().variables.x).toBe(10); + expect(useStoryStore.getState().variables.y).toBe(15); + }); }); diff --git a/test/unit/html-nesting.test.tsx b/test/unit/html-nesting.test.tsx index 6eaa11d..c754875 100644 --- a/test/unit/html-nesting.test.tsx +++ b/test/unit/html-nesting.test.tsx @@ -307,3 +307,68 @@ describe('issue #61: deeply nested HTML with {include}', () => { }); }); }); + +describe('inline HTML elements should not produce block-level markdown', () => { + beforeEach(() => { + const storyData = makeStoryData([makePassage(1, 'Start', 'Start')]); + useStoryStore.getState().init(storyData); + }); + + it('does not interpret "+" inside as a list marker', () => { + // Simulates what RelDimBar produces after spindle-lsp formatting: + // \n +\n 0.95\n + const markup = '\n+\n0.95\n'; + const container = document.createElement('div'); + const ast = buildAST(tokenize(markup)); + render(<>{renderNodes(ast)}, container); + + // Must NOT contain a

    or
  • — that's the markdown list artifact + expect(container.querySelector('ul')).toBeNull(); + expect(container.querySelector('li')).toBeNull(); + // The text content should contain the plus and value + expect(container.textContent).toContain('+'); + expect(container.textContent).toContain('0.95'); + }); + + it('does not interpret "-" inside as a list marker', () => { + const markup = '\n- text\n'; + const container = document.createElement('div'); + const ast = buildAST(tokenize(markup)); + render(<>{renderNodes(ast)}, container); + + expect(container.querySelector('ul')).toBeNull(); + expect(container.querySelector('li')).toBeNull(); + expect(container.textContent).toContain('- text'); + }); + + it('does not interpret "#" inside as a heading', () => { + const markup = '\n# not a heading\n'; + const container = document.createElement('div'); + const ast = buildAST(tokenize(markup)); + render(<>{renderNodes(ast)}, container); + + expect(container.querySelector('h1')).toBeNull(); + expect(container.textContent).toContain('# not a heading'); + }); + + it('still allows block-level markdown inside
    ', () => { + const markup = '
    \n- item one\n- item two\n
    '; + const container = document.createElement('div'); + const ast = buildAST(tokenize(markup)); + render(<>{renderNodes(ast)}, container); + + // Block elements like
    should still support markdown lists + expect(container.querySelector('ul')).not.toBeNull(); + expect(container.querySelectorAll('li')).toHaveLength(2); + }); + + it('preserves inline markdown (bold, italic) inside ', () => { + const markup = '**bold** and *italic*'; + const container = document.createElement('div'); + const ast = buildAST(tokenize(markup)); + render(<>{renderNodes(ast)}, container); + + expect(container.querySelector('strong')?.textContent).toBe('bold'); + expect(container.querySelector('em')?.textContent).toBe('italic'); + }); +});