@rohal12/spindle 0.52.9 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +5 -1
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +6484 -2558
  4. package/dist/pkg/story-variables.js +4009 -940
  5. package/dist/pkg/tooling.js +13 -1
  6. package/package.json +6 -1
  7. package/src/class-registry.ts +252 -250
  8. package/src/components/App.tsx +2 -0
  9. package/src/components/Passage.tsx +3 -3
  10. package/src/components/PassageDialog.tsx +2 -4
  11. package/src/components/RuntimeErrors.tsx +46 -0
  12. package/src/components/StoryInterface.tsx +2 -4
  13. package/src/components/macros/Computed.tsx +5 -2
  14. package/src/components/macros/Do.tsx +4 -2
  15. package/src/components/macros/Goto.tsx +13 -3
  16. package/src/components/macros/Include.tsx +2 -3
  17. package/src/components/macros/MacroError.tsx +14 -7
  18. package/src/components/macros/SaveManager.tsx +2 -2
  19. package/src/components/macros/VarDisplay.tsx +1 -1
  20. package/src/components/macros/Widget.tsx +5 -33
  21. package/src/index.tsx +57 -37
  22. package/src/interpolation.ts +4 -5
  23. package/src/markup/ast.ts +11 -226
  24. package/src/markup/code-attributes.ts +3 -3
  25. package/src/markup/code-end.ts +121 -0
  26. package/src/markup/parse.ts +118 -0
  27. package/src/markup/render.tsx +1 -1
  28. package/src/markup/spindle.d.peggy.ts +24 -0
  29. package/src/markup/spindle.peggy +463 -0
  30. package/src/markup/tokens.ts +95 -0
  31. package/src/markup/validate.ts +219 -0
  32. package/src/parser.ts +19 -4
  33. package/src/runtime-errors.ts +65 -0
  34. package/src/saves/format.ts +199 -0
  35. package/src/saves/save-manager.ts +45 -63
  36. package/src/saves/types.ts +71 -88
  37. package/src/store.ts +167 -87
  38. package/src/story-init.ts +3 -5
  39. package/src/story-variables.ts +18 -4
  40. package/src/structural.ts +266 -57
  41. package/src/styles.css +45 -0
  42. package/src/tooling.ts +50 -0
  43. package/src/types-drift-check.ts +28 -1
  44. package/src/utils/object-path.ts +13 -12
  45. package/src/utils/stable-key.ts +2 -1
  46. package/src/utils/value-kinds.ts +45 -0
  47. package/src/widgets/widget-def.ts +60 -0
  48. package/types/index.d.ts +32 -8
  49. package/types/tooling.d.ts +42 -0
  50. package/src/markup/tokenizer.ts +0 -1112
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Markup validation: parse every passage when the story starts and report
3
+ * malformed markup and unknown macros with the passage, line and column
4
+ * they are at, instead of failing when the passage renders. Tooling runs it
5
+ * too (see src/tooling.ts).
6
+ */
7
+ import { lineColumn, MarkupError, parseMarkup, tokenizeMarkup } from './parse';
8
+ import type { Token } from './tokens';
9
+ import { isCodeAttribute } from './code-attributes';
10
+ import { parseWidgetDef } from '../widgets/widget-def';
11
+
12
+ /** A passage to validate. */
13
+ export interface MarkupPassage {
14
+ name: string;
15
+ content: string;
16
+ tags?: string[];
17
+ /**
18
+ * Passage attributes; `data-source-file` and `data-source-line` (the
19
+ * line of its `::` header) place errors in the source file.
20
+ */
21
+ metadata?: Record<string, string>;
22
+ }
23
+
24
+ /** An error in a passage's markup. */
25
+ export interface MarkupDiagnostic {
26
+ passage: string;
27
+ /** 1-based line within the passage's content. */
28
+ line: number;
29
+ /** 1-based column (UTF-16 code units). */
30
+ column: number;
31
+ message: string;
32
+ /** The source file and line, when the passage says where it came from. */
33
+ file?: string;
34
+ fileLine?: number;
35
+ }
36
+
37
+ export interface MarkupValidationOptions {
38
+ /**
39
+ * Whether a macro of this name exists. Widgets that passages define with
40
+ * `{widget}` and branch macros (`{else}`, `{case}`, …) always do.
41
+ */
42
+ isKnownMacro(name: string): boolean;
43
+ /** Names to suggest for a misspelt macro. */
44
+ macroNames?: Iterable<string>;
45
+ /** Whether a macro takes a body (default: the registered block macros). */
46
+ isBlockMacro?(name: string): boolean;
47
+ /**
48
+ * Report on only the passages for which this holds (default: all). The
49
+ * others are still read for the widgets they define.
50
+ */
51
+ only?(passage: MarkupPassage): boolean;
52
+ }
53
+
54
+ /** Passages that hold no markup. */
55
+ const NOT_MARKUP = new Set(['StoryVariables', 'StoryTransients']);
56
+ const NOT_MARKUP_TAGS = ['script', 'stylesheet'];
57
+
58
+ /** Branch macros, which their parent macro renders. */
59
+ const BRANCH_MACROS = new Set(['elseif', 'else', 'case', 'default', 'next']);
60
+
61
+ /** A diagnostic as one line of text. */
62
+ export function formatDiagnostic(d: MarkupDiagnostic): string {
63
+ const source = d.file
64
+ ? ` (${d.file}${d.fileLine ? `:${d.fileLine}` : ''})`
65
+ : '';
66
+ return `Passage "${d.passage}", line ${d.line}, column ${d.column}${source}: ${d.message}`;
67
+ }
68
+
69
+ /** Edit distance, for "did you mean" suggestions. */
70
+ function distance(a: string, b: string): number {
71
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
72
+ for (let i = 1; i <= a.length; i++) {
73
+ const row = [i];
74
+ for (let j = 1; j <= b.length; j++) {
75
+ row[j] = Math.min(
76
+ prev[j]! + 1,
77
+ row[j - 1]! + 1,
78
+ prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
79
+ );
80
+ }
81
+ prev = row;
82
+ }
83
+ return prev[b.length]!;
84
+ }
85
+
86
+ /** " Did you mean {name}?" for the closest known name, if one is close. */
87
+ function suggestion(name: string, names: readonly string[]): string {
88
+ const lower = name.toLowerCase();
89
+ let best = '';
90
+ let bestDistance = Math.max(2, Math.floor(lower.length / 3)) + 1;
91
+ for (const candidate of names) {
92
+ const d = distance(lower, candidate.toLowerCase());
93
+ if (d < bestDistance) {
94
+ best = candidate;
95
+ bestDistance = d;
96
+ }
97
+ }
98
+ return best ? ` Did you mean {${best}}?` : '';
99
+ }
100
+
101
+ /** The name of the widget a `{widget}` with these arguments defines. */
102
+ function widgetName(rawArgs: string): string | undefined {
103
+ try {
104
+ return parseWidgetDef(rawArgs).name.toLowerCase() || undefined;
105
+ } catch {
106
+ return undefined; // the {widget} macro reports it when it renders
107
+ }
108
+ }
109
+
110
+ /** Validate the markup of every passage. */
111
+ export function validateMarkup(
112
+ passages: Iterable<MarkupPassage>,
113
+ options: MarkupValidationOptions,
114
+ ): MarkupDiagnostic[] {
115
+ const diagnostics: MarkupDiagnostic[] = [];
116
+ const hooks = options.isBlockMacro
117
+ ? { isBlock: options.isBlockMacro }
118
+ : undefined;
119
+ const report = (passage: MarkupPassage, offset: number, message: string) => {
120
+ const { line, column } = lineColumn(passage.content, offset);
121
+ const d: MarkupDiagnostic = {
122
+ passage: passage.name,
123
+ line,
124
+ column,
125
+ message,
126
+ };
127
+ const file = passage.metadata?.['data-source-file'];
128
+ if (file) {
129
+ d.file = file;
130
+ const header = Number(passage.metadata?.['data-source-line']);
131
+ if (header) d.fileLine = header + line;
132
+ }
133
+ diagnostics.push(d);
134
+ };
135
+
136
+ // Parse every passage first: the widgets they define are known macros.
137
+ const parsed: [MarkupPassage, Token[]][] = [];
138
+ const widgets = new Set<string>();
139
+ for (const passage of passages) {
140
+ if (NOT_MARKUP.has(passage.name)) continue;
141
+ if (passage.tags?.some((tag) => NOT_MARKUP_TAGS.includes(tag))) continue;
142
+ const reported = !options.only || options.only(passage);
143
+ try {
144
+ // The tokens first: they don't depend on which macros take a body
145
+ const tokens = tokenizeMarkup(passage.content);
146
+ for (const token of tokens) {
147
+ if (token.type !== 'macro' || token.isClose) continue;
148
+ if (token.name.toLowerCase() !== 'widget') continue;
149
+ const name = widgetName(token.rawArgs);
150
+ if (name) widgets.add(name);
151
+ }
152
+ if (!reported) continue;
153
+ parseMarkup(passage.content, { hooks });
154
+ parsed.push([passage, tokens]);
155
+ } catch (err) {
156
+ if (!(err instanceof MarkupError)) throw err;
157
+ if (reported) {
158
+ report(passage, err.offset, err.reason);
159
+ }
160
+ }
161
+ }
162
+ const names = [...(options.macroNames ?? []), ...widgets];
163
+
164
+ /** Report the unknown macros among `tokens`, which start at `base`. */
165
+ const checkMacros = (
166
+ passage: MarkupPassage,
167
+ tokens: Token[],
168
+ base: number,
169
+ where: string,
170
+ ) => {
171
+ for (const token of tokens) {
172
+ if (token.type !== 'macro' || token.isClose) continue;
173
+ const lower = token.name.toLowerCase();
174
+ if (
175
+ BRANCH_MACROS.has(lower) ||
176
+ widgets.has(lower) ||
177
+ options.isKnownMacro(lower)
178
+ ) {
179
+ continue;
180
+ }
181
+ report(
182
+ passage,
183
+ base + token.start,
184
+ `${where}Unknown macro {${token.name}}.${suggestion(token.name, names)}`,
185
+ );
186
+ }
187
+ };
188
+
189
+ for (const [passage, tokens] of parsed) {
190
+ checkMacros(passage, tokens, 0, '');
191
+ for (const token of tokens) {
192
+ if (token.type !== 'html') continue;
193
+ for (const [attr, value] of Object.entries(token.attributes)) {
194
+ if (isCodeAttribute(attr) || !value.includes('{')) continue;
195
+ // The value is the source text between its quotes
196
+ const found = passage.content.indexOf(value, token.start);
197
+ const at = found === -1 ? token.start : found;
198
+ const where = `In the ${attr} attribute of <${token.tag}>: `;
199
+ try {
200
+ parseMarkup(value, { text: true, hooks });
201
+ checkMacros(
202
+ passage,
203
+ tokenizeMarkup(value, { text: true }),
204
+ at,
205
+ where,
206
+ );
207
+ } catch (err) {
208
+ if (!(err instanceof MarkupError)) throw err;
209
+ report(
210
+ passage,
211
+ found === -1 ? at : at + err.offset,
212
+ where + err.reason,
213
+ );
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return diagnostics;
219
+ }
package/src/parser.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { tokenize } from './markup/tokenizer';
1
+ import { MarkupError, tokenizeMarkup } from './markup/parse';
2
2
  import { isCodeAttribute, splitSigilTemplate } from './markup/code-attributes';
3
3
 
4
4
  export interface Passage {
@@ -88,7 +88,7 @@ function elementMarkup(el: Element): string {
88
88
  }
89
89
 
90
90
  /**
91
- * An attribute value quoted so that the passage tokenizer reads it back:
91
+ * An attribute value quoted so that the passage parser reads it back:
92
92
  * in double quotes, else single ones, else with the double quotes in its
93
93
  * text written as `&quot;`, which rendering decodes. Quotes inside markup in
94
94
  * the value (`{print "a"}`) are code, and don't end it.
@@ -97,7 +97,14 @@ function quoteAttribute(name: string, value: string): string {
97
97
  const code = isCodeAttribute(name);
98
98
  const probe = code ? 'on' : 'a';
99
99
  const readsBack = (quoted: string) => {
100
- const tokens = tokenize(`<i ${probe}=${quoted}>`);
100
+ let tokens;
101
+ try {
102
+ tokens = tokenizeMarkup(`<i ${probe}=${quoted}>`);
103
+ } catch (err) {
104
+ // Malformed markup in the value, such as an unclosed quote
105
+ if (err instanceof MarkupError) return false;
106
+ throw err;
107
+ }
101
108
  const tag = tokens[0];
102
109
  return (
103
110
  tokens.length === 1 &&
@@ -129,7 +136,15 @@ function escapeTextQuotes(value: string, code: boolean): string {
129
136
  )
130
137
  .join('');
131
138
  }
132
- return tokenize(value, { text: true })
139
+ let tokens;
140
+ try {
141
+ tokens = tokenizeMarkup(value, { text: true });
142
+ } catch (err) {
143
+ // Malformed markup: validation reports it when the story starts
144
+ if (err instanceof MarkupError) return escape(value);
145
+ throw err;
146
+ }
147
+ return tokens
133
148
  .map((token) => {
134
149
  const source = value.slice(token.start, token.end);
135
150
  return token.type === 'text' ? escape(source) : source;
@@ -0,0 +1,65 @@
1
+ // Runtime errors shown to the player on the page (see RuntimeErrors), for
2
+ // errors that would otherwise only reach the browser console.
3
+
4
+ import { errorMessage } from './utils/error-message';
5
+
6
+ /** A runtime error shown on the page until the player dismisses it. */
7
+ export interface RuntimeError {
8
+ /** Identifies the entry, for dismissing it. */
9
+ id: number;
10
+ /** What failed, for the player (e.g. "Could not save the game…"). */
11
+ context: string;
12
+ /** The error message, without the "spindle: " prefix. */
13
+ message: string;
14
+ /** How often the same error happened since it was shown. */
15
+ count: number;
16
+ }
17
+
18
+ let errors: readonly RuntimeError[] = [];
19
+ let nextId = 1;
20
+ const listeners = new Set<() => void>();
21
+
22
+ function update(next: readonly RuntimeError[]): void {
23
+ errors = next;
24
+ for (const listener of listeners) listener();
25
+ }
26
+
27
+ /**
28
+ * Show `error` on the page, after `context` (what failed). The same error
29
+ * again, while it is shown, counts up instead of adding an entry. Logging
30
+ * it to the console is up to the caller.
31
+ */
32
+ export function showRuntimeError(context: string, error: unknown): void {
33
+ const message = errorMessage(error).replace(/^spindle: /, '');
34
+ const same = errors.find(
35
+ (e) => e.context === context && e.message === message,
36
+ );
37
+ update(
38
+ same
39
+ ? errors.map((e) => (e === same ? { ...e, count: e.count + 1 } : e))
40
+ : [...errors, { id: nextId++, context, message, count: 1 }],
41
+ );
42
+ }
43
+
44
+ /** Remove the shown error `id`. */
45
+ export function dismissRuntimeError(id: number): void {
46
+ update(errors.filter((e) => e.id !== id));
47
+ }
48
+
49
+ /** Remove every shown error. */
50
+ export function clearRuntimeErrors(): void {
51
+ if (errors.length) update([]);
52
+ }
53
+
54
+ /** The errors shown now, oldest first. */
55
+ export function getRuntimeErrors(): readonly RuntimeError[] {
56
+ return errors;
57
+ }
58
+
59
+ /** Call `listener` whenever the shown errors change; returns unsubscribe. */
60
+ export function subscribeRuntimeErrors(listener: () => void): () => void {
61
+ listeners.add(listener);
62
+ return () => {
63
+ listeners.delete(listener);
64
+ };
65
+ }
@@ -0,0 +1,199 @@
1
+ // The stored form of a save payload: what saves, exports and the session
2
+ // hold, and the version of that form.
3
+
4
+ import { deserialize, serialize } from '../class-registry';
5
+ import { createCounts } from '../utils/namespace';
6
+ import type { SavePayload } from './types';
7
+
8
+ /**
9
+ * The version of the save format this build writes. Saves, exports and the
10
+ * session carry it. Bump it when the stored form changes, and add a
11
+ * migration from the previous version to MIGRATIONS.
12
+ */
13
+ export const SAVE_FORMAT_VERSION = 1;
14
+
15
+ /**
16
+ * A payload as stored: `data` is the payload serialized in one piece (see
17
+ * serialize() in class-registry.ts), so values the history moments share
18
+ * are stored once, and cycles survive.
19
+ */
20
+ export interface EncodedPayload {
21
+ formatVersion: number;
22
+ data: string;
23
+ }
24
+
25
+ /** A save, export or session from a version of Spindle this one cannot read. */
26
+ export class IncompatibleSaveError extends Error {
27
+ constructor() {
28
+ super('This save was made by an incompatible version of Spindle');
29
+ this.name = 'IncompatibleSaveError';
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Migrations of the stored data: MIGRATIONS[v] turns the `data` of format
35
+ * version v into that of version v + 1. Version 1 is the first versioned
36
+ * format; data from before it has no version and is refused.
37
+ */
38
+ const MIGRATIONS: Readonly<Record<number, (data: string) => string>> = {};
39
+
40
+ /** Throw an IncompatibleSaveError unless this build reads format `version`. */
41
+ export function checkFormatVersion(
42
+ version: unknown,
43
+ ): asserts version is number {
44
+ if (
45
+ typeof version !== 'number' ||
46
+ !Number.isInteger(version) ||
47
+ version < 1 ||
48
+ version > SAVE_FORMAT_VERSION
49
+ ) {
50
+ throw new IncompatibleSaveError();
51
+ }
52
+ }
53
+
54
+ /** Bring `data` of format `version` up to SAVE_FORMAT_VERSION. */
55
+ export function migrate(version: number, data: string): string {
56
+ checkFormatVersion(version);
57
+ let current = data;
58
+ for (let v = version; v < SAVE_FORMAT_VERSION; v++) {
59
+ const step = MIGRATIONS[v];
60
+ if (!step) throw new IncompatibleSaveError();
61
+ current = step(current);
62
+ }
63
+ return current;
64
+ }
65
+
66
+ /**
67
+ * The passage counters as a Map: a passage may be named "__proto__", which
68
+ * serialize() refuses as an object key (as every story-state key).
69
+ */
70
+ const countsToMap = (counts: Record<string, number> | undefined) =>
71
+ counts && new Map(Object.entries(counts));
72
+
73
+ /**
74
+ * The error of serialize() with its path told as a story variable:
75
+ * `(at .variables.inv[0])` becomes `(at $inv[0])`, and a value only an
76
+ * earlier history moment holds `(at $inv[0] in history moment 3)`.
77
+ */
78
+ function variablePathError(err: unknown): unknown {
79
+ if (!(err instanceof TypeError)) return err;
80
+ const message = err.message
81
+ .replace(/\(at \.variables(?=[.[]|\))/, '(at $')
82
+ .replace(
83
+ /\(at \.history\[(\d+)\]\.variables(.*)\)$/,
84
+ (_, i: string, path: string) => `(at $${path} in history moment ${i})`,
85
+ )
86
+ .replace(/\(at \$\.?/, '(at $');
87
+ return message === err.message ? err : new TypeError(message);
88
+ }
89
+
90
+ /**
91
+ * The stored form of `payload`. Throws, naming the variable, when the
92
+ * payload holds a value a save cannot (see serialize()).
93
+ */
94
+ export function encodePayload(payload: SavePayload): EncodedPayload {
95
+ try {
96
+ return {
97
+ formatVersion: SAVE_FORMAT_VERSION,
98
+ data: serialize({
99
+ ...payload,
100
+ visitCounts: countsToMap(payload.visitCounts),
101
+ renderCounts: countsToMap(payload.renderCounts),
102
+ }),
103
+ };
104
+ } catch (err) {
105
+ throw variablePathError(err);
106
+ }
107
+ }
108
+
109
+ /** Thrown for stored data that is not a payload this format can hold. */
110
+ const invalid = () => new Error('Invalid save data');
111
+
112
+ /** A plain object or one without a prototype (a variable namespace). */
113
+ function isRecord(value: unknown): value is Record<string, unknown> {
114
+ if (typeof value !== 'object' || value === null) return false;
115
+ const proto = Object.getPrototypeOf(value) as object | null;
116
+ return proto === Object.prototype || proto === null;
117
+ }
118
+
119
+ /** Absent, null, or a `{ seed, pull }` PRNG snapshot. */
120
+ function isOptionalPRNGSnapshot(value: unknown): boolean {
121
+ if (value == null) return true;
122
+ return (
123
+ isRecord(value) &&
124
+ typeof value.seed === 'string' &&
125
+ typeof value.pull === 'number' &&
126
+ Number.isInteger(value.pull) &&
127
+ value.pull >= 0
128
+ );
129
+ }
130
+
131
+ const isMoment = (value: unknown): boolean =>
132
+ isRecord(value) &&
133
+ typeof value.passage === 'string' &&
134
+ isRecord(value.variables) &&
135
+ typeof value.timestamp === 'number' &&
136
+ isOptionalPRNGSnapshot(value.prng);
137
+
138
+ const isCountsMap = (value: unknown): boolean =>
139
+ value === undefined ||
140
+ (value instanceof Map &&
141
+ [...value.keys()].every((k) => typeof k === 'string'));
142
+
143
+ /**
144
+ * Whether a deserialized payload is well formed: every history moment is,
145
+ * and `historyIndex` is an integer that points at a moment of the payload's
146
+ * passage.
147
+ */
148
+ function isPayload(value: unknown): value is Record<string, unknown> {
149
+ if (!isRecord(value)) return false;
150
+ if (typeof value.passage !== 'string' || !isRecord(value.variables)) {
151
+ return false;
152
+ }
153
+ const { history, historyIndex } = value;
154
+ // Array.from: every() skips the holes of a sparse array
155
+ if (!Array.isArray(history) || history.length === 0) return false;
156
+ if (!Array.from(history).every(isMoment)) return false;
157
+ if (
158
+ typeof historyIndex !== 'number' ||
159
+ !Number.isInteger(historyIndex) ||
160
+ historyIndex < 0 ||
161
+ historyIndex >= history.length ||
162
+ (history[historyIndex] as { passage: string }).passage !== value.passage
163
+ ) {
164
+ return false;
165
+ }
166
+ return (
167
+ isCountsMap(value.visitCounts) &&
168
+ isCountsMap(value.renderCounts) &&
169
+ isOptionalPRNGSnapshot(value.prng)
170
+ );
171
+ }
172
+
173
+ const mapToCounts = (counts: unknown) =>
174
+ counts instanceof Map
175
+ ? createCounts(Object.fromEntries(counts as Map<string, unknown>))
176
+ : undefined;
177
+
178
+ /**
179
+ * The live payload of a stored one: class instances and built-ins
180
+ * restored. Throws an IncompatibleSaveError when `encoded` has no format
181
+ * version or one this build cannot read (saves from before versioning, or
182
+ * from a newer Spindle), and an error when its data is malformed.
183
+ */
184
+ export function decodePayload(encoded: unknown): SavePayload {
185
+ if (typeof encoded !== 'object' || encoded === null) throw invalid();
186
+ const { formatVersion, data } = encoded as Record<string, unknown>;
187
+ checkFormatVersion(formatVersion);
188
+ if (typeof data !== 'string') throw invalid();
189
+ const body = deserialize(migrate(formatVersion, data));
190
+ if (!isPayload(body)) throw invalid();
191
+ const payload = {
192
+ ...body,
193
+ visitCounts: mapToCounts(body.visitCounts),
194
+ renderCounts: mapToCounts(body.renderCounts),
195
+ } as SavePayload;
196
+ if (payload.visitCounts === undefined) delete payload.visitCounts;
197
+ if (payload.renderCounts === undefined) delete payload.renderCounts;
198
+ return payload;
199
+ }