@rohal12/spindle 0.51.4 → 0.52.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 (64) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4313 -1640
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1636 -177
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +214 -103
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +5 -31
  12. package/src/components/macros/Checkbox.tsx +7 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/For.tsx +29 -3
  15. package/src/components/macros/If.tsx +8 -0
  16. package/src/components/macros/Include.tsx +7 -6
  17. package/src/components/macros/MacroError.tsx +2 -1
  18. package/src/components/macros/MacroLink.tsx +12 -45
  19. package/src/components/macros/Meter.tsx +11 -3
  20. package/src/components/macros/Nobr.tsx +1 -0
  21. package/src/components/macros/PassageDisplay.tsx +3 -0
  22. package/src/components/macros/Print.tsx +4 -0
  23. package/src/components/macros/Radiobutton.tsx +5 -2
  24. package/src/components/macros/SaveManager.tsx +25 -8
  25. package/src/components/macros/Span.tsx +1 -0
  26. package/src/components/macros/StoryTitle.tsx +1 -0
  27. package/src/components/macros/Switch.tsx +13 -0
  28. package/src/components/macros/Unset.tsx +30 -10
  29. package/src/components/macros/VarDisplay.tsx +21 -4
  30. package/src/components/macros/Widget.tsx +20 -1
  31. package/src/components/macros/WidgetInvocation.tsx +17 -75
  32. package/src/components/macros/arg-utils.ts +107 -1
  33. package/src/components/macros/detached-body.tsx +68 -0
  34. package/src/components/macros/option-utils.ts +3 -2
  35. package/src/define-macro.ts +32 -5
  36. package/src/execute-mutation.ts +270 -67
  37. package/src/expression.ts +86 -55
  38. package/src/hooks/use-action.ts +18 -3
  39. package/src/hooks/use-interpolate.ts +36 -5
  40. package/src/index.tsx +10 -1
  41. package/src/interpolation.ts +394 -96
  42. package/src/js-lexer.ts +1231 -97
  43. package/src/markup/code-attributes.ts +64 -0
  44. package/src/markup/markdown.ts +188 -9
  45. package/src/markup/render.tsx +430 -49
  46. package/src/markup/tokenizer.ts +578 -110
  47. package/src/prng.ts +8 -8
  48. package/src/registry.ts +35 -0
  49. package/src/saves/save-manager.ts +339 -158
  50. package/src/saves/storage.ts +16 -7
  51. package/src/saves/types.ts +2 -1
  52. package/src/store.ts +521 -154
  53. package/src/story-api.ts +31 -67
  54. package/src/story-init.ts +1 -1
  55. package/src/story-variables.ts +98 -102
  56. package/src/triggers.ts +6 -5
  57. package/src/utils/error-message.ts +12 -0
  58. package/src/utils/live-locals.ts +10 -3
  59. package/src/utils/namespace.ts +71 -0
  60. package/src/utils/object-path.ts +99 -14
  61. package/src/utils/stable-key.ts +14 -9
  62. package/src/widgets/widget-registry.ts +9 -0
  63. package/types/index.d.ts +43 -7
  64. package/types/tooling.d.ts +1 -0
@@ -0,0 +1,64 @@
1
+ import { createScanMemo, scanBalancedBrace } from './tokenizer';
2
+
3
+ /**
4
+ * Attributes whose value is code with braces of its own, not text: event
5
+ * handlers (`on…`, JavaScript), `pattern` (a regular expression, as in
6
+ * `\p{L}{2,3}`) and `srcdoc` (an HTML document, with its own scripts and
7
+ * styles). Their braces are code, so they are not read as markup: only
8
+ * sigil references (`{$x}`, `{_x}`, `{@x}`, `{%x}` and expressions starting
9
+ * with one) are resolved, and any other `{` is literal.
10
+ */
11
+ export function isCodeAttribute(name: string): boolean {
12
+ const lower = name.toLowerCase();
13
+ return lower.startsWith('on') || lower === 'pattern' || lower === 'srcdoc';
14
+ }
15
+
16
+ /** Literal text, the source of one `{…}` reference (without braces), or a
17
+ * `{…}` block that is kept exactly as written. */
18
+ export type SigilPart =
19
+ | { text: string }
20
+ | { expr: string }
21
+ | { verbatim: string };
22
+
23
+ /**
24
+ * Split a code attribute value into literal text and its `{…}` blocks that
25
+ * start with a sigil. Braces inside strings don't close a block; an
26
+ * unclosed block, any other brace and backslashes are text. A block whose
27
+ * sigil is not followed by a word character (`{$(…)}`) is code, kept
28
+ * verbatim: not resolved, and not decoded like text.
29
+ */
30
+ export function splitSigilTemplate(template: string): SigilPart[] {
31
+ const parts: SigilPart[] = [];
32
+ const memo = createScanMemo();
33
+ let text = '';
34
+ let i = 0;
35
+
36
+ while (i < template.length) {
37
+ const brace = template.indexOf('{', i);
38
+ if (brace === -1) {
39
+ text += template.slice(i);
40
+ break;
41
+ }
42
+ text += template.slice(i, brace);
43
+ i = brace + 1;
44
+ const sigil = template[i];
45
+ const end =
46
+ sigil === '$' || sigil === '_' || sigil === '@' || sigil === '%'
47
+ ? scanBalancedBrace(template, i, memo)
48
+ : -1;
49
+ if (end === -1) {
50
+ text += '{';
51
+ continue;
52
+ }
53
+ if (text) parts.push({ text });
54
+ text = '';
55
+ const block = template.slice(i, end);
56
+ parts.push(
57
+ /^.\w/.test(block) ? { expr: block } : { verbatim: `{${block}}` },
58
+ );
59
+ i = end + 1;
60
+ }
61
+
62
+ if (text) parts.push({ text });
63
+ return parts;
64
+ }
@@ -1,4 +1,11 @@
1
1
  import { micromark } from 'micromark';
2
+ import type {
3
+ Code,
4
+ Construct,
5
+ Extension,
6
+ Options,
7
+ Resolver,
8
+ } from 'micromark-util-types';
2
9
  import { gfmTable, gfmTableHtml } from 'micromark-extension-gfm-table';
3
10
  import {
4
11
  gfmStrikethrough,
@@ -6,17 +13,15 @@ import {
6
13
  } from 'micromark-extension-gfm-strikethrough';
7
14
 
8
15
  /**
9
- * Parse a text string as CommonMark markdown and return an HTML string.
10
- * Includes GFM table and strikethrough extensions.
16
+ * micromark options for spindle's markdown: CommonMark with GFM tables and
17
+ * strikethrough, raw HTML passed through, and no indented code.
11
18
  *
12
19
  * When `inline` is true, block-level constructs (lists, headings, blockquotes,
13
- * thematic breaks) are disabled. This is used when rendering content inside
14
- * inline HTML elements like `<span>` where block-level output is invalid.
20
+ * thematic breaks, fenced code, tables) are disabled. This is used when
21
+ * rendering content inside inline HTML elements like `<span>` where
22
+ * block-level output is invalid. Fences then read as code spans.
15
23
  */
16
- export function markdownToHtml(
17
- text: string,
18
- options?: { inline?: boolean },
19
- ): string {
24
+ export function markdownOptions(options?: { inline?: boolean }): Options {
20
25
  const disabled: string[] = ['codeIndented'];
21
26
  if (options?.inline) {
22
27
  disabled.push(
@@ -25,9 +30,11 @@ export function markdownToHtml(
25
30
  'setextUnderline',
26
31
  'thematicBreak',
27
32
  'blockQuote',
33
+ 'codeFenced',
34
+ 'table',
28
35
  );
29
36
  }
30
- return micromark(text, {
37
+ return {
31
38
  allowDangerousHtml: true,
32
39
  extensions: [
33
40
  gfmTable(),
@@ -35,5 +42,177 @@ export function markdownToHtml(
35
42
  { disable: { null: disabled } },
36
43
  ],
37
44
  htmlExtensions: [gfmTableHtml(), gfmStrikethroughHtml()],
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Parse a text string as CommonMark markdown and return an HTML string, as
50
+ * micromark does with `markdownOptions`, but in about linear time (see
51
+ * `unclosedHtml` and `textData`).
52
+ */
53
+ export function markdownToHtml(
54
+ text: string,
55
+ options?: { inline?: boolean },
56
+ ): string {
57
+ const base = markdownOptions(options);
58
+ return micromark(text, {
59
+ ...base,
60
+ extensions: [...(base.extensions ?? []), unclosedHtml(text), textData],
38
61
  });
39
62
  }
63
+
64
+ /** Join a data token just read to a data token right before it. */
65
+ const joinData: Resolver = (events) => {
66
+ const at = events.length - 2;
67
+ const before = events[at - 1];
68
+ if (before && before[0] === 'exit' && before[1].type === 'data') {
69
+ before[1].end = events[at]![1].end;
70
+ events.length = at;
71
+ }
72
+ return events;
73
+ };
74
+
75
+ /**
76
+ * Text that no construct takes, read as data joined to the data before it.
77
+ *
78
+ * Where every text construct fails at a character (a `!` without `[`, a `]`
79
+ * without a label, a `\` before a letter, an unclosed `<!--`), micromark
80
+ * reads it, on to the next character a construct may start at, as a new data
81
+ * token. Runs of data tokens are joined only after the paragraph is read,
82
+ * each run by splicing the paragraph's whole list of events: time quadratic
83
+ * in the number of runs where other tokens part them (`\]a!` repeated: an
84
+ * escape, data `a`, data `!`), and erratically so, as the cost of moving
85
+ * the list's elements depends on where the garbage collector keeps it.
86
+ *
87
+ * Tried where micromark's text tries constructs, after all of them (as a
88
+ * construct of every character), this reads that data as micromark would
89
+ * and joins it to the data before it at once, at the end of the list.
90
+ */
91
+ const textData: Extension = {
92
+ text: {
93
+ null: {
94
+ name: 'textData',
95
+ tokenize(effects, ok) {
96
+ const self = this;
97
+ const constructs = this.parser.constructs.text;
98
+
99
+ /** Whether a construct may start at `code`, as micromark's text asks. */
100
+ function atBreak(code: Code): boolean {
101
+ if (code === null) return true;
102
+ const list = constructs[code] ?? [];
103
+ return (Array.isArray(list) ? list : [list]).some(
104
+ (item) => !item.previous || item.previous.call(self, self.previous),
105
+ );
106
+ }
107
+
108
+ function data(code: Code) {
109
+ if (atBreak(code)) {
110
+ effects.exit('data');
111
+ return ok(code);
112
+ }
113
+ effects.consume(code);
114
+ return data;
115
+ }
116
+
117
+ return (code) => {
118
+ effects.enter('data');
119
+ effects.consume(code);
120
+ return data;
121
+ };
122
+ },
123
+ resolveTo: joinData,
124
+ },
125
+ },
126
+ };
127
+
128
+ /** Characters of an email autolink's local part (micromark's `asciiAtext`). */
129
+ const ATEXT = /[#-'*+\--9=?A-Z^-~]/;
130
+ const ALPHANUMERIC = /[0-9A-Za-z]/;
131
+ const ALPHA = /[A-Za-z]/;
132
+
133
+ /**
134
+ * Inline raw HTML that can't be closed in `text`, read as the text it is.
135
+ *
136
+ * At a `<` opening a comment (`<!--`), a processing instruction (`<?`), a
137
+ * CDATA section (`<![CDATA[`) or a declaration (`<!` and a letter), micromark
138
+ * reads on for the closer (`-->`, `?>`, `]]>`, `>`) to the end of the
139
+ * paragraph, and without one the `<` is text. Many such openers took time
140
+ * quadratic in their number. Where no closer comes later in the whole text,
141
+ * this construct, tried at each `<` before micromark's own, takes the `<`
142
+ * as text at once, as micromark would end up doing.
143
+ *
144
+ * It is tried exactly where micromark tries raw HTML: not in code, link
145
+ * destinations or HTML blocks (which start at a line start and run to their
146
+ * end condition or the end of the document), whose output stays micromark's.
147
+ * A `<?` may begin an email autolink (`<?a@b.c>`, tried before raw HTML),
148
+ * which it then leaves to micromark.
149
+ */
150
+ function unclosedHtml(text: string): Extension {
151
+ // micromark drops a byte order mark, which offsets don't count
152
+ const shift = text.charCodeAt(0) === 0xfeff ? 1 : 0;
153
+ const comment = text.lastIndexOf('-->');
154
+ const instruction = text.lastIndexOf('?>');
155
+ const cdata = text.lastIndexOf(']]>');
156
+ const declaration = text.lastIndexOf('>');
157
+
158
+ /** Whether the raw HTML opening at `p` can't be closed. */
159
+ function unclosed(p: number): boolean {
160
+ if (text[p] !== '<') return false;
161
+ // A comment closes with a `-->` from just past `<!`: `<!-->` is one
162
+ if (text.startsWith('<!--', p)) return comment < p + 2;
163
+ if (text.startsWith('<![CDATA[', p)) return cdata < p + 9;
164
+ if (text[p + 1] === '!' && ALPHA.test(text[p + 2] ?? '')) {
165
+ return declaration < p + 3;
166
+ }
167
+ if (text[p + 1] === '?') return instruction < p + 2 && !emailAutolink(p);
168
+ return false;
169
+ }
170
+
171
+ /** Whether an email autolink opens at `p`, as micromark reads one. */
172
+ function emailAutolink(p: number): boolean {
173
+ let i = p + 1;
174
+ while (ATEXT.test(text[i] ?? '')) i++;
175
+ if (text[i] !== '@') return false;
176
+ i++;
177
+ let size = 0;
178
+ let state: 'atSignOrDot' | 'label' | 'value' = 'atSignOrDot';
179
+ for (;;) {
180
+ const c = text[i] ?? '';
181
+ if (state === 'atSignOrDot') {
182
+ if (!ALPHANUMERIC.test(c)) return false;
183
+ state = 'label';
184
+ } else if (state === 'label') {
185
+ if (c === '>') return true;
186
+ if (c === '.') {
187
+ i++;
188
+ size = 0;
189
+ state = 'atSignOrDot';
190
+ } else {
191
+ state = 'value';
192
+ }
193
+ } else {
194
+ if (!((c === '-' || ALPHANUMERIC.test(c)) && size++ < 63)) {
195
+ return false;
196
+ }
197
+ i++;
198
+ state = c === '-' ? 'value' : 'label';
199
+ }
200
+ }
201
+ }
202
+
203
+ const construct: Construct = {
204
+ name: 'unclosedHtml',
205
+ resolveTo: joinData,
206
+ tokenize(effects, ok, nok) {
207
+ const at = this.now().offset + shift;
208
+ return (code) => {
209
+ if (code !== 60 || !unclosed(at)) return nok(code);
210
+ effects.enter('data');
211
+ effects.consume(code);
212
+ effects.exit('data');
213
+ return ok;
214
+ };
215
+ },
216
+ };
217
+ return { text: { 60: construct } };
218
+ }