radiant-docs 0.1.37 → 0.1.39

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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/template/astro.config.mjs +2 -0
  3. package/template/src/components/Footer.astro +1 -1
  4. package/template/src/components/Header.astro +8 -8
  5. package/template/src/components/OpenApiPage.astro +18 -18
  6. package/template/src/components/Search.astro +18 -18
  7. package/template/src/components/Sidebar.astro +4 -2
  8. package/template/src/components/SidebarDropdown.astro +82 -79
  9. package/template/src/components/SidebarSegmented.astro +5 -5
  10. package/template/src/components/TableOfContents.astro +24 -15
  11. package/template/src/components/ThemeSwitcher.astro +15 -8
  12. package/template/src/components/chat/AskAiWidget.tsx +4 -3
  13. package/template/src/components/endpoint/PlaygroundBar.astro +3 -3
  14. package/template/src/components/endpoint/PlaygroundButton.astro +2 -2
  15. package/template/src/components/endpoint/PlaygroundField.astro +53 -53
  16. package/template/src/components/endpoint/PlaygroundForm.astro +38 -22
  17. package/template/src/components/endpoint/RequestSnippets.astro +54 -21
  18. package/template/src/components/endpoint/ResponseDisplay.astro +24 -24
  19. package/template/src/components/endpoint/ResponseFieldTree.astro +12 -12
  20. package/template/src/components/endpoint/ResponseFields.astro +19 -19
  21. package/template/src/components/endpoint/ResponseSnippets.astro +66 -29
  22. package/template/src/components/ui/CodeTabEdge.astro +6 -4
  23. package/template/src/components/ui/Field.astro +7 -7
  24. package/template/src/components/ui/demo/Demo.astro +1 -1
  25. package/template/src/components/user/Accordion.astro +3 -3
  26. package/template/src/components/user/Callout.astro +8 -8
  27. package/template/src/components/user/CodeBlock.astro +63 -25
  28. package/template/src/components/user/CodeGroup.astro +259 -22
  29. package/template/src/components/user/ComponentPreviewBlock.astro +36 -10
  30. package/template/src/components/user/Image.astro +2 -2
  31. package/template/src/components/user/Step.astro +4 -4
  32. package/template/src/components/user/Tab.astro +1 -1
  33. package/template/src/components/user/Tabs.astro +142 -42
  34. package/template/src/layouts/Layout.astro +1 -1
  35. package/template/src/lib/code/code-block.ts +150 -15
  36. package/template/src/lib/mdx/remark-resolve-internal-links.ts +639 -0
  37. package/template/src/pages/404.astro +44 -0
  38. package/template/src/styles/global.css +51 -19
@@ -101,7 +101,7 @@ validateProps(
101
101
 
102
102
  <figure
103
103
  class:list={[
104
- "p-1.5 pb-1 xs:pb-1.5 group border border-neutral-200/80 dark:border-neutral-800 shadow-xs bg-neutral-50 dark:bg-neutral-900 rounded-2xl",
104
+ "p-1.5 pb-1 xs:pb-1.5 group border border-neutral-200 dark:border-neutral-800 shadow-xs bg-neutral-50 dark:bg-(--rd-code-surface) rounded-2xl",
105
105
  hasCustomImageWidth ? "w-fit max-w-full mx-auto" : "w-full",
106
106
  ]}
107
107
  x-data="{
@@ -204,7 +204,7 @@ validateProps(
204
204
  }"
205
205
  >
206
206
  <div
207
- class="overflow-hidden rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 bg-neutral-100 dark:bg-black/20"
207
+ class="overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-(--rd-code-surface)"
208
208
  >
209
209
  <img
210
210
  {...attrs}
@@ -14,24 +14,24 @@ validateProps(
14
14
  {
15
15
  title: { required: true, type: "string" },
16
16
  },
17
- Astro.url.pathname
17
+ Astro.url.pathname,
18
18
  );
19
19
  ---
20
20
 
21
21
  <div
22
22
  class:list={[
23
23
  "relative pl-10 step-item pb-4 last:pb-0 space-y-4",
24
- "before:absolute before:left-[10.5px] before:top-8 before:bottom-0 before:w-px before:bg-linear-[transparent,var(--color-neutral-200)_10%,var(--color-neutral-200)_90%,transparent]",
24
+ "before:absolute before:left-[10.5px] before:top-8 before:bottom-0 before:w-px before:bg-linear-[transparent,var(--color-neutral-200)_10%,var(--color-neutral-200)_90%,transparent] dark:before:bg-linear-[transparent,var(--color-neutral-700)_10%,var(--color-neutral-700)_90%,transparent]",
25
25
  ]}
26
26
  data-step-panel
27
27
  >
28
28
  <div
29
29
  class:list={[
30
30
  "flex items-center gap-1.5 not-prose",
31
- "step-number before:bg-neutral-900 before:size-[22px] before:rounded-full before:text-white before:flex before:items-center before:justify-center before:text-xs before:font-bold before:absolute before:left-px before:top-[3px] before:shadow-[inset_0_1px_0_rgb(255,255,255,0.2),0_0_0_1px_var(--color-neutral-800),var(--shadow-md)]",
31
+ "step-number before:size-6 before:bg-linear-to-b before:from-neutral-900/80 before:to-neutral-900 dark:before:from-neutral-100 dark:before:to-neutral-200 before:rounded-full before:text-white before:flex before:items-center before:justify-center before:text-xs before:font-bold dark:before:font-extrabold before:absolute before:left-px before:top-[3px] before:shadow-sm dark:before:bg-neutral-200 dark:before:text-neutral-900",
32
32
  ]}
33
33
  >
34
- <h3 class="text-lg font-semibold text-neutral-900">
34
+ <h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
35
35
  {title}
36
36
  </h3>
37
37
  </div>
@@ -19,7 +19,7 @@ validateProps(
19
19
  ---
20
20
 
21
21
  <section data-label={label} data-icon={icon || ""}>
22
- <div class="*:m-0! *:mb-2">
22
+ <div class="*:first:mt-0! *:last:mb-0!">
23
23
  <slot />
24
24
  </div>
25
25
  </section>
@@ -3,16 +3,18 @@ import Icon from '../ui/Icon.astro';
3
3
 
4
4
  const html = await Astro.slots.render("default");
5
5
 
6
- const labelRegex = /label="([^"]+)"/g;
7
- const iconRegex = /icon="([^"]*)"/g;
6
+ const tabRegex = /<section\b([^>]*)\bdata-label="([^"]*)"([^>]*)>(.*?)<\/section>/gs;
7
+ const dataIconRegex = /\bdata-icon="([^"]*)"/;
8
8
  let labels = [];
9
9
  let icons = [];
10
+ let tabContents = [];
10
11
  let match;
11
- while ((match = labelRegex.exec(html)) !== null) {
12
- labels.push(match[1]);
13
- }
14
- while ((match = iconRegex.exec(html)) !== null) {
15
- icons.push(match[1]);
12
+ while ((match = tabRegex.exec(html)) !== null) {
13
+ const openingAttributes = `${match[1]} ${match[3]}`;
14
+ const iconMatch = openingAttributes.match(dataIconRegex);
15
+ labels.push(match[2]);
16
+ icons.push(iconMatch?.[1] ?? "");
17
+ tabContents.push(match[4]);
16
18
  }
17
19
 
18
20
  if (labels.length === 0) {
@@ -21,28 +23,59 @@ if (labels.length === 0) {
21
23
  `[USER_ERROR]: <Tabs>: Must contain at least two <Tab> children (in ${pagePath}.mdx)`
22
24
  );
23
25
  }
24
-
25
- const tabRegex = /<section[^>]*data-label="[^"]*"[^>]*>(.*?)<\/section>/gs;
26
- let tabContents = [];
27
- let contentMatch;
28
- while ((contentMatch = tabRegex.exec(html)) !== null) {
29
- tabContents.push(contentMatch[1]);
30
- }
31
26
  ---
32
27
 
33
28
  <div x-data="{
34
29
  activeTab: 0,
30
+ previousTab: null,
31
+ transitionDirection: 1,
32
+ isTransitioning: false,
33
+ transitionDurationMs: 300,
34
+ transitionTimeout: null,
35
35
  containerHeight: 'auto',
36
36
  markerStyle: { left: null, width: null },
37
+ resizeHandler: null,
37
38
  init() {
39
+ this.resizeHandler = () => {
40
+ this.updateMarker(this.activeTab);
41
+ this.updateHeight(this.isTransitioning);
42
+ };
43
+ window.addEventListener('resize', this.resizeHandler);
44
+
38
45
  this.$nextTick(() => {
39
46
  this.updateMarker(this.activeTab);
40
47
  this.updateHeight();
41
48
  });
42
- this.$watch('activeTab', (value) => {
43
- this.updateMarker(value);
49
+ },
50
+ destroy() {
51
+ if (this.resizeHandler) {
52
+ window.removeEventListener('resize', this.resizeHandler);
53
+ }
54
+ if (this.transitionTimeout) {
55
+ window.clearTimeout(this.transitionTimeout);
56
+ }
57
+ },
58
+ selectTab(index) {
59
+ if (index === this.activeTab) return;
60
+
61
+ if (this.transitionTimeout) {
62
+ window.clearTimeout(this.transitionTimeout);
63
+ this.transitionTimeout = null;
64
+ }
65
+
66
+ this.previousTab = this.activeTab;
67
+ this.transitionDirection = index > this.activeTab ? 1 : -1;
68
+ this.isTransitioning = true;
69
+ this.activeTab = index;
70
+ this.updateMarker(this.activeTab);
71
+ this.updateHeight(true);
72
+
73
+ this.transitionTimeout = window.setTimeout(() => {
74
+ this.isTransitioning = false;
75
+ this.previousTab = null;
76
+ this.transitionTimeout = null;
44
77
  this.updateHeight();
45
- });
78
+ }, this.transitionDurationMs);
46
79
  },
47
80
  updateMarker(index) {
48
81
  const el = this.$refs['tab-' + index];
@@ -53,23 +86,57 @@ while ((contentMatch = tabRegex.exec(html)) !== null) {
53
86
  };
54
87
  }
55
88
  },
56
- updateHeight() {
89
+ getPanelStyle(index) {
90
+ const base = 'position: absolute; inset: 0;';
91
+
92
+ const isActive = index === this.activeTab;
93
+ const isPrevious = this.isTransitioning && index === this.previousTab;
94
+
95
+ if (!isActive && !isPrevious) {
96
+ return `${base} opacity: 0; pointer-events: none; visibility: hidden; z-index: 0;`;
97
+ }
98
+
99
+ if (!this.isTransitioning) {
100
+ return 'position: relative; transform: translateX(0); opacity: 1; pointer-events: auto; visibility: visible; z-index: 1;';
101
+ }
102
+
103
+ if (isActive) {
104
+ const animationName =
105
+ this.transitionDirection === 1
106
+ ? 'rd-tabs-slide-in-from-right'
107
+ : 'rd-tabs-slide-in-from-left';
108
+ return `${base} opacity: 1; pointer-events: auto; visibility: visible; z-index: 2; animation: ${animationName} ${this.transitionDurationMs}ms ease-in-out both;`;
109
+ }
110
+
111
+ const animationName =
112
+ this.transitionDirection === 1
113
+ ? 'rd-tabs-slide-out-to-left'
114
+ : 'rd-tabs-slide-out-to-right';
115
+ return `${base} opacity: 1; pointer-events: none; visibility: visible; z-index: 1; animation: ${animationName} ${this.transitionDurationMs}ms ease-in-out both;`;
116
+ },
117
+ updateHeight(includePrevious = false) {
57
118
  this.$nextTick(() => {
58
- // We look for the internal wrapper or the content div specifically
59
119
  const activeSlide = this.$refs['content-' + this.activeTab];
60
- if (activeSlide) {
61
- // scrollHeight is often more reliable than offsetHeight for hidden overflow
62
- this.containerHeight = activeSlide.scrollHeight + 'px';
120
+ if (!activeSlide) return;
121
+
122
+ let nextHeight = activeSlide.scrollHeight;
123
+ if (includePrevious && this.previousTab !== null) {
124
+ const previousSlide = this.$refs['content-' + this.previousTab];
125
+ if (previousSlide) {
126
+ nextHeight = Math.max(nextHeight, previousSlide.scrollHeight);
127
+ }
63
128
  }
129
+
130
+ this.containerHeight = nextHeight + 'px';
64
131
  });
65
132
  }
66
133
  }"
67
134
  class="my-5">
68
135
  <ul
69
- class="relative isolate not-prose flex border border-neutral-200 w-fit bg-neutral-100/80 rounded-lg p-[3px] inset-shadow-sm"
136
+ class="relative isolate not-prose flex w-full max-w-full min-w-0 rounded-lg border border-neutral-100 bg-neutral-100/80 p-[3px] dark:border-none dark:bg-neutral-800/50"
70
137
  >
71
138
  <div
72
- class="absolute top-[3px] bottom-[3px] bg-white rounded-md shadow-sm transition-all duration-300 ease-out -z-10 flex items-center justify-center"
139
+ class="absolute top-[3px] bottom-[3px] -z-10 flex items-center justify-center rounded-md bg-white shadow-sm transition-all duration-300 ease-out dark:bg-neutral-700/30 dark:border dark:border-neutral-700/40 dark:shadow-black/30."
73
140
  style="left: 3px;"
74
141
  :style="markerStyle.width ? `left: ${markerStyle.left}; width: ${markerStyle.width}` : ''"
75
142
  >
@@ -79,44 +146,77 @@ class="my-5">
79
146
  </div>
80
147
 
81
148
  { labels.map((label, index) => (
82
- <li>
149
+ <li class="min-w-0 flex-1">
83
150
  <button
84
151
  type="button"
85
152
  x-ref={`tab-${index}`}
86
- @click={`activeTab = ${index}`}
87
- class="relative px-3 h-[32px] font-medium text-sm transition-colors duration-200 cursor-pointer text-nowrap flex items-center gap-2"
153
+ @click={`selectTab(${index})`}
154
+ class="relative flex h-[32px] w-full min-w-0 max-w-full cursor-pointer items-center gap-2 px-3 text-sm font-medium transition-colors duration-200"
88
155
  style={index === 0 ? "" : ""}
89
- class:list={[index === 0 ? "text-neutral-900" : "text-neutral-500"]}
156
+ class:list={[index === 0 ? "text-foreground" : "text-muted-foreground"]}
90
157
  :class={`{
91
- 'text-neutral-900': activeTab === ${index},
92
- 'text-neutral-500 hover:text-neutral-600': activeTab !== ${index}
158
+ 'text-foreground': activeTab === ${index},
159
+ 'text-muted-foreground hover:text-foreground': activeTab !== ${index}
93
160
  }`}
94
161
  >
95
162
  {icons[index] && <Icon name={icons[index]} class="size-4 shrink-0" />}
96
- {label}
163
+ <span class="min-w-0 flex-1 truncate" title={label}>{label}</span>
97
164
  </button>
98
165
  </li>
99
166
  )) }
100
167
  </ul>
101
168
 
102
169
  <div
103
- class="mt-4 overflow-hidden transition-[height] duration-300 ease-in-out"
170
+ class="relative mt-4 overflow-hidden transition-[height] duration-300 ease-in-out"
104
171
  :style="'height: ' + containerHeight"
105
172
  >
106
- <div
107
- class="flex items-start transition-transform duration-300 ease-in-out"
108
- :style="'transform: translateX(-' + (activeTab * 100) + '%)'"
109
- >
110
173
  { tabContents.map((content, index) => (
111
- // We add a ref here so we can measure the height
112
174
  <div
175
+ {...(index !== 0 ? { "x-cloak": true } : {})}
113
176
  x-ref={`content-${index}`}
114
- class="w-full shrink-0 transition-opacity duration-300 ease-in-out"
115
- :style={`activeTab === ${index} ? 'opacity: 1' : 'opacity: 0 pointer-events-none'`}
116
- style={index === 0 ? 'opacity: 1' : 'opacity: 0'}
177
+ class="w-full"
178
+ :style={`getPanelStyle(${index})`}
179
+ style={index === 0 ? 'position: relative;' : ''}
117
180
  set:html={content}
118
181
  />
119
182
  )) }
120
- </div>
121
183
  </div>
122
- </div>
184
+ </div>
185
+
186
+ <style>
187
+ @keyframes rd-tabs-slide-in-from-right {
188
+ from {
189
+ transform: translateX(100%);
190
+ }
191
+ to {
192
+ transform: translateX(0);
193
+ }
194
+ }
195
+
196
+ @keyframes rd-tabs-slide-in-from-left {
197
+ from {
198
+ transform: translateX(-100%);
199
+ }
200
+ to {
201
+ transform: translateX(0);
202
+ }
203
+ }
204
+
205
+ @keyframes rd-tabs-slide-out-to-left {
206
+ from {
207
+ transform: translateX(0);
208
+ }
209
+ to {
210
+ transform: translateX(-100%);
211
+ }
212
+ }
213
+
214
+ @keyframes rd-tabs-slide-out-to-right {
215
+ from {
216
+ transform: translateX(0);
217
+ }
218
+ to {
219
+ transform: translateX(100%);
220
+ }
221
+ }
222
+ </style>
@@ -180,7 +180,7 @@ if (isDev && hasAskAiDevConfig) {
180
180
  class="fixed top-1 inset-x-1 h-16 -z-10 bg-background-dark"
181
181
  data-vaul-scale-chrome
182
182
  >
183
- <div class="bg-white w-full h-full rounded-t-2xl"></div>
183
+ <div class="bg-background w-full h-full rounded-t-2xl"></div>
184
184
  </div>
185
185
  <div
186
186
  class="fixed top-[63px] z-30 w-[5px] right-0 bottom-0 bg-background-dark border-l border-l-border"
@@ -137,7 +137,9 @@ const CODE_BLOCK_LANGUAGE_ICON_FILE_BY_VALUE: Record<string, string> = {
137
137
  yaml: "file_type_yaml_official.svg",
138
138
  };
139
139
 
140
- const SHIKI_THEME = "github-light";
140
+ const SHIKI_LIGHT_THEME = "github-light";
141
+ const SHIKI_DARK_THEME = "github-dark";
142
+ const SHIKI_THEMES = [SHIKI_LIGHT_THEME, SHIKI_DARK_THEME] as const;
141
143
  const BUNDLED_LANGUAGE_SET = new Set(Object.keys(bundledLanguages));
142
144
  const LANGUAGE_RUNTIME_DEPENDENCIES: Record<string, string[]> = {
143
145
  // MDX tokenization relies on TSX grammar injections for JSX-style tags.
@@ -228,7 +230,7 @@ function pickIconFromFileName(fileName: string): string | null {
228
230
  const candidates = uniqueValues([trimmedFileName, trimmedFileName.toLowerCase()]);
229
231
  for (const candidate of candidates) {
230
232
  const iconFileName = getIconForFile(candidate);
231
- if (iconFileName !== DEFAULT_FILE) return iconFileName;
233
+ if (iconFileName && iconFileName !== DEFAULT_FILE) return iconFileName;
232
234
  }
233
235
 
234
236
  return null;
@@ -372,7 +374,7 @@ function namespaceSvgIds(svg: string, namespace: string): string {
372
374
  async function getHighlighter() {
373
375
  if (!highlighterPromise) {
374
376
  highlighterPromise = getSingletonHighlighter({
375
- themes: [SHIKI_THEME],
377
+ themes: [...SHIKI_THEMES],
376
378
  langs: [DEFAULT_CODE_BLOCK_LANGUAGE],
377
379
  });
378
380
  }
@@ -414,7 +416,7 @@ async function ensureLanguageLoaded(
414
416
  }
415
417
 
416
418
  const loadPromise = highlighter
417
- .loadLanguage(language)
419
+ .loadLanguage(language as keyof typeof bundledLanguages)
418
420
  .then(() => {
419
421
  loadedLanguageSet.add(language);
420
422
  })
@@ -493,7 +495,7 @@ export async function getCodeLineTokens({
493
495
  language: string;
494
496
  }): Promise<{
495
497
  normalizedLanguage: string;
496
- lines: ThemedToken[][];
498
+ lines: CodeLineToken[][];
497
499
  }> {
498
500
  const highlighter = await getHighlighter();
499
501
  const normalizedLanguage = normalizeCodeLanguageValue(language);
@@ -523,24 +525,157 @@ export async function getCodeLineTokens({
523
525
  }
524
526
 
525
527
  try {
526
- const tokenResult = highlighter.codeToTokens(code, {
527
- lang: targetLanguage,
528
- theme: SHIKI_THEME,
529
- });
528
+ const themedTokenLines = getThemedTokenLines(highlighter, code, targetLanguage);
530
529
 
531
530
  return {
532
531
  normalizedLanguage: targetLanguage,
533
- lines: tokenResult.tokens,
532
+ lines: mergeTokenLines(themedTokenLines.light, themedTokenLines.dark),
534
533
  };
535
534
  } catch {
536
- const tokenResult = highlighter.codeToTokens(code, {
537
- lang: DEFAULT_CODE_BLOCK_LANGUAGE,
538
- theme: SHIKI_THEME,
539
- });
535
+ const themedTokenLines = getThemedTokenLines(
536
+ highlighter,
537
+ code,
538
+ DEFAULT_CODE_BLOCK_LANGUAGE,
539
+ );
540
540
 
541
541
  return {
542
542
  normalizedLanguage: DEFAULT_CODE_BLOCK_LANGUAGE,
543
- lines: tokenResult.tokens,
543
+ lines: mergeTokenLines(themedTokenLines.light, themedTokenLines.dark),
544
544
  };
545
545
  }
546
546
  }
547
+
548
+ export type CodeLineToken = {
549
+ content: string;
550
+ color?: string;
551
+ darkColor?: string;
552
+ bgColor?: string;
553
+ darkBgColor?: string;
554
+ fontStyle?: number;
555
+ htmlStyle?: Record<string, string>;
556
+ darkHtmlStyle?: Record<string, string>;
557
+ };
558
+
559
+ function getThemedTokenLines(
560
+ highlighter: Awaited<ReturnType<typeof getSingletonHighlighter>>,
561
+ code: string,
562
+ lang: string,
563
+ ): {
564
+ light: ThemedToken[][];
565
+ dark: ThemedToken[][];
566
+ } {
567
+ const shikiLanguage = lang as keyof typeof bundledLanguages;
568
+ const lightTokenResult = highlighter.codeToTokens(code, {
569
+ lang: shikiLanguage,
570
+ theme: SHIKI_LIGHT_THEME,
571
+ });
572
+ const darkTokenResult = highlighter.codeToTokens(code, {
573
+ lang: shikiLanguage,
574
+ theme: SHIKI_DARK_THEME,
575
+ });
576
+
577
+ return {
578
+ light: lightTokenResult.tokens,
579
+ dark: darkTokenResult.tokens,
580
+ };
581
+ }
582
+
583
+ function buildLightOnlyTokenLine(lightLine: ThemedToken[]): CodeLineToken[] {
584
+ return lightLine.map((token) => ({
585
+ content: token.content,
586
+ color: token.color,
587
+ bgColor: token.bgColor,
588
+ fontStyle: token.fontStyle,
589
+ htmlStyle: token.htmlStyle,
590
+ }));
591
+ }
592
+
593
+ function mergeTokenLineByContent(
594
+ lightLine: ThemedToken[],
595
+ darkLine: ThemedToken[],
596
+ ): CodeLineToken[] | null {
597
+ const lightContent = lightLine.map((token) => token.content).join("");
598
+ const darkContent = darkLine.map((token) => token.content).join("");
599
+
600
+ // When themes tokenize the same text with different boundaries (common for markdown),
601
+ // align on content segments so we can still apply dark token colors reliably.
602
+ if (lightContent !== darkContent) return null;
603
+ if (lightContent.length === 0) return [];
604
+
605
+ const mergedLine: CodeLineToken[] = [];
606
+ let lightTokenIndex = 0;
607
+ let darkTokenIndex = 0;
608
+ let lightTokenOffset = 0;
609
+ let darkTokenOffset = 0;
610
+
611
+ while (
612
+ lightTokenIndex < lightLine.length &&
613
+ darkTokenIndex < darkLine.length
614
+ ) {
615
+ const lightToken = lightLine[lightTokenIndex];
616
+ const darkToken = darkLine[darkTokenIndex];
617
+
618
+ if (lightTokenOffset >= lightToken.content.length) {
619
+ lightTokenIndex += 1;
620
+ lightTokenOffset = 0;
621
+ continue;
622
+ }
623
+
624
+ if (darkTokenOffset >= darkToken.content.length) {
625
+ darkTokenIndex += 1;
626
+ darkTokenOffset = 0;
627
+ continue;
628
+ }
629
+
630
+ const lightRemainingLength = lightToken.content.length - lightTokenOffset;
631
+ const darkRemainingLength = darkToken.content.length - darkTokenOffset;
632
+ const segmentLength = Math.min(lightRemainingLength, darkRemainingLength);
633
+ if (segmentLength <= 0) break;
634
+
635
+ const lightSegment = lightToken.content.slice(
636
+ lightTokenOffset,
637
+ lightTokenOffset + segmentLength,
638
+ );
639
+ const darkSegment = darkToken.content.slice(
640
+ darkTokenOffset,
641
+ darkTokenOffset + segmentLength,
642
+ );
643
+
644
+ if (lightSegment !== darkSegment) return null;
645
+
646
+ mergedLine.push({
647
+ content: lightSegment,
648
+ color: lightToken.color,
649
+ darkColor: darkToken.color,
650
+ bgColor: lightToken.bgColor,
651
+ darkBgColor: darkToken.bgColor,
652
+ fontStyle: lightToken.fontStyle,
653
+ htmlStyle: lightToken.htmlStyle,
654
+ darkHtmlStyle: darkToken.htmlStyle,
655
+ });
656
+
657
+ lightTokenOffset += segmentLength;
658
+ darkTokenOffset += segmentLength;
659
+ }
660
+
661
+ const mergedContent = mergedLine.map((token) => token.content).join("");
662
+ return mergedContent === lightContent ? mergedLine : null;
663
+ }
664
+
665
+ function mergeTokenLines(
666
+ lightTokenLines: ThemedToken[][],
667
+ darkTokenLines: ThemedToken[][],
668
+ ): CodeLineToken[][] {
669
+ const lineCount = Math.max(lightTokenLines.length, darkTokenLines.length);
670
+ const mergedLines: CodeLineToken[][] = [];
671
+
672
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
673
+ const lightLine = lightTokenLines[lineIndex] ?? [];
674
+ const darkLine = darkTokenLines[lineIndex] ?? [];
675
+
676
+ const mergedLine = mergeTokenLineByContent(lightLine, darkLine);
677
+ mergedLines.push(mergedLine ?? buildLightOnlyTokenLine(lightLine));
678
+ }
679
+
680
+ return mergedLines;
681
+ }