@mui/internal-docs-infra 0.5.1-canary.0 → 0.6.1-canary.0

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/cli/index.mjs CHANGED
@@ -7,4 +7,4 @@ function getVersion() {
7
7
  }
8
8
  yargs().scriptName('docs-infra').usage('$0 <command> [args]').command(runValidate).demandCommand(1, 'You need at least one command before moving on').strict().help()
9
9
  // MUI_VERSION is set through the code-infra build command.
10
- .version("0.5.0" || getVersion()).parse(hideBin(process.argv));
10
+ .version("0.6.0" || getVersion()).parse(hideBin(process.argv));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-docs-infra",
3
- "version": "0.5.1-canary.0",
3
+ "version": "0.6.1-canary.0",
4
4
  "author": "MUI Team",
5
5
  "description": "MUI Infra - internal documentation creation tools.",
6
6
  "keywords": [
@@ -452,6 +452,16 @@
452
452
  "default": "./pipeline/loadServerSource/index.mjs"
453
453
  }
454
454
  },
455
+ "./pipeline/enhanceCodeEmphasis": {
456
+ "import": {
457
+ "types": "./pipeline/enhanceCodeEmphasis/index.d.mts",
458
+ "default": "./pipeline/enhanceCodeEmphasis/index.mjs"
459
+ },
460
+ "default": {
461
+ "types": "./pipeline/enhanceCodeEmphasis/index.d.mts",
462
+ "default": "./pipeline/enhanceCodeEmphasis/index.mjs"
463
+ }
464
+ },
455
465
  "./pipeline/parseSource": {
456
466
  "import": {
457
467
  "types": "./pipeline/parseSource/index.d.mts",
@@ -506,5 +516,5 @@
506
516
  "bin": {
507
517
  "docs-infra": "./cli/index.mjs"
508
518
  },
509
- "gitSha": "df142b55588e09b911db8be848c133b0ebad1cc0"
519
+ "gitSha": "cad146f1682e0cb63baeeab28454423d77ef7c89"
510
520
  }
@@ -0,0 +1,12 @@
1
+ import type { Element } from 'hast';
2
+ /**
3
+ * Calculates the shared indent level for a set of line elements.
4
+ *
5
+ * Finds the minimum leading whitespace across all non-empty lines,
6
+ * then divides by the indent size (2 spaces) and floors to get
7
+ * the indent level.
8
+ *
9
+ * @param lineElements - Array of HAST line elements to analyze
10
+ * @returns The shared indent level (e.g., 2 for 4 leading spaces with 2-space indent)
11
+ */
12
+ export declare function calculateFrameIndent(lineElements: Element[]): number;
@@ -0,0 +1,62 @@
1
+ const INDENT_SIZE = 2;
2
+
3
+ /**
4
+ * Counts leading spaces in an element by walking the HAST tree.
5
+ *
6
+ * Returns the number of leading space characters before the first
7
+ * non-space character, or -1 if the line is empty/whitespace-only.
8
+ * Only counts space characters. Tab indentation is not supported
9
+ * since the input is HAST output from starry-night which uses spaces.
10
+ */
11
+ function countLeadingSpaces(element) {
12
+ let spaces = 0;
13
+ function walk(node) {
14
+ for (const child of node.children) {
15
+ if (child.type === 'text') {
16
+ for (const char of child.value) {
17
+ if (char === ' ') {
18
+ spaces += 1;
19
+ } else {
20
+ return true;
21
+ }
22
+ }
23
+ } else if (child.type === 'element') {
24
+ if (walk(child)) {
25
+ return true;
26
+ }
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+ const foundNonSpace = walk(element);
32
+ return foundNonSpace ? spaces : -1;
33
+ }
34
+
35
+ /**
36
+ * Calculates the shared indent level for a set of line elements.
37
+ *
38
+ * Finds the minimum leading whitespace across all non-empty lines,
39
+ * then divides by the indent size (2 spaces) and floors to get
40
+ * the indent level.
41
+ *
42
+ * @param lineElements - Array of HAST line elements to analyze
43
+ * @returns The shared indent level (e.g., 2 for 4 leading spaces with 2-space indent)
44
+ */
45
+ export function calculateFrameIndent(lineElements) {
46
+ let minLeadingSpaces = Infinity;
47
+ for (const element of lineElements) {
48
+ const leadingSpaces = countLeadingSpaces(element);
49
+
50
+ // Skip empty/whitespace-only lines
51
+ if (leadingSpaces === -1) {
52
+ continue;
53
+ }
54
+ if (leadingSpaces < minLeadingSpaces) {
55
+ minLeadingSpaces = leadingSpaces;
56
+ }
57
+ }
58
+ if (minLeadingSpaces === Infinity) {
59
+ return 0;
60
+ }
61
+ return Math.floor(minLeadingSpaces / INDENT_SIZE);
62
+ }
@@ -1,13 +1,16 @@
1
1
  import type { SourceEnhancer } from "../../CodeHighlighter/types.mjs";
2
+ import type { EnhanceCodeEmphasisOptions } from "../parseSource/calculateFrameRanges.mjs";
3
+ export type { EmphasisMeta, EnhanceCodeEmphasisOptions, FrameRange } from "../parseSource/calculateFrameRanges.mjs";
2
4
  /**
3
5
  * The prefix used to identify emphasis comments in source code.
4
6
  * Comments starting with this prefix will be processed for emphasis.
5
7
  */
6
8
  export declare const EMPHASIS_COMMENT_PREFIX = "@highlight";
7
9
  /**
8
- * Source enhancer that adds emphasis to code lines based on `@highlight` comments.
10
+ * Creates a source enhancer that adds emphasis to code lines based on `@highlight` comments
11
+ * and restructures frames around highlighted regions.
9
12
  *
10
- * Supports four patterns:
13
+ * Supports five patterns:
11
14
  *
12
15
  * 1. **Single line emphasis** - emphasizes the line containing the comment:
13
16
  * ```jsx
@@ -37,12 +40,30 @@ export declare const EMPHASIS_COMMENT_PREFIX = "@highlight";
37
40
  * <h1>Heading 1</h1> {/* @highlight-text "Heading 1" *\/}
38
41
  * ```
39
42
  *
43
+ * 5. **Focus override** - mark a region for padding focus:
44
+ * ```jsx
45
+ * <h1>Heading 1</h1> {/* @highlight @focus *\/}
46
+ * ```
47
+ *
40
48
  * Emphasized lines receive a `data-hl` attribute on their `<span class="line">` element.
49
+ * When highlights exist, frames are restructured with `data-frame-type` attributes
50
+ * (`highlighted`, `padding-top`, `padding-bottom`, or omitted for normal).
51
+ * Highlighted frames also receive `data-frame-indent` with the shared indent level.
52
+ *
53
+ * @param options - Optional configuration for padding frames
54
+ * @returns A `SourceEnhancer` function
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { createEnhanceCodeEmphasis } from '@mui/internal-docs-infra/pipeline/enhanceCodeEmphasis';
41
59
  *
42
- * @param root - The HAST root node to enhance
43
- * @param comments - Comments extracted from the source code, keyed by line number
44
- * @param _fileName - The name of the file being processed (unused)
45
- * @returns The enhanced HAST root node with emphasis attributes added
60
+ * const enhancers = [createEnhanceCodeEmphasis({ paddingFrameMaxSize: 5, focusFramesMaxSize: 8 })];
61
+ * ```
62
+ */
63
+ export declare function createEnhanceCodeEmphasis(options?: EnhanceCodeEmphasisOptions): SourceEnhancer;
64
+ /**
65
+ * Default source enhancer that adds emphasis to code lines based on `@highlight` comments.
66
+ * Uses no padding frames by default. Use `createEnhanceCodeEmphasis` for configurable padding.
46
67
  *
47
68
  * @example
48
69
  * ```ts
@@ -1,3 +1,6 @@
1
+ import { calculateFrameRanges } from "../parseSource/calculateFrameRanges.mjs";
2
+ import { calculateFrameIndent } from "./calculateFrameIndent.mjs";
3
+ import { restructureFrames } from "../parseSource/restructureFrames.mjs";
1
4
  /**
2
5
  * The prefix used to identify emphasis comments in source code.
3
6
  * Comments starting with this prefix will be processed for emphasis.
@@ -8,19 +11,15 @@ export const EMPHASIS_COMMENT_PREFIX = '@highlight';
8
11
  * Parsed emphasis directive from a comment.
9
12
  */
10
13
 
11
- /**
12
- * Metadata for an emphasized line.
13
- */
14
-
15
14
  /**
16
15
  * Extracts a quoted string from content.
17
16
  * Supports both double quotes ("...") and single quotes ('...').
17
+ * Escaped quotes within the string are not supported.
18
18
  *
19
19
  * @param content - The content to extract the quoted string from
20
20
  * @returns The extracted string (without quotes) or undefined if no quoted string found
21
21
  */
22
22
  function extractQuotedString(content) {
23
- // Match either double-quoted or single-quoted string
24
23
  const match = content.match(/^["'](.*)["']$/);
25
24
  if (match) {
26
25
  return match[1];
@@ -30,14 +29,40 @@ function extractQuotedString(content) {
30
29
  return anyMatch?.[1];
31
30
  }
32
31
 
32
+ /**
33
+ * Extracts and removes the `@focus` keyword from content.
34
+ *
35
+ * @param content - The content to check for `@focus`
36
+ * @returns An object with `focus` boolean and the `remaining` content with `@focus` removed
37
+ */
38
+ function extractFocus(content) {
39
+ // Match @focus only as a standalone token (not inside quotes)
40
+ const match = content.match(/(^|\s)@focus(\s|$)/);
41
+ if (!match) {
42
+ return {
43
+ focus: false,
44
+ remaining: content
45
+ };
46
+ }
47
+ const start = match.index + match[1].length;
48
+ const remaining = (content.slice(0, start) + content.slice(start + '@focus'.length)).trim();
49
+ return {
50
+ focus: true,
51
+ remaining
52
+ };
53
+ }
54
+
33
55
  /**
34
56
  * Parses emphasis comments and returns structured directives.
35
57
  *
36
58
  * Supported formats:
37
59
  * - Single line: `@highlight` or `@highlight "description"`
60
+ * - Single line focused: `@highlight @focus` or `@highlight @focus "description"`
38
61
  * - Multiline start: `@highlight-start` or `@highlight-start "description"`
62
+ * - Multiline start focused: `@highlight-start @focus` or `@highlight-start @focus "description"`
39
63
  * - Multiline end: `@highlight-end`
40
64
  * - Text highlight: `@highlight-text "text to highlight"`
65
+ * - Text highlight focused: `@highlight-text @focus "text to highlight"`
41
66
  *
42
67
  * @param comments - Source comments keyed by line number
43
68
  * @returns Array of parsed emphasis directives
@@ -63,31 +88,46 @@ function parseEmphasisDirectives(comments) {
63
88
  } else if (content.startsWith('-start')) {
64
89
  // Start of multiline emphasis: @highlight-start or @highlight-start "description"
65
90
  const afterStart = content.slice('-start'.length).trim();
66
- const description = extractQuotedString(afterStart);
91
+ const {
92
+ focus,
93
+ remaining: remainingStart
94
+ } = extractFocus(afterStart);
95
+ const description = extractQuotedString(remainingStart);
67
96
  directives.push({
68
97
  line,
69
98
  type: 'start',
70
- description
99
+ description,
100
+ focus
71
101
  });
72
102
  } else if (content.startsWith('-text')) {
73
103
  // Text highlight: @highlight-text "text to highlight"
74
104
  const afterText = content.slice('-text'.length).trim();
75
- const highlightText = extractQuotedString(afterText);
105
+ const {
106
+ focus,
107
+ remaining: remainingText
108
+ } = extractFocus(afterText);
109
+ const highlightText = extractQuotedString(remainingText);
76
110
  if (highlightText) {
77
111
  directives.push({
78
112
  line,
79
113
  type: 'text',
80
- highlightText
114
+ highlightText,
115
+ focus
81
116
  });
82
117
  }
83
118
  } else {
84
119
  // Single line emphasis: @highlight or @highlight "description"
85
120
  const afterHighlight = content.trim();
86
- const description = extractQuotedString(afterHighlight) || undefined;
121
+ const {
122
+ focus,
123
+ remaining: remainingSingle
124
+ } = extractFocus(afterHighlight);
125
+ const description = extractQuotedString(remainingSingle) || undefined;
87
126
  directives.push({
88
127
  line,
89
128
  type: 'single',
90
- description
129
+ description,
130
+ focus
91
131
  });
92
132
  }
93
133
  }
@@ -231,13 +271,15 @@ function calculateEmphasizedLines(directives, lineElements) {
231
271
  emphasizedLines.set(directive.line, {
232
272
  description,
233
273
  strong,
234
- position: 'single'
274
+ position: 'single',
275
+ focus: directive.focus
235
276
  });
236
277
  } else if (directive.type === 'text') {
237
278
  // Text highlight - emphasize specific text within the line
238
279
  emphasizedLines.set(directive.line, {
239
280
  position: 'single',
240
- highlightText: directive.highlightText
281
+ highlightText: directive.highlightText,
282
+ focus: directive.focus
241
283
  });
242
284
  }
243
285
  }
@@ -286,11 +328,14 @@ function calculateEmphasizedLines(directives, lineElements) {
286
328
  strong: true,
287
329
  // Nested = always strong
288
330
  description: existing.description ?? (line === startLine ? description : undefined),
289
- position: existing.position ?? position // Inner range position takes precedence
331
+ position: existing.position ?? position,
332
+ // Inner range position takes precedence
333
+ focus: existing.focus || startDirective.focus
290
334
  } : {
291
335
  strong,
292
336
  description: line === startLine ? description : undefined,
293
- position
337
+ position,
338
+ focus: startDirective.focus
294
339
  };
295
340
  emphasizedLines.set(line, meta);
296
341
  }
@@ -362,52 +407,113 @@ function wrapTextInHighlightSpan(children, textToHighlight) {
362
407
  }
363
408
 
364
409
  /**
365
- * Recursively finds and modifies line elements in a HAST tree.
410
+ * Single-pass traversal that applies emphasis attributes to line elements
411
+ * AND collects leading whitespace for indent calculation on highlighted lines.
412
+ *
413
+ * This merges what would otherwise be two separate traversals into one.
366
414
  *
367
415
  * @param node - The node to process
368
416
  * @param emphasizedLines - Map of line numbers to their emphasis metadata
417
+ * @returns Array of line elements that are highlighted, grouped by region
369
418
  */
370
- function addEmphasisToLines(node, emphasizedLines) {
371
- if (!('children' in node) || !node.children) {
372
- return;
373
- }
374
- for (let i = 0; i < node.children.length; i += 1) {
375
- const child = node.children[i];
376
- if (child.type !== 'element') {
377
- continue;
419
+ function applyEmphasisAndCollectHighlightedElements(node, emphasizedLines) {
420
+ const highlightedLineElements = [];
421
+ function traverse(n) {
422
+ if (!('children' in n) || !n.children) {
423
+ return;
378
424
  }
425
+ for (let i = 0; i < n.children.length; i += 1) {
426
+ const child = n.children[i];
427
+ if (child.type !== 'element') {
428
+ continue;
429
+ }
379
430
 
380
- // Check if this is a line element
381
- if (child.tagName === 'span' && child.properties?.className === 'line' && typeof child.properties.dataLn === 'number') {
382
- const lineNumber = child.properties.dataLn;
383
- const meta = emphasizedLines.get(lineNumber);
384
- if (meta !== undefined) {
385
- if (meta.highlightText) {
386
- // For text highlight, wrap the specific text in a span with data-hl
387
- // Don't add data-hl to the line itself
388
- child.children = wrapTextInHighlightSpan(child.children, meta.highlightText);
389
- } else {
390
- // Use data-hl with optional "strong" value on the line
391
- child.properties.dataHl = meta.strong ? 'strong' : '';
392
- if (meta.description) {
393
- child.properties.dataHlDescription = meta.description;
394
- }
395
- if (meta.position) {
396
- child.properties.dataHlPosition = meta.position;
431
+ // Check if this is a line element
432
+ if (child.tagName === 'span' && child.properties?.className === 'line' && typeof child.properties.dataLn === 'number') {
433
+ const lineNumber = child.properties.dataLn;
434
+ const meta = emphasizedLines.get(lineNumber);
435
+ 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);
440
+ } else {
441
+ // Use data-hl with optional "strong" value on the line
442
+ child.properties.dataHl = meta.strong ? 'strong' : '';
443
+ if (meta.description) {
444
+ child.properties.dataHlDescription = meta.description;
445
+ }
446
+ if (meta.position) {
447
+ child.properties.dataHlPosition = meta.position;
448
+ }
397
449
  }
450
+
451
+ // Collect this line element for indent calculation
452
+ highlightedLineElements.push(child);
398
453
  }
399
454
  }
455
+
456
+ // Recurse into children (for frames containing lines)
457
+ traverse(child);
458
+ }
459
+ }
460
+ traverse(node);
461
+ return highlightedLineElements;
462
+ }
463
+
464
+ /**
465
+ * Groups highlighted line elements by their highlight regions and calculates
466
+ * the indent level for each region.
467
+ *
468
+ * @param highlightedElements - Line elements that are highlighted, in order
469
+ * @param emphasizedLines - The emphasis metadata map
470
+ * @returns Map from region index to indent level
471
+ */
472
+ function calculateRegionIndentLevels(highlightedElements, emphasizedLines) {
473
+ const regionIndentLevels = new Map();
474
+ if (highlightedElements.length === 0) {
475
+ return regionIndentLevels;
476
+ }
477
+
478
+ // Group elements by consecutive regions
479
+ const sortedLines = Array.from(emphasizedLines.keys()).sort((a, b) => a - b);
480
+ let regionIndex = 0;
481
+ let regionElements = [];
482
+ let prevLine = -1;
483
+
484
+ // Build a quick lookup from lineNumber to element
485
+ const elementByLine = new Map();
486
+ for (const el of highlightedElements) {
487
+ const ln = el.properties?.dataLn;
488
+ elementByLine.set(ln, el);
489
+ }
490
+ for (const line of sortedLines) {
491
+ const el = elementByLine.get(line);
492
+ if (!el) {
493
+ continue;
494
+ }
495
+ if (prevLine >= 0 && line !== prevLine + 1) {
496
+ // Gap: close current region
497
+ regionIndentLevels.set(regionIndex, calculateFrameIndent(regionElements));
498
+ regionIndex += 1;
499
+ regionElements = [];
400
500
  }
501
+ regionElements.push(el);
502
+ prevLine = line;
503
+ }
401
504
 
402
- // Recurse into children (for frames containing lines)
403
- addEmphasisToLines(child, emphasizedLines);
505
+ // Close the last region
506
+ if (regionElements.length > 0) {
507
+ regionIndentLevels.set(regionIndex, calculateFrameIndent(regionElements));
404
508
  }
509
+ return regionIndentLevels;
405
510
  }
406
511
 
407
512
  /**
408
- * Source enhancer that adds emphasis to code lines based on `@highlight` comments.
513
+ * Creates a source enhancer that adds emphasis to code lines based on `@highlight` comments
514
+ * and restructures frames around highlighted regions.
409
515
  *
410
- * Supports four patterns:
516
+ * Supports five patterns:
411
517
  *
412
518
  * 1. **Single line emphasis** - emphasizes the line containing the comment:
413
519
  * ```jsx
@@ -437,12 +543,66 @@ function addEmphasisToLines(node, emphasizedLines) {
437
543
  * <h1>Heading 1</h1> {/* @highlight-text "Heading 1" *\/}
438
544
  * ```
439
545
  *
546
+ * 5. **Focus override** - mark a region for padding focus:
547
+ * ```jsx
548
+ * <h1>Heading 1</h1> {/* @highlight @focus *\/}
549
+ * ```
550
+ *
440
551
  * Emphasized lines receive a `data-hl` attribute on their `<span class="line">` element.
552
+ * When highlights exist, frames are restructured with `data-frame-type` attributes
553
+ * (`highlighted`, `padding-top`, `padding-bottom`, or omitted for normal).
554
+ * Highlighted frames also receive `data-frame-indent` with the shared indent level.
441
555
  *
442
- * @param root - The HAST root node to enhance
443
- * @param comments - Comments extracted from the source code, keyed by line number
444
- * @param _fileName - The name of the file being processed (unused)
445
- * @returns The enhanced HAST root node with emphasis attributes added
556
+ * @param options - Optional configuration for padding frames
557
+ * @returns A `SourceEnhancer` function
558
+ *
559
+ * @example
560
+ * ```ts
561
+ * import { createEnhanceCodeEmphasis } from '@mui/internal-docs-infra/pipeline/enhanceCodeEmphasis';
562
+ *
563
+ * const enhancers = [createEnhanceCodeEmphasis({ paddingFrameMaxSize: 5, focusFramesMaxSize: 8 })];
564
+ * ```
565
+ */
566
+ export function createEnhanceCodeEmphasis(options = {}) {
567
+ return (root, comments) => {
568
+ if (!comments || Object.keys(comments).length === 0) {
569
+ return root;
570
+ }
571
+
572
+ // Step 1: Parse directives from comments (no tree traversal)
573
+ const directives = parseEmphasisDirectives(comments);
574
+ if (directives.length === 0) {
575
+ return root;
576
+ }
577
+
578
+ // Step 2 (Traversal 1): Build line element map
579
+ const lineElements = buildLineElementMap(root);
580
+
581
+ // Step 3: Calculate which lines are emphasized (no tree traversal)
582
+ const emphasizedLines = calculateEmphasizedLines(directives, lineElements);
583
+ if (emphasizedLines.size === 0) {
584
+ return root;
585
+ }
586
+
587
+ // Step 4 (Traversal 2): Apply emphasis attributes AND collect highlighted elements
588
+ const highlightedElements = applyEmphasisAndCollectHighlightedElements(root, emphasizedLines);
589
+
590
+ // Step 5: Calculate indent levels per region (uses collected elements, no tree traversal)
591
+ const regionIndentLevels = calculateRegionIndentLevels(highlightedElements, emphasizedLines);
592
+
593
+ // Step 6: Calculate frame ranges (pure math, no tree traversal)
594
+ const totalLines = root.data?.totalLines ?? lineElements.size;
595
+ const frameRanges = calculateFrameRanges(emphasizedLines, totalLines, options);
596
+
597
+ // Step 7: Restructure frames (flat iteration, not deep recursive traversal)
598
+ restructureFrames(root, frameRanges, regionIndentLevels);
599
+ return root;
600
+ };
601
+ }
602
+
603
+ /**
604
+ * Default source enhancer that adds emphasis to code lines based on `@highlight` comments.
605
+ * Uses no padding frames by default. Use `createEnhanceCodeEmphasis` for configurable padding.
446
606
  *
447
607
  * @example
448
608
  * ```ts
@@ -451,19 +611,4 @@ function addEmphasisToLines(node, emphasizedLines) {
451
611
  * const enhancers = [enhanceCodeEmphasis];
452
612
  * ```
453
613
  */
454
- export const enhanceCodeEmphasis = (root, comments) => {
455
- if (!comments || Object.keys(comments).length === 0) {
456
- return root;
457
- }
458
- const directives = parseEmphasisDirectives(comments);
459
- if (directives.length === 0) {
460
- return root;
461
- }
462
- const lineElements = buildLineElementMap(root);
463
- const emphasizedLines = calculateEmphasizedLines(directives, lineElements);
464
- if (emphasizedLines.size === 0) {
465
- return root;
466
- }
467
- addEmphasisToLines(root, emphasizedLines);
468
- return root;
469
- };
614
+ export const enhanceCodeEmphasis = createEnhanceCodeEmphasis();
@@ -1,5 +1,7 @@
1
1
  // Example copied from https://github.com/wooorm/starry-night#example-adding-line-numbers
2
2
 
3
+ import { createFrame } from "./createFrame.mjs";
4
+
3
5
  /**
4
6
  * Counts the number of lines in a HAST tree without mutating it.
5
7
  * Uses the same logic as starryNightGutter but only returns the count.
@@ -94,7 +96,7 @@ export function starryNightGutter(tree, sourceLines, frameSize = 120) {
94
96
 
95
97
  // Check if we need to create a frame (only if sourceLines provided, otherwise keep everything in one frame)
96
98
  if (sourceLines && lineNumber % frameSize === 0) {
97
- replacement.push(createFrame(frameLines, sourceLines, frameStartLine, lineNumber));
99
+ replacement.push(createFrame(frameLines, frameStartLine, lineNumber));
98
100
  frameLines = [];
99
101
  frameStartLine = lineNumber + 1;
100
102
  }
@@ -125,7 +127,7 @@ export function starryNightGutter(tree, sourceLines, frameSize = 120) {
125
127
 
126
128
  // Add any remaining lines as the final frame
127
129
  if (frameLines.length > 0) {
128
- replacement.push(createFrame(frameLines, sourceLines, frameStartLine, lineNumber));
130
+ replacement.push(createFrame(frameLines, frameStartLine, lineNumber));
129
131
  }
130
132
 
131
133
  // If there are multiple frames and sourceLines provided, add dataAsString to each frame
@@ -158,21 +160,4 @@ function createLine(children, line) {
158
160
  },
159
161
  children
160
162
  };
161
- }
162
- function createFrame(frameChildren, sourceLines, startLine, endLine) {
163
- const properties = {
164
- className: 'frame'
165
- };
166
-
167
- // Store line range information if provided (for dataAsString generation)
168
- if (sourceLines && startLine !== undefined && endLine !== undefined) {
169
- properties.dataFrameStartLine = startLine;
170
- properties.dataFrameEndLine = endLine;
171
- }
172
- return {
173
- type: 'element',
174
- tagName: 'span',
175
- properties,
176
- children: frameChildren
177
- };
178
163
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Metadata for an emphasized line.
3
+ */
4
+ export interface EmphasisMeta {
5
+ /** Optional description for this emphasis */
6
+ description?: string;
7
+ /** Position: 'single' for single-line, 'start'/'end' for multiline range bounds, undefined for middle */
8
+ position?: 'single' | 'start' | 'end';
9
+ /** Whether this is a strong emphasis (description ended with !) */
10
+ strong?: boolean;
11
+ /** For text highlighting: the specific text to highlight within the line */
12
+ highlightText?: string;
13
+ /** Whether this line's region is the focused region (for padding) */
14
+ focus?: boolean;
15
+ }
16
+ /**
17
+ * A range of lines that forms a frame in the output.
18
+ */
19
+ export interface FrameRange {
20
+ /** First line number (1-based, inclusive) */
21
+ startLine: number;
22
+ /** Last line number (1-based, inclusive) */
23
+ endLine: number;
24
+ /** The type of frame */
25
+ type: 'normal' | 'padding-top' | 'highlighted' | 'highlighted-unfocused' | 'padding-bottom' | 'comment';
26
+ }
27
+ /**
28
+ * Options for the enhance code emphasis factory.
29
+ */
30
+ export interface EnhanceCodeEmphasisOptions {
31
+ /**
32
+ * Maximum number of padding lines above and below the focused highlight region.
33
+ * Padding frames provide surrounding context for the highlighted code.
34
+ * Set to 0 or omit to disable padding frames.
35
+ */
36
+ paddingFrameMaxSize?: number;
37
+ /**
38
+ * Maximum total number of lines in the focus area (padding-top + highlighted + padding-bottom).
39
+ * When set, padding sizes are reduced so the total focus area fits within this limit.
40
+ * The remainder after subtracting the highlighted size is split: floor(remainder/2) for
41
+ * padding-top and ceil(remainder/2) for padding-bottom.
42
+ */
43
+ focusFramesMaxSize?: number;
44
+ }
45
+ /**
46
+ * Calculates frame ranges for the code block based on emphasized lines.
47
+ *
48
+ * This is a pure function that operates on line numbers — no HAST traversal.
49
+ * It groups consecutive highlighted lines into regions, determines the focused
50
+ * region (first by default, or the one with `focus: true`), computes padding
51
+ * for the focused region, and returns an ordered array of frame ranges covering
52
+ * all lines 1 through totalLines.
53
+ *
54
+ * @param emphasizedLines - Map of line numbers to their emphasis metadata
55
+ * @param totalLines - Total number of lines in the code block
56
+ * @param options - Optional padding configuration
57
+ * @returns Ordered array of frame ranges covering all lines
58
+ */
59
+ export declare function calculateFrameRanges(emphasizedLines: Map<number, EmphasisMeta>, totalLines: number, options?: EnhanceCodeEmphasisOptions): FrameRange[];
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Metadata for an emphasized line.
3
+ */
4
+
5
+ /**
6
+ * A range of lines that forms a frame in the output.
7
+ */
8
+
9
+ /**
10
+ * A contiguous region of highlighted lines.
11
+ */
12
+
13
+ /**
14
+ * Options for the enhance code emphasis factory.
15
+ */
16
+
17
+ /**
18
+ * Groups consecutive emphasized line numbers into highlight regions.
19
+ *
20
+ * @param emphasizedLines - Map of line numbers to their emphasis metadata
21
+ * @returns Sorted array of highlight regions
22
+ */
23
+ function groupHighlightRegions(emphasizedLines) {
24
+ if (emphasizedLines.size === 0) {
25
+ return [];
26
+ }
27
+ const sortedLines = Array.from(emphasizedLines.keys()).sort((a, b) => a - b);
28
+ const regions = [];
29
+ let regionStart = sortedLines[0];
30
+ let regionEnd = sortedLines[0];
31
+ let hasFocus = emphasizedLines.get(sortedLines[0])?.focus ?? false;
32
+ for (let i = 1; i < sortedLines.length; i += 1) {
33
+ const line = sortedLines[i];
34
+ if (line === regionEnd + 1) {
35
+ // Consecutive line, extend current region
36
+ regionEnd = line;
37
+ if (emphasizedLines.get(line)?.focus) {
38
+ hasFocus = true;
39
+ }
40
+ } else {
41
+ // Gap found, close current region and start a new one
42
+ regions.push({
43
+ startLine: regionStart,
44
+ endLine: regionEnd,
45
+ index: regions.length,
46
+ focused: hasFocus
47
+ });
48
+ regionStart = line;
49
+ regionEnd = line;
50
+ hasFocus = emphasizedLines.get(line)?.focus ?? false;
51
+ }
52
+ }
53
+
54
+ // Close the last region
55
+ regions.push({
56
+ startLine: regionStart,
57
+ endLine: regionEnd,
58
+ index: regions.length,
59
+ focused: hasFocus
60
+ });
61
+ return regions;
62
+ }
63
+
64
+ /**
65
+ * Determines the focused region index.
66
+ * Returns the region explicitly marked with `focus: true`, or the first region.
67
+ *
68
+ * @param regions - Highlight regions
69
+ * @returns The index of the focused region
70
+ */
71
+ function determineFocusedRegionIndex(regions) {
72
+ const focusedIndex = regions.findIndex(r => r.focused);
73
+ return focusedIndex >= 0 ? focusedIndex : 0;
74
+ }
75
+
76
+ /**
77
+ * Calculates padding sizes for the focused highlight region.
78
+ *
79
+ * @param region - The focused highlight region
80
+ * @param prevRegionEnd - End line of the previous highlight region (or 0)
81
+ * @param nextRegionStart - Start line of the next highlight region (or totalLines + 1)
82
+ * @param options - Padding configuration options
83
+ * @returns Padding sizes [paddingTop, paddingBottom]
84
+ */
85
+ function calculatePadding(region, prevRegionEnd, nextRegionStart, options) {
86
+ const {
87
+ paddingFrameMaxSize = 0,
88
+ focusFramesMaxSize
89
+ } = options;
90
+ if (paddingFrameMaxSize <= 0) {
91
+ return [0, 0];
92
+ }
93
+ const highlightSize = region.endLine - region.startLine + 1;
94
+ let paddingTop = paddingFrameMaxSize;
95
+ let paddingBottom = paddingFrameMaxSize;
96
+
97
+ // Apply focusFramesMaxSize constraint
98
+ if (focusFramesMaxSize !== undefined) {
99
+ const remaining = focusFramesMaxSize - highlightSize;
100
+ if (remaining <= 0) {
101
+ return [0, 0];
102
+ }
103
+ paddingTop = Math.min(paddingTop, Math.floor(remaining / 2));
104
+ paddingBottom = Math.min(paddingBottom, Math.ceil(remaining / 2));
105
+ }
106
+
107
+ // Clamp to available lines before the highlight (don't overlap previous region)
108
+ const availableBefore = region.startLine - 1 - prevRegionEnd;
109
+ paddingTop = Math.min(paddingTop, Math.max(0, availableBefore));
110
+
111
+ // Clamp to available lines after the highlight (don't overlap next region)
112
+ const availableAfter = nextRegionStart - 1 - region.endLine;
113
+ paddingBottom = Math.min(paddingBottom, Math.max(0, availableAfter));
114
+ return [paddingTop, paddingBottom];
115
+ }
116
+
117
+ /**
118
+ * Calculates frame ranges for the code block based on emphasized lines.
119
+ *
120
+ * This is a pure function that operates on line numbers — no HAST traversal.
121
+ * It groups consecutive highlighted lines into regions, determines the focused
122
+ * region (first by default, or the one with `focus: true`), computes padding
123
+ * for the focused region, and returns an ordered array of frame ranges covering
124
+ * all lines 1 through totalLines.
125
+ *
126
+ * @param emphasizedLines - Map of line numbers to their emphasis metadata
127
+ * @param totalLines - Total number of lines in the code block
128
+ * @param options - Optional padding configuration
129
+ * @returns Ordered array of frame ranges covering all lines
130
+ */
131
+ export function calculateFrameRanges(emphasizedLines, totalLines, options = {}) {
132
+ if (totalLines <= 0) {
133
+ return [];
134
+ }
135
+ const regions = groupHighlightRegions(emphasizedLines);
136
+ if (regions.length === 0) {
137
+ return [{
138
+ startLine: 1,
139
+ endLine: totalLines,
140
+ type: 'normal'
141
+ }];
142
+ }
143
+ const focusedIndex = determineFocusedRegionIndex(regions);
144
+
145
+ // Calculate padding for the focused region
146
+ const focusedRegion = regions[focusedIndex];
147
+ const prevRegionEnd = focusedIndex > 0 ? regions[focusedIndex - 1].endLine : 0;
148
+ const nextRegionStart = focusedIndex < regions.length - 1 ? regions[focusedIndex + 1].startLine : totalLines + 1;
149
+ const [paddingTop, paddingBottom] = calculatePadding(focusedRegion, prevRegionEnd, nextRegionStart, options);
150
+
151
+ // Build frame ranges by iterating through all regions
152
+ const frames = [];
153
+ let currentLine = 1;
154
+ for (let i = 0; i < regions.length; i += 1) {
155
+ const region = regions[i];
156
+ const isFocused = i === focusedIndex;
157
+ if (isFocused && paddingTop > 0) {
158
+ // Normal lines before padding-top
159
+ const paddingTopStart = region.startLine - paddingTop;
160
+ if (currentLine < paddingTopStart) {
161
+ frames.push({
162
+ startLine: currentLine,
163
+ endLine: paddingTopStart - 1,
164
+ type: 'normal'
165
+ });
166
+ }
167
+ // Padding-top frame
168
+ frames.push({
169
+ startLine: paddingTopStart,
170
+ endLine: region.startLine - 1,
171
+ type: 'padding-top'
172
+ });
173
+ } else if (currentLine < region.startLine) {
174
+ // Normal lines before this region
175
+ frames.push({
176
+ startLine: currentLine,
177
+ endLine: region.startLine - 1,
178
+ type: 'normal'
179
+ });
180
+ }
181
+
182
+ // Highlighted frame (focused gets 'highlighted', others get 'highlighted-unfocused')
183
+ frames.push({
184
+ startLine: region.startLine,
185
+ endLine: region.endLine,
186
+ type: isFocused ? 'highlighted' : 'highlighted-unfocused'
187
+ });
188
+ currentLine = region.endLine + 1;
189
+ if (isFocused && paddingBottom > 0) {
190
+ // Padding-bottom frame
191
+ frames.push({
192
+ startLine: currentLine,
193
+ endLine: currentLine + paddingBottom - 1,
194
+ type: 'padding-bottom'
195
+ });
196
+ currentLine = currentLine + paddingBottom;
197
+ }
198
+ }
199
+
200
+ // Remaining normal lines after all regions
201
+ if (currentLine <= totalLines) {
202
+ frames.push({
203
+ startLine: currentLine,
204
+ endLine: totalLines,
205
+ type: 'normal'
206
+ });
207
+ }
208
+ return frames;
209
+ }
@@ -0,0 +1,9 @@
1
+ import type { Element, ElementContent } from 'hast';
2
+ import type { FrameRange } from "./calculateFrameRanges.mjs";
3
+ /**
4
+ * Creates a HAST frame element (`span.frame`) with the given children and optional metadata.
5
+ *
6
+ * Used by both `addLineGutters` (initial frame creation) and `restructureFrames`
7
+ * (splitting/rebuilding frames for highlighting or comment extraction).
8
+ */
9
+ export declare function createFrame(children: Array<ElementContent>, startLine?: number, endLine?: number, frameType?: FrameRange['type'], indentLevel?: number): Element;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Creates a HAST frame element (`span.frame`) with the given children and optional metadata.
3
+ *
4
+ * Used by both `addLineGutters` (initial frame creation) and `restructureFrames`
5
+ * (splitting/rebuilding frames for highlighting or comment extraction).
6
+ */
7
+ export function createFrame(children, startLine, endLine, frameType, indentLevel) {
8
+ const properties = {
9
+ className: 'frame'
10
+ };
11
+ if (startLine !== undefined && endLine !== undefined) {
12
+ properties.dataFrameStartLine = startLine;
13
+ properties.dataFrameEndLine = endLine;
14
+ }
15
+ if (frameType && frameType !== 'normal') {
16
+ properties.dataFrameType = frameType;
17
+ }
18
+
19
+ // Set indent level on highlighted frames (focused or unfocused)
20
+ if ((frameType === 'highlighted' || frameType === 'highlighted-unfocused') && indentLevel !== undefined) {
21
+ properties.dataFrameIndent = indentLevel;
22
+ }
23
+ return {
24
+ type: 'element',
25
+ tagName: 'span',
26
+ properties,
27
+ children
28
+ };
29
+ }
@@ -0,0 +1,15 @@
1
+ import type { HastRoot } from "../../CodeHighlighter/types.mjs";
2
+ import type { FrameRange } from "./calculateFrameRanges.mjs";
3
+ /**
4
+ * Restructures the HAST frame tree based on computed frame ranges.
5
+ *
6
+ * This function flattens all existing frame children into a single ordered array,
7
+ * then redistributes them into new frames based on the provided frame ranges.
8
+ * It's a flat iteration (not a deep recursive traversal) since the HAST structure
9
+ * is always Root → Frame(s) → Line(s).
10
+ *
11
+ * @param root - The HAST root node to restructure (mutated in place)
12
+ * @param frameRanges - Ordered array of frame ranges
13
+ * @param regionIndentLevels - Map of highlighted region index to indent level
14
+ */
15
+ export declare function restructureFrames(root: HastRoot, frameRanges: FrameRange[], regionIndentLevels: Map<number, number>): void;
@@ -0,0 +1,100 @@
1
+ import { createFrame } from "./createFrame.mjs";
2
+
3
+ /**
4
+ * Represents a line element and its trailing newline text node (if any).
5
+ */
6
+
7
+ /**
8
+ * Flattens all line elements from existing frames into a single ordered array.
9
+ * This is a flat iteration over root.children (frames) and their direct children
10
+ * (lines + newline text nodes). Not a deep recursive traversal.
11
+ *
12
+ * @param root - The HAST root node
13
+ * @returns Ordered array of line entries
14
+ */
15
+ function flattenLineEntries(root) {
16
+ const entries = [];
17
+ for (const frame of root.children) {
18
+ if (frame.type !== 'element' || frame.tagName !== 'span') {
19
+ continue;
20
+ }
21
+ const children = frame.children ?? [];
22
+ for (let i = 0; i < children.length; i += 1) {
23
+ const child = children[i];
24
+ if (child.type === 'element' && child.tagName === 'span' && child.properties?.className === 'line' && typeof child.properties.dataLn === 'number') {
25
+ const entry = {
26
+ lineNumber: child.properties.dataLn,
27
+ element: child
28
+ };
29
+
30
+ // Check if the next child is a trailing newline text node
31
+ const next = children[i + 1];
32
+ if (next && next.type === 'text' && /^[\r\n]+$/.test(next.value)) {
33
+ entry.trailingNewline = next;
34
+ i += 1; // Skip the newline in the next iteration
35
+ }
36
+ entries.push(entry);
37
+ }
38
+ }
39
+ }
40
+ return entries;
41
+ }
42
+
43
+ /**
44
+ * Restructures the HAST frame tree based on computed frame ranges.
45
+ *
46
+ * This function flattens all existing frame children into a single ordered array,
47
+ * then redistributes them into new frames based on the provided frame ranges.
48
+ * It's a flat iteration (not a deep recursive traversal) since the HAST structure
49
+ * is always Root → Frame(s) → Line(s).
50
+ *
51
+ * @param root - The HAST root node to restructure (mutated in place)
52
+ * @param frameRanges - Ordered array of frame ranges
53
+ * @param regionIndentLevels - Map of highlighted region index to indent level
54
+ */
55
+ export function restructureFrames(root, frameRanges, regionIndentLevels) {
56
+ // Step 1: Flatten all lines from existing frames
57
+ const lineEntries = flattenLineEntries(root);
58
+
59
+ // Build a lookup from line number to entry for O(1) access
60
+ const lineEntryMap = new Map();
61
+ for (const entry of lineEntries) {
62
+ lineEntryMap.set(entry.lineNumber, entry);
63
+ }
64
+
65
+ // Step 2: Track which highlighted region index each frame range belongs to
66
+ let highlightedRegionIndex = 0;
67
+
68
+ // Step 3: Build new frames
69
+ const newFrames = [];
70
+ for (const range of frameRanges) {
71
+ const children = [];
72
+ for (let line = range.startLine; line <= range.endLine; line += 1) {
73
+ const entry = lineEntryMap.get(line);
74
+ if (!entry) {
75
+ continue;
76
+ }
77
+ children.push(entry.element);
78
+
79
+ // Always add trailing newline when present to preserve original line breaks
80
+ if (entry.trailingNewline) {
81
+ children.push(entry.trailingNewline);
82
+ }
83
+ }
84
+
85
+ // Only create frame if it has children
86
+ if (children.length > 0) {
87
+ const isHighlighted = range.type === 'highlighted' || range.type === 'highlighted-unfocused';
88
+ const indentLevel = isHighlighted ? regionIndentLevels.get(highlightedRegionIndex) : undefined;
89
+ newFrames.push(createFrame(children, range.startLine, range.endLine, range.type, indentLevel));
90
+ }
91
+
92
+ // Increment region index after each highlighted frame (focused or unfocused)
93
+ if (range.type === 'highlighted' || range.type === 'highlighted-unfocused') {
94
+ highlightedRegionIndex += 1;
95
+ }
96
+ }
97
+
98
+ // Step 4: Replace root children
99
+ root.children = newFrames;
100
+ }
package/useCode/Pre.mjs CHANGED
@@ -146,6 +146,8 @@ export function Pre({
146
146
  className: "frame",
147
147
  "data-frame": index,
148
148
  "data-lined": shouldRenderHast ? '' : undefined,
149
+ "data-frame-type": child.properties.dataFrameType ? String(child.properties.dataFrameType) : undefined,
150
+ "data-frame-indent": child.properties.dataFrameIndent != null ? String(child.properties.dataFrameIndent) : undefined,
149
151
  ref: observeFrame,
150
152
  children: renderCode(child.children, shouldRenderHast, child.properties?.dataAsString ? String(child.properties?.dataAsString) : undefined)
151
153
  }, index);