@fragments-sdk/ui 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/assets/ui.css +196 -199
  2. package/dist/codeblock.cjs +183 -184
  3. package/dist/codeblock.cjs.map +1 -1
  4. package/dist/codeblock.js +179 -180
  5. package/dist/codeblock.js.map +1 -1
  6. package/dist/components/CodeBlock/CodeBlock.module.scss.cjs +20 -23
  7. package/dist/components/CodeBlock/CodeBlock.module.scss.cjs.map +1 -1
  8. package/dist/components/CodeBlock/CodeBlock.module.scss.js +20 -23
  9. package/dist/components/CodeBlock/CodeBlock.module.scss.js.map +1 -1
  10. package/dist/components/CodeBlock/index.d.ts +6 -6
  11. package/dist/components/CodeBlock/index.d.ts.map +1 -1
  12. package/dist/components/Combobox/Combobox.module.scss.cjs +15 -15
  13. package/dist/components/Combobox/Combobox.module.scss.js +15 -15
  14. package/dist/components/Markdown/Markdown.module.scss.cjs +1 -1
  15. package/dist/components/Markdown/Markdown.module.scss.js +1 -1
  16. package/dist/components/Message/Message.module.scss.cjs +22 -16
  17. package/dist/components/Message/Message.module.scss.cjs.map +1 -1
  18. package/dist/components/Message/Message.module.scss.js +22 -16
  19. package/dist/components/Message/Message.module.scss.js.map +1 -1
  20. package/dist/components/Message/index.cjs +5 -3
  21. package/dist/components/Message/index.cjs.map +1 -1
  22. package/dist/components/Message/index.d.ts +5 -1
  23. package/dist/components/Message/index.d.ts.map +1 -1
  24. package/dist/components/Message/index.js +5 -3
  25. package/dist/components/Message/index.js.map +1 -1
  26. package/fragments.json +1 -1
  27. package/package.json +2 -2
  28. package/src/components/CodeBlock/CodeBlock.module.scss +16 -34
  29. package/src/components/CodeBlock/index.tsx +345 -347
  30. package/src/components/Combobox/Combobox.module.scss +13 -9
  31. package/src/components/ConversationList/ConversationList.fragment.tsx +96 -129
  32. package/src/components/Message/Message.fragment.tsx +34 -0
  33. package/src/components/Message/Message.module.scss +11 -0
  34. package/src/components/Message/index.tsx +12 -3
  35. package/src/tokens/_computed.scss +7 -6
  36. package/src/tokens/_density.scss +87 -47
  37. package/src/tokens/_variables.scss +46 -31
@@ -1,12 +1,14 @@
1
- 'use client';
1
+ "use client";
2
2
 
3
- import * as React from 'react';
4
- import { useState, useCallback, useEffect, useMemo } from 'react';
3
+ import * as React from "react";
4
+ import { useState, useCallback, useEffect, useMemo } from "react";
5
5
  // ============================================
6
6
  // Lazy-loaded dependency (shiki)
7
7
  // ============================================
8
8
 
9
- let _codeToHtml: ((code: string, options: { lang: string; theme: string }) => Promise<string>) | null = null;
9
+ let _codeToHtml:
10
+ | ((code: string, options: { lang: string; theme: string }) => Promise<string>)
11
+ | null = null;
10
12
  let _shikiLoaded = false;
11
13
  let _shikiFailed = false;
12
14
 
@@ -15,66 +17,66 @@ function loadShikiDeps() {
15
17
  _shikiLoaded = true;
16
18
  try {
17
19
  // eslint-disable-next-line @typescript-eslint/no-require-imports
18
- const shiki = require('shiki');
20
+ const shiki = require("shiki");
19
21
  _codeToHtml = shiki.codeToHtml;
20
22
  } catch {
21
23
  _shikiFailed = true;
22
24
  }
23
25
  }
24
- import { TabsRoot, TabsList, Tab, TabsPanel } from '../Tabs';
25
- import { Button } from '../Button';
26
- import styles from './CodeBlock.module.scss';
27
- import '../../styles/globals.scss';
26
+ import { TabsRoot, TabsList, Tab, TabsPanel } from "../Tabs";
27
+ import { Button } from "../Button";
28
+ import styles from "./CodeBlock.module.scss";
29
+ import "../../styles/globals.scss";
28
30
 
29
31
  export type CodeBlockLanguage =
30
- | 'tsx'
31
- | 'typescript'
32
- | 'javascript'
33
- | 'jsx'
34
- | 'bash'
35
- | 'shell'
36
- | 'css'
37
- | 'scss'
38
- | 'sass'
39
- | 'json'
40
- | 'html'
41
- | 'xml'
42
- | 'markdown'
43
- | 'md'
44
- | 'yaml'
45
- | 'yml'
46
- | 'python'
47
- | 'py'
48
- | 'ruby'
49
- | 'go'
50
- | 'rust'
51
- | 'java'
52
- | 'kotlin'
53
- | 'swift'
54
- | 'c'
55
- | 'cpp'
56
- | 'csharp'
57
- | 'php'
58
- | 'sql'
59
- | 'graphql'
60
- | 'diff'
61
- | 'plaintext';
32
+ | "tsx"
33
+ | "typescript"
34
+ | "javascript"
35
+ | "jsx"
36
+ | "bash"
37
+ | "shell"
38
+ | "css"
39
+ | "scss"
40
+ | "sass"
41
+ | "json"
42
+ | "html"
43
+ | "xml"
44
+ | "markdown"
45
+ | "md"
46
+ | "yaml"
47
+ | "yml"
48
+ | "python"
49
+ | "py"
50
+ | "ruby"
51
+ | "go"
52
+ | "rust"
53
+ | "java"
54
+ | "kotlin"
55
+ | "swift"
56
+ | "c"
57
+ | "cpp"
58
+ | "csharp"
59
+ | "php"
60
+ | "sql"
61
+ | "graphql"
62
+ | "diff"
63
+ | "plaintext";
62
64
 
63
65
  /** Available syntax highlighting themes */
64
66
  export type CodeBlockTheme =
65
- | 'synthwave-84'
66
- | 'github-dark'
67
- | 'github-light'
68
- | 'one-dark-pro'
69
- | 'dracula'
70
- | 'nord'
71
- | 'monokai'
72
- | 'vitesse-dark'
73
- | 'vitesse-light'
74
- | 'min-dark'
75
- | 'min-light';
76
-
77
- export type CodeBlockCopyPlacement = 'auto' | 'header' | 'overlay';
67
+ | "synthwave-84"
68
+ | "github-dark"
69
+ | "github-light"
70
+ | "one-dark-pro"
71
+ | "dracula"
72
+ | "nord"
73
+ | "monokai"
74
+ | "vitesse-dark"
75
+ | "vitesse-light"
76
+ | "min-dark"
77
+ | "min-light";
78
+
79
+ export type CodeBlockCopyPlacement = "auto" | "header" | "overlay";
78
80
 
79
81
  export interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
80
82
  /** Code string to display */
@@ -200,11 +202,11 @@ function ChevronUpIcon({ className }: { className?: string }) {
200
202
 
201
203
  function escapeHtml(str: string): string {
202
204
  return str
203
- .replace(/&/g, '&amp;')
204
- .replace(/</g, '&lt;')
205
- .replace(/>/g, '&gt;')
206
- .replace(/"/g, '&quot;')
207
- .replace(/'/g, '&#039;');
205
+ .replace(/&/g, "&amp;")
206
+ .replace(/</g, "&lt;")
207
+ .replace(/>/g, "&gt;")
208
+ .replace(/"/g, "&quot;")
209
+ .replace(/'/g, "&#039;");
208
210
  }
209
211
 
210
212
  /**
@@ -212,12 +214,12 @@ function escapeHtml(str: string): string {
212
214
  * This handles template literals that have extra indentation from code formatting.
213
215
  */
214
216
  function dedent(str: string): string {
215
- const lines = str.split('\n');
217
+ const lines = str.split("\n");
216
218
 
217
219
  // Find the minimum indentation (ignoring empty lines)
218
220
  let minIndent = Infinity;
219
221
  for (const line of lines) {
220
- if (line.trim() === '') continue;
222
+ if (line.trim() === "") continue;
221
223
  const match = line.match(/^(\s*)/);
222
224
  if (match) {
223
225
  minIndent = Math.min(minIndent, match[1].length);
@@ -230,16 +232,14 @@ function dedent(str: string): string {
230
232
  }
231
233
 
232
234
  // Remove the common indentation from all lines
233
- return lines
234
- .map(line => line.slice(minIndent))
235
- .join('\n');
235
+ return lines.map((line) => line.slice(minIndent)).join("\n");
236
236
  }
237
237
 
238
238
  /**
239
239
  * Normalize indentation while handling JSX where first line is already at column 0.
240
240
  */
241
241
  function normalizeIndentation(str: string): string {
242
- const lines = str.split('\n');
242
+ const lines = str.split("\n");
243
243
  if (lines.length <= 1) return str;
244
244
 
245
245
  let minIndent = Infinity;
@@ -260,18 +260,18 @@ function normalizeIndentation(str: string): string {
260
260
 
261
261
  return lines
262
262
  .map((line) => line.slice(Math.min(minIndent, line.match(/^(\s*)/)?.[1].length ?? 0)))
263
- .join('\n');
263
+ .join("\n");
264
264
  }
265
265
 
266
266
  function trimTrailingWhitespace(str: string): string {
267
267
  return str
268
- .split('\n')
269
- .map((line) => line.replace(/[ \t]+$/g, ''))
270
- .join('\n');
268
+ .split("\n")
269
+ .map((line) => line.replace(/[ \t]+$/g, ""))
270
+ .join("\n");
271
271
  }
272
272
 
273
273
  function findTagEnd(line: string): number {
274
- let quote: '"' | '\'' | '`' | null = null;
274
+ let quote: '"' | "'" | "`" | null = null;
275
275
  let escaped = false;
276
276
  let braceDepth = 0;
277
277
  let bracketDepth = 0;
@@ -281,7 +281,7 @@ function findTagEnd(line: string): number {
281
281
  const char = line[i];
282
282
 
283
283
  if (quote) {
284
- if (char === '\\' && !escaped) {
284
+ if (char === "\\" && !escaped) {
285
285
  escaped = true;
286
286
  continue;
287
287
  }
@@ -292,18 +292,18 @@ function findTagEnd(line: string): number {
292
292
  continue;
293
293
  }
294
294
 
295
- if (char === '"' || char === '\'' || char === '`') {
295
+ if (char === '"' || char === "'" || char === "`") {
296
296
  quote = char;
297
297
  continue;
298
298
  }
299
299
 
300
- if (char === '{') braceDepth += 1;
301
- else if (char === '}') braceDepth = Math.max(0, braceDepth - 1);
302
- else if (char === '[') bracketDepth += 1;
303
- else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1);
304
- else if (char === '(') parenDepth += 1;
305
- else if (char === ')') parenDepth = Math.max(0, parenDepth - 1);
306
- else if (char === '>' && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
300
+ if (char === "{") braceDepth += 1;
301
+ else if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
302
+ else if (char === "[") bracketDepth += 1;
303
+ else if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
304
+ else if (char === "(") parenDepth += 1;
305
+ else if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
306
+ else if (char === ">" && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
307
307
  return i;
308
308
  }
309
309
  }
@@ -313,8 +313,8 @@ function findTagEnd(line: string): number {
313
313
 
314
314
  function splitJsxAttributes(attrs: string): string[] {
315
315
  const parts: string[] = [];
316
- let current = '';
317
- let quote: '"' | '\'' | '`' | null = null;
316
+ let current = "";
317
+ let quote: '"' | "'" | "`" | null = null;
318
318
  let escaped = false;
319
319
  let braceDepth = 0;
320
320
  let bracketDepth = 0;
@@ -323,7 +323,7 @@ function splitJsxAttributes(attrs: string): string[] {
323
323
  for (const char of attrs) {
324
324
  if (quote) {
325
325
  current += char;
326
- if (char === '\\' && !escaped) {
326
+ if (char === "\\" && !escaped) {
327
327
  escaped = true;
328
328
  continue;
329
329
  }
@@ -334,23 +334,23 @@ function splitJsxAttributes(attrs: string): string[] {
334
334
  continue;
335
335
  }
336
336
 
337
- if (char === '"' || char === '\'' || char === '`') {
337
+ if (char === '"' || char === "'" || char === "`") {
338
338
  quote = char;
339
339
  current += char;
340
340
  continue;
341
341
  }
342
342
 
343
- if (char === '{') braceDepth += 1;
344
- else if (char === '}') braceDepth = Math.max(0, braceDepth - 1);
345
- else if (char === '[') bracketDepth += 1;
346
- else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1);
347
- else if (char === '(') parenDepth += 1;
348
- else if (char === ')') parenDepth = Math.max(0, parenDepth - 1);
343
+ if (char === "{") braceDepth += 1;
344
+ else if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
345
+ else if (char === "[") bracketDepth += 1;
346
+ else if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
347
+ else if (char === "(") parenDepth += 1;
348
+ else if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
349
349
 
350
350
  if (/\s/.test(char) && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
351
351
  if (current.trim().length > 0) {
352
352
  parts.push(current.trim());
353
- current = '';
353
+ current = "";
354
354
  }
355
355
  continue;
356
356
  }
@@ -369,14 +369,14 @@ function formatLongJsxTagLine(line: string): string {
369
369
  const maxInlineLength = 110;
370
370
  if (line.length <= maxInlineLength) return line;
371
371
 
372
- const indent = line.match(/^(\s*)/)?.[1] ?? '';
372
+ const indent = line.match(/^(\s*)/)?.[1] ?? "";
373
373
  const trimmed = line.trimStart();
374
374
 
375
375
  if (
376
- !trimmed.startsWith('<')
377
- || trimmed.startsWith('</')
378
- || trimmed.startsWith('<!')
379
- || trimmed.startsWith('<?')
376
+ !trimmed.startsWith("<") ||
377
+ trimmed.startsWith("</") ||
378
+ trimmed.startsWith("<!") ||
379
+ trimmed.startsWith("<?")
380
380
  ) {
381
381
  return line;
382
382
  }
@@ -386,7 +386,7 @@ function formatLongJsxTagLine(line: string): string {
386
386
  if (trimmed.slice(tagEnd + 1).trim().length > 0) return line;
387
387
 
388
388
  const rawTagBody = trimmed.slice(1, tagEnd).trim();
389
- const isSelfClosing = rawTagBody.endsWith('/');
389
+ const isSelfClosing = rawTagBody.endsWith("/");
390
390
  const tagBody = isSelfClosing ? rawTagBody.slice(0, -1).trimEnd() : rawTagBody;
391
391
  const firstSpace = tagBody.search(/\s/);
392
392
  if (firstSpace === -1) return line;
@@ -395,31 +395,31 @@ function formatLongJsxTagLine(line: string): string {
395
395
  if (!/^[A-Za-z][\w.:-]*$/.test(tagName)) return line;
396
396
 
397
397
  const attrsSource = tagBody.slice(firstSpace).trim();
398
- if (!attrsSource.includes('=') && !attrsSource.includes('{...')) return line;
398
+ if (!attrsSource.includes("=") && !attrsSource.includes("{...")) return line;
399
399
 
400
400
  const attrs = splitJsxAttributes(attrsSource);
401
401
  if (attrs.length === 0) return line;
402
402
 
403
403
  const attrIndent = `${indent} `;
404
- const close = isSelfClosing ? '/>' : '>';
404
+ const close = isSelfClosing ? "/>" : ">";
405
405
 
406
406
  return [
407
407
  `${indent}<${tagName}`,
408
408
  ...attrs.map((attr) => `${attrIndent}${attr}`),
409
409
  `${indent}${close}`,
410
- ].join('\n');
410
+ ].join("\n");
411
411
  }
412
412
 
413
413
  function formatLongJsxTags(code: string): string {
414
414
  return code
415
- .split('\n')
416
- .flatMap((line) => formatLongJsxTagLine(line).split('\n'))
417
- .join('\n');
415
+ .split("\n")
416
+ .flatMap((line) => formatLongJsxTagLine(line).split("\n"))
417
+ .join("\n");
418
418
  }
419
419
 
420
420
  function normalizeCode(code: string): string {
421
421
  const trimmed = code.trim();
422
- if (trimmed.length === 0) return '';
422
+ if (trimmed.length === 0) return "";
423
423
 
424
424
  const normalized = normalizeIndentation(trimmed);
425
425
  const dedented = dedent(normalized);
@@ -436,9 +436,9 @@ function parseLineSpec(spec?: (number | string)[]): Set<number> {
436
436
  if (!spec) return lines;
437
437
 
438
438
  for (const item of spec) {
439
- if (typeof item === 'number') {
439
+ if (typeof item === "number") {
440
440
  lines.add(item);
441
- } else if (typeof item === 'string') {
441
+ } else if (typeof item === "string") {
442
442
  const rangeMatch = item.match(/^(\d+)-(\d+)$/);
443
443
  if (rangeMatch) {
444
444
  const start = parseInt(rangeMatch[1], 10);
@@ -483,7 +483,7 @@ function processShikiHtml(html: string, options: ProcessOptions): string {
483
483
  if (!codeMatch) return html;
484
484
 
485
485
  const codeContent = codeMatch[1];
486
- const lines = codeContent.split('\n');
486
+ const lines = codeContent.split("\n");
487
487
 
488
488
  // Process each line
489
489
  const processedLines = lines.map((line, index) => {
@@ -493,265 +493,263 @@ function processShikiHtml(html: string, options: ProcessOptions): string {
493
493
  const isAdded = addedLines.has(lineNum);
494
494
  const isRemoved = removedLines.has(lineNum);
495
495
 
496
- const lineClasses = ['line'];
497
- if (isHighlighted) lineClasses.push('highlighted');
498
- if (isAdded) lineClasses.push('diff-added');
499
- if (isRemoved) lineClasses.push('diff-removed');
496
+ const lineClasses = ["line"];
497
+ if (isHighlighted) lineClasses.push("highlighted");
498
+ if (isAdded) lineClasses.push("diff-added");
499
+ if (isRemoved) lineClasses.push("diff-removed");
500
500
 
501
- const lineClass = lineClasses.join(' ');
502
- const diffMarker = isAdded ? '+' : isRemoved ? '-' : ' ';
501
+ const lineClass = lineClasses.join(" ");
502
+ const diffMarker = isAdded ? "+" : isRemoved ? "-" : " ";
503
503
 
504
504
  if (showLineNumbers || hasDiff) {
505
505
  const lineNumHtml = showLineNumbers
506
506
  ? `<span class="line-number">${displayLineNum}</span>`
507
- : '';
508
- const diffMarkerHtml = hasDiff
509
- ? `<span class="diff-marker">${diffMarker}</span>`
510
- : '';
507
+ : "";
508
+ const diffMarkerHtml = hasDiff ? `<span class="diff-marker">${diffMarker}</span>` : "";
511
509
  return `<span class="${lineClass}">${lineNumHtml}${diffMarkerHtml}${line}</span>`;
512
510
  }
513
511
  return `<span class="${lineClass}">${line}</span>`;
514
512
  });
515
513
 
516
514
  // Reconstruct the HTML
517
- return html.replace(
518
- /<code[^>]*>[\s\S]*?<\/code>/,
519
- `<code>${processedLines.join('\n')}</code>`
520
- );
515
+ return html.replace(/<code[^>]*>[\s\S]*?<\/code>/, `<code>${processedLines.join("\n")}</code>`);
521
516
  }
522
517
 
523
- const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(
524
- function CodeBlock(
525
- {
526
- code,
527
- language = 'tsx',
528
- theme = 'one-dark-pro',
529
- showCopy = true,
530
- title,
531
- filename,
532
- caption,
533
- showLineNumbers = false,
534
- startLineNumber = 1,
535
- highlightLines,
536
- addedLines,
537
- removedLines,
538
- wordWrap = false,
539
- maxHeight,
540
- collapsible = false,
541
- defaultCollapsed = false,
542
- collapsedLines = 5,
543
- compact = false,
544
- persistentCopy = false,
545
- copyPlacement = 'auto',
546
- onCopy,
547
- className,
548
- ...htmlProps
549
- },
550
- ref
551
- ) {
552
- const [copied, setCopied] = useState(false);
553
- const [highlightedHtml, setHighlightedHtml] = useState<string>('');
554
- const [isLoading, setIsLoading] = useState(true);
555
- const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
556
-
557
- const trimmedCode = useMemo(() => normalizeCode(code), [code]);
558
- const codeLines = trimmedCode.split('\n');
559
- const totalLines = codeLines.length;
560
- const shouldShowCollapse = collapsible && totalLines > collapsedLines;
561
-
562
- // Compute visible code when collapsed
563
- const visibleCode = shouldShowCollapse && isCollapsed
564
- ? codeLines.slice(0, collapsedLines).join('\n')
565
- : trimmedCode;
566
-
567
- const highlightSet = useMemo(() => parseLineSpec(highlightLines), [highlightLines]);
568
- const addedSet = useMemo(() => parseLineSpec(addedLines), [addedLines]);
569
- const removedSet = useMemo(() => parseLineSpec(removedLines), [removedLines]);
570
- const hasDiff = addedSet.size > 0 || removedSet.size > 0;
571
- const resolvedCopyPlacement = copyPlacement === 'auto'
572
- ? (filename ? 'header' : 'overlay')
573
- : copyPlacement;
574
- const shouldShowHeaderCopy = showCopy && !persistentCopy && resolvedCopyPlacement === 'header';
575
- const shouldShowOverlayCopy = showCopy && !persistentCopy && resolvedCopyPlacement === 'overlay';
576
- const shouldRenderHeader = Boolean(filename) || shouldShowHeaderCopy;
577
-
578
- // Apply syntax highlighting
579
- useEffect(() => {
580
- let cancelled = false;
581
- setIsLoading(true);
582
-
583
- loadShikiDeps();
584
-
585
- if (_shikiFailed || !_codeToHtml) {
586
- if (_shikiFailed && process.env.NODE_ENV === 'development') {
587
- console.warn(
588
- '[@fragments-sdk/ui] CodeBlock: shiki is not installed. ' +
589
- 'Install it with: npm install shiki'
590
- );
591
- }
592
- // Fallback to plain text without syntax highlighting
593
- setHighlightedHtml(
594
- `<pre class="shiki"><code>${escapeHtml(visibleCode)}</code></pre>`
518
+ const CodeBlockBase = React.forwardRef<HTMLDivElement, CodeBlockProps>(function CodeBlock(
519
+ {
520
+ code,
521
+ language = "tsx",
522
+ theme = "one-dark-pro",
523
+ showCopy = true,
524
+ title,
525
+ filename,
526
+ caption,
527
+ showLineNumbers = false,
528
+ startLineNumber = 1,
529
+ highlightLines,
530
+ addedLines,
531
+ removedLines,
532
+ wordWrap = false,
533
+ maxHeight,
534
+ collapsible = false,
535
+ defaultCollapsed = false,
536
+ collapsedLines = 5,
537
+ compact = false,
538
+ persistentCopy = false,
539
+ copyPlacement = "auto",
540
+ onCopy,
541
+ className,
542
+ ...htmlProps
543
+ },
544
+ ref
545
+ ) {
546
+ const [copied, setCopied] = useState(false);
547
+ const [highlightedHtml, setHighlightedHtml] = useState<string>("");
548
+ const [isLoading, setIsLoading] = useState(true);
549
+ const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
550
+
551
+ const trimmedCode = useMemo(() => normalizeCode(code), [code]);
552
+ const codeLines = trimmedCode.split("\n");
553
+ const totalLines = codeLines.length;
554
+ const shouldShowCollapse = collapsible && totalLines > collapsedLines;
555
+
556
+ // Compute visible code when collapsed
557
+ const visibleCode =
558
+ shouldShowCollapse && isCollapsed ? codeLines.slice(0, collapsedLines).join("\n") : trimmedCode;
559
+
560
+ const highlightSet = useMemo(() => parseLineSpec(highlightLines), [highlightLines]);
561
+ const addedSet = useMemo(() => parseLineSpec(addedLines), [addedLines]);
562
+ const removedSet = useMemo(() => parseLineSpec(removedLines), [removedLines]);
563
+ const hasDiff = addedSet.size > 0 || removedSet.size > 0;
564
+ const resolvedCopyPlacement =
565
+ copyPlacement === "auto" ? (filename ? "header" : "overlay") : copyPlacement;
566
+ const shouldShowHeaderCopy = showCopy && !persistentCopy && resolvedCopyPlacement === "header";
567
+ const shouldShowOverlayCopy = showCopy && !persistentCopy && resolvedCopyPlacement === "overlay";
568
+ const shouldRenderHeader = Boolean(filename) || shouldShowHeaderCopy;
569
+
570
+ // Apply syntax highlighting
571
+ useEffect(() => {
572
+ let cancelled = false;
573
+ setIsLoading(true);
574
+
575
+ loadShikiDeps();
576
+
577
+ if (_shikiFailed || !_codeToHtml) {
578
+ if (_shikiFailed && process.env.NODE_ENV === "development") {
579
+ console.warn(
580
+ "[@fragments-sdk/ui] CodeBlock: shiki is not installed. " +
581
+ "Install it with: npm install shiki"
595
582
  );
596
- setIsLoading(false);
597
- return;
598
583
  }
584
+ // Fallback to plain text without syntax highlighting
585
+ setHighlightedHtml(`<pre class="shiki"><code>${escapeHtml(visibleCode)}</code></pre>`);
586
+ setIsLoading(false);
587
+ return;
588
+ }
599
589
 
600
- _codeToHtml(visibleCode, {
601
- lang: language,
602
- theme,
590
+ _codeToHtml(visibleCode, {
591
+ lang: language,
592
+ theme,
593
+ })
594
+ .then((html) => {
595
+ if (!cancelled) {
596
+ const processed = processShikiHtml(html, {
597
+ showLineNumbers,
598
+ startLineNumber,
599
+ highlightLines: highlightSet,
600
+ addedLines: addedSet,
601
+ removedLines: removedSet,
602
+ });
603
+ setHighlightedHtml(processed);
604
+ setIsLoading(false);
605
+ }
603
606
  })
604
- .then((html) => {
605
- if (!cancelled) {
606
- const processed = processShikiHtml(html, {
607
- showLineNumbers,
608
- startLineNumber,
609
- highlightLines: highlightSet,
610
- addedLines: addedSet,
611
- removedLines: removedSet,
612
- });
613
- setHighlightedHtml(processed);
614
- setIsLoading(false);
615
- }
616
- })
617
- .catch((err) => {
618
- if (process.env.NODE_ENV !== 'production') {
619
- console.error('Syntax highlighting failed:', err);
620
- }
621
- if (!cancelled) {
622
- // Fallback to plain text
623
- setHighlightedHtml(
624
- `<pre class="shiki"><code>${escapeHtml(visibleCode)}</code></pre>`
625
- );
626
- setIsLoading(false);
627
- }
628
- });
629
-
630
- return () => {
631
- cancelled = true;
632
- };
633
- }, [visibleCode, language, theme, showLineNumbers, startLineNumber, highlightSet, addedSet, removedSet]);
634
-
635
- const handleCopy = useCallback(async () => {
636
- try {
637
- // Always copy the full code, even when collapsed
638
- await navigator.clipboard.writeText(trimmedCode);
639
- setCopied(true);
640
- setTimeout(() => setCopied(false), 2000);
641
- onCopy?.();
642
- } catch (err) {
643
- if (process.env.NODE_ENV !== 'production') {
644
- console.error('Failed to copy:', err);
607
+ .catch((err) => {
608
+ if (process.env.NODE_ENV !== "production") {
609
+ console.error("Syntax highlighting failed:", err);
645
610
  }
611
+ if (!cancelled) {
612
+ // Fallback to plain text
613
+ setHighlightedHtml(`<pre class="shiki"><code>${escapeHtml(visibleCode)}</code></pre>`);
614
+ setIsLoading(false);
615
+ }
616
+ });
617
+
618
+ return () => {
619
+ cancelled = true;
620
+ };
621
+ }, [
622
+ visibleCode,
623
+ language,
624
+ theme,
625
+ showLineNumbers,
626
+ startLineNumber,
627
+ highlightSet,
628
+ addedSet,
629
+ removedSet,
630
+ ]);
631
+
632
+ const handleCopy = useCallback(async () => {
633
+ try {
634
+ // Always copy the full code, even when collapsed
635
+ await navigator.clipboard.writeText(trimmedCode);
636
+ setCopied(true);
637
+ setTimeout(() => setCopied(false), 2000);
638
+ onCopy?.();
639
+ } catch (err) {
640
+ if (process.env.NODE_ENV !== "production") {
641
+ console.error("Failed to copy:", err);
646
642
  }
647
- }, [trimmedCode, onCopy]);
648
-
649
- const toggleCollapsed = useCallback(() => {
650
- setIsCollapsed((prev) => !prev);
651
- }, []);
652
-
653
- const classNames = [
654
- styles.container,
655
- showLineNumbers && styles.withLineNumbers,
656
- hasDiff && styles.withDiff,
657
- wordWrap && styles.wordWrap,
658
- compact && styles.compact,
659
- className,
660
- ]
661
- .filter(Boolean)
662
- .join(' ');
663
-
664
- const wrapperClasses = [
665
- styles.wrapper,
666
- persistentCopy && styles.persistentCopyWrapper,
667
- shouldShowOverlayCopy && styles.withCopyOverlay,
668
- ].filter(Boolean).join(' ');
669
-
670
- const codeContainerStyle: React.CSSProperties = maxHeight
671
- ? { maxHeight, overflow: 'auto' }
672
- : {};
673
-
674
- return (
675
- <div ref={ref} {...htmlProps} className={classNames} data-theme="dark">
676
- {title && <div className={styles.title}>{title}</div>}
677
- <div className={wrapperClasses}>
678
- {shouldRenderHeader && (
679
- <div className={styles.header}>
680
- <span className={styles.filename}>{filename ?? ''}</span>
681
- {shouldShowHeaderCopy && (
682
- <button
683
- type="button"
684
- onClick={handleCopy}
685
- className={`${styles.copyButton} ${copied ? styles.copied : ''}`}
686
- aria-label={copied ? 'Copied!' : 'Copy code'}
687
- >
688
- {copied ? <CheckIcon className={styles.icon} /> : <CopyIcon className={styles.icon} />}
689
- </button>
690
- )}
691
- </div>
692
- )}
693
- {shouldShowOverlayCopy && (
694
- <button
695
- type="button"
696
- onClick={handleCopy}
697
- className={`${styles.copyButton} ${styles.copyOverlay} ${copied ? styles.copied : ''}`}
698
- aria-label={copied ? 'Copied!' : 'Copy code'}
699
- >
700
- {copied ? <CheckIcon className={styles.icon} /> : <CopyIcon className={styles.icon} />}
701
- </button>
702
- )}
703
- {isLoading ? (
704
- <div className={styles.loading} style={codeContainerStyle}>
705
- <pre>
706
- <code>{visibleCode}</code>
707
- </pre>
708
- </div>
709
- ) : (
710
- <div
711
- className={styles.codeContainer}
712
- style={codeContainerStyle}
713
- dangerouslySetInnerHTML={{ __html: highlightedHtml }}
714
- />
715
- )}
716
- {persistentCopy && (
717
- <div className={styles.persistentCopy}>
718
- <Button
719
- size="sm"
643
+ }
644
+ }, [trimmedCode, onCopy]);
645
+
646
+ const toggleCollapsed = useCallback(() => {
647
+ setIsCollapsed((prev) => !prev);
648
+ }, []);
649
+
650
+ const classNames = [
651
+ styles.container,
652
+ showLineNumbers && styles.withLineNumbers,
653
+ hasDiff && styles.withDiff,
654
+ wordWrap && styles.wordWrap,
655
+ compact && styles.compact,
656
+ className,
657
+ ]
658
+ .filter(Boolean)
659
+ .join(" ");
660
+
661
+ const wrapperClasses = [
662
+ styles.wrapper,
663
+ persistentCopy && styles.persistentCopyWrapper,
664
+ shouldShowOverlayCopy && styles.withCopyOverlay,
665
+ ]
666
+ .filter(Boolean)
667
+ .join(" ");
668
+
669
+ const codeContainerStyle: React.CSSProperties = maxHeight ? { maxHeight, overflow: "auto" } : {};
670
+
671
+ return (
672
+ <div ref={ref} {...htmlProps} className={classNames} data-theme="dark">
673
+ {title && <div className={styles.title}>{title}</div>}
674
+ <div className={wrapperClasses}>
675
+ {shouldRenderHeader && (
676
+ <div className={styles.header}>
677
+ <span className={styles.filename}>{filename ?? ""}</span>
678
+ {shouldShowHeaderCopy && (
679
+ <button
680
+ type="button"
720
681
  onClick={handleCopy}
721
- aria-label={copied ? 'Copied!' : 'Copy code'}
722
- icon={copied ? true : false}
682
+ className={`${styles.copyButton} ${copied ? styles.copied : ""}`}
683
+ aria-label={copied ? "Copied!" : "Copy code"}
723
684
  >
724
- {copied ? <CheckIcon className={styles.icon} /> : <CopyIcon className={styles.icon} />}
725
- </Button>
726
- </div>
727
- )}
728
- {shouldShowCollapse && (
729
- <button
730
- type="button"
731
- onClick={toggleCollapsed}
732
- className={styles.collapseButton}
733
- aria-expanded={!isCollapsed}
734
- aria-label={isCollapsed ? 'Expand code' : 'Collapse code'}
735
- >
736
- {isCollapsed ? (
737
- <>
738
- <ChevronDownIcon className={styles.icon} />
739
- <span>Show {totalLines - collapsedLines} more lines</span>
740
- </>
741
- ) : (
742
- <>
743
- <ChevronUpIcon className={styles.icon} />
744
- <span>Show less</span>
745
- </>
746
- )}
747
- </button>
748
- )}
749
- </div>
750
- {caption && <div className={styles.caption}>{caption}</div>}
685
+ {copied ? (
686
+ <CheckIcon className={styles.icon} />
687
+ ) : (
688
+ <CopyIcon className={styles.icon} />
689
+ )}
690
+ </button>
691
+ )}
692
+ </div>
693
+ )}
694
+ {shouldShowOverlayCopy && (
695
+ <button
696
+ type="button"
697
+ onClick={handleCopy}
698
+ className={`${styles.copyButton} ${styles.copyOverlay} ${copied ? styles.copied : ""}`}
699
+ aria-label={copied ? "Copied!" : "Copy code"}
700
+ >
701
+ {copied ? <CheckIcon className={styles.icon} /> : <CopyIcon className={styles.icon} />}
702
+ </button>
703
+ )}
704
+ {isLoading ? (
705
+ <div className={styles.loading} style={codeContainerStyle}>
706
+ <pre>
707
+ <code>{visibleCode}</code>
708
+ </pre>
709
+ </div>
710
+ ) : (
711
+ <div
712
+ className={styles.codeContainer}
713
+ style={codeContainerStyle}
714
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
715
+ />
716
+ )}
717
+ {persistentCopy && (
718
+ <button
719
+ type="button"
720
+ onClick={handleCopy}
721
+ className={`${styles.persistentCopy} ${styles.copyButton} ${styles.copyOverlay} ${copied ? styles.copied : ""}`}
722
+ aria-label={copied ? "Copied!" : "Copy code"}
723
+ >
724
+ {copied ? <CheckIcon className={styles.icon} /> : <CopyIcon className={styles.icon} />}
725
+ </button>
726
+ )}
727
+ {shouldShowCollapse && (
728
+ <button
729
+ type="button"
730
+ onClick={toggleCollapsed}
731
+ className={styles.collapseButton}
732
+ aria-expanded={!isCollapsed}
733
+ aria-label={isCollapsed ? "Expand code" : "Collapse code"}
734
+ >
735
+ {isCollapsed ? (
736
+ <>
737
+ <ChevronDownIcon className={styles.icon} />
738
+ <span>Show {totalLines - collapsedLines} more lines</span>
739
+ </>
740
+ ) : (
741
+ <>
742
+ <ChevronUpIcon className={styles.icon} />
743
+ <span>Show less</span>
744
+ </>
745
+ )}
746
+ </button>
747
+ )}
751
748
  </div>
752
- );
753
- }
754
- );
749
+ {caption && <div className={styles.caption}>{caption}</div>}
750
+ </div>
751
+ );
752
+ });
755
753
 
756
754
  // ============================================
757
755
  // Tabbed Code Block
@@ -780,7 +778,7 @@ export interface TabbedCodeBlockProps {
780
778
  /** Syntax highlighting theme (applies to all tabs) */
781
779
  theme?: CodeBlockTheme;
782
780
  /** Tab list visual style */
783
- tabsVariant?: 'underline' | 'pills';
781
+ tabsVariant?: "underline" | "pills";
784
782
  /** Enable word wrapping for long lines */
785
783
  wordWrap?: boolean;
786
784
  /** Maximum height in pixels (enables scrolling) */
@@ -795,16 +793,16 @@ function TabbedCodeBlock({
795
793
  tabs,
796
794
  defaultTab,
797
795
  showCopy = true,
798
- copyPlacement = 'auto',
796
+ copyPlacement = "auto",
799
797
  showLineNumbers = false,
800
798
  theme,
801
- tabsVariant = 'pills',
799
+ tabsVariant = "pills",
802
800
  wordWrap,
803
801
  maxHeight,
804
802
  className,
805
803
  onCopy,
806
804
  }: TabbedCodeBlockProps) {
807
- const defaultValue = defaultTab || tabs[0]?.label || '';
805
+ const defaultValue = defaultTab || tabs[0]?.label || "";
808
806
 
809
807
  return (
810
808
  <div className={className}>