@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.
- package/README.md +5 -1
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +6484 -2558
- package/dist/pkg/story-variables.js +4009 -940
- package/dist/pkg/tooling.js +13 -1
- package/package.json +6 -1
- package/src/class-registry.ts +252 -250
- package/src/components/App.tsx +2 -0
- package/src/components/Passage.tsx +3 -3
- package/src/components/PassageDialog.tsx +2 -4
- package/src/components/RuntimeErrors.tsx +46 -0
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Computed.tsx +5 -2
- package/src/components/macros/Do.tsx +4 -2
- package/src/components/macros/Goto.tsx +13 -3
- package/src/components/macros/Include.tsx +2 -3
- package/src/components/macros/MacroError.tsx +14 -7
- package/src/components/macros/SaveManager.tsx +2 -2
- package/src/components/macros/VarDisplay.tsx +1 -1
- package/src/components/macros/Widget.tsx +5 -33
- package/src/index.tsx +57 -37
- package/src/interpolation.ts +4 -5
- package/src/markup/ast.ts +11 -226
- package/src/markup/code-attributes.ts +3 -3
- package/src/markup/code-end.ts +121 -0
- package/src/markup/parse.ts +118 -0
- package/src/markup/render.tsx +1 -1
- package/src/markup/spindle.d.peggy.ts +24 -0
- package/src/markup/spindle.peggy +463 -0
- package/src/markup/tokens.ts +95 -0
- package/src/markup/validate.ts +219 -0
- package/src/parser.ts +19 -4
- package/src/runtime-errors.ts +65 -0
- package/src/saves/format.ts +199 -0
- package/src/saves/save-manager.ts +45 -63
- package/src/saves/types.ts +71 -88
- package/src/store.ts +167 -87
- package/src/story-init.ts +3 -5
- package/src/story-variables.ts +18 -4
- package/src/structural.ts +266 -57
- package/src/styles.css +45 -0
- package/src/tooling.ts +50 -0
- package/src/types-drift-check.ts +28 -1
- package/src/utils/object-path.ts +13 -12
- package/src/utils/stable-key.ts +2 -1
- package/src/utils/value-kinds.ts +45 -0
- package/src/widgets/widget-def.ts +60 -0
- package/types/index.d.ts +32 -8
- package/types/tooling.d.ts +42 -0
- 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 {
|
|
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
|
|
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 `"`, 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
|
-
|
|
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
|
-
|
|
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
|
+
}
|