@rohal12/spindle 0.59.16 → 0.59.18
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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +7167 -6968
- package/dist/pkg/story-variables.js +221 -147
- package/package.json +1 -1
- package/src/components/StoryInterface.tsx +17 -6
- package/src/components/macros/PassageDisplay.tsx +9 -1
- package/src/components/macros/Repeat.tsx +6 -1
- package/src/components/macros/Timed.tsx +5 -1
- package/src/components/macros/Watch.tsx +11 -0
- package/src/components/macros/detached-body.tsx +14 -3
- package/src/define-macro.ts +10 -2
- package/src/hooks/use-modal-focus.ts +2 -0
- package/src/hooks/use-story-fields.ts +8 -0
- package/src/markup/ast.ts +2 -0
- package/src/markup/render.tsx +9 -1
- package/src/markup/spindle.peggy +6 -4
- package/src/saves/format.ts +3 -1
- package/src/saves/types.ts +5 -0
- package/src/store.ts +25 -2
- package/src/structural.ts +47 -16
- package/src/tooling.ts +5 -2
- package/src/triggers.ts +48 -2
- package/src/utils/object-path.ts +13 -2
- package/src/utils/stable-key.ts +57 -3
- package/src/utils/value-kinds.ts +3 -0
- package/src/widgets/ast-scanner.ts +76 -6
- package/src/widgets/widget-def.ts +8 -4
|
@@ -151,7 +151,11 @@ function lexeme(lex, loc, fields) {
|
|
|
151
151
|
/** The AST node of a lexeme that stands alone (not a macro or element). */
|
|
152
152
|
function nodeOf(x) {
|
|
153
153
|
switch (x.lex) {
|
|
154
|
-
case "text": return {
|
|
154
|
+
case "text": return x.comment ? {
|
|
155
|
+
type: "text",
|
|
156
|
+
value: x.value,
|
|
157
|
+
comment: true
|
|
158
|
+
} : {
|
|
155
159
|
type: "text",
|
|
156
160
|
value: x.value
|
|
157
161
|
};
|
|
@@ -223,7 +227,7 @@ function tokenOf(x) {
|
|
|
223
227
|
}
|
|
224
228
|
/**
|
|
225
229
|
* Flatten items (lexemes, nodes, arrays of them, nulls) and join adjacent
|
|
226
|
-
* text, except escaped braces, which stay a text item of their own.
|
|
230
|
+
* text, except escaped braces and comments, which stay a text item of their own.
|
|
227
231
|
*/
|
|
228
232
|
function joinText(items, convert) {
|
|
229
233
|
const out = [];
|
|
@@ -231,7 +235,7 @@ function joinText(items, convert) {
|
|
|
231
235
|
const add = (item) => {
|
|
232
236
|
if (item === null || item === void 0) return;
|
|
233
237
|
if (Array.isArray(item)) return item.forEach(add);
|
|
234
|
-
if (item.lex === "text" && !item.escape && last && last.lex === "text" && !last.escape && last.end === item.start) {
|
|
238
|
+
if (item.lex === "text" && !item.escape && !item.comment && last && last.lex === "text" && !last.escape && !last.comment && last.end === item.start) {
|
|
235
239
|
last.value += item.value;
|
|
236
240
|
last.end = item.end;
|
|
237
241
|
return;
|
|
@@ -725,7 +729,9 @@ function peg$parse(input, options) {
|
|
|
725
729
|
return lexeme("text", location(), { value });
|
|
726
730
|
}
|
|
727
731
|
function peg$f41(value) {
|
|
728
|
-
|
|
732
|
+
const node = lexeme("text", location(), { value });
|
|
733
|
+
node.comment = true;
|
|
734
|
+
return node;
|
|
729
735
|
}
|
|
730
736
|
function peg$f42(bs) {
|
|
731
737
|
return bs.length % 2 === 1;
|
|
@@ -10586,70 +10592,6 @@ function evaluatePassageName(expr, evaluate, { storyData, currentPassage }) {
|
|
|
10586
10592
|
if (storyData && !storyData.passages.has(name)) throw noPassageError(name, currentPassage);
|
|
10587
10593
|
return name;
|
|
10588
10594
|
}
|
|
10589
|
-
//#endregion
|
|
10590
|
-
//#region src/widgets/widget-def.ts
|
|
10591
|
-
/**
|
|
10592
|
-
* Reading widget definitions without rendering anything: the `{widget}`
|
|
10593
|
-
* macro, startup and markup validation (also in tooling) share these.
|
|
10594
|
-
*/
|
|
10595
|
-
/** A {widget} definition's name, then its `@` parameters. */
|
|
10596
|
-
var WIDGET_PARAMETERS = [{
|
|
10597
|
-
name: "name",
|
|
10598
|
-
type: "text",
|
|
10599
|
-
holds: "text",
|
|
10600
|
-
required: true
|
|
10601
|
-
}, {
|
|
10602
|
-
name: "parameters",
|
|
10603
|
-
type: "text",
|
|
10604
|
-
holds: "text"
|
|
10605
|
-
}];
|
|
10606
|
-
/**
|
|
10607
|
-
* The widget a {widget} definition's arguments declare: its name and its
|
|
10608
|
-
* parameters, the words after it that start with `@` (docs/widgets.md).
|
|
10609
|
-
* Other words, such as `$name`, are not parameters.
|
|
10610
|
-
*/
|
|
10611
|
-
function widgetDef({ name = "", parameters = "" }) {
|
|
10612
|
-
return {
|
|
10613
|
-
name,
|
|
10614
|
-
params: parameters.split(/\s+/).filter((word) => word.startsWith("@"))
|
|
10615
|
-
};
|
|
10616
|
-
}
|
|
10617
|
-
/** Read the arguments of a {widget} definition (see widgetDef). */
|
|
10618
|
-
function parseWidgetDef(rawArgs) {
|
|
10619
|
-
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
10620
|
-
}
|
|
10621
|
-
/**
|
|
10622
|
-
* The names of the block widgets (those whose body renders `{@children}`)
|
|
10623
|
-
* that StoryInit and the passages tagged `widget` define. They must be
|
|
10624
|
-
* known as block macros before any passage is parsed, so that passages
|
|
10625
|
-
* invoking them nest their content whatever the passage order.
|
|
10626
|
-
*
|
|
10627
|
-
* The passages are read as flat tokens, which need no block macros to be
|
|
10628
|
-
* known, so a `{@children}` in an HTML comment or in a `{do}` body is text
|
|
10629
|
-
* here as it is to the parser, and does not make a block widget (#387).
|
|
10630
|
-
*/
|
|
10631
|
-
function blockWidgetNames(passages) {
|
|
10632
|
-
const names = [];
|
|
10633
|
-
for (const passage of passages) {
|
|
10634
|
-
if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
|
|
10635
|
-
const open = [];
|
|
10636
|
-
for (const token of tokenizeMarkupTolerant(passage.content).tokens) if (token.type === "macro" && token.name.toLowerCase() === "widget") {
|
|
10637
|
-
if (!token.isClose) {
|
|
10638
|
-
open.push({
|
|
10639
|
-
rawArgs: token.rawArgs,
|
|
10640
|
-
isBlock: false
|
|
10641
|
-
});
|
|
10642
|
-
continue;
|
|
10643
|
-
}
|
|
10644
|
-
const def = open.pop();
|
|
10645
|
-
if (!def?.isBlock) continue;
|
|
10646
|
-
try {
|
|
10647
|
-
names.push(parseWidgetDef(def.rawArgs).name);
|
|
10648
|
-
} catch {}
|
|
10649
|
-
} else if (token.type === "variable" && token.scope === "local" && token.name === "children" && open.length > 0) open[open.length - 1].isBlock = true;
|
|
10650
|
-
}
|
|
10651
|
-
return names;
|
|
10652
|
-
}
|
|
10653
10595
|
/** The parameters of the built-in sub-macros that take arguments. */
|
|
10654
10596
|
var SUB_MACRO_PARAMETERS = new Map([["option", [{
|
|
10655
10597
|
name: "value",
|
|
@@ -10983,6 +10925,214 @@ function collectPassageReferences(src, tokens, parametersOf) {
|
|
|
10983
10925
|
});
|
|
10984
10926
|
return refs;
|
|
10985
10927
|
}
|
|
10928
|
+
new NameMap();
|
|
10929
|
+
new NameMap();
|
|
10930
|
+
new NameSet();
|
|
10931
|
+
/**
|
|
10932
|
+
* How a macro argument is read (see components/macros/macro-args.ts). Every
|
|
10933
|
+
* declared parameter names one: there is no default.
|
|
10934
|
+
* Quoted strings accept `\"`, `\'` and `\\` escapes.
|
|
10935
|
+
* - `expression`: code, as written.
|
|
10936
|
+
* - `statements`: code run as statements (`{set}`), as written.
|
|
10937
|
+
* - `passage`: a passage name: a quoted string or an expression, as written.
|
|
10938
|
+
* - `variable`: a variable reference such as `$name` or `"$name"`, as written.
|
|
10939
|
+
* - `string`: one quoted string; anything else leaves the argument unset.
|
|
10940
|
+
* - `text`: one quoted string, or text with any loose quotes stripped.
|
|
10941
|
+
* - `names`: a comma-separated list of names (`@item, @i`).
|
|
10942
|
+
* - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
|
|
10943
|
+
* - `number`: a number.
|
|
10944
|
+
* - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
|
|
10945
|
+
* - `separator`: a word (`of`) or `=` separating the parameters before it
|
|
10946
|
+
* from those after it; a boolean.
|
|
10947
|
+
* - `options`: keywords, the names of its `parameters`, each followed by a
|
|
10948
|
+
* quoted string or a number unless it is a flag.
|
|
10949
|
+
*/
|
|
10950
|
+
var PARAMETER_TYPES = [
|
|
10951
|
+
"expression",
|
|
10952
|
+
"statements",
|
|
10953
|
+
"passage",
|
|
10954
|
+
"variable",
|
|
10955
|
+
"string",
|
|
10956
|
+
"text",
|
|
10957
|
+
"names",
|
|
10958
|
+
"delay",
|
|
10959
|
+
"number",
|
|
10960
|
+
"flag",
|
|
10961
|
+
"separator",
|
|
10962
|
+
"options"
|
|
10963
|
+
];
|
|
10964
|
+
/**
|
|
10965
|
+
* What the value of a `string` or `text` argument holds, for the check at
|
|
10966
|
+
* story start and for tooling (see code-check.ts):
|
|
10967
|
+
* - `markup`: markup the macro renders (`{button}`'s label): its markup
|
|
10968
|
+
* is checked.
|
|
10969
|
+
* - `text`: plain text the macro uses as written (`{checkbox}`'s label).
|
|
10970
|
+
* - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
|
|
10971
|
+
* - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
|
|
10972
|
+
* is checked as code, and its variable references against the schema.
|
|
10973
|
+
*
|
|
10974
|
+
* Without it, the argument of a macro with `interpolate` holds `markup`,
|
|
10975
|
+
* any other `text`.
|
|
10976
|
+
*/
|
|
10977
|
+
var STRING_HOLDS = [
|
|
10978
|
+
"markup",
|
|
10979
|
+
"text",
|
|
10980
|
+
"passage",
|
|
10981
|
+
"expression",
|
|
10982
|
+
"statements"
|
|
10983
|
+
];
|
|
10984
|
+
/**
|
|
10985
|
+
* Throw if a parameter `macro` declares (or an option of one) has no type,
|
|
10986
|
+
* or one that isn't a ParameterType: arguments are read by their type, and
|
|
10987
|
+
* there is no default. Throw too if it declares what it `holds` wrongly.
|
|
10988
|
+
*/
|
|
10989
|
+
function checkParameterTypes(macro, parameters) {
|
|
10990
|
+
const holdsFix = `Give a \`string\` or \`text\` parameter one of ${STRING_HOLDS.join(", ")} to hold (see docs/custom-macros.md#what-a-string-holds).`;
|
|
10991
|
+
for (const param of parameters) {
|
|
10992
|
+
const type = param.type;
|
|
10993
|
+
if (!PARAMETER_TYPES.includes(type)) throw parameterError(macro, param, type === void 0 ? "has no type" : `has the unknown type ${JSON.stringify(type)}`, `Give it one of the types ${PARAMETER_TYPES.join(", ")} (see docs/custom-macros.md#parameter-types), or declare no parameters and read props.rawArgs.`);
|
|
10994
|
+
const holds = param.holds;
|
|
10995
|
+
if (holds !== void 0 && type !== "string" && type !== "text") throw parameterError(macro, param, `is of the type ${type}, which holds what it is`, holdsFix);
|
|
10996
|
+
if (holds !== void 0 && !STRING_HOLDS.includes(holds)) throw parameterError(macro, param, `holds the unknown ${JSON.stringify(holds)}`, holdsFix);
|
|
10997
|
+
if (param.parameters) checkParameterTypes(macro, param.parameters);
|
|
10998
|
+
}
|
|
10999
|
+
}
|
|
11000
|
+
/** The error for the parameter `param` of `macro`: what is wrong, and the fix. */
|
|
11001
|
+
function parameterError(macro, param, problem, fix) {
|
|
11002
|
+
return /* @__PURE__ */ new Error(`spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ${fix}`);
|
|
11003
|
+
}
|
|
11004
|
+
var metadataRegistry = new NameMap();
|
|
11005
|
+
function getMacroRegistry() {
|
|
11006
|
+
return Array.from(metadataRegistry.values());
|
|
11007
|
+
}
|
|
11008
|
+
//#endregion
|
|
11009
|
+
//#region src/widgets/ast-scanner.ts
|
|
11010
|
+
/** The declared parameters of the macros registered so far. */
|
|
11011
|
+
var registeredParameters = (name) => parameterLookup(getMacroRegistry())(name);
|
|
11012
|
+
/**
|
|
11013
|
+
* Whether the text of an attribute value or macro label renders a
|
|
11014
|
+
* `{@children}`: it is markup, parsed as the renderer parses it.
|
|
11015
|
+
*/
|
|
11016
|
+
function textContainsChildren(text, parametersOf) {
|
|
11017
|
+
if (!text.includes("{")) return false;
|
|
11018
|
+
let nodes;
|
|
11019
|
+
try {
|
|
11020
|
+
nodes = parseMarkup(text, { text: true });
|
|
11021
|
+
} catch {
|
|
11022
|
+
return false;
|
|
11023
|
+
}
|
|
11024
|
+
return astContainsChildren(nodes, parametersOf);
|
|
11025
|
+
}
|
|
11026
|
+
/**
|
|
11027
|
+
* Whether a tag renders a `{@children}` in an attribute value or a macro
|
|
11028
|
+
* argument that holds markup (`<span title="{@children}">`,
|
|
11029
|
+
* `{button "{@children}"}`). Literal arguments and code are not markup.
|
|
11030
|
+
*/
|
|
11031
|
+
function tokenTextContainsChildren(token, parametersOf = registeredParameters) {
|
|
11032
|
+
for (const piece of codeAndText("", [token], parametersOf)) if (piece.kind === "text" && textContainsChildren(piece.text, parametersOf)) return true;
|
|
11033
|
+
return false;
|
|
11034
|
+
}
|
|
11035
|
+
/**
|
|
11036
|
+
* Recursively scan an AST node array for a VariableNode
|
|
11037
|
+
* with scope 'local' and name 'children' (@children), in the content and in
|
|
11038
|
+
* the attributes and labels that render markup.
|
|
11039
|
+
*/
|
|
11040
|
+
function astContainsChildren(nodes, parametersOf = registeredParameters) {
|
|
11041
|
+
for (const node of nodes) {
|
|
11042
|
+
if (node.type === "variable" && node.scope === "local" && node.name === "children") return true;
|
|
11043
|
+
if (node.type === "html") {
|
|
11044
|
+
const span = {
|
|
11045
|
+
start: 0,
|
|
11046
|
+
end: 0
|
|
11047
|
+
};
|
|
11048
|
+
if (tokenTextContainsChildren({
|
|
11049
|
+
isClose: false,
|
|
11050
|
+
isSelfClose: false,
|
|
11051
|
+
...node,
|
|
11052
|
+
...span
|
|
11053
|
+
}, parametersOf) || astContainsChildren(node.children, parametersOf)) return true;
|
|
11054
|
+
}
|
|
11055
|
+
if (node.type === "macro") {
|
|
11056
|
+
const span = {
|
|
11057
|
+
start: 0,
|
|
11058
|
+
end: 0
|
|
11059
|
+
};
|
|
11060
|
+
if (tokenTextContainsChildren({
|
|
11061
|
+
isClose: false,
|
|
11062
|
+
...node,
|
|
11063
|
+
...span
|
|
11064
|
+
}, parametersOf) || astContainsChildren(node.children, parametersOf)) return true;
|
|
11065
|
+
if (node.branches) {
|
|
11066
|
+
for (const branch of node.branches) if (astContainsChildren(branch.children, parametersOf)) return true;
|
|
11067
|
+
}
|
|
11068
|
+
}
|
|
11069
|
+
}
|
|
11070
|
+
return false;
|
|
11071
|
+
}
|
|
11072
|
+
//#endregion
|
|
11073
|
+
//#region src/widgets/widget-def.ts
|
|
11074
|
+
/**
|
|
11075
|
+
* Reading widget definitions without rendering anything: the `{widget}`
|
|
11076
|
+
* macro, startup and markup validation (also in tooling) share these.
|
|
11077
|
+
*/
|
|
11078
|
+
/** A {widget} definition's name, then its `@` parameters. */
|
|
11079
|
+
var WIDGET_PARAMETERS = [{
|
|
11080
|
+
name: "name",
|
|
11081
|
+
type: "text",
|
|
11082
|
+
holds: "text",
|
|
11083
|
+
required: true
|
|
11084
|
+
}, {
|
|
11085
|
+
name: "parameters",
|
|
11086
|
+
type: "text",
|
|
11087
|
+
holds: "text"
|
|
11088
|
+
}];
|
|
11089
|
+
/**
|
|
11090
|
+
* The widget a {widget} definition's arguments declare: its name and its
|
|
11091
|
+
* parameters, the words after it that start with `@` (docs/widgets.md).
|
|
11092
|
+
* Other words, such as `$name`, are not parameters.
|
|
11093
|
+
*/
|
|
11094
|
+
function widgetDef({ name = "", parameters = "" }) {
|
|
11095
|
+
return {
|
|
11096
|
+
name,
|
|
11097
|
+
params: parameters.split(/\s+/).filter((word) => word.startsWith("@"))
|
|
11098
|
+
};
|
|
11099
|
+
}
|
|
11100
|
+
/** Read the arguments of a {widget} definition (see widgetDef). */
|
|
11101
|
+
function parseWidgetDef(rawArgs) {
|
|
11102
|
+
return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
|
|
11103
|
+
}
|
|
11104
|
+
/**
|
|
11105
|
+
* The names of the block widgets (those whose body renders `{@children}`)
|
|
11106
|
+
* that StoryInit and the passages tagged `widget` define. They must be
|
|
11107
|
+
* known as block macros before any passage is parsed, so that passages
|
|
11108
|
+
* invoking them nest their content whatever the passage order.
|
|
11109
|
+
*
|
|
11110
|
+
* The passages are read as flat tokens, which need no block macros to be
|
|
11111
|
+
* known, so a `{@children}` in an HTML comment or in a `{do}` body is text
|
|
11112
|
+
* here as it is to the parser, and does not make a block widget (#387).
|
|
11113
|
+
*/
|
|
11114
|
+
function blockWidgetNames(passages, parametersOf = registeredParameters) {
|
|
11115
|
+
const names = [];
|
|
11116
|
+
for (const passage of passages) {
|
|
11117
|
+
if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
|
|
11118
|
+
const open = [];
|
|
11119
|
+
for (const token of tokenizeMarkupTolerant(passage.content).tokens) if (token.type === "macro" && token.name.toLowerCase() === "widget") {
|
|
11120
|
+
if (!token.isClose) {
|
|
11121
|
+
open.push({
|
|
11122
|
+
rawArgs: token.rawArgs,
|
|
11123
|
+
isBlock: false
|
|
11124
|
+
});
|
|
11125
|
+
continue;
|
|
11126
|
+
}
|
|
11127
|
+
const def = open.pop();
|
|
11128
|
+
if (!def?.isBlock) continue;
|
|
11129
|
+
try {
|
|
11130
|
+
names.push(parseWidgetDef(def.rawArgs).name);
|
|
11131
|
+
} catch {}
|
|
11132
|
+
} else if (open.length > 0 && (token.type === "variable" && token.scope === "local" && token.name === "children" || tokenTextContainsChildren(token, parametersOf))) open[open.length - 1].isBlock = true;
|
|
11133
|
+
}
|
|
11134
|
+
return names;
|
|
11135
|
+
}
|
|
10986
11136
|
//#endregion
|
|
10987
11137
|
//#region src/markup/validate.ts
|
|
10988
11138
|
/**
|
|
@@ -11157,83 +11307,6 @@ function validateMarkup(passages, options) {
|
|
|
11157
11307
|
for (const [passage, tokens] of parsed) withParseCache(() => checkTokens(passage, passage.content, tokens, 0, ""));
|
|
11158
11308
|
return diagnostics;
|
|
11159
11309
|
}
|
|
11160
|
-
new NameMap();
|
|
11161
|
-
new NameMap();
|
|
11162
|
-
new NameSet();
|
|
11163
|
-
/**
|
|
11164
|
-
* How a macro argument is read (see components/macros/macro-args.ts). Every
|
|
11165
|
-
* declared parameter names one: there is no default.
|
|
11166
|
-
* Quoted strings accept `\"`, `\'` and `\\` escapes.
|
|
11167
|
-
* - `expression`: code, as written.
|
|
11168
|
-
* - `statements`: code run as statements (`{set}`), as written.
|
|
11169
|
-
* - `passage`: a passage name: a quoted string or an expression, as written.
|
|
11170
|
-
* - `variable`: a variable reference such as `$name` or `"$name"`, as written.
|
|
11171
|
-
* - `string`: one quoted string; anything else leaves the argument unset.
|
|
11172
|
-
* - `text`: one quoted string, or text with any loose quotes stripped.
|
|
11173
|
-
* - `names`: a comma-separated list of names (`@item, @i`).
|
|
11174
|
-
* - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
|
|
11175
|
-
* - `number`: a number.
|
|
11176
|
-
* - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
|
|
11177
|
-
* - `separator`: a word (`of`) or `=` separating the parameters before it
|
|
11178
|
-
* from those after it; a boolean.
|
|
11179
|
-
* - `options`: keywords, the names of its `parameters`, each followed by a
|
|
11180
|
-
* quoted string or a number unless it is a flag.
|
|
11181
|
-
*/
|
|
11182
|
-
var PARAMETER_TYPES = [
|
|
11183
|
-
"expression",
|
|
11184
|
-
"statements",
|
|
11185
|
-
"passage",
|
|
11186
|
-
"variable",
|
|
11187
|
-
"string",
|
|
11188
|
-
"text",
|
|
11189
|
-
"names",
|
|
11190
|
-
"delay",
|
|
11191
|
-
"number",
|
|
11192
|
-
"flag",
|
|
11193
|
-
"separator",
|
|
11194
|
-
"options"
|
|
11195
|
-
];
|
|
11196
|
-
/**
|
|
11197
|
-
* What the value of a `string` or `text` argument holds, for the check at
|
|
11198
|
-
* story start and for tooling (see code-check.ts):
|
|
11199
|
-
* - `markup`: markup the macro renders (`{button}`'s label): its markup
|
|
11200
|
-
* is checked.
|
|
11201
|
-
* - `text`: plain text the macro uses as written (`{checkbox}`'s label).
|
|
11202
|
-
* - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
|
|
11203
|
-
* - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
|
|
11204
|
-
* is checked as code, and its variable references against the schema.
|
|
11205
|
-
*
|
|
11206
|
-
* Without it, the argument of a macro with `interpolate` holds `markup`,
|
|
11207
|
-
* any other `text`.
|
|
11208
|
-
*/
|
|
11209
|
-
var STRING_HOLDS = [
|
|
11210
|
-
"markup",
|
|
11211
|
-
"text",
|
|
11212
|
-
"passage",
|
|
11213
|
-
"expression",
|
|
11214
|
-
"statements"
|
|
11215
|
-
];
|
|
11216
|
-
/**
|
|
11217
|
-
* Throw if a parameter `macro` declares (or an option of one) has no type,
|
|
11218
|
-
* or one that isn't a ParameterType: arguments are read by their type, and
|
|
11219
|
-
* there is no default. Throw too if it declares what it `holds` wrongly.
|
|
11220
|
-
*/
|
|
11221
|
-
function checkParameterTypes(macro, parameters) {
|
|
11222
|
-
const holdsFix = `Give a \`string\` or \`text\` parameter one of ${STRING_HOLDS.join(", ")} to hold (see docs/custom-macros.md#what-a-string-holds).`;
|
|
11223
|
-
for (const param of parameters) {
|
|
11224
|
-
const type = param.type;
|
|
11225
|
-
if (!PARAMETER_TYPES.includes(type)) throw parameterError(macro, param, type === void 0 ? "has no type" : `has the unknown type ${JSON.stringify(type)}`, `Give it one of the types ${PARAMETER_TYPES.join(", ")} (see docs/custom-macros.md#parameter-types), or declare no parameters and read props.rawArgs.`);
|
|
11226
|
-
const holds = param.holds;
|
|
11227
|
-
if (holds !== void 0 && type !== "string" && type !== "text") throw parameterError(macro, param, `is of the type ${type}, which holds what it is`, holdsFix);
|
|
11228
|
-
if (holds !== void 0 && !STRING_HOLDS.includes(holds)) throw parameterError(macro, param, `holds the unknown ${JSON.stringify(holds)}`, holdsFix);
|
|
11229
|
-
if (param.parameters) checkParameterTypes(macro, param.parameters);
|
|
11230
|
-
}
|
|
11231
|
-
}
|
|
11232
|
-
/** The error for the parameter `param` of `macro`: what is wrong, and the fix. */
|
|
11233
|
-
function parameterError(macro, param, problem, fix) {
|
|
11234
|
-
return /* @__PURE__ */ new Error(`spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ${fix}`);
|
|
11235
|
-
}
|
|
11236
|
-
new NameMap();
|
|
11237
11310
|
//#endregion
|
|
11238
11311
|
//#region src/utils/namespace.ts
|
|
11239
11312
|
/** The variable name no namespace can hold. */
|
|
@@ -11430,7 +11503,8 @@ function validateStoryMarkup(passages, macros, options = {}) {
|
|
|
11430
11503
|
const list = [...passages];
|
|
11431
11504
|
const all = [...macros];
|
|
11432
11505
|
const known = /* @__PURE__ */ new Set();
|
|
11433
|
-
const
|
|
11506
|
+
const parametersOf = parameterLookup(all);
|
|
11507
|
+
const blocks = new Set(blockWidgetNames(list, parametersOf).map((n) => n.toLowerCase()));
|
|
11434
11508
|
for (const macro of all) {
|
|
11435
11509
|
const name = macro.name.toLowerCase();
|
|
11436
11510
|
known.add(name);
|
|
@@ -11440,7 +11514,7 @@ function validateStoryMarkup(passages, macros, options = {}) {
|
|
|
11440
11514
|
return validateMarkup(list, {
|
|
11441
11515
|
isKnownMacro: (name) => known.has(name),
|
|
11442
11516
|
macroNames: known,
|
|
11443
|
-
parametersOf
|
|
11517
|
+
parametersOf,
|
|
11444
11518
|
isBlockMacro: (name) => blocks.has(name.toLowerCase()) || isBlockMacro(name),
|
|
11445
11519
|
checkPassageNames: options.checkPassageNames
|
|
11446
11520
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { useMemo } from 'preact/hooks';
|
|
1
|
+
import { useLayoutEffect, useMemo } from 'preact/hooks';
|
|
2
2
|
import { useStoryFields } from '../hooks/use-story-fields';
|
|
3
3
|
import { parseMarkup } from '../markup/parse';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
renderInlineNodes,
|
|
6
|
+
InterfaceContext,
|
|
7
|
+
NobrContext,
|
|
8
|
+
} from '../markup/render';
|
|
9
|
+
import { declareInterfaceMounted } from '../triggers';
|
|
5
10
|
import { errorMessage } from '../utils/error-message';
|
|
6
11
|
|
|
7
12
|
const DEFAULT_MARKUP =
|
|
@@ -26,9 +31,15 @@ export function StoryInterface() {
|
|
|
26
31
|
}
|
|
27
32
|
}, [markup]);
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
useLayoutEffect(declareInterfaceMounted, []);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<InterfaceContext.Provider value={true}>
|
|
38
|
+
{nobr ? (
|
|
39
|
+
<NobrContext.Provider value={true}>{rendered}</NobrContext.Provider>
|
|
40
|
+
) : (
|
|
41
|
+
rendered
|
|
42
|
+
)}
|
|
43
|
+
</InterfaceContext.Provider>
|
|
33
44
|
);
|
|
34
45
|
}
|
|
@@ -9,6 +9,7 @@ import { useStoryStore, type StoryState } from '../../store';
|
|
|
9
9
|
import {
|
|
10
10
|
useStoryFields,
|
|
11
11
|
FrozenStateContext,
|
|
12
|
+
OutgoingContext,
|
|
12
13
|
} from '../../hooks/use-story-fields';
|
|
13
14
|
import { Passage, renderPassageContent } from '../Passage';
|
|
14
15
|
import { defineMacro } from '../../define-macro';
|
|
@@ -140,6 +141,11 @@ defineMacro({
|
|
|
140
141
|
(state: StoryState) => state.navigationId !== displayedId,
|
|
141
142
|
[displayedId],
|
|
142
143
|
);
|
|
144
|
+
// Read live: a timer asks it before the outgoing tree has unmounted
|
|
145
|
+
const hasLeft = useCallback(
|
|
146
|
+
() => isOutgoing(useStoryStore.getState()),
|
|
147
|
+
[isOutgoing],
|
|
148
|
+
);
|
|
143
149
|
const lastElement = useRef<preact.VNode | null>(null);
|
|
144
150
|
|
|
145
151
|
// Container ref for snapshot insertion
|
|
@@ -350,7 +356,9 @@ defineMacro({
|
|
|
350
356
|
>
|
|
351
357
|
{passageElement && (
|
|
352
358
|
<FrozenStateContext.Provider value={isOutgoing}>
|
|
353
|
-
{
|
|
359
|
+
<OutgoingContext.Provider value={hasLeft}>
|
|
360
|
+
{passageElement}
|
|
361
|
+
</OutgoingContext.Provider>
|
|
354
362
|
</FrozenStateContext.Provider>
|
|
355
363
|
)}
|
|
356
364
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
2
|
import { defineMacro } from '../../define-macro';
|
|
3
3
|
import { DELAY_PARAMETER } from './macro-args';
|
|
4
|
+
import { OutgoingContext } from '../../hooks/use-story-fields';
|
|
4
5
|
|
|
5
6
|
export const RepeatContext = createContext<{ stop: () => void }>({
|
|
6
7
|
stop: () => {},
|
|
@@ -12,17 +13,21 @@ defineMacro({
|
|
|
12
13
|
interpolate: true,
|
|
13
14
|
parameters: [DELAY_PARAMETER],
|
|
14
15
|
render({ children = [] }, ctx) {
|
|
15
|
-
const { useState, useEffect, useCallback } = ctx.hooks;
|
|
16
|
+
const { useState, useEffect, useCallback, useContext } = ctx.hooks;
|
|
16
17
|
|
|
17
18
|
const delay = ctx.args.delay ?? 0;
|
|
18
19
|
const [count, setCount] = useState(0);
|
|
19
20
|
const [stopped, setStopped] = useState(false);
|
|
20
21
|
|
|
22
|
+
const hasLeft = useContext(OutgoingContext);
|
|
23
|
+
|
|
21
24
|
const stop = useCallback(() => setStopped(true), []);
|
|
22
25
|
|
|
23
26
|
useEffect(() => {
|
|
24
27
|
if (stopped) return;
|
|
25
28
|
const interval = setInterval(() => {
|
|
29
|
+
// The passage was left: its body must not run again (#410)
|
|
30
|
+
if (hasLeft?.()) return;
|
|
26
31
|
setCount((c) => c + 1);
|
|
27
32
|
}, delay);
|
|
28
33
|
return () => clearInterval(interval);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { DELAY_PARAMETER, parseMacroArgs } from './macro-args';
|
|
3
3
|
import { wrapContent } from './display';
|
|
4
|
+
import { OutgoingContext } from '../../hooks/use-story-fields';
|
|
4
5
|
|
|
5
6
|
/** The parameters of {timed} and of each {next}: the delay before it shows. */
|
|
6
7
|
const TIMED_PARAMETERS = [DELAY_PARAMETER] as const;
|
|
@@ -11,7 +12,8 @@ defineMacro({
|
|
|
11
12
|
interpolate: true,
|
|
12
13
|
parameters: TIMED_PARAMETERS,
|
|
13
14
|
render({ branches = [] }, ctx) {
|
|
14
|
-
const { useState, useEffect, useMemo } = ctx.hooks;
|
|
15
|
+
const { useState, useEffect, useMemo, useContext } = ctx.hooks;
|
|
16
|
+
const hasLeft = useContext(OutgoingContext);
|
|
15
17
|
|
|
16
18
|
const sections = useMemo(() => {
|
|
17
19
|
return branches.map((branch) => ({
|
|
@@ -31,6 +33,8 @@ defineMacro({
|
|
|
31
33
|
const delay = sections[nextIndex]!.delay;
|
|
32
34
|
|
|
33
35
|
const timer = setTimeout(() => {
|
|
36
|
+
// The passage was left: its body must not run (#410)
|
|
37
|
+
if (hasLeft?.()) return;
|
|
34
38
|
setVisibleIndex(nextIndex);
|
|
35
39
|
}, delay);
|
|
36
40
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { defineMacro } from '../../define-macro';
|
|
2
2
|
import { FrozenStateContext } from '../../hooks/use-story-fields';
|
|
3
3
|
import { useStoryStore } from '../../store';
|
|
4
|
+
import { InterfaceContext } from '../../markup/render';
|
|
4
5
|
import {
|
|
5
6
|
addMacroTrigger,
|
|
7
|
+
declareInterfaceWatch,
|
|
6
8
|
removeTrigger,
|
|
7
9
|
subscribeTriggerResets,
|
|
8
10
|
triggerResets,
|
|
@@ -50,6 +52,15 @@ defineMacro({
|
|
|
50
52
|
() => subscribeTriggerResets(() => rerender((n) => n + 1)),
|
|
51
53
|
[],
|
|
52
54
|
);
|
|
55
|
+
// The interface's watchers are re-registered by a load of a save made
|
|
56
|
+
// before the interface mounted (#419)
|
|
57
|
+
const inInterface = hooks.useContext(InterfaceContext);
|
|
58
|
+
const declaredKey = JSON.stringify([condition, options]);
|
|
59
|
+
hooks.useLayoutEffect(
|
|
60
|
+
() =>
|
|
61
|
+
inInterface ? declareInterfaceWatch(condition, options) : undefined,
|
|
62
|
+
[inInterface, declaredKey],
|
|
63
|
+
);
|
|
53
64
|
const registered = hooks.useRef<number | null>(null);
|
|
54
65
|
if (
|
|
55
66
|
registered.current === null ||
|
|
@@ -10,12 +10,16 @@ import {
|
|
|
10
10
|
} from '../../markup/render';
|
|
11
11
|
import type { ASTNode } from '../../markup/ast';
|
|
12
12
|
import { useRenderOptions } from '../../hooks/use-render-options';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
FrozenStateContext,
|
|
15
|
+
OutgoingContext,
|
|
16
|
+
} from '../../hooks/use-story-fields';
|
|
14
17
|
import { liveLocalsView } from '../../utils/live-locals';
|
|
15
18
|
import { runWithCommittedMutations } from '../../execute-mutation';
|
|
16
19
|
import { subscribeStateReplacement } from '../../store';
|
|
17
20
|
import { RepeatContext } from './Repeat';
|
|
18
21
|
import { DialogCloseContext } from '../PassageDialog';
|
|
22
|
+
import { ItemEditContext } from '../../utils/control-edits';
|
|
19
23
|
|
|
20
24
|
const frozenAlways = () => true;
|
|
21
25
|
|
|
@@ -35,7 +39,8 @@ const frozenAlways = () => true;
|
|
|
35
39
|
* the owning macro sees and provides them again: the locals scope (read
|
|
36
40
|
* through a live view, so a local assigned earlier in the body is visible),
|
|
37
41
|
* nobr/inline rendering, the enclosing block widget's {@children}, the
|
|
38
|
-
* enclosing {repeat}'s {stop} and the enclosing dialog's close callback
|
|
42
|
+
* enclosing {repeat}'s {stop} and the enclosing dialog's close callback and whether it runs in a {for} iteration
|
|
43
|
+
* (#411) and the owning passage's departure (#410).
|
|
39
44
|
* The body then behaves as if it rendered in place.
|
|
40
45
|
*
|
|
41
46
|
* The body's macros read story state from the store. Run from mutation code
|
|
@@ -50,6 +55,8 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
50
55
|
const widgetChildren = useContext(WidgetChildrenContext);
|
|
51
56
|
const repeat = useContext(RepeatContext);
|
|
52
57
|
const closeDialog = useContext(DialogCloseContext);
|
|
58
|
+
const inIteration = useContext(ItemEditContext);
|
|
59
|
+
const hasLeft = useContext(OutgoingContext);
|
|
53
60
|
|
|
54
61
|
const mounted = useRef<HTMLElement[]>([]);
|
|
55
62
|
useEffect(() => {
|
|
@@ -81,7 +88,11 @@ export function useDetachedBody(): (children: ASTNode[]) => void {
|
|
|
81
88
|
<WidgetChildrenContext.Provider value={widgetChildren}>
|
|
82
89
|
<RepeatContext.Provider value={repeat}>
|
|
83
90
|
<DialogCloseContext.Provider value={closeDialog}>
|
|
84
|
-
|
|
91
|
+
<ItemEditContext.Provider value={inIteration}>
|
|
92
|
+
<OutgoingContext.Provider value={hasLeft}>
|
|
93
|
+
{renderNodes(children, { nobr, inline, locals })}
|
|
94
|
+
</OutgoingContext.Provider>
|
|
95
|
+
</ItemEditContext.Provider>
|
|
85
96
|
</DialogCloseContext.Provider>
|
|
86
97
|
</RepeatContext.Provider>
|
|
87
98
|
</WidgetChildrenContext.Provider>
|
package/src/define-macro.ts
CHANGED
|
@@ -216,11 +216,19 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
216
216
|
if (config.storeVar) {
|
|
217
217
|
const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, '');
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
// Only story variables ($name) can be bound: another sigil would be
|
|
220
|
+
// read as a story key spelled with it (#414)
|
|
221
|
+
const unbindable: Record<string, string> = {
|
|
222
|
+
'%': 'transient variables',
|
|
223
|
+
_: 'temporary variables',
|
|
224
|
+
'@': 'locals',
|
|
225
|
+
};
|
|
226
|
+
const kind = unbindable[firstToken.charAt(0)];
|
|
227
|
+
if (kind) {
|
|
220
228
|
return h(
|
|
221
229
|
'span',
|
|
222
230
|
{ class: 'error' },
|
|
223
|
-
`{${config.name}}:
|
|
231
|
+
`{${config.name}}: ${kind} (${firstToken}) cannot be bound to input macros`,
|
|
224
232
|
);
|
|
225
233
|
}
|
|
226
234
|
|