Skip to content

Commit 7e805ec

Browse files
clemclaude
andcommitted
feat: add passage metadata, currentPassage() and previousPassage()
- Parse Twee 3 metadata from passage headers into `metadata` field on Passage - Add `Story.currentPassage()` and `Story.previousPassage()` to Story API - Add `currentPassage()` and `previousPassage()` as expression functions - Add pre-commit hook with husky + lint-staged for prettier formatting release-npm Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fcbe7fe commit 7e805ec

16 files changed

+185
-9
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ PLAN.md
1212
TODO
1313
skills-lock.json
1414
menubar-default.png
15+
.vscode-diagnostics.json

.husky/pre-commit

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
bunx lint-staged

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.0] - 2026-3-5
11+
1012
### Added
1113

1214
- Seedable PRNG (Mulberry32) with `Story.prng.init()`, `Story.random()`, `Story.randomInt()`
1315
- `random()` and `randomInt(min, max)` available in expressions
1416
- PRNG state survives save/load and history navigation via pull-counter approach
17+
- `metadata` field on passages, parsed from Twee 3 passage header metadata (exposed as `Record<string, string>`)
18+
- `Story.currentPassage()` and `Story.previousPassage()` return the full `Passage` object
19+
- `currentPassage()` and `previousPassage()` available in expressions
20+
- Pre-commit hook with husky + lint-staged to run prettier on staged files
1521

1622
## [0.3.2] - 2026-3-5
1723

bun.lock

Lines changed: 58 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/story-api.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,50 @@ Register a class so its instances can be cloned, saved, and restored with their
8282

8383
See [Using Classes](variables.md#using-classes) for full details.
8484

85+
## Passage Lookup
86+
87+
### `Story.currentPassage()`
88+
89+
Returns the full passage object for the current passage, or `undefined` if not found.
90+
91+
```js
92+
var p = Story.currentPassage();
93+
console.log(p.name); // "Forest"
94+
console.log(p.tags); // ["dark", "outdoor"]
95+
console.log(p.metadata); // { position: "600,400" }
96+
```
97+
98+
### `Story.previousPassage()`
99+
100+
Returns the full passage object for the previous passage in history, or `undefined` if there is no previous passage (e.g. on the start passage).
101+
102+
```js
103+
var prev = Story.previousPassage();
104+
if (prev) {
105+
console.log('Came from: ' + prev.name);
106+
}
107+
```
108+
109+
### Passage object
110+
111+
Both methods return a passage object with these properties:
112+
113+
| Property | Type | Description |
114+
| ---------- | ------------------------ | --------------------------------------------------------------------------------- |
115+
| `pid` | `number` | Passage ID from the story data |
116+
| `name` | `string` | Passage name |
117+
| `tags` | `string[]` | Tags from the passage header |
118+
| `metadata` | `Record<string, string>` | Metadata from the Twee 3 passage header (e.g. `position`, `size`, or custom keys) |
119+
| `content` | `string` | Raw passage content |
120+
121+
The `metadata` field contains all attributes from the passage header's JSON metadata block. In Twee 3 format, this is the JSON object at the end of the header line:
122+
123+
```
124+
:: Forest [dark outdoor] {"position":"600,400","size":"100,200","difficulty":"hard"}
125+
```
126+
127+
Standard keys like `position` and `size` are included alongside any custom keys the author adds.
128+
85129
## Passage Tracking
86130

87131
Spindle tracks how many times each passage has been **visited** (navigated to) and **rendered** (visited or included). Back/forward navigation does not increment counts — only new visits and `{include}` calls do.

docs/variables.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ The following functions are available in any expression to check passage visit a
129129

130130
| Function | Returns | Description |
131131
| ------------------------------- | --------- | ------------------------------------------------------------ |
132+
| `currentPassage()` | `object` | The current passage object (name, tags, metadata, content) |
133+
| `previousPassage()` | `object` | The previous passage object, or `undefined` on start |
132134
| `visited("name")` | `number` | Times the passage was visited |
133135
| `hasVisited("name")` | `boolean` | Whether the passage was visited at least once |
134136
| `hasVisitedAny("a", "b", ...)` | `boolean` | Whether **any** of the passages were visited |
@@ -154,6 +156,18 @@ Use these directly in `{if}` conditions or any expression:
154156
{print visited("Start")} visits to the start passage.
155157
```
156158

159+
Access passage metadata in expressions:
160+
161+
```
162+
{if currentPassage().tags.includes("dark")}
163+
It's too dark to see.
164+
{/if}
165+
166+
{if previousPassage()}
167+
You came from {print previousPassage().name}.
168+
{/if}
169+
```
170+
157171
**Visited** counts passages the player navigated to via links, `{goto}`, or as the start passage. Back/forward navigation does not increment the count.
158172

159173
**Rendered** is a superset of visited — it also counts passages rendered inline via `{include}`.

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"docs:dev": "vitepress dev docs",
4747
"docs:build": "vitepress build docs",
4848
"docs:preview": "vitepress preview docs",
49-
"prepublishOnly": "bun run test && bun run build"
49+
"prepublishOnly": "bun run test && bun run build",
50+
"prepare": "husky"
5051
},
5152
"dependencies": {
5253
"immer": "^11.1.4",
@@ -56,13 +57,18 @@
5657
"preact": "^10.28.4",
5758
"zustand": "^5.0.11"
5859
},
60+
"lint-staged": {
61+
"*": "prettier --write --ignore-unknown"
62+
},
5963
"devDependencies": {
6064
"@preact/preset-vite": "^2.10.3",
6165
"@rohal12/twee-ts": "^1.1.2",
6266
"@types/js-yaml": "^4.0.9",
6367
"@vitest/coverage-v8": "^4.0.18",
6468
"happy-dom": "^20.8.3",
69+
"husky": "^9.1.7",
6570
"js-yaml": "^4.1.1",
71+
"lint-staged": "^16.3.2",
6672
"playwright": "^1.58.2",
6773
"prettier": "^3.8.1",
6874
"typescript": "^5.9.3",

src/expression.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import type { StoryState } from './store';
22
import { useStoryStore } from './store';
3+
import type { Passage } from './parser';
34
import { random, randomInt } from './prng';
45

56
interface ExpressionFns {
7+
currentPassage: () => Passage | undefined;
8+
previousPassage: () => Passage | undefined;
69
visited: (name: string) => number;
710
hasVisited: (name: string) => boolean;
811
hasVisitedAny: (...names: string[]) => boolean;
@@ -39,7 +42,7 @@ function transform(expr: string): string {
3942
}
4043

4144
const preamble =
42-
'const {visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
45+
'const {currentPassage,previousPassage,visited,hasVisited,hasVisitedAny,hasVisitedAll,rendered,hasRendered,hasRenderedAny,hasRenderedAll,random,randomInt}=__fns;';
4346

4447
function getOrCompile(key: string, body: string): CompiledExpression {
4548
const cached = fnCache.get(key);
@@ -94,7 +97,20 @@ export function buildExpressionFns() {
9497
const hasRenderedAll = (...names: string[]): boolean =>
9598
names.every((n) => rendered(n) > 0);
9699

100+
const currentPassage = (): Passage | undefined => {
101+
const s = useStoryStore.getState();
102+
return s.storyData?.passages.get(s.currentPassage);
103+
};
104+
const previousPassage = (): Passage | undefined => {
105+
const s = useStoryStore.getState();
106+
if (s.historyIndex <= 0) return undefined;
107+
const prevName = s.history[s.historyIndex - 1]?.passage;
108+
return prevName ? s.storyData?.passages.get(prevName) : undefined;
109+
};
110+
97111
cachedFns = {
112+
currentPassage,
113+
previousPassage,
98114
visited,
99115
hasVisited,
100116
hasVisitedAny,

src/parser.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export interface Passage {
22
pid: number;
33
name: string;
44
tags: string[];
5+
metadata: Record<string, string>;
56
content: string;
67
}
78

@@ -53,7 +54,21 @@ export function parseStoryData(): StoryData {
5354
.filter((t) => t.length > 0);
5455
const content = el.textContent || '';
5556

56-
const passage: Passage = { pid, name: passageName, tags, content };
57+
const metadata: Record<string, string> = {};
58+
const skipAttrs = new Set(['pid', 'name', 'tags']);
59+
for (const attr of el.attributes) {
60+
if (!skipAttrs.has(attr.name)) {
61+
metadata[attr.name] = attr.value;
62+
}
63+
}
64+
65+
const passage: Passage = {
66+
pid,
67+
name: passageName,
68+
tags,
69+
metadata,
70+
content,
71+
};
5772
passages.set(passageName, passage);
5873
passagesById.set(pid, passage);
5974
}

src/story-api.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useStoryStore } from './store';
2+
import type { Passage } from './parser';
23
import { settings } from './settings';
34
import type { SavePayload } from './saves/types';
45
import { setTitleGenerator } from './saves/save-manager';
@@ -46,6 +47,8 @@ export interface StoryAPI {
4647
hasRendered(name: string): boolean;
4748
hasRenderedAny(...names: string[]): boolean;
4849
hasRenderedAll(...names: string[]): boolean;
50+
currentPassage(): Passage | undefined;
51+
previousPassage(): Passage | undefined;
4952
readonly title: string;
5053
readonly passage: string;
5154
readonly settings: typeof settings;
@@ -150,6 +153,18 @@ function createStoryAPI(): StoryAPI {
150153
return names.every((n) => (renderCounts[n] ?? 0) > 0);
151154
},
152155

156+
currentPassage(): Passage | undefined {
157+
const state = useStoryStore.getState();
158+
return state.storyData?.passages.get(state.currentPassage);
159+
},
160+
161+
previousPassage(): Passage | undefined {
162+
const state = useStoryStore.getState();
163+
if (state.historyIndex <= 0) return undefined;
164+
const prevName = state.history[state.historyIndex - 1]?.passage;
165+
return prevName ? state.storyData?.passages.get(prevName) : undefined;
166+
},
167+
153168
get title(): string {
154169
return useStoryStore.getState().storyData?.name || '';
155170
},

0 commit comments

Comments
 (0)