@rohal12/spindle 0.59.10 → 0.59.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.10",
3
+ "version": "0.59.11",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,8 +1,7 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { MacroError } from './MacroError';
3
3
  import type { Branch } from '../../markup/ast';
4
- import { selectBranch } from './branches';
5
- import { wrapContent } from './display';
4
+ import { selectBranch, renderBranch } from './branches';
6
5
 
7
6
  /**
8
7
  * The {if}/{elseif}/{else} branch whose condition holds, else the {else}
@@ -37,11 +36,7 @@ defineMacro({
37
36
  );
38
37
  }
39
38
  if (!branch) return null;
40
- return wrapContent(
41
- ctx.resolve!(branch.className),
42
- ctx.resolve!(branch.id),
43
- ctx.renderNodes(branch.children),
44
- );
39
+ return renderBranch(branch, ctx);
45
40
  },
46
41
  text({ branches = [] }, ctx) {
47
42
  const branch = selectIfBranch(branches, ctx.evaluate);
@@ -345,12 +345,15 @@ export function SaveManagerContent() {
345
345
  class="playthrough-group"
346
346
  key={group.playthrough.id}
347
347
  >
348
- <div
348
+ <button
349
+ type="button"
349
350
  class="playthrough-header"
351
+ aria-expanded={!isCollapsed}
350
352
  onClick={() => toggleCollapse(group.playthrough.id)}
351
353
  >
352
354
  <span
353
355
  class={`playthrough-chevron ${isCollapsed ? '' : 'open'}`}
356
+ aria-hidden="true"
354
357
  >
355
358
  ▶
356
359
  </span>
@@ -361,7 +364,7 @@ export function SaveManagerContent() {
361
364
  <span class="playthrough-date">
362
365
  {formatDate(group.playthrough.createdAt)}
363
366
  </span>
364
- </div>
367
+ </button>
365
368
 
366
369
  {!isCollapsed && (
367
370
  <div class="playthrough-saves">
@@ -1,7 +1,7 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { MacroError } from './MacroError';
3
3
  import type { Branch } from '../../markup/ast';
4
- import { selectBranch } from './branches';
4
+ import { selectBranch, renderBranch } from './branches';
5
5
 
6
6
  /**
7
7
  * The first {case} branch whose value equals `value`, else the {default}
@@ -20,6 +20,7 @@ defineMacro({
20
20
  name: 'switch',
21
21
  subMacros: ['case', 'default'],
22
22
  parameters: [{ name: 'expression', type: 'expression', required: true }],
23
+ interpolate: true,
23
24
  merged: true,
24
25
  render({ rawArgs, branches = [] }, ctx) {
25
26
  let switchValue: unknown;
@@ -46,7 +47,8 @@ defineMacro({
46
47
  );
47
48
  }
48
49
 
49
- return branch && <>{ctx.renderNodes(branch.children)}</>;
50
+ if (!branch) return null;
51
+ return renderBranch(branch, ctx);
50
52
  },
51
53
  text({ rawArgs, branches = [] }, ctx) {
52
54
  const branch = selectCase(ctx.evaluate(rawArgs), branches, ctx.evaluate);
@@ -7,6 +7,8 @@ import { splitArgs } from './arg-utils';
7
7
  import { createNamespace } from '../../utils/namespace';
8
8
  import { useRenderOptions } from '../../hooks/use-render-options';
9
9
  import { LocalsScope } from './locals-scope';
10
+ import { useInterpolate } from '../../hooks/use-interpolate';
11
+ import { wrapContent } from './display';
10
12
 
11
13
  export { splitArgs };
12
14
 
@@ -20,17 +22,14 @@ interface WidgetInvocationProps {
20
22
  }
21
23
 
22
24
  export function WidgetInvocation(props: WidgetInvocationProps) {
23
- const { className, id } = props;
24
- const content = <WidgetContent {...props} />;
25
- // Invocation selectors (`{.badge#hero Stats}`) need an element to land on.
26
- if (!className && !id) return content;
27
- return (
28
- <span
29
- class={className}
30
- id={id}
31
- >
32
- {content}
33
- </span>
25
+ const resolve = useInterpolate();
26
+ // Invocation selectors (`{.badge#hero Stats}`) need an element to land on;
27
+ // dynamic ones (`{.{$theme} Stats}`) follow the state like a built-in
28
+ // macro's.
29
+ return wrapContent(
30
+ resolve(props.className),
31
+ resolve(props.id),
32
+ <WidgetContent {...props} />,
34
33
  );
35
34
  }
36
35
 
@@ -39,8 +38,6 @@ function WidgetContent({
39
38
  params,
40
39
  rawArgs,
41
40
  invocationChildren,
42
- className,
43
- id,
44
41
  }: WidgetInvocationProps) {
45
42
  const renderOptions = useRenderOptions();
46
43
  const parentValues = renderOptions.locals;
@@ -1,4 +1,7 @@
1
+ import type { ComponentChildren, VNode } from 'preact';
1
2
  import type { Branch } from '../../markup/ast';
3
+ import type { MacroContext } from '../../define-macro';
4
+ import { wrapContent } from './display';
2
5
 
3
6
  /**
4
7
  * The branch a conditional block ({if}, {switch}) shows: the first of
@@ -16,3 +19,20 @@ export function selectBranch(
16
19
  }
17
20
  return fallback;
18
21
  }
22
+
23
+ /**
24
+ * A selected branch's content, in a wrapper if the branch has CSS selectors
25
+ * (`{.hot#id case 1}`). The macro must be defined with `interpolate`.
26
+ */
27
+ export function renderBranch(
28
+ branch: Branch,
29
+ ctx: Pick<MacroContext, 'resolve'> & {
30
+ renderNodes: (nodes: Branch['children']) => ComponentChildren;
31
+ },
32
+ ): VNode<any> {
33
+ return wrapContent(
34
+ ctx.resolve!(branch.className),
35
+ ctx.resolve!(branch.id),
36
+ ctx.renderNodes(branch.children),
37
+ );
38
+ }
@@ -29,7 +29,12 @@ function isRendered(el: HTMLElement): boolean {
29
29
 
30
30
  function focusables(root: HTMLElement): HTMLElement[] {
31
31
  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
32
- (el) => !el.hidden && !el.closest('[hidden], [inert]') && isRendered(el),
32
+ (el) =>
33
+ !el.hidden &&
34
+ !el.closest('[hidden], [inert]') &&
35
+ // Also covers controls disabled by a <fieldset disabled> ancestor
36
+ !el.matches(':disabled') &&
37
+ isRendered(el),
33
38
  );
34
39
  }
35
40
 
package/src/js-lexer.ts CHANGED
@@ -128,7 +128,7 @@ const OPERAND_ENDS: ReadonlySet<TokenType> = new Set([
128
128
  * whether a `{` opens a block or an object literal, which decides whether a
129
129
  * `/` after its `}` opens a regex and a `%` a transient: after the `:` of a
130
130
  * conditional it opens an object literal (`a ? b : {} / 2`), not a block as
131
- * after a label; after a block's `}` it opens another block (`{}{} %n = 1`),
131
+ * after a label, and a `function` or `class` there is an expression; after a block's `}` it opens another block (`{}{} %n = 1`),
132
132
  * and so it does on a new line after a statement (`p⏎{} %n = 1`).
133
133
  */
134
134
  const SigilParser = class extends (Parser as unknown as Base) {
@@ -146,6 +146,8 @@ const SigilParser = class extends (Parser as unknown as Base) {
146
146
  closedBlock = false;
147
147
  /** The value of the token before the one being finished. */
148
148
  prevValue: unknown;
149
+ /** The type of the token before the last one. */
150
+ typeBeforeLast: TokenType = tt.eof;
149
151
  /** Look ahead after a `%name` starting a line (off in look-aheads). */
150
152
  lookahead = true;
151
153
  /**
@@ -222,6 +224,21 @@ const SigilParser = class extends (Parser as unknown as Base) {
222
224
  (prevType === tt.dot || prevType === tt.questionDot)) ||
223
225
  ((type === tt._function || type === tt._class) &&
224
226
  notABody(self.input, self.pos));
227
+ // acorn takes a `function` or `class` for a statement's (whose `}` a
228
+ // regex may follow) after the `:` of a conditional, and `async function`
229
+ // always. Both are expressions where an operand is (`a ? b : class {} / 2`,
230
+ // `x = async function () {} / 2`): read them as after what comes before.
231
+ if (!property && (type === tt._function || type === tt._class)) {
232
+ const afterAsync =
233
+ type === tt._function &&
234
+ prevType === tt.name &&
235
+ this.prevValue === 'async' &&
236
+ !this.breakHere;
237
+ const before = afterAsync ? this.typeBeforeLast : prevType;
238
+ self.type =
239
+ before === tt.colon && this.colonEndsTernary ? tt.parenL : before;
240
+ }
241
+ this.typeBeforeLast = prevType;
225
242
  // @ts-expect-error acorn internals
226
243
  super.finishToken(property ? tt.name : type, value);
227
244
  // A variable named `of` (`const of of list`): acorn takes it for the
@@ -524,25 +524,43 @@ function useDirectAttributes(
524
524
  return direct.length > 0 ? elementRef : undefined;
525
525
  }
526
526
 
527
+ const decodedText = new Map<string, string>();
527
528
  const decodedAttributeText = new Map<string, string>();
528
529
 
529
530
  /**
530
- * Decode character references (`&amp;`, `&#123;`) in attribute text the way
531
- * the HTML parser decodes an attribute value, by letting it parse one.
531
+ * Decode character references (`&amp;`, `&#123;`) in `text` the way the HTML
532
+ * parser decodes them, by letting it parse them: in an attribute value, or
533
+ * else in element text.
532
534
  */
533
- function decodeAttributeText(text: string): string {
535
+ function decodeReferences(
536
+ text: string,
537
+ cache: Map<string, string>,
538
+ parse: (text: string) => string | null,
539
+ ): string {
534
540
  if (!text.includes('&')) return text;
535
- let decoded = decodedAttributeText.get(text);
541
+ let decoded = cache.get(text);
536
542
  if (decoded === undefined) {
537
- const html = `<i title="${text.replace(/"/g, '&quot;')}"></i>`;
538
- decoded =
539
- (parseHtmlInert(html).firstChild as Element).getAttribute('title') ??
540
- text;
541
- decodedAttributeText.set(text, decoded);
543
+ decoded = parse(text) ?? text;
544
+ cache.set(text, decoded);
542
545
  }
543
546
  return decoded;
544
547
  }
545
548
 
549
+ function decodeAttributeText(text: string): string {
550
+ return decodeReferences(text, decodedAttributeText, (t) => {
551
+ const html = `<i title="${t.replace(/"/g, '&quot;')}"></i>`;
552
+ return (parseHtmlInert(html).firstChild as Element).getAttribute('title');
553
+ });
554
+ }
555
+
556
+ /** Literal text outside markdown (preformatted and SVG elements). */
557
+ function decodeText(text: string): string {
558
+ return decodeReferences(text, decodedText, (t) => {
559
+ const html = `<i>${t.replace(/</g, '&lt;')}</i>`;
560
+ return parseHtmlInert(html).firstChild!.textContent;
561
+ });
562
+ }
563
+
546
564
  /** A `{` and a sigil starting a reference, in a code attribute. */
547
565
  const SIGIL_REFERENCE = /\{[$_@%]\w/;
548
566
 
@@ -725,7 +743,8 @@ function renderMacro(node: MacroNode, key: string) {
725
743
  * Render a non-text AST node to a Preact element.
726
744
  */
727
745
  function renderSingleNode(node: ASTNode): preact.ComponentChildren {
728
- if (node.type === 'text') return node.value;
746
+ // Authored text outside markdown still has its character references decoded
747
+ if (node.type === 'text') return decodeText(node.value);
729
748
  const key = nodeKey(node);
730
749
  switch (node.type) {
731
750
  case 'variable':
@@ -233,6 +233,7 @@ Content
233
233
  Item
234
234
  = TextRun
235
235
  / Escape
236
+ / Comment
236
237
  / LinkLexeme
237
238
  / HtmlElement
238
239
  / VoidCloser
@@ -291,6 +292,7 @@ Tokens
291
292
  TokenItem
292
293
  = TextRun
293
294
  / Escape
295
+ / Comment
294
296
  / LinkLexeme
295
297
  / &{ return !textMode; } tag:HtmlOpen { return tag; }
296
298
  / &{ return !textMode; } tag:HtmlClose { return VOID_TAGS.has(tag.lower) ? null : tag; }
@@ -317,6 +319,15 @@ Here
317
319
  TextRun
318
320
  = value:$[^\\{[<]+ { return lexeme('text', location(), { value }); }
319
321
 
322
+ /**
323
+ * A closed HTML comment, opaque: its tags and macros are not markup, and
324
+ * rendering drops it. (An unclosed `<!--` is ordinary text.)
325
+ */
326
+ Comment
327
+ = value:$("<!--" (">" / "->" / (!"-->" .)* "-->")) {
328
+ return lexeme('text', location(), { value });
329
+ }
330
+
320
331
  /**
321
332
  * A backslash run. Before a brace, an odd run escapes it; in text mode the
322
333
  * run is also paired up (`\\` is one backslash), in passage text markdown
package/src/styles.css CHANGED
@@ -430,6 +430,11 @@ tw-storydata {
430
430
 
431
431
  .playthrough-header {
432
432
  display: flex;
433
+ width: 100%;
434
+ border: 0;
435
+ background: none;
436
+ font-family: inherit;
437
+ text-align: left;
433
438
  align-items: center;
434
439
  gap: 0.5em;
435
440
  padding: 0.4em 0;
package/src/triggers.ts CHANGED
@@ -251,7 +251,7 @@ export function checkTriggersOnNavigation(): void {
251
251
 
252
252
  /**
253
253
  * Re-evaluate watchers whenever a namespace their conditions can read
254
- * ($variables, _temporary, %transient) changes. Navigation is left to the
254
+ * ($variables, _temporary, %transient) or the render counts change. Navigation is left to the
255
255
  * store: navigate() checks watchers once the new moment is complete
256
256
  * (checkTriggersOnNavigation), while history traversal and loads
257
257
  * reinitialize watcher state instead of firing.
@@ -269,7 +269,9 @@ export function connectTriggersToStore(): () => void {
269
269
  if (
270
270
  state.variables === before.variables &&
271
271
  state.temporary === before.temporary &&
272
- state.transient === before.transient
272
+ state.transient === before.transient &&
273
+ // hasRendered() / rendered() read the counts an {include} bumps
274
+ state.renderCounts === before.renderCounts
273
275
  ) {
274
276
  return;
275
277
  }