@humanspeak/svelte-motion 0.3.4 → 0.3.6

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.
@@ -35,6 +35,19 @@ export type AnimatePresenceContext = {
35
35
  updateChildAnimatedStyle: (key: string, opacity: string, transform: string) => void;
36
36
  /** Unregister a child. If it has an exit, clone and animate it out. */
37
37
  unregisterChild: (key: string) => void;
38
+ /**
39
+ * @internal Used by `PresenceChild` to participate in the same exit
40
+ * accounting as the clone-based motion-element exit path. Increments the
41
+ * in-flight exit counter and applies mode='wait' enter blocking. Not
42
+ * intended for direct consumer use.
43
+ */
44
+ notifyExitStart: () => void;
45
+ /**
46
+ * @internal Pairs with `notifyExitStart`. Decrements the in-flight exit
47
+ * counter, fires `onExitComplete` once it reaches zero, and unblocks
48
+ * pending enters in mode='wait'. Not intended for direct consumer use.
49
+ */
50
+ notifyExitComplete: () => void;
38
51
  };
39
52
  /**
40
53
  * Create a new `AnimatePresence` context instance.
@@ -113,20 +126,34 @@ export declare const getPresenceDepth: () => number | undefined;
113
126
  */
114
127
  export declare const setPresenceDepth: (depth: number) => void;
115
128
  /**
116
- * Hook used by motion elements to participate in presence.
117
- * Registers the element and ensures its exit animation runs on teardown.
129
+ * Per-`PresenceChild` Svelte context payload. Read by the `useIsPresent` and
130
+ * `usePresence` hooks (and consulted by motion elements so they can opt out of
131
+ * the outer `AnimatePresence` clone path when a `PresenceChild` is driving
132
+ * the exit themselves).
118
133
  *
119
- * Note: Svelte lifecycle wrapper - ignored for coverage.
134
+ * `isPresent` is exposed as a getter so consumers see live updates as the
135
+ * wrapper toggles between mounted, exiting, and re-entered states.
120
136
  */
137
+ export type PresenceChildContext = {
138
+ /** Reactive flag — `true` while present, `false` once the exit hold begins. */
139
+ readonly isPresent: boolean;
140
+ /**
141
+ * Signal that the consumer's exit work is complete. Triggers actual
142
+ * unmount and decrements the parent `AnimatePresenceContext` exit count.
143
+ * Idempotent and versioned (calls from a canceled exit cycle are no-ops).
144
+ */
145
+ safeToRemove: () => void;
146
+ };
121
147
  /**
122
- * Hook used by motion elements to participate in presence.
148
+ * Get the nearest `PresenceChild` context from Svelte component context, or
149
+ * `undefined` if the caller is not wrapped in one.
123
150
  *
124
- * Registers the element with the presence context and guarantees that the
125
- * exit animation is scheduled on teardown.
151
+ * Note: Trivial wrapper - ignored for coverage.
152
+ */
153
+ export declare const getPresenceChildContext: () => PresenceChildContext | undefined;
154
+ /**
155
+ * Install a `PresenceChild` context for descendants.
126
156
  *
127
- * @param key Unique identifier for the presence child.
128
- * @param element The DOM element to track.
129
- * @param exit The exit keyframes definition.
130
- * @param mergedTransition The element's merged transition for precedence.
157
+ * Note: Trivial wrapper - ignored for coverage.
131
158
  */
132
- export declare const usePresence: (key: string, element: HTMLElement | null, exit: MotionExit, mergedTransition?: MotionTransition) => void;
159
+ export declare const setPresenceChildContext: (context: PresenceChildContext) => void;
@@ -1,7 +1,7 @@
1
1
  import { mergeTransitions } from './animation';
2
2
  import { pwLog } from './log';
3
3
  import { animate } from 'motion';
4
- import { getContext, onDestroy, setContext } from 'svelte';
4
+ import { getContext, setContext } from 'svelte';
5
5
  /**
6
6
  * Context key for `AnimatePresence`.
7
7
  *
@@ -195,6 +195,66 @@ export const createAnimatePresenceContext = (context) => {
195
195
  const children = new Map();
196
196
  // Track number of in-flight exit animations to invoke onExitComplete once
197
197
  let inFlightExits = 0;
198
+ /**
199
+ * Begin tracking an exit.
200
+ *
201
+ * Increments the `inFlightExits` counter and, in `mode='wait'`, raises the
202
+ * `enterBlocked` flag so sibling motion-element enters defer until every
203
+ * exit reports back via {@link finishExit}. Shared by the clone-based exit
204
+ * path in {@link unregisterChild} and the user-driven `PresenceChild` hold.
205
+ *
206
+ * Must be paired with exactly one {@link finishExit} call per invocation.
207
+ *
208
+ * @returns void
209
+ * @example
210
+ * ```ts
211
+ * // unregisterChild (clone path)
212
+ * startExit()
213
+ * requestAnimationFrame(() => {
214
+ * animate(clone, exitKeyframes, transition).finished.finally(finishExit)
215
+ * })
216
+ *
217
+ * // PresenceChild (user-driven path) — exposed as `notifyExitStart`
218
+ * presenceContext.notifyExitStart()
219
+ * // ... later, on transitionend or user signal ...
220
+ * presenceContext.notifyExitComplete()
221
+ * ```
222
+ */
223
+ const startExit = () => {
224
+ if (mode === 'wait') {
225
+ enterBlocked = true;
226
+ }
227
+ inFlightExits += 1;
228
+ };
229
+ /**
230
+ * Mark an exit as finished.
231
+ *
232
+ * Decrements the `inFlightExits` counter. When the count reaches zero,
233
+ * fires the consumer's `onExitComplete` callback and, in `mode='wait'`,
234
+ * lowers `enterBlocked` plus notifies any deferred-enter callbacks
235
+ * registered via {@link onEnterUnblocked}.
236
+ *
237
+ * Must be called exactly once per matching {@link startExit}; double-fires
238
+ * underflow the counter and can permanently mis-route subsequent exits.
239
+ *
240
+ * @returns void
241
+ * @example
242
+ * ```ts
243
+ * startExit()
244
+ * // ... exit work ...
245
+ * finishExit() // fires onExitComplete if the last exit, unblocks waiters
246
+ * ```
247
+ */
248
+ const finishExit = () => {
249
+ inFlightExits -= 1;
250
+ if (inFlightExits === 0) {
251
+ context.onExitComplete?.();
252
+ if (mode === 'wait' && enterBlocked) {
253
+ enterBlocked = false;
254
+ notifyEnterUnblocked();
255
+ }
256
+ }
257
+ };
198
258
  /**
199
259
  * Register a child element and snapshot its initial rect/styles.
200
260
  */
@@ -275,11 +335,6 @@ export const createAnimatePresenceContext = (context) => {
275
335
  children.delete(key);
276
336
  return;
277
337
  }
278
- // For mode='wait': block new enters while exit is in progress
279
- if (mode === 'wait') {
280
- enterBlocked = true;
281
- pwLog('[presence] mode=wait: blocking enters during exit');
282
- }
283
338
  const rect = child.lastRect;
284
339
  const computed = child.lastComputedStyle;
285
340
  // For sync/wait, preserve layout by inserting a hidden placeholder.
@@ -415,8 +470,8 @@ export const createAnimatePresenceContext = (context) => {
415
470
  // This prevents race conditions where re-entry registers a new element with the same key
416
471
  // before this exit animation completes
417
472
  const exitingElement = child.element;
418
- // Start exit and track in-flight count
419
- inFlightExits += 1;
473
+ // Start exit and track in-flight count (handles wait-mode blocking)
474
+ startExit();
420
475
  requestAnimationFrame(() => {
421
476
  animate(clone, exitKeyframes, finalTransition)
422
477
  .finished.catch(() => { })
@@ -459,17 +514,7 @@ export const createAnimatePresenceContext = (context) => {
459
514
  inFlightExits: inFlightExits - 1,
460
515
  clonesInDOM: document.querySelectorAll('[data-clone="true"]').length
461
516
  });
462
- inFlightExits -= 1;
463
- if (inFlightExits === 0) {
464
- pwLog('[presence] all exits complete, calling onExitComplete');
465
- context.onExitComplete?.();
466
- // For mode='wait': unblock enters now that all exits are complete
467
- if (mode === 'wait' && enterBlocked) {
468
- enterBlocked = false;
469
- pwLog('[presence] mode=wait: unblocking enters, notifying callbacks');
470
- notifyEnterUnblocked();
471
- }
472
- }
517
+ finishExit();
473
518
  });
474
519
  });
475
520
  };
@@ -483,7 +528,9 @@ export const createAnimatePresenceContext = (context) => {
483
528
  registerChild,
484
529
  updateChildState,
485
530
  updateChildAnimatedStyle,
486
- unregisterChild
531
+ unregisterChild,
532
+ notifyExitStart: startExit,
533
+ notifyExitComplete: finishExit
487
534
  };
488
535
  };
489
536
  /**
@@ -547,44 +594,23 @@ export const getPresenceDepth = () => getContext(PRESENCE_DEPTH_CONTEXT);
547
594
  export const setPresenceDepth = (depth) => {
548
595
  setContext(PRESENCE_DEPTH_CONTEXT, depth);
549
596
  };
597
+ const PRESENCE_CHILD_CONTEXT = Symbol('presence-child-context');
550
598
  /**
551
- * Hook used by motion elements to participate in presence.
552
- * Registers the element and ensures its exit animation runs on teardown.
599
+ * Get the nearest `PresenceChild` context from Svelte component context, or
600
+ * `undefined` if the caller is not wrapped in one.
553
601
  *
554
- * Note: Svelte lifecycle wrapper - ignored for coverage.
602
+ * Note: Trivial wrapper - ignored for coverage.
555
603
  */
556
- /* c8 ignore start */
604
+ /* c8 ignore next 3 */
605
+ export const getPresenceChildContext = () => {
606
+ return getContext(PRESENCE_CHILD_CONTEXT);
607
+ };
557
608
  /**
558
- * Hook used by motion elements to participate in presence.
559
- *
560
- * Registers the element with the presence context and guarantees that the
561
- * exit animation is scheduled on teardown.
609
+ * Install a `PresenceChild` context for descendants.
562
610
  *
563
- * @param key Unique identifier for the presence child.
564
- * @param element The DOM element to track.
565
- * @param exit The exit keyframes definition.
566
- * @param mergedTransition The element's merged transition for precedence.
611
+ * Note: Trivial wrapper - ignored for coverage.
567
612
  */
568
- export const usePresence = (key, element, exit, mergedTransition) => {
569
- const context = getAnimatePresenceContext();
570
- pwLog('[presence] usePresence called', {
571
- key,
572
- hasElement: !!element,
573
- hasContext: !!context,
574
- hasExit: !!exit,
575
- exit
576
- });
577
- if (element && context && exit) {
578
- context.registerChild(key, element, exit, mergedTransition);
579
- onDestroy(() => {
580
- pwLog('[presence] onDestroy triggered', { key });
581
- context.unregisterChild(key);
582
- });
583
- }
584
- else {
585
- pwLog('[presence] usePresence - skipping registration', {
586
- reason: !element ? 'no element' : !context ? 'no context' : 'no exit'
587
- });
588
- }
613
+ /* c8 ignore next 3 */
614
+ export const setPresenceChildContext = (context) => {
615
+ setContext(PRESENCE_CHILD_CONTEXT, context);
589
616
  };
590
- /* c8 ignore end */
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tuple returned by {@link usePresence}, matching framer-motion's shape:
3
+ * `[true, null]` while present (or when not inside a `PresenceChild`), and
4
+ * `[false, () => void]` after the wrapper enters its exit hold.
5
+ */
6
+ export type UsePresenceState = [true, null] | [false, () => void];
7
+ /**
8
+ * Returns whether the calling component is currently present in its parent
9
+ * `<PresenceChild>`. While the wrapper holds the component for an exit, this
10
+ * flips to `false` so the consumer can branch (render different markup, run
11
+ * a custom exit animation, etc.).
12
+ *
13
+ * Outside of a `<PresenceChild>` always returns `true`.
14
+ *
15
+ * Reactivity note: the boolean tracks the wrapper's state and updates in
16
+ * Svelte 5 reactive contexts (`$derived`, `$effect`, template). For non-
17
+ * reactive snapshots, prefer `usePresence()` which exposes the same state
18
+ * alongside the `safeToRemove` callback.
19
+ *
20
+ * @returns `true` while present, `false` while exiting.
21
+ * @see https://motion.dev/docs/react-use-is-present
22
+ *
23
+ * @example
24
+ * ```svelte
25
+ * <script lang="ts">
26
+ * import { useIsPresent } from '@humanspeak/svelte-motion'
27
+ * const isPresent = $derived(useIsPresent())
28
+ * </script>
29
+ * <div class:exiting={!isPresent}>{isPresent ? 'live' : 'goodbye'}</div>
30
+ * ```
31
+ */
32
+ export declare const useIsPresent: () => boolean;
33
+ /**
34
+ * Returns `[isPresent, safeToRemove]`. `isPresent` reflects the wrapper's
35
+ * presence state; `safeToRemove` is the callback to invoke once a custom exit
36
+ * animation finishes. Calling it triggers the actual unmount and decrements
37
+ * the parent `<AnimatePresence>` exit-completion count.
38
+ *
39
+ * Outside of a `<PresenceChild>` returns `[true, null]` — the consumer is
40
+ * effectively always present and there is nothing to safely remove.
41
+ *
42
+ * `safeToRemove` is idempotent and versioned: a stale callback from a
43
+ * canceled exit cycle (re-entry before the consumer signaled completion) is
44
+ * a no-op.
45
+ *
46
+ * @returns `[true, null]` while present (or outside any `PresenceChild`),
47
+ * `[false, () => void]` while the wrapper holds the component for exit.
48
+ * @see https://motion.dev/docs/react-use-presence
49
+ *
50
+ * @example
51
+ * ```svelte
52
+ * <script lang="ts">
53
+ * import { usePresence } from '@humanspeak/svelte-motion'
54
+ *
55
+ * let node: HTMLElement | undefined = $state()
56
+ * const presence = $derived(usePresence())
57
+ *
58
+ * $effect(() => {
59
+ * const [isPresent, safeToRemove] = presence
60
+ * if (isPresent || !node) return
61
+ * const onEnd = () => safeToRemove()
62
+ * node.addEventListener('transitionend', onEnd, { once: true })
63
+ * node.classList.add('exiting')
64
+ * return () => node?.removeEventListener('transitionend', onEnd)
65
+ * })
66
+ * </script>
67
+ *
68
+ * <div bind:this={node}>…</div>
69
+ * ```
70
+ */
71
+ export declare const usePresence: () => UsePresenceState;
@@ -0,0 +1,74 @@
1
+ import { getPresenceChildContext } from './presence';
2
+ /**
3
+ * Returns whether the calling component is currently present in its parent
4
+ * `<PresenceChild>`. While the wrapper holds the component for an exit, this
5
+ * flips to `false` so the consumer can branch (render different markup, run
6
+ * a custom exit animation, etc.).
7
+ *
8
+ * Outside of a `<PresenceChild>` always returns `true`.
9
+ *
10
+ * Reactivity note: the boolean tracks the wrapper's state and updates in
11
+ * Svelte 5 reactive contexts (`$derived`, `$effect`, template). For non-
12
+ * reactive snapshots, prefer `usePresence()` which exposes the same state
13
+ * alongside the `safeToRemove` callback.
14
+ *
15
+ * @returns `true` while present, `false` while exiting.
16
+ * @see https://motion.dev/docs/react-use-is-present
17
+ *
18
+ * @example
19
+ * ```svelte
20
+ * <script lang="ts">
21
+ * import { useIsPresent } from '@humanspeak/svelte-motion'
22
+ * const isPresent = $derived(useIsPresent())
23
+ * </script>
24
+ * <div class:exiting={!isPresent}>{isPresent ? 'live' : 'goodbye'}</div>
25
+ * ```
26
+ */
27
+ export const useIsPresent = () => {
28
+ const context = getPresenceChildContext();
29
+ return context ? context.isPresent : true;
30
+ };
31
+ /**
32
+ * Returns `[isPresent, safeToRemove]`. `isPresent` reflects the wrapper's
33
+ * presence state; `safeToRemove` is the callback to invoke once a custom exit
34
+ * animation finishes. Calling it triggers the actual unmount and decrements
35
+ * the parent `<AnimatePresence>` exit-completion count.
36
+ *
37
+ * Outside of a `<PresenceChild>` returns `[true, null]` — the consumer is
38
+ * effectively always present and there is nothing to safely remove.
39
+ *
40
+ * `safeToRemove` is idempotent and versioned: a stale callback from a
41
+ * canceled exit cycle (re-entry before the consumer signaled completion) is
42
+ * a no-op.
43
+ *
44
+ * @returns `[true, null]` while present (or outside any `PresenceChild`),
45
+ * `[false, () => void]` while the wrapper holds the component for exit.
46
+ * @see https://motion.dev/docs/react-use-presence
47
+ *
48
+ * @example
49
+ * ```svelte
50
+ * <script lang="ts">
51
+ * import { usePresence } from '@humanspeak/svelte-motion'
52
+ *
53
+ * let node: HTMLElement | undefined = $state()
54
+ * const presence = $derived(usePresence())
55
+ *
56
+ * $effect(() => {
57
+ * const [isPresent, safeToRemove] = presence
58
+ * if (isPresent || !node) return
59
+ * const onEnd = () => safeToRemove()
60
+ * node.addEventListener('transitionend', onEnd, { once: true })
61
+ * node.classList.add('exiting')
62
+ * return () => node?.removeEventListener('transitionend', onEnd)
63
+ * })
64
+ * </script>
65
+ *
66
+ * <div bind:this={node}>…</div>
67
+ * ```
68
+ */
69
+ export const usePresence = () => {
70
+ const context = getPresenceChildContext();
71
+ if (!context)
72
+ return [true, null];
73
+ return context.isPresent ? [true, null] : [false, context.safeToRemove];
74
+ };
package/dist/vite.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Parser } from 'acorn';
1
2
  /**
2
3
  * Tag-to-component name mapping. Each key is the lowercase HTML/SVG tag,
3
4
  * and the value is the PascalCase component filename (without .svelte).
@@ -318,16 +319,18 @@ export const svelteMotionOptimize = () => ({
318
319
  for (const [tag, localName] of tagToLocal) {
319
320
  const openRe = new RegExp(`<motion\\.${escapeRegExp(tag)}(?=[\\s/>])`, 'g');
320
321
  const closeRe = new RegExp(`</motion\\.${escapeRegExp(tag)}\\s*>`, 'g');
321
- const scriptRe = new RegExp(`\\bmotion\\.${escapeRegExp(tag)}\\b`, 'g');
322
322
  transformed = transformed.replace(openRe, `<${localName}`);
323
323
  transformed = transformed.replace(closeRe, `</${localName}>`);
324
- // Also replace script-block references (e.g., const Component = motion.div)
325
- // But only outside of the import statement we already handled
326
- const importEndIdx = transformed.indexOf(localName) + localName.length;
327
- const beforeImport = transformed.slice(0, importEndIdx);
328
- const afterImport = transformed.slice(importEndIdx);
329
- transformed = beforeImport + afterImport.replace(scriptRe, localName);
330
324
  }
325
+ // Rewrite `motion.TAG` JS references (e.g. `const Component = motion.div`)
326
+ // inside <script> blocks only. A naive regex over the script body would
327
+ // also clobber the same substring in string literals (`"motion.div"`)
328
+ // and comments (`// motion.div`). Parse the script as JS instead and
329
+ // only rewrite real `motion.<tag>` MemberExpressions. For scripts that
330
+ // fail to parse as plain JS (e.g. `<script lang="ts">`), fall back to a
331
+ // string/comment-aware lexer that achieves the same correctness without
332
+ // needing a TS parser.
333
+ transformed = transformed.replace(/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/g, (_full, open, content, close) => open + rewriteMotionRefsInScript(content, tagToLocal) + close);
331
334
  return {
332
335
  code: transformed,
333
336
  map: null
@@ -341,3 +344,169 @@ export const svelteMotionOptimize = () => ({
341
344
  * @returns The escaped string safe for use in a RegExp.
342
345
  */
343
346
  const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
347
+ /**
348
+ * Rewrite `motion.<tag>` member-expression references inside a `<script>`
349
+ * body to the matching `SvelteMotionTag` local. Preserves string literals
350
+ * and comments — they look like `motion.div` to a regex but must not be
351
+ * rewritten.
352
+ *
353
+ * Strategy: parse the body as JS with acorn and splice only real
354
+ * MemberExpression matches. If parsing fails (TypeScript, JSX, etc.) fall
355
+ * back to a string/comment-aware lexer that skips literals and comments.
356
+ *
357
+ * @param content - Raw script body (between `<script ...>` and `</script>`).
358
+ * @param tagToLocal - Map of lowercase tag → local component identifier.
359
+ * @returns The rewritten body, ready to splice back into the source.
360
+ */
361
+ const rewriteMotionRefsInScript = (content, tagToLocal) => {
362
+ try {
363
+ return rewriteViaAst(content, tagToLocal);
364
+ }
365
+ catch {
366
+ return rewriteViaLexer(content, tagToLocal);
367
+ }
368
+ };
369
+ const isIdentifier = (n) => !!n && n.type === 'Identifier';
370
+ const isMemberExpression = (n) => !!n && n.type === 'MemberExpression';
371
+ /**
372
+ * Walk an acorn AST and collect every `motion.<tag>` MemberExpression range
373
+ * we should rewrite. Splice from end to start so earlier indices stay valid.
374
+ */
375
+ const rewriteViaAst = (content, tagToLocal) => {
376
+ const ast = Parser.parse(content, {
377
+ ecmaVersion: 'latest',
378
+ sourceType: 'module',
379
+ allowImportExportEverywhere: true,
380
+ allowReturnOutsideFunction: true,
381
+ allowAwaitOutsideFunction: true,
382
+ allowHashBang: true
383
+ });
384
+ const edits = [];
385
+ const visit = (node) => {
386
+ if (!node || typeof node !== 'object' || typeof node.type !== 'string')
387
+ return;
388
+ if (isMemberExpression(node) &&
389
+ !node.computed &&
390
+ isIdentifier(node.object) &&
391
+ node.object.name === 'motion' &&
392
+ isIdentifier(node.property)) {
393
+ const localName = tagToLocal.get(node.property.name);
394
+ if (localName) {
395
+ edits.push({ start: node.start, end: node.end, replacement: localName });
396
+ return;
397
+ }
398
+ }
399
+ for (const key of Object.keys(node)) {
400
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc')
401
+ continue;
402
+ const value = node[key];
403
+ if (Array.isArray(value))
404
+ value.forEach((v) => visit(v));
405
+ else if (value && typeof value === 'object')
406
+ visit(value);
407
+ }
408
+ };
409
+ visit(ast);
410
+ if (edits.length === 0)
411
+ return content;
412
+ edits.sort((a, b) => b.start - a.start);
413
+ let out = content;
414
+ for (const edit of edits) {
415
+ out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
416
+ }
417
+ return out;
418
+ };
419
+ /**
420
+ * Fallback for scripts acorn can't parse (TS, JSX). Walks the source
421
+ * character-by-character, skipping string literals (`'`, `"`, backtick incl.
422
+ * `${…}` substitutions) and line/block comments, then applies a `motion.<tag>`
423
+ * regex to the remaining "code" regions. Less precise than AST but covers
424
+ * the same correctness contract for literal/comment preservation.
425
+ */
426
+ const rewriteViaLexer = (content, tagToLocal) => {
427
+ const len = content.length;
428
+ const out = [];
429
+ let i = 0;
430
+ const isIdStart = (ch) => /[A-Za-z_$]/.test(ch);
431
+ const isIdPart = (ch) => /[A-Za-z0-9_$-]/.test(ch);
432
+ while (i < len) {
433
+ const ch = content[i];
434
+ const next = content[i + 1];
435
+ // Line comment
436
+ if (ch === '/' && next === '/') {
437
+ const end = content.indexOf('\n', i);
438
+ const stop = end === -1 ? len : end;
439
+ out.push(content.slice(i, stop));
440
+ i = stop;
441
+ continue;
442
+ }
443
+ // Block comment
444
+ if (ch === '/' && next === '*') {
445
+ const end = content.indexOf('*/', i + 2);
446
+ const stop = end === -1 ? len : end + 2;
447
+ out.push(content.slice(i, stop));
448
+ i = stop;
449
+ continue;
450
+ }
451
+ // String literals (single/double)
452
+ if (ch === '"' || ch === "'") {
453
+ const quote = ch;
454
+ let j = i + 1;
455
+ while (j < len) {
456
+ if (content[j] === '\\') {
457
+ j += 2;
458
+ continue;
459
+ }
460
+ if (content[j] === quote) {
461
+ j++;
462
+ break;
463
+ }
464
+ j++;
465
+ }
466
+ out.push(content.slice(i, j));
467
+ i = j;
468
+ continue;
469
+ }
470
+ // Template literal — naive: skip to matching backtick, no `${…}` parsing
471
+ // is needed for our use case (we only need to NOT rewrite the literal
472
+ // text; substitutions still look like code but `motion.<tag>` inside
473
+ // a template substitution is vanishingly rare and acorn would normally
474
+ // handle it).
475
+ if (ch === '`') {
476
+ let j = i + 1;
477
+ while (j < len) {
478
+ if (content[j] === '\\') {
479
+ j += 2;
480
+ continue;
481
+ }
482
+ if (content[j] === '`') {
483
+ j++;
484
+ break;
485
+ }
486
+ j++;
487
+ }
488
+ out.push(content.slice(i, j));
489
+ i = j;
490
+ continue;
491
+ }
492
+ // Possible `motion.<tag>` identifier — require word boundary on left
493
+ if ((i === 0 || !isIdPart(content[i - 1])) &&
494
+ isIdStart(ch) &&
495
+ content.slice(i, i + 7) === 'motion.') {
496
+ let j = i + 7;
497
+ const tagStart = j;
498
+ while (j < len && isIdPart(content[j]))
499
+ j++;
500
+ const tag = content.slice(tagStart, j);
501
+ const localName = tagToLocal.get(tag);
502
+ if (localName && (j === len || !isIdPart(content[j]))) {
503
+ out.push(localName);
504
+ i = j;
505
+ continue;
506
+ }
507
+ }
508
+ out.push(ch);
509
+ i++;
510
+ }
511
+ return out.join('');
512
+ };