@mui/internal-docs-infra 0.7.1-canary.2 → 0.7.1-canary.3

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.7.1-canary.2",
3
+ "version": "0.7.1-canary.3",
4
4
  "author": "MUI Team",
5
5
  "description": "MUI Infra - internal documentation creation tools.",
6
6
  "license": "MIT",
@@ -633,5 +633,5 @@
633
633
  "bin": {
634
634
  "docs-infra": "./cli/index.mjs"
635
635
  },
636
- "gitSha": "22bc6b0900b737de25dbbbe0225bd32fc26523e6"
636
+ "gitSha": "ae05209d2cf5d9188b942dee401846b821f58130"
637
637
  }
@@ -1,3 +1,4 @@
1
+ import { getHastTextContent } from "../loadServerTypes/hastTypeUtils.mjs";
1
2
  import { calculateFrameRanges } from "../parseSource/calculateFrameRanges.mjs";
2
3
  import { calculateFrameIndent } from "./calculateFrameIndent.mjs";
3
4
  import { restructureFrames } from "../parseSource/restructureFrames.mjs";
@@ -20,15 +21,32 @@ export const EMPHASIS_COMMENT_PREFIX = '@highlight';
20
21
  * @returns The extracted string (without quotes) or undefined if no quoted string found
21
22
  */
22
23
  function extractQuotedString(content) {
23
- const match = content.match(/^["'](.*)["']$/);
24
+ const match = content.match(/^(["'])(.*)\1$/);
24
25
  if (match) {
25
- return match[1];
26
+ return match[2];
26
27
  }
27
28
  // Also try to find quoted string anywhere in the content
28
- const anyMatch = content.match(/["']([^"']+)["']/);
29
- return anyMatch?.[1];
29
+ const anyMatch = content.match(/(["'])([^"']+)\1/);
30
+ return anyMatch?.[2];
30
31
  }
31
32
 
33
+ /**
34
+ * Extracts all quoted strings from content.
35
+ * Supports both double quotes ("...") and single quotes ('...').
36
+ *
37
+ * @param content - The content to extract quoted strings from
38
+ * @returns Array of extracted strings (without quotes), empty if none found
39
+ */
40
+ function extractAllQuotedStrings(content) {
41
+ const results = [];
42
+ const regex = /(["'])([^"']+)\1/g;
43
+ let match = regex.exec(content);
44
+ while (match) {
45
+ results.push(match[2]);
46
+ match = regex.exec(content);
47
+ }
48
+ return results;
49
+ }
32
50
  /**
33
51
  * Extracts and removes the `@focus` keyword from content.
34
52
  *
@@ -61,7 +79,7 @@ function extractFocus(content) {
61
79
  * - Multiline start: `@highlight-start` or `@highlight-start "description"`
62
80
  * - Multiline start focused: `@highlight-start @focus` or `@highlight-start @focus "description"`
63
81
  * - Multiline end: `@highlight-end`
64
- * - Text highlight: `@highlight-text "text to highlight"`
82
+ * - Text highlight: `@highlight-text "text to highlight"` or `@highlight-text "one" "two"`
65
83
  * - Text highlight focused: `@highlight-text @focus "text to highlight"`
66
84
  *
67
85
  * @param comments - Source comments keyed by line number
@@ -100,18 +118,18 @@ function parseEmphasisDirectives(comments) {
100
118
  focus
101
119
  });
102
120
  } else if (content.startsWith('-text')) {
103
- // Text highlight: @highlight-text "text to highlight"
121
+ // Text highlight: @highlight-text "text" or @highlight-text "one" "two" "three"
104
122
  const afterText = content.slice('-text'.length).trim();
105
123
  const {
106
124
  focus,
107
125
  remaining: remainingText
108
126
  } = extractFocus(afterText);
109
- const highlightText = extractQuotedString(remainingText);
110
- if (highlightText) {
127
+ const highlightTexts = extractAllQuotedStrings(remainingText);
128
+ if (highlightTexts.length > 0) {
111
129
  directives.push({
112
130
  line,
113
131
  type: 'text',
114
- highlightText,
132
+ highlightTexts,
115
133
  focus
116
134
  });
117
135
  }
@@ -179,21 +197,6 @@ function buildLineElementMap(node) {
179
197
  return map;
180
198
  }
181
199
 
182
- /**
183
- * Gets the text content of an element recursively.
184
- */
185
- function getElementText(element) {
186
- let text = '';
187
- for (const child of element.children || []) {
188
- if (child.type === 'text') {
189
- text += child.value;
190
- } else if (child.type === 'element') {
191
- text += getElementText(child);
192
- }
193
- }
194
- return text;
195
- }
196
-
197
200
  /**
198
201
  * Checks if a line element contains only a comment with the given text.
199
202
  * A line is considered "comment-only" if it contains only whitespace, a .pl-c element,
@@ -222,7 +225,7 @@ function isCommentOnlyLine(lineElement, commentText) {
222
225
  const classNames = Array.isArray(className) ? className : [className];
223
226
  if (classNames.includes('pl-c')) {
224
227
  // This is a comment element - check if it contains the expected text
225
- const text = getElementText(child);
228
+ const text = getHastTextContent(child);
226
229
  if (text.includes(commentText)) {
227
230
  hasMatchingComment = true;
228
231
  } else {
@@ -232,14 +235,14 @@ function isCommentOnlyLine(lineElement, commentText) {
232
235
  } else if (classNames.includes('pl-pse')) {
233
236
  // This is punctuation for special expressions (JSX braces for comments)
234
237
  // Check if it's just `{` or `}` which are used for JSX comment syntax
235
- const text = getElementText(child);
238
+ const text = getHastTextContent(child);
236
239
  if (text !== '{' && text !== '}') {
237
240
  hasNonWhitespaceContent = true;
238
241
  }
239
242
  // Otherwise ignore - these are just JSX comment syntax
240
243
  } else {
241
244
  // Non-comment element - check if it has non-whitespace content
242
- const text = getElementText(child);
245
+ const text = getHastTextContent(child);
243
246
  if (text.trim() !== '') {
244
247
  hasNonWhitespaceContent = true;
245
248
  }
@@ -272,14 +275,21 @@ function calculateEmphasizedLines(directives, lineElements) {
272
275
  description,
273
276
  strong,
274
277
  position: 'single',
278
+ lineHighlight: true,
275
279
  focus: directive.focus
276
280
  });
277
281
  } else if (directive.type === 'text') {
278
- // Text highlight - emphasize specific text within the line
282
+ // Text highlight - emphasize specific text(s) within the line.
283
+ // Merge with any existing entry (e.g. when @highlight and @highlight-text
284
+ // map to the same line after comment removal).
285
+ const existing = emphasizedLines.get(directive.line);
286
+ // Concatenate highlight texts when multiple directives target the same line.
287
+ const mergedTexts = existing?.highlightTexts ? [...existing.highlightTexts, ...(directive.highlightTexts ?? [])] : directive.highlightTexts;
279
288
  emphasizedLines.set(directive.line, {
280
- position: 'single',
281
- highlightText: directive.highlightText,
282
- focus: directive.focus
289
+ ...existing,
290
+ position: existing?.position ?? 'single',
291
+ highlightTexts: mergedTexts,
292
+ focus: directive.focus || existing?.focus
283
293
  });
284
294
  }
285
295
  }
@@ -325,16 +335,22 @@ function calculateEmphasizedLines(directives, lineElements) {
325
335
  // If this line is already emphasized (from an inner range), mark it as strong
326
336
  // since it's now nested inside multiple emphasis ranges, and preserve inner positions
327
337
  const meta = existing ? {
328
- strong: true,
329
- // Nested = always strong
338
+ // Nested ranges are strong unless the inner is a text highlight
339
+ strong: existing.highlightTexts ? strong : true,
330
340
  description: existing.description ?? (line === startLine ? description : undefined),
331
- position: existing.position ?? position,
332
- // Inner range position takes precedence
341
+ // Inner range position takes precedence, but 'single' from a standalone
342
+ // @highlight-text should be replaced by the multiline range's position
343
+ position: existing.position && existing.position !== 'single' ? existing.position : position,
344
+ highlightTexts: existing.highlightTexts,
345
+ // Preserve text highlights from @highlight-text
346
+ lineHighlight: true,
347
+ // Inside a multiline region = always line highlight
333
348
  focus: existing.focus || startDirective.focus
334
349
  } : {
335
350
  strong,
336
351
  description: line === startLine ? description : undefined,
337
352
  position,
353
+ lineHighlight: true,
338
354
  focus: startDirective.focus
339
355
  };
340
356
  emphasizedLines.set(line, meta);
@@ -345,65 +361,345 @@ function calculateEmphasizedLines(directives, lineElements) {
345
361
  }
346
362
 
347
363
  /**
348
- * Recursively wraps occurrences of a specific text within an element's children
349
- * with a span that has `data-hl` attribute.
364
+ * Like {@link getHastTextContent} but replaces any text inside a
365
+ * `data-hl` element with sentinel null characters so that those
366
+ * regions are invisible to the text search in `wrapTextInHighlightSpan`.
367
+ * This prevents nesting highlights when successive tokens overlap.
368
+ */
369
+ function getSearchableText(node) {
370
+ if (node.type === 'text') {
371
+ return node.value;
372
+ }
373
+ if (node.type === 'element') {
374
+ if (node.properties?.dataHl !== undefined) {
375
+ return '\0'.repeat(getHastTextContent(node).length);
376
+ }
377
+ if (node.children) {
378
+ return node.children.map(getSearchableText).join('');
379
+ }
380
+ }
381
+ return '';
382
+ }
383
+
384
+ /**
385
+ * Injects a `data-hl` highlight span for a character range inside an
386
+ * element's children, without splitting the element itself.
387
+ * Used when a semantic element partially overlaps a match — the element
388
+ * stays intact and the highlight is placed inside it.
350
389
  *
351
- * @param children - The children array to process
352
- * @param textToHighlight - The text to find and wrap
353
- * @returns New children array with text wrapped in highlight spans
390
+ * Uses a plan-based approach (like {@link wrapTextInHighlightSpan}) to
391
+ * detect when the range produces multiple highlight fragments. When the
392
+ * caller already provides a `part` value, every fragment inherits it.
393
+ * Otherwise, if multiple fragments are detected, `data-hl-part` values
394
+ * (`"start"`, `"middle"`, `"end"`) are computed locally.
395
+ *
396
+ * @param children - The children of the element to modify
397
+ * @param from - Start offset within the element's text content
398
+ * @param to - End offset within the element's text content
399
+ * @param part - Optional `data-hl-part` value inherited from the parent
354
400
  */
355
- function wrapTextInHighlightSpan(children, textToHighlight) {
356
- const result = [];
357
- for (const child of children) {
358
- if (child.type === 'text') {
359
- // Check if this text node contains the text to highlight
360
- const text = child.value;
361
- const index = text.indexOf(textToHighlight);
362
- if (index !== -1) {
363
- // Split the text and wrap the matched portion
364
- const before = text.slice(0, index);
365
- const after = text.slice(index + textToHighlight.length);
366
- if (before) {
367
- result.push({
368
- type: 'text',
369
- value: before
370
- });
371
- }
401
+ function injectHighlightInChildren(children, from, to, part, strict, textToHighlight) {
402
+ const before = [];
403
+ const after = [];
404
+ const plan = [];
405
+ let currentGroup = [];
406
+ let offset = 0;
407
+ let pastRange = false;
408
+
409
+ // Precompute text lengths to avoid repeated recursive walks per child.
410
+ const childLengths = children.map(child => getHastTextContent(child).length);
411
+ function flushGroup() {
412
+ if (currentGroup.length > 0) {
413
+ plan.push({
414
+ kind: 'group',
415
+ nodes: currentGroup
416
+ });
417
+ currentGroup = [];
418
+ }
419
+ }
420
+ for (let i = 0; i < children.length; i += 1) {
421
+ const child = children[i];
422
+ const len = childLengths[i];
423
+ const childStart = offset;
424
+ const childEnd = offset + len;
425
+ offset = childEnd;
426
+ if (pastRange) {
427
+ after.push(child);
428
+ continue;
429
+ }
430
+ if (childEnd <= from) {
431
+ before.push(child);
432
+ continue;
433
+ }
434
+ if (childStart >= to) {
435
+ flushGroup();
436
+ after.push(child);
437
+ pastRange = true;
438
+ continue;
439
+ }
440
+ if (childStart >= from && childEnd <= to) {
441
+ currentGroup.push(child);
442
+ continue;
443
+ }
372
444
 
373
- // Create highlighted span
374
- result.push({
375
- type: 'element',
376
- tagName: 'span',
377
- properties: {
378
- dataHl: ''
379
- },
380
- children: [{
381
- type: 'text',
382
- value: textToHighlight
383
- }]
445
+ // Straddling
446
+ const overlapFrom = Math.max(from, childStart) - childStart;
447
+ const overlapTo = Math.min(to, childEnd) - childStart;
448
+ if (child.type === 'text') {
449
+ const beforeText = child.value.slice(0, overlapFrom);
450
+ const matchedText = child.value.slice(overlapFrom, overlapTo);
451
+ const afterText = child.value.slice(overlapTo);
452
+ if (beforeText) {
453
+ flushGroup();
454
+ before.push({
455
+ type: 'text',
456
+ value: beforeText
384
457
  });
385
- if (after) {
386
- // Recursively process the remaining text in case there are more matches
387
- const remainingChildren = wrapTextInHighlightSpan([{
388
- type: 'text',
389
- value: after
390
- }], textToHighlight);
391
- result.push(...remainingChildren);
392
- }
458
+ }
459
+ if (matchedText) {
460
+ currentGroup.push({
461
+ type: 'text',
462
+ value: matchedText
463
+ });
464
+ }
465
+ if (afterText) {
466
+ flushGroup();
467
+ after.push({
468
+ type: 'text',
469
+ value: afterText
470
+ });
471
+ pastRange = true;
472
+ }
473
+ } else if (child.type === 'element' && child.children) {
474
+ // Nested element straddling — this is fragmentation across an element boundary
475
+ if (strict) {
476
+ throw new Error(`Base UI: @highlight-text "${textToHighlight}" straddles an element boundary. ` + 'In strict mode, highlighted text must not be fragmented across elements. ' + 'Adjust the highlighted text so it aligns with syntax token boundaries.');
477
+ }
478
+ flushGroup();
479
+ plan.push({
480
+ kind: 'inject',
481
+ element: child,
482
+ from: overlapFrom,
483
+ to: overlapTo
484
+ });
485
+ if (childEnd > to) {
486
+ pastRange = true;
487
+ }
488
+ } else {
489
+ currentGroup.push(child);
490
+ }
491
+ }
492
+ flushGroup();
493
+
494
+ // When the caller already supplied a part, every fragment inherits it.
495
+ // Otherwise, when multiple fragments exist, compute parts locally.
496
+ const needsParts = part === undefined && plan.length > 1;
497
+ const highlighted = [];
498
+ for (let i = 0; i < plan.length; i += 1) {
499
+ const item = plan[i];
500
+ let effectivePart = part;
501
+ if (needsParts) {
502
+ if (i === 0) {
503
+ effectivePart = 'start';
504
+ } else if (i === plan.length - 1) {
505
+ effectivePart = 'end';
393
506
  } else {
394
- result.push(child);
507
+ effectivePart = 'middle';
508
+ }
509
+ }
510
+ if (item.kind === 'group') {
511
+ const props = {
512
+ dataHl: ''
513
+ };
514
+ if (effectivePart !== undefined) {
515
+ props.dataHlPart = effectivePart;
516
+ }
517
+ highlighted.push({
518
+ type: 'element',
519
+ tagName: 'span',
520
+ properties: props,
521
+ children: item.nodes
522
+ });
523
+ } else {
524
+ highlighted.push({
525
+ ...item.element,
526
+ children: injectHighlightInChildren(item.element.children, item.from, item.to, effectivePart, strict, textToHighlight)
527
+ });
528
+ }
529
+ }
530
+ return [...before, ...highlighted, ...after];
531
+ }
532
+
533
+ /**
534
+ * Wraps all occurrences of a specific text within a line's children in
535
+ * highlight spans with `data-hl`.
536
+ *
537
+ * Semantic element nodes (syntax-highlighting spans) are never split or
538
+ * cloned. When a match partially overlaps an element, the highlight span
539
+ * is injected *inside* the element via {@link injectHighlightInChildren}.
540
+ * When a match covers entire elements, a single wrapper `data-hl` span
541
+ * groups them all.
542
+ *
543
+ * If a match is fragmented (spans a partial element boundary), each
544
+ * fragment gets a `data-hl-part` attribute (`"start"`, `"middle"`, or
545
+ * `"end"`) so the segments can be styled together (e.g. border-radius).
546
+ *
547
+ * Already-highlighted nodes (`dataHl`) are excluded from matching so that
548
+ * successive calls for different tokens don't nest or double-highlight.
549
+ */
550
+ function wrapTextInHighlightSpan(children, textToHighlight, strict) {
551
+ // Build searchable text, masking already-highlighted regions with sentinels.
552
+ const segments = children.map(getSearchableText);
553
+ const fullText = segments.join('');
554
+ const matchIndex = fullText.indexOf(textToHighlight);
555
+ if (matchIndex === -1) {
556
+ return children;
557
+ }
558
+ const matchEnd = matchIndex + textToHighlight.length;
559
+
560
+ // Classify each child relative to [matchIndex, matchEnd).
561
+ // "group" items are fully-contained nodes wrapped in a single data-hl span.
562
+ // "inject" items are elements that straddle a boundary — the highlight goes
563
+ // inside them, preserving the semantic element.
564
+
565
+ const before = [];
566
+ const after = [];
567
+ const plan = [];
568
+ let currentGroup = [];
569
+ let offset = 0;
570
+ let pastMatch = false;
571
+ function flushGroup() {
572
+ if (currentGroup.length > 0) {
573
+ plan.push({
574
+ kind: 'group',
575
+ nodes: currentGroup
576
+ });
577
+ currentGroup = [];
578
+ }
579
+ }
580
+ for (let i = 0; i < children.length; i += 1) {
581
+ const child = children[i];
582
+ const len = segments[i].length;
583
+ const childStart = offset;
584
+ const childEnd = offset + len;
585
+ offset = childEnd;
586
+ if (pastMatch) {
587
+ after.push(child);
588
+ continue;
589
+ }
590
+
591
+ // Entirely before match
592
+ if (childEnd <= matchIndex) {
593
+ before.push(child);
594
+ continue;
595
+ }
596
+
597
+ // Entirely after match
598
+ if (childStart >= matchEnd) {
599
+ flushGroup();
600
+ after.push(child);
601
+ pastMatch = true;
602
+ continue;
603
+ }
604
+
605
+ // Entirely within match
606
+ if (childStart >= matchIndex && childEnd <= matchEnd) {
607
+ currentGroup.push(child);
608
+ continue;
609
+ }
610
+
611
+ // Straddling a boundary
612
+ const overlapFrom = Math.max(matchIndex, childStart) - childStart;
613
+ const overlapTo = Math.min(matchEnd, childEnd) - childStart;
614
+ if (child.type === 'text') {
615
+ // Text nodes can be split freely — they carry no semantic class.
616
+ const beforeText = child.value.slice(0, overlapFrom);
617
+ const matchedText = child.value.slice(overlapFrom, overlapTo);
618
+ const afterText = child.value.slice(overlapTo);
619
+ if (beforeText) {
620
+ flushGroup();
621
+ before.push({
622
+ type: 'text',
623
+ value: beforeText
624
+ });
625
+ }
626
+ if (matchedText) {
627
+ currentGroup.push({
628
+ type: 'text',
629
+ value: matchedText
630
+ });
631
+ }
632
+ if (afterText) {
633
+ flushGroup();
634
+ after.push({
635
+ type: 'text',
636
+ value: afterText
637
+ });
638
+ pastMatch = true;
395
639
  }
396
640
  } else if (child.type === 'element' && child.children) {
397
- // Recursively process element children
398
- result.push({
399
- ...child,
400
- children: wrapTextInHighlightSpan(child.children, textToHighlight)
641
+ // Element nodes are never split — inject highlight inside them.
642
+ flushGroup();
643
+ plan.push({
644
+ kind: 'inject',
645
+ element: child,
646
+ from: overlapFrom,
647
+ to: overlapTo
648
+ });
649
+ if (childEnd > matchEnd) {
650
+ pastMatch = true;
651
+ }
652
+ } else {
653
+ currentGroup.push(child);
654
+ }
655
+ }
656
+ flushGroup();
657
+
658
+ // When multiple highlight pieces exist (due to element boundary straddling),
659
+ // mark each with data-hl-part so they can be styled as a group.
660
+ const needsParts = plan.length > 1;
661
+ if (strict && needsParts) {
662
+ throw new Error(`Base UI: @highlight-text "${textToHighlight}" straddles an element boundary. ` + 'In strict mode, highlighted text must not be fragmented across elements. ' + 'Adjust the highlighted text so it aligns with syntax token boundaries.');
663
+ }
664
+ const highlighted = [];
665
+ for (let i = 0; i < plan.length; i += 1) {
666
+ const item = plan[i];
667
+ let part;
668
+ if (needsParts) {
669
+ if (i === 0) {
670
+ part = 'start';
671
+ } else if (i === plan.length - 1) {
672
+ part = 'end';
673
+ } else {
674
+ part = 'middle';
675
+ }
676
+ }
677
+ if (item.kind === 'group') {
678
+ const props = {
679
+ dataHl: ''
680
+ };
681
+ if (part !== undefined) {
682
+ props.dataHlPart = part;
683
+ }
684
+ highlighted.push({
685
+ type: 'element',
686
+ tagName: 'span',
687
+ properties: props,
688
+ children: item.nodes
401
689
  });
402
690
  } else {
403
- result.push(child);
691
+ const injectedChildren = injectHighlightInChildren(item.element.children, item.from, item.to, part, strict, textToHighlight);
692
+ highlighted.push({
693
+ ...item.element,
694
+ // Re-scan: the injected element may contain additional occurrences of the
695
+ // text beyond the just-highlighted region (e.g. repeated tokens in its tail).
696
+ children: wrapTextInHighlightSpan(injectedChildren, textToHighlight, strict)
697
+ });
404
698
  }
405
699
  }
406
- return result;
700
+ return [...before, ...highlighted,
701
+ // Recursively process the remainder for additional occurrences
702
+ ...wrapTextInHighlightSpan(after, textToHighlight, strict)];
407
703
  }
408
704
 
409
705
  /**
@@ -416,7 +712,7 @@ function wrapTextInHighlightSpan(children, textToHighlight) {
416
712
  * @param emphasizedLines - Map of line numbers to their emphasis metadata
417
713
  * @returns Array of line elements that are highlighted, grouped by region
418
714
  */
419
- function applyEmphasisAndCollectHighlightedElements(node, emphasizedLines) {
715
+ function applyEmphasisAndCollectHighlightedElements(node, emphasizedLines, options) {
420
716
  const highlightedLineElements = [];
421
717
  function traverse(n) {
422
718
  if (!('children' in n) || !n.children) {
@@ -433,10 +729,26 @@ function applyEmphasisAndCollectHighlightedElements(node, emphasizedLines) {
433
729
  const lineNumber = child.properties.dataLn;
434
730
  const meta = emphasizedLines.get(lineNumber);
435
731
  if (meta !== undefined) {
436
- if (meta.highlightText) {
437
- // For text highlight, wrap the specific text in a span with data-hl
438
- // Don't add data-hl to the line itself
439
- child.children = wrapTextInHighlightSpan(child.children, meta.highlightText);
732
+ if (meta.highlightTexts) {
733
+ // For text highlight, wrap the specific text(s) in a span with data-hl
734
+ let children = child.children;
735
+ for (const text of meta.highlightTexts) {
736
+ children = wrapTextInHighlightSpan(children, text, options.strictHighlightText === true);
737
+ }
738
+ child.children = children;
739
+
740
+ // Only mark the line with data-hl when the line also has a line-level
741
+ // highlight (from @highlight, or from being inside a @highlight-start region).
742
+ // Standalone @highlight-text lines should not get line-level highlights.
743
+ if (meta.lineHighlight) {
744
+ child.properties.dataHl = meta.strong ? 'strong' : '';
745
+ if (meta.description) {
746
+ child.properties.dataHlDescription = meta.description;
747
+ }
748
+ if (meta.position) {
749
+ child.properties.dataHlPosition = meta.position;
750
+ }
751
+ }
440
752
  } else {
441
753
  // Use data-hl with optional "strong" value on the line
442
754
  child.properties.dataHl = meta.strong ? 'strong' : '';
@@ -585,7 +897,7 @@ export function createEnhanceCodeEmphasis(options = {}) {
585
897
  }
586
898
 
587
899
  // Step 4 (Traversal 2): Apply emphasis attributes AND collect highlighted elements
588
- const highlightedElements = applyEmphasisAndCollectHighlightedElements(root, emphasizedLines);
900
+ const highlightedElements = applyEmphasisAndCollectHighlightedElements(root, emphasizedLines, options);
589
901
 
590
902
  // Step 5: Calculate indent levels per region (uses collected elements, no tree traversal)
591
903
  const regionIndentLevels = calculateRegionIndentLevels(highlightedElements, emphasizedLines);
@@ -1,3 +1,4 @@
1
+ import { getHastTextContent } from "../loadServerTypes/hastTypeUtils.mjs";
1
2
  import { isLinkableSpan, isPropertySpan, isKeywordSpan, isCommentSpan, isSmiSpan, isStringLiteralSpan, getTextContent, getClassName, propPathToString } from "./hastUtils.mjs";
2
3
  import { createLinkElement, createPropRefElement, createParamRefElement, createValueRefElement } from "./createElements.mjs";
3
4
  import { currentOwner, buildPropHref, buildParamHref, finalizePendingDefaultExport, getResolvedValueExportAt, resetImportState, resetExportState, recordExport } from "./scanState.mjs";
@@ -1506,19 +1507,6 @@ function extractStringLiteralValue(node) {
1506
1507
  return `${quote}${raw}${quote}`;
1507
1508
  }
1508
1509
 
1509
- /**
1510
- * Recursively extracts all text content from an element, including nested elements.
1511
- */
1512
- function getDeepTextContent(node) {
1513
- if (node.type === 'text') {
1514
- return node.value;
1515
- }
1516
- if (node.type === 'element') {
1517
- return node.children.map(getDeepTextContent).join('');
1518
- }
1519
- return '';
1520
- }
1521
-
1522
1510
  /**
1523
1511
  * Checks whether a pl-s span is a template literal with interpolations.
1524
1512
  * Looks for a backtick pl-pds delimiter and pl-pse interpolation boundaries.
@@ -1601,7 +1589,7 @@ function extractTemplateLiteralTokens(node, state) {
1601
1589
 
1602
1590
  // Interpolation boundary: pl-pse wrapping `${` or `}`
1603
1591
  if (cls.includes('pl-pse')) {
1604
- const delimText = getDeepTextContent(child);
1592
+ const delimText = getHastTextContent(child);
1605
1593
  if (delimText === '${') {
1606
1594
  inInterpolation = true;
1607
1595
  interpolationContentCount = 0;
@@ -9,6 +9,7 @@
9
9
  import { unified } from 'unified';
10
10
  import transformHtmlCodeInline from "../transformHtmlCodeInline/index.mjs";
11
11
  import { starryNightGutter } from "../parseSource/addLineGutters.mjs";
12
+ import { getHastTextContent } from "./hastTypeUtils.mjs";
12
13
 
13
14
  /**
14
15
  * Options for formatting inline types as HAST.
@@ -42,17 +43,6 @@ export function formatMultilineUnionHast(hast) {
42
43
  if (!codeElement || codeElement.type !== 'element') {
43
44
  return hast;
44
45
  }
45
-
46
- // Helper to get text content from a node (needed for depth tracking)
47
- const getTextContent = node => {
48
- if (node.type === 'text') {
49
- return node.value || '';
50
- }
51
- if (node.children) {
52
- return node.children.map(getTextContent).join('');
53
- }
54
- return '';
55
- };
56
46
  const children = codeElement.children || [];
57
47
 
58
48
  // Group children by top-level pipes (matching TableCode.tsx behavior)
@@ -62,7 +52,7 @@ export function formatMultilineUnionHast(hast) {
62
52
  let angleDepth = 0;
63
53
  let groupIndex = 0;
64
54
  children.forEach((child, index) => {
65
- const nodeText = getTextContent(child);
55
+ const nodeText = getHastTextContent(child);
66
56
 
67
57
  // Track depth changes
68
58
  for (const char of nodeText) {
@@ -8,10 +8,12 @@ export interface EmphasisMeta {
8
8
  position?: 'single' | 'start' | 'end';
9
9
  /** Whether this is a strong emphasis (description ended with !) */
10
10
  strong?: boolean;
11
- /** For text highlighting: the specific text to highlight within the line */
12
- highlightText?: string;
11
+ /** For text highlighting: the specific texts to highlight within the line */
12
+ highlightTexts?: string[];
13
13
  /** Whether this line's region is the focused region (for padding) */
14
14
  focus?: boolean;
15
+ /** Whether the line itself should receive data-hl (from @highlight or multiline region) */
16
+ lineHighlight?: boolean;
15
17
  }
16
18
  /**
17
19
  * A range of lines that forms a frame in the output.
@@ -41,6 +43,13 @@ export interface EnhanceCodeEmphasisOptions {
41
43
  * padding-top and ceil(remainder/2) for padding-bottom.
42
44
  */
43
45
  focusFramesMaxSize?: number;
46
+ /**
47
+ * When `true`, throws an error if a `@highlight-text` match has to be
48
+ * fragmented across element boundaries (producing `data-hl-part` spans).
49
+ * Wrapping multiple complete elements in a single `data-hl` span is still
50
+ * allowed — only boundary-straddling matches are rejected.
51
+ */
52
+ strictHighlightText?: boolean;
44
53
  }
45
54
  /**
46
55
  * Calculates frame ranges for the code block based on emphasized lines.
@@ -6,7 +6,8 @@
6
6
  */
7
7
  export function createFrame(children, startLine, endLine, frameType, indentLevel) {
8
8
  const properties = {
9
- className: 'frame'
9
+ className: 'frame',
10
+ dataLined: ''
10
11
  };
11
12
  if (startLine !== undefined && endLine !== undefined) {
12
13
  properties.dataFrameStartLine = startLine;
@@ -1,4 +1,5 @@
1
1
  import { visit } from 'unist-util-visit';
2
+ import { getHastTextContent } from "../loadServerTypes/hastTypeUtils.mjs";
2
3
  import { loadCodeVariant } from "../loadCodeVariant/loadCodeVariant.mjs";
3
4
  import { createParseSource } from "../parseSource/index.mjs";
4
5
  import { TypescriptToJavascriptTransformer } from "../transformTypescriptToJavascript/index.mjs";
@@ -99,25 +100,15 @@ const JSX_LANGUAGES = new Set(['jsx', 'tsx']);
99
100
  * from MDX parsing. If the source ends with `>;`, the trailing `;` is removed.
100
101
  */
101
102
  function stripJsxExpressionSemicolon(source) {
103
+ if (source.endsWith('>;\n')) {
104
+ return source.slice(0, -2);
105
+ }
102
106
  if (source.endsWith('>;')) {
103
107
  return source.slice(0, -1);
104
108
  }
105
109
  return source;
106
110
  }
107
111
 
108
- /**
109
- * Extracts text content from HAST nodes
110
- */
111
- function extractTextContent(node) {
112
- if (node.type === 'text') {
113
- return node.value;
114
- }
115
- if (node.type === 'element' && node.children) {
116
- return node.children.map(child => extractTextContent(child)).join('');
117
- }
118
- return '';
119
- }
120
-
121
112
  /**
122
113
  * Extracts code elements and filenames from semantic HTML structure
123
114
  * Handles both section/figure/dl and standalone dl structures
@@ -254,7 +245,7 @@ export const transformHtmlCodeBlock = () => {
254
245
 
255
246
  // Process each extracted element to extract comments and prepare variants
256
247
  const processElementForVariant = async (codeElement, filename, language, explicitVariantName, index) => {
257
- let sourceCode = extractTextContent(codeElement);
248
+ let sourceCode = getHastTextContent(codeElement);
258
249
  const derivedFilename = filename || getFileName(codeElement);
259
250
 
260
251
  // Strip trailing semicolon from JSX expressions