@mui/internal-docs-infra 0.11.1-canary.15 → 0.11.1-canary.17

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": "@mui/internal-docs-infra",
3
- "version": "0.11.1-canary.15",
3
+ "version": "0.11.1-canary.17",
4
4
  "author": "MUI Team",
5
5
  "description": "MUI Infra - internal documentation creation tools.",
6
6
  "license": "MIT",
@@ -768,5 +768,5 @@
768
768
  "bin": {
769
769
  "docs-infra": "./cli/index.mjs"
770
770
  },
771
- "gitSha": "6ad5e4fa528c6617251224a57814890fdf1c4389"
771
+ "gitSha": "5f0871264ec4d7c78d5fd7e62888de0f7bf61cda"
772
772
  }
@@ -140,6 +140,334 @@ function enhanceStringSpan(element) {
140
140
  }
141
141
  }
142
142
 
143
+ /**
144
+ * Tests whether a `pl-k` token's text consists entirely of symbol characters
145
+ * (no letters, digits, or underscore). Used to distinguish symbolic operators
146
+ * (`=`, `=>`, `&&`, `...`) from word keywords (`const`, `if`, `function`).
147
+ */
148
+ function isSymbolicPunctuation(text) {
149
+ if (text.length === 0) {
150
+ return false;
151
+ }
152
+ for (let i = 0; i < text.length; i += 1) {
153
+ const code = text.charCodeAt(i);
154
+ // 0-9
155
+ if (code >= 48 && code <= 57) {
156
+ return false;
157
+ }
158
+ // A-Z
159
+ if (code >= 65 && code <= 90) {
160
+ return false;
161
+ }
162
+ // a-z
163
+ if (code >= 97 && code <= 122) {
164
+ return false;
165
+ }
166
+ // _
167
+ if (code === 95) {
168
+ return false;
169
+ }
170
+ }
171
+ return true;
172
+ }
173
+
174
+ /**
175
+ * Splits a text value into nodes, wrapping bare identifier object-literal keys
176
+ * (e.g. `height` in `{ height: 400 }`) in a `<span>` carrying `di-op` plus, when
177
+ * `inJsx` is true, also `di-jv`. Returns `null` if no key pattern is found,
178
+ * leaving the original text node untouched.
179
+ *
180
+ * A key is detected as `[A-Za-z_$][\w$]*` immediately preceded by `{` or `,`
181
+ * (with optional whitespace) and followed by optional whitespace, then a single
182
+ * `:` not part of `::`. The leading-context check avoids tagging ternary/label
183
+ * patterns; the trailing check avoids `::` (TypeScript namespace, pseudo-elements).
184
+ */
185
+ function splitObjectKeys(value, inJsx) {
186
+ const nodes = [];
187
+ let lastEnd = 0;
188
+ let i = 0;
189
+ while (i < value.length) {
190
+ const code = value.charCodeAt(i);
191
+ const isIdentStart = code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 95 || code === 36;
192
+ if (!isIdentStart) {
193
+ i += 1;
194
+ continue;
195
+ }
196
+
197
+ // Find preceding non-whitespace character; must be `{` or `,` (object key context)
198
+ let prev = i - 1;
199
+ while (prev >= 0) {
200
+ const pc = value.charCodeAt(prev);
201
+ if (pc !== 32 && pc !== 9 && pc !== 10 && pc !== 13) {
202
+ break;
203
+ }
204
+ prev -= 1;
205
+ }
206
+ if (prev < 0) {
207
+ i += 1;
208
+ continue;
209
+ }
210
+ const prevCode = value.charCodeAt(prev);
211
+ if (prevCode !== 123 /* { */ && prevCode !== 44 /* , */) {
212
+ i += 1;
213
+ continue;
214
+ }
215
+
216
+ // Consume identifier chars
217
+ let end = i + 1;
218
+ while (end < value.length) {
219
+ const ec = value.charCodeAt(end);
220
+ const isIdentPart = ec >= 48 && ec <= 57 || ec >= 65 && ec <= 90 || ec >= 97 && ec <= 122 || ec === 95 || ec === 36;
221
+ if (!isIdentPart) {
222
+ break;
223
+ }
224
+ end += 1;
225
+ }
226
+
227
+ // Skip whitespace, expect a single `:` (not `::`)
228
+ let after = end;
229
+ while (after < value.length) {
230
+ const ac = value.charCodeAt(after);
231
+ if (ac !== 32 && ac !== 9 && ac !== 10 && ac !== 13) {
232
+ break;
233
+ }
234
+ after += 1;
235
+ }
236
+ if (after >= value.length || value.charCodeAt(after) !== 58 /* : */) {
237
+ i = end;
238
+ continue;
239
+ }
240
+ if (after + 1 < value.length && value.charCodeAt(after + 1) === 58) {
241
+ i = end;
242
+ continue;
243
+ }
244
+ if (i > lastEnd) {
245
+ nodes.push({
246
+ type: 'text',
247
+ value: value.slice(lastEnd, i)
248
+ });
249
+ }
250
+ const className = inJsx ? ['di-op', 'di-jv'] : ['di-op'];
251
+ nodes.push({
252
+ type: 'element',
253
+ tagName: 'span',
254
+ properties: {
255
+ className
256
+ },
257
+ children: [{
258
+ type: 'text',
259
+ value: value.slice(i, end)
260
+ }]
261
+ });
262
+ lastEnd = end;
263
+ i = end;
264
+ }
265
+ if (nodes.length === 0) {
266
+ return null;
267
+ }
268
+ if (lastEnd < value.length) {
269
+ nodes.push({
270
+ type: 'text',
271
+ value: value.slice(lastEnd)
272
+ });
273
+ }
274
+ return nodes;
275
+ }
276
+
277
+ /**
278
+ * Tests whether a string starts with optional whitespace followed by `:`.
279
+ * Used to detect that a `pl-s` span sits in object property-key position.
280
+ */
281
+ function startsWithColon(text) {
282
+ for (let i = 0; i < text.length; i += 1) {
283
+ const ch = text.charCodeAt(i);
284
+ if (ch === 58) {
285
+ return true;
286
+ }
287
+ // space, tab, newline, carriage return
288
+ if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) {
289
+ return false;
290
+ }
291
+ }
292
+ return false;
293
+ }
294
+
295
+ /**
296
+ * Frame in the template-literal interpolation stack. A `string` frame means we
297
+ * are inside template-string content; an `expr` frame means we are inside a
298
+ * `${ ... }` interpolation expression, tracking `{`/`}` nesting via `braceDepth`
299
+ * so the matching close brace can be found across object literals and lines.
300
+ */
301
+
302
+ /** Creates an empty `di-te` interpolation-region span. */
303
+ function createInterpolationRegion() {
304
+ return {
305
+ type: 'element',
306
+ tagName: 'span',
307
+ properties: {
308
+ className: ['di-te']
309
+ },
310
+ children: []
311
+ };
312
+ }
313
+
314
+ /** Creates a `di-td` delimiter span wrapping the given `${` or `}` glyph. */
315
+ function createInterpolationDelimiter(value) {
316
+ return {
317
+ type: 'element',
318
+ tagName: 'span',
319
+ properties: {
320
+ className: ['di-td']
321
+ },
322
+ children: [{
323
+ type: 'text',
324
+ value
325
+ }]
326
+ };
327
+ }
328
+
329
+ /** True when a node is a `pl-pds` span whose text is a backtick. */
330
+ function isBacktickDelimiter(node) {
331
+ return !!node && node.type === 'element' && node.tagName === 'span' && getFirstClass(node) === 'pl-pds' && getShallowTextContent(node) === '`';
332
+ }
333
+ function pushText(target, value) {
334
+ target.push({
335
+ type: 'text',
336
+ value
337
+ });
338
+ }
339
+
340
+ /**
341
+ * Scans one text node of a template literal, splitting it around interpolation
342
+ * boundaries. In `string` mode it looks for `${` (opening a `di-te` region with a
343
+ * `di-td` delimiter); in `expr` mode it counts `{`/`}` to find the matching close
344
+ * (emitting the closing `di-td`). Mutates `stack` and `targets` in place as it
345
+ * crosses boundaries, appending nodes to the innermost current target.
346
+ */
347
+ function processTemplateText(value, stack, targets) {
348
+ let i = 0;
349
+ let segStart = 0;
350
+ while (i < value.length) {
351
+ const top = stack[stack.length - 1];
352
+ const target = targets[targets.length - 1];
353
+ if (top.mode === 'string') {
354
+ const open = value.indexOf('${', i);
355
+ if (open === -1) {
356
+ break;
357
+ }
358
+ if (open > segStart) {
359
+ pushText(target, value.slice(segStart, open));
360
+ }
361
+ const region = createInterpolationRegion();
362
+ target.push(region);
363
+ region.children.push(createInterpolationDelimiter('${'));
364
+ stack.push({
365
+ mode: 'expr',
366
+ braceDepth: 1
367
+ });
368
+ targets.push(region.children);
369
+ i = open + 2;
370
+ segStart = i;
371
+ } else {
372
+ const code = value.charCodeAt(i);
373
+ if (code === 123 /* { */) {
374
+ top.braceDepth += 1;
375
+ i += 1;
376
+ } else if (code === 125 /* } */) {
377
+ top.braceDepth -= 1;
378
+ if (top.braceDepth === 0) {
379
+ if (i > segStart) {
380
+ pushText(target, value.slice(segStart, i));
381
+ }
382
+ target.push(createInterpolationDelimiter('}'));
383
+ stack.pop();
384
+ targets.pop();
385
+ i += 1;
386
+ segStart = i;
387
+ } else {
388
+ i += 1;
389
+ }
390
+ } else {
391
+ i += 1;
392
+ }
393
+ }
394
+ }
395
+ if (segStart < value.length) {
396
+ pushText(targets[targets.length - 1], value.slice(segStart));
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Restructures one `pl-s` template-literal line, wrapping each `${ ... }`
402
+ * interpolation slice on the line in a `di-te` region with `di-td` delimiters.
403
+ *
404
+ * `entryStack` carries the interpolation state from previous lines (a single
405
+ * `string` frame for the opening line). `isOpener` is true for the line that
406
+ * holds the opening backtick. Because starry-night emits one `pl-s` span per line
407
+ * and the line gutter splits on top-level newlines, a region can never cross a
408
+ * line boundary — each line's slice is wrapped on its own, so a continuation
409
+ * line opens a fresh `di-te` with no leading `${`. Returns the stack to carry to
410
+ * the next line, or `null` once the closing backtick is consumed (run complete).
411
+ */
412
+ function restructureTemplateLine(pls, entryStack, isOpener) {
413
+ const source = pls.children;
414
+ const out = [];
415
+ const stack = entryStack.map(frame => ({
416
+ ...frame
417
+ }));
418
+
419
+ // Rebuild the physical target chain for the carried stack: each open `expr`
420
+ // frame gets a fresh `di-te` region on this line; nested `string` frames share
421
+ // their parent expression's region as the target.
422
+ const targets = [out];
423
+ for (let depth = 1; depth < stack.length; depth += 1) {
424
+ if (stack[depth].mode === 'expr') {
425
+ const region = createInterpolationRegion();
426
+ targets[depth - 1].push(region);
427
+ targets.push(region.children);
428
+ } else {
429
+ targets.push(targets[depth - 1]);
430
+ }
431
+ }
432
+ let runEnded = false;
433
+ let index = 0;
434
+ if (isOpener) {
435
+ out.push(source[0]);
436
+ index = 1;
437
+ }
438
+ for (; index < source.length; index += 1) {
439
+ const node = source[index];
440
+ const target = targets[targets.length - 1];
441
+ if (node.type === 'text') {
442
+ processTemplateText(node.value, stack, targets);
443
+ continue;
444
+ }
445
+ if (isBacktickDelimiter(node)) {
446
+ if (stack[stack.length - 1].mode === 'expr') {
447
+ // A nested template literal opens inside the interpolation expression.
448
+ target.push(node);
449
+ stack.push({
450
+ mode: 'string'
451
+ });
452
+ targets.push(target);
453
+ } else if (stack.length === 1) {
454
+ // The outer template's closing backtick — the run is complete.
455
+ out.push(node);
456
+ runEnded = true;
457
+ } else {
458
+ // A nested template literal's closing backtick.
459
+ target.push(node);
460
+ stack.pop();
461
+ targets.pop();
462
+ }
463
+ continue;
464
+ }
465
+ target.push(node);
466
+ }
467
+ pls.children = out;
468
+ return runEnded ? null : stack;
469
+ }
470
+
143
471
  /**
144
472
  * Single-pass enhancement of a HAST children array. Processes each child exactly
145
473
  * once, applying all per-element and sibling-context enhancements in one iteration.
@@ -148,6 +476,7 @@ function enhanceStringSpan(element) {
148
476
  * Per-element enhancements (applied to individual spans):
149
477
  * - `pl-c1` → `di-num`, `di-bool`, `di-n`, `di-this`, `di-bt` via enhanceConstantSpan
150
478
  * - `pl-s` → `di-n` for empty strings via enhanceStringSpan
479
+ * - `pl-k` symbolic operators (`=`, `=>`, `&&`, `...`) → `di-pu`
151
480
  *
152
481
  * Sibling-context enhancements (depend on neighbor nodes or positional state):
153
482
  * - CSS `&` nesting selector → wraps in `pl-ent` span
@@ -155,6 +484,9 @@ function enhanceStringSpan(element) {
155
484
  * - CSS `property: value` → `di-cp` / `di-cv` based on colon position
156
485
  * - HTML/JSX `<tag attr=value>` → `di-ak`, `di-ae`, `di-av`
157
486
  * - JSX `<Component>` → `di-jsx` on component name spans
487
+ * - JSX `{expression}` → `di-jv` on `pl-smi`/`pl-v` identifier spans inside braces
488
+ * - JS `'key':` object property string → `di-ps` on `pl-s` spans
489
+ * - JS template literals → `di-te` region / `di-td` delimiters around `${ ... }`
158
490
  */
159
491
  function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
160
492
  // CSS declaration state: tracks position relative to { } : ; [ ]
@@ -165,9 +497,17 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
165
497
  // HTML/JSX tag state: whether we're between < and >
166
498
  let htmlInsideTag = false;
167
499
 
500
+ // JSX expression depth: how many `pl-pse` `{` braces are currently open.
501
+ // Identifiers (`pl-smi`, `pl-v`) inside an expression are tagged as JSX variables.
502
+ let jsxExpressionDepth = 0;
503
+
168
504
  // Whether a span appeared between the last text node and the current position.
169
505
  // Used to detect attribute context for = wrapping (replaces backward scanning).
170
506
  let hasSpanSinceLastText = false;
507
+
508
+ // Template-literal interpolation state, carried across the per-line `pl-s` spans
509
+ // of one multi-line literal. `null` when not inside a template-literal run.
510
+ let templateRun = null;
171
511
  for (let index = 0; index < children.length; index += 1) {
172
512
  const child = children[index];
173
513
 
@@ -238,56 +578,91 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
238
578
  }
239
579
  }
240
580
 
241
- // HTML/JSX: track < > tag boundaries and wrap bare = in attribute context
581
+ // HTML/JSX: track < > tag boundaries and wrap bare = in attribute context.
582
+ // A trailing `<` (next sibling = element) only enters tag mode when the next
583
+ // element looks like a JSX tag — `pl-ent` (HTML element) or `pl-c1` whose text
584
+ // isn't a TS built-in primitive. This avoids treating TS generics like
585
+ // `useState<number | null>` or `<T = string>` as JSX tags.
242
586
  if (isHtmlJsx) {
243
587
  for (let ci = 0; ci < value.length; ci += 1) {
244
588
  if (value[ci] === '<') {
245
- htmlInsideTag = true;
589
+ if (ci === value.length - 1) {
590
+ const nextChild = children[index + 1];
591
+ if (isJsx && nextChild && nextChild.type === 'element' && nextChild.tagName === 'span') {
592
+ const nextClass = getFirstClass(nextChild);
593
+ if (nextClass === 'pl-ent') {
594
+ htmlInsideTag = true;
595
+ } else if (nextClass === 'pl-c1') {
596
+ const nextText = getShallowTextContent(nextChild);
597
+ if (nextText && !BUILT_IN_TYPES.has(nextText)) {
598
+ htmlInsideTag = true;
599
+ }
600
+ }
601
+ } else {
602
+ htmlInsideTag = true;
603
+ }
604
+ } else {
605
+ htmlInsideTag = true;
606
+ }
246
607
  } else if (value[ci] === '>') {
247
608
  htmlInsideTag = false;
248
609
  }
249
610
  }
250
- if (htmlInsideTag && savedSpanFlag) {
251
- const equalsIndex = value.indexOf('=');
252
- if (equalsIndex !== -1) {
253
- // Tag the following pl-s span as attribute value
254
- const nextChild = children[index + 1];
255
- if (nextChild && nextChild.type === 'element' && nextChild.tagName === 'span' && getFirstClass(nextChild) === 'pl-s') {
256
- addClass(nextChild, 'di-av');
257
- }
611
+ }
258
612
 
259
- // Split text around = and wrap in di-ae span
260
- const before = value.slice(0, equalsIndex);
261
- const after = value.slice(equalsIndex + 1);
262
- const equalsSpan = {
263
- type: 'element',
264
- tagName: 'span',
265
- properties: {
266
- className: ['di-ae']
267
- },
268
- children: [{
269
- type: 'text',
270
- value: '='
271
- }]
272
- };
273
- const newNodes = [];
274
- if (before) {
275
- newNodes.push({
276
- type: 'text',
277
- value: before
278
- });
279
- }
280
- newNodes.push(equalsSpan);
281
- if (after) {
282
- newNodes.push({
283
- type: 'text',
284
- value: after
285
- });
286
- }
287
- children.splice(index, 1, ...newNodes);
288
- index += newNodes.length - 1;
289
- hasSpanSinceLastText = newNodes[newNodes.length - 1].type === 'element';
613
+ // Bare object-literal keys (e.g. `height` in `{ height: 400 }`) become di-op spans.
614
+ // Inside a JSX attribute expression they also receive di-jv. Done before the `=` split
615
+ // below, which only fires in attribute context (htmlInsideTag) the two paths don't conflict.
616
+ // Children expressions (e.g. `<Comp>{children}</Comp>`) are excluded by the htmlInsideTag check.
617
+ if (isJs) {
618
+ const split = splitObjectKeys(value, isJsx && jsxExpressionDepth > 0 && htmlInsideTag);
619
+ if (split) {
620
+ children.splice(index, 1, ...split);
621
+ index += split.length - 1;
622
+ hasSpanSinceLastText = split[split.length - 1].type === 'element';
623
+ continue;
624
+ }
625
+ }
626
+ if (isHtmlJsx && htmlInsideTag && savedSpanFlag) {
627
+ const equalsIndex = value.indexOf('=');
628
+ if (equalsIndex !== -1) {
629
+ // Tag the following pl-s span as attribute value
630
+ const nextChild = children[index + 1];
631
+ if (nextChild && nextChild.type === 'element' && nextChild.tagName === 'span' && getFirstClass(nextChild) === 'pl-s') {
632
+ addClass(nextChild, 'di-av');
633
+ }
634
+
635
+ // Split text around = and wrap in di-ae span
636
+ const before = value.slice(0, equalsIndex);
637
+ const after = value.slice(equalsIndex + 1);
638
+ const equalsSpan = {
639
+ type: 'element',
640
+ tagName: 'span',
641
+ properties: {
642
+ className: ['di-ae']
643
+ },
644
+ children: [{
645
+ type: 'text',
646
+ value: '='
647
+ }]
648
+ };
649
+ const newNodes = [];
650
+ if (before) {
651
+ newNodes.push({
652
+ type: 'text',
653
+ value: before
654
+ });
655
+ }
656
+ newNodes.push(equalsSpan);
657
+ if (after) {
658
+ newNodes.push({
659
+ type: 'text',
660
+ value: after
661
+ });
290
662
  }
663
+ children.splice(index, 1, ...newNodes);
664
+ index += newNodes.length - 1;
665
+ hasSpanSinceLastText = newNodes[newNodes.length - 1].type === 'element';
291
666
  }
292
667
  }
293
668
  continue;
@@ -298,6 +673,34 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
298
673
  continue;
299
674
  }
300
675
 
676
+ // ── Template-literal interpolation (JS family) ──
677
+ // starry-night tokenizes a backtick string as a `pl-s` span (one per line for
678
+ // multi-line literals). Wrap each `${ ... }` slice in a `di-te` region with
679
+ // `di-td` delimiters so the interpolated expression resets from the string
680
+ // color. `templateRun` carries the brace/nesting state across the per-line
681
+ // `pl-s` spans; a run starts on the line whose first child is the opening
682
+ // backtick. Handled here, before the generic recursion, so the expression
683
+ // tokens are enhanced inside their regions and the outer `pl-s` is skipped.
684
+ if (isJs && child.tagName === 'span' && getFirstClass(child) === 'pl-s') {
685
+ const opensRun = templateRun === null && isBacktickDelimiter(child.children[0]);
686
+ if (templateRun !== null || opensRun) {
687
+ templateRun = restructureTemplateLine(child, templateRun ?? [{
688
+ mode: 'string'
689
+ }], opensRun);
690
+ // Empty backtick literals (`` `` ``) keep their nullish (`di-n`) classification.
691
+ enhanceStringSpan(child);
692
+ // Enhance the interpolated expressions (e.g. `di-num` on `${42}`) within
693
+ // each region; nested regions are reached by the recursion.
694
+ for (const region of child.children) {
695
+ if (region.type === 'element' && getFirstClass(region) === 'di-te') {
696
+ enhanceChildren(region.children, isCss, isHtmlJsx, isJs, isTs, isJsx);
697
+ }
698
+ }
699
+ hasSpanSinceLastText = true;
700
+ continue;
701
+ }
702
+ }
703
+
301
704
  // Recurse into nested elements (frames, lines, nested spans)
302
705
  if (child.children.length > 0) {
303
706
  enhanceChildren(child.children, isCss, isHtmlJsx, isJs, isTs, isJsx);
@@ -314,6 +717,56 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
314
717
  enhanceConstantSpan(child, isJs, isTs);
315
718
  } else if (firstClass === 'pl-s') {
316
719
  enhanceStringSpan(child);
720
+ } else if (firstClass === 'pl-k') {
721
+ const text = getShallowTextContent(child);
722
+ if (text && isSymbolicPunctuation(text)) {
723
+ addClass(child, 'di-pu');
724
+ }
725
+ }
726
+
727
+ // ── JSX expression brace tracking ──
728
+ if (isJsx && firstClass === 'pl-pse') {
729
+ const text = getShallowTextContent(child);
730
+ if (text === '{') {
731
+ jsxExpressionDepth += 1;
732
+ } else if (text === '}' && jsxExpressionDepth > 0) {
733
+ jsxExpressionDepth -= 1;
734
+ }
735
+ }
736
+
737
+ // ── JSX variable: identifier-like spans inside an attribute expression ──
738
+ // - `pl-smi` plain identifier (e.g. `row` in `{row.name}`)
739
+ // - `pl-v` parameter / variable (e.g. arrow function params)
740
+ // - `pl-c1` after a `.` text node — member-access property (e.g. `name` in `{row.name}`).
741
+ // Skips numbers/booleans/nullish (which `enhanceConstantSpan` has already classified)
742
+ // and JSX components (handled below by the `<`/`</` detection).
743
+ //
744
+ // Restricted to attribute context (`htmlInsideTag`) so children expressions like
745
+ // `<Comp>{children}</Comp>` are not tagged.
746
+ if (isJsx && jsxExpressionDepth > 0 && htmlInsideTag) {
747
+ if (firstClass === 'pl-smi' || firstClass === 'pl-v') {
748
+ addClass(child, 'di-jv');
749
+ } else if (firstClass === 'pl-c1' && index > 0) {
750
+ const prev = children[index - 1];
751
+ if (prev.type === 'text' && prev.value.endsWith('.')) {
752
+ addClass(child, 'di-jv');
753
+ }
754
+ }
755
+ }
756
+
757
+ // ── JS object property string: pl-s followed by text starting with `:` ──
758
+ // String keys (e.g. `'aria-label': value`) get the dedicated `di-op` class plus
759
+ // `di-ps` for the string-shape detail. Inside JSX expressions, also add `di-jv`
760
+ // so themes that style JSX variables can include string keys.
761
+ if (isJs && firstClass === 'pl-s') {
762
+ const next = children[index + 1];
763
+ if (next && next.type === 'text' && startsWithColon(next.value)) {
764
+ addClass(child, 'di-op');
765
+ addClass(child, 'di-ps');
766
+ if (isJsx && jsxExpressionDepth > 0 && htmlInsideTag) {
767
+ addClass(child, 'di-jv');
768
+ }
769
+ }
317
770
  }
318
771
 
319
772
  // ── CSS-specific enhancements ──
@@ -353,10 +806,15 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
353
806
  if (isJsx && index > 0) {
354
807
  const prev = children[index - 1];
355
808
 
356
- // Opening/closing: text ending in < or </ followed by pl-c1
809
+ // Opening/closing: text ending in < or </ followed by pl-c1.
810
+ // Skip TS built-in types (e.g. `number` in `useState<number>`) so generic
811
+ // type arguments aren't mistaken for JSX components.
357
812
  if (firstClass === 'pl-c1' && prev.type === 'text') {
358
813
  if (prev.value.endsWith('<') || prev.value.endsWith('</')) {
359
- addClass(child, 'di-jsx');
814
+ const text = getShallowTextContent(child);
815
+ if (!text || !BUILT_IN_TYPES.has(text)) {
816
+ addClass(child, 'di-jsx');
817
+ }
360
818
  }
361
819
  }
362
820
 
@@ -0,0 +1,12 @@
1
+ import type { ElementContent } from 'hast';
2
+ /**
3
+ * Removes a suffix from the end of highlighted HAST nodes.
4
+ *
5
+ * Mirror of `removePrefixFromHighlightedNodes`: used to strip temporary
6
+ * trailing characters that were appended to the source before highlighting
7
+ * (e.g., closing `)` for object-literal wrapping).
8
+ *
9
+ * @param children - The array of HAST nodes to modify
10
+ * @param suffixLength - The number of characters to remove from the end
11
+ */
12
+ export declare function removeSuffixFromHighlightedNodes(children: ElementContent[], suffixLength: number): void;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Removes a suffix from the end of highlighted HAST nodes.
3
+ *
4
+ * Mirror of `removePrefixFromHighlightedNodes`: used to strip temporary
5
+ * trailing characters that were appended to the source before highlighting
6
+ * (e.g., closing `)` for object-literal wrapping).
7
+ *
8
+ * @param children - The array of HAST nodes to modify
9
+ * @param suffixLength - The number of characters to remove from the end
10
+ */
11
+ export function removeSuffixFromHighlightedNodes(children, suffixLength) {
12
+ let removedLength = 0;
13
+ while (removedLength < suffixLength && children.length > 0) {
14
+ const lastChild = children[children.length - 1];
15
+ if (lastChild.type === 'text') {
16
+ const textLength = lastChild.value.length;
17
+ if (removedLength + textLength <= suffixLength) {
18
+ children.pop();
19
+ removedLength += textLength;
20
+ } else {
21
+ const charsToRemove = suffixLength - removedLength;
22
+ lastChild.value = lastChild.value.slice(0, textLength - charsToRemove);
23
+ removedLength = suffixLength;
24
+ }
25
+ } else if (lastChild.type === 'element') {
26
+ const element = lastChild;
27
+ if (element.children && element.children.length > 0) {
28
+ const lastElementChild = element.children[element.children.length - 1];
29
+ if (lastElementChild.type === 'text') {
30
+ const textLength = lastElementChild.value.length;
31
+ if (removedLength + textLength <= suffixLength) {
32
+ element.children.pop();
33
+ removedLength += textLength;
34
+ if (element.children.length === 0) {
35
+ children.pop();
36
+ }
37
+ } else {
38
+ const charsToRemove = suffixLength - removedLength;
39
+ lastElementChild.value = lastElementChild.value.slice(0, textLength - charsToRemove);
40
+ removedLength = suffixLength;
41
+ }
42
+ } else {
43
+ break;
44
+ }
45
+ } else {
46
+ children.pop();
47
+ }
48
+ } else {
49
+ break;
50
+ }
51
+ }
52
+ }
@@ -3,8 +3,10 @@ import { visit } from 'unist-util-visit';
3
3
  import { grammars } from "../parseSource/grammars.mjs";
4
4
  import { extensionMap } from "../parseSource/grammarMaps.mjs";
5
5
  import { extendSyntaxTokens } from "../parseSource/extendSyntaxTokens.mjs";
6
+ import { getLanguageCapabilitiesFromScope } from "../parseSource/languageCapabilities.mjs";
6
7
  import { getHastTextContent } from "../hastUtils/index.mjs";
7
8
  import { removePrefixFromHighlightedNodes } from "./removePrefixFromHighlightedNodes.mjs";
9
+ import { removeSuffixFromHighlightedNodes } from "./removeSuffixFromHighlightedNodes.mjs";
8
10
  const STARRY_NIGHT_KEY = '__docs_infra_starry_night_instance__';
9
11
 
10
12
  /**
@@ -67,7 +69,14 @@ export default function transformHtmlCodeInline(options = {}) {
67
69
  const highlightingPrefix = typeof node.properties?.dataHighlightingPrefix === 'string' ? node.properties.dataHighlightingPrefix : undefined;
68
70
 
69
71
  // Temporarily prepend the prefix for proper syntax highlighting
70
- const sourceToHighlight = highlightingPrefix ? `${highlightingPrefix}${source}` : source;
72
+ let sourceToHighlight = highlightingPrefix ? `${highlightingPrefix}${source}` : source;
73
+
74
+ // Inline JS-family snippets that look like a bare object literal (e.g. `{ height: 400 }`)
75
+ // are tokenized by starry-night as a block statement with labeled statements, which makes
76
+ // the keys appear as `pl-en` (entity name) tokens rather than property names. Wrap them
77
+ // in `(...)` so the snippet parses as an expression and `extendSyntaxTokens` can split
78
+ // out the keys via `splitObjectKeys`. The wrapping characters are stripped after highlighting.
79
+ let objectWrap = false;
71
80
 
72
81
  // Determine language from className (e.g., 'language-ts')
73
82
  const className = node.properties?.className;
@@ -103,6 +112,14 @@ export default function transformHtmlCodeInline(options = {}) {
103
112
  if (!fileType || !extensionMap[fileType]) {
104
113
  return;
105
114
  }
115
+ const grammarScope = extensionMap[fileType];
116
+ if (!highlightingPrefix && getLanguageCapabilitiesFromScope(grammarScope).semantics === 'js') {
117
+ const trimmed = sourceToHighlight.trim();
118
+ if (trimmed.length >= 2 && trimmed.startsWith('{') && trimmed.endsWith('}')) {
119
+ sourceToHighlight = `(${sourceToHighlight})`;
120
+ objectWrap = true;
121
+ }
122
+ }
106
123
 
107
124
  // Apply syntax highlighting
108
125
  const highlighted = starryNight.highlight(sourceToHighlight, extensionMap[fileType]);
@@ -116,6 +133,10 @@ export default function transformHtmlCodeInline(options = {}) {
116
133
  if (highlightingPrefix && node.children.length > 0) {
117
134
  removePrefixFromHighlightedNodes(node.children, highlightingPrefix.length);
118
135
  }
136
+ if (objectWrap && node.children.length > 0) {
137
+ removePrefixFromHighlightedNodes(node.children, 1);
138
+ removeSuffixFromHighlightedNodes(node.children, 1);
139
+ }
119
140
  }
120
141
 
121
142
  // Mark this code element as inline highlighted (only for inline code, not pre>code)
@@ -1146,12 +1146,19 @@ export const createEditableEngine = ctx => {
1146
1146
  state.repeatFlushId = null;
1147
1147
  // The user may have moved focus or cleared the selection in the
1148
1148
  // 100ms since the last repeat keydown (e.g. clicked elsewhere,
1149
- // unmounted, blurred). The debounced flush is best-effort; if
1150
- // there's no live selection inside the editable any more, skip
1151
- // — the next real event will pick up state. Without this guard
1152
- // `getPosition` throws from a stray timer after teardown.
1149
+ // unmounted, blurred). The debounced flush is best-effort; if the
1150
+ // engine is gone or there's no live selection inside the editable
1151
+ // any more, skip — the next real event will pick up state.
1152
+ //
1153
+ // Bail out before touching `window`: a stray timer can fire after
1154
+ // teardown, and in a test environment the `window` global may already
1155
+ // be removed, so `window.getSelection()` would throw a `ReferenceError`
1156
+ // (an unhandled rejection that can mask real failures).
1157
+ if (state.disconnected || typeof window === 'undefined') {
1158
+ return;
1159
+ }
1153
1160
  const selection = window.getSelection();
1154
- if (state.disconnected || !selection || selection.rangeCount === 0 || !element.contains(selection.getRangeAt(0).startContainer)) {
1161
+ if (!selection || selection.rangeCount === 0 || !element.contains(selection.getRangeAt(0).startContainer)) {
1155
1162
  return;
1156
1163
  }
1157
1164
  flushChanges();
@@ -48,6 +48,14 @@ export type UseCodeWindowResult<ToggleElement extends HTMLElement = HTMLElement,
48
48
  * so the anchor stays put against the panel's own scroll rather than the
49
49
  * page. When left unattached, the page is compensated — the right default
50
50
  * for code that grows the document flow. Forwarded from `useScrollAnchor`.
51
+ *
52
+ * When attached, this element is also treated as the horizontal scroll
53
+ * owner: the scrollbar-gutter swap (`data-scrollbar-gutter`) and the
54
+ * collapse scroll-back run on it instead of the inner `<pre>`. Use this when
55
+ * the window owns both scroll axes so the horizontal scrollbar sits at the
56
+ * window's edge (in view) rather than at the bottom of the inner `<pre>`,
57
+ * which can extend past the window's height and scroll out of view. Your
58
+ * gutter CSS must then key off this element's attribute.
51
59
  */
52
60
  scrollContainerRef: React.RefObject<ScrollElement | null>;
53
61
  /**
@@ -53,33 +53,38 @@ function cancelScheduled(handle) {
53
53
  * Smoothly slides the `<code>` element back to the left edge over `duration`
54
54
  * ms using an ease-out cubic via the Web Animations API.
55
55
  *
56
- * Used during collapse instead of tweening `pre.scrollLeft` because the
57
- * scrollbar-gutter animation forces `overflow-x: hidden` on the pre, which
56
+ * `scrollEl` is whichever element owns the horizontal scroll the inner
57
+ * `<pre>` by default, or an attached scroll container (see `scrollContainerRef`)
58
+ * when the code block is rendered inside a fixed-size window. `code` is this
59
+ * code window's own `<code>` (scoped to its container by the caller) so a shared
60
+ * scroll container holding several blocks animates the right one.
61
+ *
62
+ * Used during collapse instead of tweening `scrollEl.scrollLeft` because the
63
+ * scrollbar-gutter animation forces `overflow-x: hidden` on `scrollEl`, which
58
64
  * snaps `scrollLeft` to 0 instantly. Animating a transform on the inner
59
65
  * `code` element produces the same visual effect, isn't reset by the overflow
60
- * change, and is naturally clipped by the pre's hidden overflow.
66
+ * change, and is naturally clipped by the scroll element's hidden overflow.
61
67
  *
62
68
  * Honors `prefers-reduced-motion` by snapping immediately.
63
69
  */
64
- function smoothCollapseScrollLeft(pre, duration) {
65
- const startLeft = pre.scrollLeft;
70
+ function smoothCollapseScrollLeft(scrollEl, code, duration) {
71
+ const startLeft = scrollEl.scrollLeft;
66
72
  if (startLeft <= 0) {
67
73
  return null;
68
74
  }
69
- const code = pre.querySelector('code');
70
- if (!code || typeof code.animate !== 'function') {
71
- return null;
72
- }
73
75
 
74
76
  // Cancel any leftover scroll-back animation from a previous toggle so we
75
77
  // don't end up with two transforms competing on the same element.
76
- scrollbackAnimations.get(pre)?.cancel();
77
- scrollbackAnimations.delete(pre);
78
+ scrollbackAnimations.get(scrollEl)?.cancel();
79
+ scrollbackAnimations.delete(scrollEl);
78
80
 
79
- // Reset the actual scroll position now; the WAAPI animation visually
80
- // compensates by translating the element from `-startLeft` back to `0`.
81
- pre.scrollLeft = 0;
82
- if (prefersReducedMotion() || duration <= 0) {
81
+ // Snap the actual scroll position back to the left edge now. When we can
82
+ // animate, the WAAPI transform below visually compensates by translating the
83
+ // element from `-startLeft` back to `0`; otherwise (no WAAPI, no `code`,
84
+ // reduced motion, or zero duration) this stands as an instant snap — still
85
+ // the correct collapsed end state.
86
+ scrollEl.scrollLeft = 0;
87
+ if (!code || typeof code.animate !== 'function' || prefersReducedMotion() || duration <= 0) {
83
88
  return null;
84
89
  }
85
90
  const anim = code.animate([{
@@ -91,10 +96,10 @@ function smoothCollapseScrollLeft(pre, duration) {
91
96
  easing: 'cubic-bezier(0, 0, 0.2, 1)',
92
97
  fill: 'none'
93
98
  });
94
- scrollbackAnimations.set(pre, anim);
99
+ scrollbackAnimations.set(scrollEl, anim);
95
100
  const onSettle = () => {
96
- if (scrollbackAnimations.get(pre) === anim) {
97
- scrollbackAnimations.delete(pre);
101
+ if (scrollbackAnimations.get(scrollEl) === anim) {
102
+ scrollbackAnimations.delete(scrollEl);
98
103
  }
99
104
  };
100
105
  anim.finished.then(onSettle, onSettle);
@@ -106,74 +111,76 @@ function isElementInViewport(element) {
106
111
  }
107
112
 
108
113
  /**
109
- * Measures the horizontal scrollbar height of a `<pre>` element by
114
+ * Measures the horizontal scrollbar height of the scroll element by
110
115
  * temporarily forcing `overflow-x: scroll`.
111
116
  */
112
- function measureScrollbarHeight(pre) {
113
- const prevOverflow = pre.style.overflowX;
114
- pre.style.overflowX = 'scroll';
115
- const scrollbarHeight = pre.offsetHeight - pre.clientHeight;
116
- pre.style.overflowX = prevOverflow;
117
+ function measureScrollbarHeight(scrollEl) {
118
+ const prevOverflow = scrollEl.style.overflowX;
119
+ scrollEl.style.overflowX = 'scroll';
120
+ const scrollbarHeight = scrollEl.offsetHeight - scrollEl.clientHeight;
121
+ scrollEl.style.overflowX = prevOverflow;
117
122
  return scrollbarHeight;
118
123
  }
119
- function clearGutterState(pre) {
120
- cancelScheduled(gutterCleanupTimers.get(pre));
121
- gutterCleanupTimers.delete(pre);
122
- const flipTimer = gutterFlipTimers.get(pre);
124
+ function clearGutterState(scrollEl) {
125
+ cancelScheduled(gutterCleanupTimers.get(scrollEl));
126
+ gutterCleanupTimers.delete(scrollEl);
127
+ const flipTimer = gutterFlipTimers.get(scrollEl);
123
128
  if (flipTimer !== undefined) {
124
129
  clearTimeout(flipTimer);
125
- gutterFlipTimers.delete(pre);
130
+ gutterFlipTimers.delete(scrollEl);
126
131
  }
127
- pre.removeAttribute(GUTTER_STATE_ATTRIBUTE);
132
+ scrollEl.removeAttribute(GUTTER_STATE_ATTRIBUTE);
128
133
  }
129
- function cancelAllForPre(pre) {
130
- scrollbackAnimations.get(pre)?.cancel();
131
- scrollbackAnimations.delete(pre);
132
- clearGutterState(pre);
134
+ function cancelAllForScrollEl(scrollEl) {
135
+ scrollbackAnimations.get(scrollEl)?.cancel();
136
+ scrollbackAnimations.delete(scrollEl);
137
+ clearGutterState(scrollEl);
133
138
  }
134
139
 
135
140
  /**
136
141
  * Drives a from→to transition on the `data-scrollbar-gutter` attribute of
137
- * `pre`, which the consumer's CSS hooks into to animate the swap between
138
- * a real scrollbar and equivalent padding-bottom.
142
+ * the scroll element, which the consumer's CSS hooks into to animate the swap
143
+ * between a real scrollbar and equivalent padding-bottom.
144
+ *
145
+ * `scrollEl` is whichever element owns the horizontal scroll — the inner
146
+ * `<pre>` by default, or the attached `scrollContainerRef` when the code block
147
+ * is rendered inside a fixed-size window. `code` is this code window's own
148
+ * `<code>` (scoped to its container by the caller).
139
149
  *
140
150
  * Skips the animation when content doesn't overflow (no scrollbar exists)
141
151
  * or when the browser uses overlay scrollbars (zero height).
142
152
  */
143
- function animateScrollbarGutter(pre, from, to, durationMs) {
144
- const scrollbarHeight = measureScrollbarHeight(pre);
153
+ function animateScrollbarGutter(scrollEl, code, from, to, durationMs) {
154
+ const scrollbarHeight = measureScrollbarHeight(scrollEl);
145
155
  if (scrollbarHeight === 0) {
146
156
  return; // Overlay scrollbars, nothing to do
147
157
  }
148
158
 
149
- // For expand, check the inner code's scrollWidth (since `min-width:
150
- // fit-content` reflects hidden frames). For collapse, the pre's own
151
- // scrollWidth is enough.
152
- if (from === 'expand-from') {
153
- const code = pre.querySelector('code');
154
- if (code && code.scrollWidth <= pre.clientWidth) {
155
- return;
156
- }
157
- } else if (pre.scrollWidth <= pre.clientWidth) {
159
+ // Decide from this code window's own `<code>`, not from `scrollEl` — the
160
+ // scroll owner may be a shared container wrapping other content. `code`'s
161
+ // `scrollWidth` reflects hidden frames (via `min-width: fit-content`), so it
162
+ // predicts the post-expand width and still reflects the wide source during
163
+ // collapse; compare it against the scroll owner's visible width.
164
+ if (!code || code.scrollWidth <= scrollEl.clientWidth) {
158
165
  return;
159
166
  }
160
- clearGutterState(pre);
161
- pre.setAttribute(GUTTER_STATE_ATTRIBUTE, from);
167
+ clearGutterState(scrollEl);
168
+ scrollEl.setAttribute(GUTTER_STATE_ATTRIBUTE, from);
162
169
 
163
170
  // Move into the transition state on the next macrotask. Tracked so the
164
171
  // flip can be cancelled if the component unmounts before it fires.
165
172
  const flipTimer = setTimeout(() => {
166
- gutterFlipTimers.delete(pre);
167
- pre.setAttribute(GUTTER_STATE_ATTRIBUTE, to);
173
+ gutterFlipTimers.delete(scrollEl);
174
+ scrollEl.setAttribute(GUTTER_STATE_ATTRIBUTE, to);
168
175
  }, 0);
169
- gutterFlipTimers.set(pre, flipTimer);
176
+ gutterFlipTimers.set(scrollEl, flipTimer);
170
177
 
171
178
  // Schedule cleanup on the animation timeline so DevTools throttling
172
179
  // scales it together with the CSS transition.
173
- const cleanup = scheduleOnAnimationTimeline(pre, durationMs + 30, () => {
174
- clearGutterState(pre);
180
+ const cleanup = scheduleOnAnimationTimeline(scrollEl, durationMs + 30, () => {
181
+ clearGutterState(scrollEl);
175
182
  });
176
- gutterCleanupTimers.set(pre, cleanup);
183
+ gutterCleanupTimers.set(scrollEl, cleanup);
177
184
  }
178
185
  const DEFAULT_ANCHOR_SELECTOR = '[data-frame-type="highlighted"], [data-frame-type="focus"]';
179
186
  const DEFAULT_COLLAPSIBLE_SELECTOR = '[data-collapsible]';
@@ -218,7 +225,7 @@ export function useCodeWindow(options = {}) {
218
225
  collapsibleProbeSelector = DEFAULT_COLLAPSIBLE_SELECTOR
219
226
  } = options;
220
227
  const toggleRef = React.useRef(null);
221
- const lastPreRef = React.useRef(null);
228
+ const lastScrollElRef = React.useRef(null);
222
229
  const {
223
230
  containerRef,
224
231
  scrollContainerRef,
@@ -226,10 +233,10 @@ export function useCodeWindow(options = {}) {
226
233
  } = useScrollAnchor();
227
234
  React.useEffect(() => {
228
235
  return () => {
229
- const pre = lastPreRef.current;
230
- if (pre) {
231
- cancelAllForPre(pre);
232
- lastPreRef.current = null;
236
+ const scrollEl = lastScrollElRef.current;
237
+ if (scrollEl) {
238
+ cancelAllForScrollEl(scrollEl);
239
+ lastScrollElRef.current = null;
233
240
  }
234
241
  };
235
242
  }, []);
@@ -247,32 +254,42 @@ export function useCodeWindow(options = {}) {
247
254
  if (!anchor) {
248
255
  return;
249
256
  }
250
- const pre = container.querySelector('pre');
251
- if (pre) {
252
- lastPreRef.current = pre;
257
+
258
+ // The element whose horizontal scrollbar we smooth: the attached scroll
259
+ // container when one is provided (the code block lives inside a
260
+ // fixed-size window that owns both scroll axes), otherwise the inner
261
+ // `<pre>`, which scrolls horizontally on its own.
262
+ const scrollEl = scrollContainerRef.current ?? container.querySelector('pre');
263
+ // Scope content lookups to *this* code window's `container`, never to
264
+ // `scrollEl`: an attached scroll container may wrap several code blocks or
265
+ // unrelated content, so `scrollEl.querySelector('code')` could match the
266
+ // wrong block. The overflow decision and scroll-back both use this code.
267
+ const code = container.querySelector('code');
268
+ if (scrollEl) {
269
+ lastScrollElRef.current = scrollEl;
253
270
  if (direction === 'collapse') {
254
271
  // Smoothly return horizontal scroll to the left edge. We animate
255
272
  // via a transform on the inner `code` element rather than
256
- // tweening `pre.scrollLeft`, because the gutter animation below
273
+ // tweening `scrollEl.scrollLeft`, because the gutter animation below
257
274
  // sets `overflow-x: hidden` which would snap `scrollLeft` to 0
258
275
  // instantly. Both animations start in the same frame: the
259
276
  // scroll-back resets `scrollLeft` to 0 up front, so the gutter
260
277
  // swap's `overflow-x` change has nothing left to snap.
261
- smoothCollapseScrollLeft(pre, scrollBackDuration);
262
- animateScrollbarGutter(pre, 'collapse-from', 'collapse-to', collapseDuration);
278
+ smoothCollapseScrollLeft(scrollEl, code, scrollBackDuration);
279
+ animateScrollbarGutter(scrollEl, code, 'collapse-from', 'collapse-to', collapseDuration);
263
280
  }
264
281
  if (direction === 'expand') {
265
282
  // Cancel any in-flight collapse scroll-back so its leftover
266
283
  // transform can't drift the code horizontally during expand.
267
- scrollbackAnimations.get(pre)?.cancel();
268
- scrollbackAnimations.delete(pre);
269
- if (collapsibleProbeSelector && pre.querySelector(collapsibleProbeSelector)) {
270
- animateScrollbarGutter(pre, 'expand-from', 'expand-to', expandDuration);
284
+ scrollbackAnimations.get(scrollEl)?.cancel();
285
+ scrollbackAnimations.delete(scrollEl);
286
+ if (collapsibleProbeSelector && container.querySelector(collapsibleProbeSelector)) {
287
+ animateScrollbarGutter(scrollEl, code, 'expand-from', 'expand-to', expandDuration);
271
288
  }
272
289
  }
273
290
  }
274
291
  rawAnchorScroll(anchor, direction === 'collapse' ? collapseDuration : expandDuration);
275
- }, [containerRef, rawAnchorScroll, anchorSelector, collapsibleProbeSelector, collapseDuration, expandDuration, scrollBackDuration]);
292
+ }, [containerRef, scrollContainerRef, rawAnchorScroll, anchorSelector, collapsibleProbeSelector, collapseDuration, expandDuration, scrollBackDuration]);
276
293
  return {
277
294
  containerRef,
278
295
  scrollContainerRef,
@@ -42,10 +42,16 @@ export function useScrollAnchor() {
42
42
  activeSessionCleanupRef.current = null;
43
43
 
44
44
  // Snapshot the scroll target at session start so a later ref change
45
- // doesn't redirect compensation mid-flight.
46
- const scrollTarget = scrollContainerRef.current ?? window;
45
+ // doesn't redirect compensation mid-flight. `scrollElement` is the attached
46
+ // container (if any); `scrollTarget` is what receives the user-interaction
47
+ // listeners (the container or the window).
48
+ const scrollElement = scrollContainerRef.current;
49
+ const scrollTarget = scrollElement ?? window;
47
50
  const interactionTarget = scrollTarget;
48
- const initialTop = anchor.getBoundingClientRect().top;
51
+
52
+ // Mutable so it can be re-baselined when an attached container can't yet
53
+ // absorb a delta (see below).
54
+ let initialTop = anchor.getBoundingClientRect().top;
49
55
  let active = true;
50
56
  let cleanupTimer;
51
57
 
@@ -58,8 +64,25 @@ export function useScrollAnchor() {
58
64
  return;
59
65
  }
60
66
  const delta = anchor.getBoundingClientRect().top - initialTop;
61
- if (Math.abs(delta) > 0.5) {
62
- scrollTarget.scrollBy(0, delta);
67
+ if (Math.abs(delta) <= 0.5) {
68
+ return;
69
+ }
70
+ if (!scrollElement) {
71
+ window.scrollBy(0, delta);
72
+ return;
73
+ }
74
+ const before = scrollElement.scrollTop;
75
+ scrollElement.scrollBy(0, delta);
76
+ const remainder = delta - (scrollElement.scrollTop - before);
77
+ if (Math.abs(remainder) > 0.5) {
78
+ // The container couldn't absorb this part — it isn't scrollable yet
79
+ // (its content hasn't exceeded its `max-height`). Re-baseline instead
80
+ // of forcing the difference elsewhere: scrolling the page would shift
81
+ // the surrounding layout, and carrying the delta forward would snap the
82
+ // anchor back the instant the container becomes scrollable. Accepting
83
+ // the small drift now keeps the surrounding layout still and lets the
84
+ // container hold the anchor smoothly from here on.
85
+ initialTop += remainder;
63
86
  }
64
87
  });
65
88
  function cleanup() {