@rohal12/spindle 0.51.2 → 0.51.4

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.
@@ -9,12 +9,14 @@ import {
9
9
  LocalsValuesContext,
10
10
  LocalsUpdateContext,
11
11
  NobrContext,
12
+ InlineContext,
12
13
  WidgetChildrenContext,
13
14
  renderNodes,
14
15
  } from '../../markup/render';
15
16
  import { useMergedLocals } from '../../hooks/use-merged-locals';
16
17
  import { evaluate } from '../../expression';
17
18
  import type { ASTNode } from '../../markup/ast';
19
+ import { isWhitespace, splitTopLevel } from './arg-utils';
18
20
 
19
21
  interface WidgetInvocationProps {
20
22
  body: ASTNode[];
@@ -53,55 +55,13 @@ function isStandaloneValue(token: string): boolean {
53
55
  }
54
56
 
55
57
  /**
56
- * Try to split a raw string on whitespace at depth 0 (respecting quotes,
57
- * parentheses, brackets, and braces). Each resulting token must pass
58
- * `isStandaloneValue()` or the split is rejected and `null` is returned.
58
+ * Try to split a raw string on whitespace at depth 0 (respecting strings,
59
+ * template literals, parentheses, brackets, and braces). Each resulting token
60
+ * must pass `isStandaloneValue()` or the split is rejected and `null` is
61
+ * returned.
59
62
  */
60
63
  function trySplitOnWhitespace(raw: string): string[] | null {
61
- const args: string[] = [];
62
- let current = '';
63
- let depth = 0;
64
- let inString: string | null = null;
65
-
66
- for (let i = 0; i < raw.length; i++) {
67
- const ch = raw[i]!;
68
-
69
- if (inString) {
70
- current += ch;
71
- if (ch === inString && raw[i - 1] !== '\\') inString = null;
72
- continue;
73
- }
74
-
75
- if (ch === '"' || ch === "'" || ch === '`') {
76
- inString = ch;
77
- current += ch;
78
- continue;
79
- }
80
-
81
- if (ch === '(' || ch === '[' || ch === '{') {
82
- depth++;
83
- current += ch;
84
- continue;
85
- }
86
-
87
- if (ch === ')' || ch === ']' || ch === '}') {
88
- depth--;
89
- current += ch;
90
- continue;
91
- }
92
-
93
- if (/\s/.test(ch) && depth === 0) {
94
- if (current) {
95
- args.push(current);
96
- current = '';
97
- }
98
- continue;
99
- }
100
-
101
- current += ch;
102
- }
103
-
104
- if (current) args.push(current);
64
+ const args = splitTopLevel(raw, isWhitespace).filter(Boolean);
105
65
 
106
66
  // Need 2+ tokens
107
67
  if (args.length < 2) return null;
@@ -120,51 +80,9 @@ function trySplitOnWhitespace(raw: string): string[] | null {
120
80
  * string literals separated by whitespace (e.g. `"Label" "target"`).
121
81
  */
122
82
  export function splitArgs(raw: string): string[] {
123
- const args: string[] = [];
124
- let current = '';
125
- let depth = 0;
126
- let inString: string | null = null;
127
- let hasComma = false;
128
-
129
- for (let i = 0; i < raw.length; i++) {
130
- const ch = raw[i]!;
131
-
132
- if (inString) {
133
- current += ch;
134
- if (ch === inString && raw[i - 1] !== '\\') inString = null;
135
- continue;
136
- }
137
-
138
- if (ch === '"' || ch === "'" || ch === '`') {
139
- inString = ch;
140
- current += ch;
141
- continue;
142
- }
143
-
144
- if (ch === '(' || ch === '[' || ch === '{') {
145
- depth++;
146
- current += ch;
147
- continue;
148
- }
149
-
150
- if (ch === ')' || ch === ']' || ch === '}') {
151
- depth--;
152
- current += ch;
153
- continue;
154
- }
155
-
156
- if (ch === ',' && depth === 0) {
157
- hasComma = true;
158
- args.push(current.trim());
159
- current = '';
160
- continue;
161
- }
162
-
163
- current += ch;
164
- }
165
-
166
- const last = current.trim();
167
- if (last) args.push(last);
83
+ const args = splitTopLevel(raw, (ch) => ch === ',').map((a) => a.trim());
84
+ const hasComma = args.length > 1;
85
+ if (args[args.length - 1] === '') args.pop();
168
86
 
169
87
  // If no commas were found and we got a single expression, try splitting
170
88
  // on whitespace at depth 0 (e.g. "Label" "target", $var "text", $x $y).
@@ -186,6 +104,7 @@ function WidgetBody({
186
104
  ownKeys: Record<string, unknown>;
187
105
  }) {
188
106
  const nobr = useContext(NobrContext);
107
+ const inline = useContext(InlineContext);
189
108
  const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
190
109
  {},
191
110
  );
@@ -211,7 +130,7 @@ function WidgetBody({
211
130
  return (
212
131
  <LocalsUpdateContext.Provider value={updater}>
213
132
  <LocalsValuesContext.Provider value={localState}>
214
- {renderNodes(body, { nobr, locals: localState })}
133
+ {renderNodes(body, { nobr, inline, locals: localState })}
215
134
  </LocalsValuesContext.Provider>
216
135
  </LocalsUpdateContext.Provider>
217
136
  );
@@ -225,6 +144,7 @@ export function WidgetInvocation({
225
144
  }: WidgetInvocationProps) {
226
145
  const parentValues = useContext(LocalsValuesContext);
227
146
  const nobr = useContext(NobrContext);
147
+ const inline = useContext(InlineContext);
228
148
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
229
149
  useMergedLocals();
230
150
 
@@ -235,7 +155,7 @@ export function WidgetInvocation({
235
155
  if (params.length === 0) {
236
156
  return (
237
157
  <WidgetChildrenContext.Provider value={childrenValue}>
238
- {renderNodes(body, { nobr, locals: parentValues })}
158
+ {renderNodes(body, { nobr, inline, locals: parentValues })}
239
159
  </WidgetChildrenContext.Provider>
240
160
  );
241
161
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shared helpers for macros that split or read their raw argument text by
3
+ * hand. Their lexical model is the expression lexer's (`js-lexer.ts`):
4
+ * string, template and regex literals and comments are opaque (a backslash
5
+ * inside a literal escapes the character after it, and `${…}` parts of a
6
+ * template are code), `/` opens a regex only where an operand is expected,
7
+ * and `(`/`[`/`{` nest.
8
+ */
9
+ import { lexJs, lexTemplate, scanStringLiteral } from '../../js-lexer';
10
+
11
+ /** True for a whitespace character. */
12
+ export function isWhitespace(ch: string): boolean {
13
+ return /\s/.test(ch);
14
+ }
15
+
16
+ function isOpener(ch: string): boolean {
17
+ return ch === '(' || ch === '[' || ch === '{';
18
+ }
19
+
20
+ function isCloser(ch: string): boolean {
21
+ return ch === ')' || ch === ']' || ch === '}';
22
+ }
23
+
24
+ /**
25
+ * Given the index of an opening quote (`"`, `'` or a backtick), return the
26
+ * index just past its closing quote, or `src.length` if it is unterminated.
27
+ * A backslash escapes the character after it, so a quote preceded by an even
28
+ * run of backslashes still closes the string and one preceded by an odd run
29
+ * does not. Inside a template literal, `${…}` interpolations are lexed as
30
+ * code, so quotes, backticks, braces and regexes inside them do not end it.
31
+ */
32
+ export function skipString(src: string, start: number): number {
33
+ return src[start] === '`'
34
+ ? lexTemplate(src, start)
35
+ : scanStringLiteral(src, start).end;
36
+ }
37
+
38
+ /**
39
+ * Undo the escapes allowed in a literal quoted macro argument: `\"`, `\'`
40
+ * and `\\` become `"`, `'` and `\`. Other backslash sequences (`\n`, `\d`)
41
+ * are kept as written, so code and regex text inside the string survive.
42
+ */
43
+ export function unescapeQuoted(body: string): string {
44
+ return body.replace(/\\(["'\\])/g, '$1');
45
+ }
46
+
47
+ /**
48
+ * Read the `"…"` or `'…'` string whose opening quote is at `start`.
49
+ * Returns its unescaped value and the index just past the closing quote, or
50
+ * `null` if there is no such quote at `start` or the string is unterminated.
51
+ */
52
+ export function readQuoted(
53
+ src: string,
54
+ start: number,
55
+ ): { value: string; end: number } | null {
56
+ const quote = src[start];
57
+ if (quote !== '"' && quote !== "'") return null;
58
+ const { end, closed } = scanStringLiteral(src, start);
59
+ if (!closed) return null;
60
+ return { value: unescapeQuoted(src.slice(start + 1, end - 1)), end };
61
+ }
62
+
63
+ /**
64
+ * The unescaped value of `src` when it is exactly one `"…"` or `'…'` string
65
+ * (as with `readQuoted`), otherwise `null`.
66
+ */
67
+ export function readWholeQuoted(src: string): string | null {
68
+ const quoted = readQuoted(src, 0);
69
+ return quoted && quoted.end === src.length ? quoted.value : null;
70
+ }
71
+
72
+ /**
73
+ * Strip an optional quote from each end of an unquoted-or-loosely-quoted
74
+ * argument (`"Red`, `Red'`), leaving at least one character. This is the
75
+ * lenient fallback for labels that are not one well-formed quoted string.
76
+ */
77
+ export function stripLooseQuotes(src: string): string {
78
+ return /^["']?(.+?)["']?$/.exec(src)?.[1] ?? src;
79
+ }
80
+
81
+ /**
82
+ * Indices of the characters in `src` that satisfy `isSeparator` and sit at
83
+ * depth 0: in code outside string, template and regex literals and comments,
84
+ * and outside `()`, `[]` and `{}` pairs.
85
+ */
86
+ export function topLevelIndices(
87
+ src: string,
88
+ isSeparator: (ch: string) => boolean,
89
+ ): number[] {
90
+ const indices: number[] = [];
91
+ let depth = 0;
92
+ lexJs(src, {
93
+ code(ch, i, nesting) {
94
+ if (nesting > 0) return; // inside a template interpolation
95
+ if (isOpener(ch)) depth++;
96
+ else if (isCloser(ch)) depth--;
97
+ else if (depth === 0 && isSeparator(ch)) indices.push(i);
98
+ },
99
+ });
100
+ return indices;
101
+ }
102
+
103
+ /**
104
+ * Split `src` at every depth-0 separator (see `topLevelIndices`). Segments
105
+ * are returned as written, untrimmed, and empty segments are kept, so
106
+ * `n` separators always give `n + 1` segments.
107
+ */
108
+ export function splitTopLevel(
109
+ src: string,
110
+ isSeparator: (ch: string) => boolean,
111
+ ): string[] {
112
+ const segments: string[] = [];
113
+ let from = 0;
114
+ for (const i of topLevelIndices(src, isSeparator)) {
115
+ segments.push(src.slice(from, i));
116
+ from = i + 1;
117
+ }
118
+ segments.push(src.slice(from));
119
+ return segments;
120
+ }
@@ -1,17 +1,20 @@
1
1
  import type { ASTNode } from '../../markup/ast';
2
+ import { readWholeQuoted } from './arg-utils';
2
3
 
3
4
  export function parseVarArgs(rawArgs: string): {
4
5
  varName: string;
5
6
  placeholder: string;
6
7
  } {
7
- const match = rawArgs.match(
8
- /^\s*(["']?\$[\w.]+["']?)\s*(?:["'](.*)["'])?\s*$/,
9
- );
8
+ const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/);
10
9
  if (!match) {
11
10
  return { varName: rawArgs.trim(), placeholder: '' };
12
11
  }
13
12
  const varName = match[1]!.replace(/["']/g, '');
14
- const placeholder = match[2] || '';
13
+ // A quoted placeholder accepts \" \' and \\ escapes.
14
+ const quoted = match[2];
15
+ const placeholder = quoted
16
+ ? (readWholeQuoted(quoted) ?? quoted.slice(1, -1))
17
+ : '';
15
18
  return { varName, placeholder };
16
19
  }
17
20
 
@@ -24,7 +27,8 @@ export function extractOptions(children: ASTNode[]): string[] {
24
27
  if (node.type === 'macro' && node.name === 'option') {
25
28
  const raw = node.rawArgs.trim();
26
29
  // Strip surrounding quotes so {option "Long Sword"} gives "Long Sword"
27
- const stripped = raw.replace(/^(["'])(.+)\1$/, '$2');
30
+ const stripped =
31
+ readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/, '$2');
28
32
  options.push(stripped);
29
33
  }
30
34
  }
@@ -15,6 +15,7 @@ import {
15
15
  LocalsUpdateContext,
16
16
  LocalsValuesContext,
17
17
  NobrContext,
18
+ InlineContext,
18
19
  renderNodes as _renderNodes,
19
20
  renderInlineNodes,
20
21
  } from './markup/render';
@@ -22,6 +23,7 @@ import type { ASTNode } from './markup/ast';
22
23
  import { executeMutation } from './execute-mutation';
23
24
  import { evaluate } from './expression';
24
25
  import { useStoryStore } from './store';
26
+ import { getByPath, setByPath } from './utils/object-path';
25
27
  import { useAction } from './hooks/use-action';
26
28
  import type { UseActionOptions } from './hooks/use-action';
27
29
  import { collectText } from './utils/extract-text';
@@ -101,33 +103,6 @@ const sharedHooks = {
101
103
  useContext,
102
104
  };
103
105
 
104
- /** Traverse a dot-path on an object, returning the nested value. */
105
- function getByPath(obj: Record<string, unknown>, segments: string[]): unknown {
106
- let current: unknown = obj;
107
- for (const seg of segments) {
108
- if (current == null || typeof current !== 'object') return undefined;
109
- current = (current as Record<string, unknown>)[seg];
110
- }
111
- return current;
112
- }
113
-
114
- /** Set a nested value by dot-path segments on an (Immer draft) object. */
115
- function setByPath(
116
- obj: Record<string, unknown>,
117
- segments: string[],
118
- value: unknown,
119
- ): void {
120
- let target: Record<string, unknown> = obj;
121
- for (let i = 0; i < segments.length - 1; i++) {
122
- const seg = segments[i]!;
123
- if (target[seg] == null || typeof target[seg] !== 'object') {
124
- target[seg] = {};
125
- }
126
- target = target[seg] as Record<string, unknown>;
127
- }
128
- target[segments[segments.length - 1]!] = value;
129
- }
130
-
131
106
  export function defineMacro(
132
107
  config: MacroDefinition,
133
108
  source: 'builtin' | 'user' = 'builtin',
@@ -146,11 +121,17 @@ export function defineMacro(
146
121
  // Always-on: cssClass + mutation
147
122
  const { update, getValues } = useContext(LocalsUpdateContext);
148
123
  const nobr = useContext(NobrContext);
124
+ const inline = useContext(InlineContext);
149
125
  const localsValues = useContext(LocalsValuesContext);
150
126
  const renderNodes = (
151
127
  nodes: ASTNode[],
152
- options?: { nobr?: boolean; locals?: Record<string, unknown> },
153
- ) => _renderNodes(nodes, { nobr, locals: localsValues, ...options });
128
+ options?: {
129
+ nobr?: boolean;
130
+ locals?: Record<string, unknown>;
131
+ inline?: boolean;
132
+ },
133
+ ) =>
134
+ _renderNodes(nodes, { nobr, inline, locals: localsValues, ...options });
154
135
  const ctx: MacroContext = {
155
136
  collectText,
156
137
  sourceLocation: currentSourceLocation,
@@ -202,11 +183,9 @@ export function defineMacro(
202
183
  getByPath(useStoryStore.getState().variables, segments);
203
184
  ctx.setValue = (value: unknown) => {
204
185
  useStoryStore.setState((state) => {
205
- setByPath(
206
- state.variables as Record<string, unknown>,
207
- segments,
208
- value,
209
- );
186
+ setByPath(state.variables, segments, value, {
187
+ createMissing: true,
188
+ });
210
189
  });
211
190
  };
212
191
  }