@tangle-network/ui 11.30.2 → 11.31.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.d.ts +8 -3
  3. package/dist/index.js +7 -2
  4. package/dist/markdown/code-block.d.ts +11 -4
  5. package/dist/markdown/code-block.js +239 -107
  6. package/dist/markdown/code-meta.d.ts +11 -0
  7. package/dist/markdown/code-meta.js +98 -0
  8. package/dist/markdown/markdown-table.d.ts +8 -0
  9. package/dist/markdown/markdown-table.js +168 -0
  10. package/dist/markdown/markdown.d.ts +17 -4
  11. package/dist/markdown/markdown.js +96 -34
  12. package/dist/markdown.d.ts +5 -3
  13. package/dist/markdown.js +4 -2
  14. package/dist/run/approval-card.d.ts +46 -0
  15. package/dist/run/approval-card.js +162 -0
  16. package/dist/run/run-phase-list.d.ts +18 -0
  17. package/dist/run/run-phase-list.js +56 -0
  18. package/dist/run/task-list.d.ts +33 -0
  19. package/dist/run/task-list.js +138 -0
  20. package/dist/run.d.ts +4 -1
  21. package/dist/run.js +4 -1
  22. package/package.json +3 -4
  23. package/src/markdown/code-block.test.tsx +35 -26
  24. package/src/markdown/code-block.tsx +197 -110
  25. package/src/markdown/code-meta.ts +68 -0
  26. package/src/markdown/index.ts +10 -2
  27. package/src/markdown/markdown-table.test.tsx +61 -0
  28. package/src/markdown/markdown-table.tsx +191 -0
  29. package/src/markdown/markdown.test.tsx +27 -0
  30. package/src/markdown/markdown.tsx +126 -48
  31. package/src/run/approval-card.test.tsx +41 -0
  32. package/src/run/approval-card.tsx +146 -0
  33. package/src/run/index.ts +17 -0
  34. package/src/run/run-phase-list.test.tsx +25 -0
  35. package/src/run/run-phase-list.tsx +56 -0
  36. package/src/run/task-list.test.tsx +35 -0
  37. package/src/run/task-list.tsx +117 -0
@@ -4,173 +4,260 @@ import {
4
4
  useEffect,
5
5
  useRef,
6
6
  useState,
7
+ type CSSProperties,
7
8
  type HTMLAttributes,
8
9
  type ReactNode,
9
10
  } from "react";
10
- import type SyntaxHighlighter from "react-syntax-highlighter";
11
- import { Check, Copy } from "lucide-react";
11
+ import type { HighlighterCore, LanguageRegistration, ThemedToken } from "shiki/core";
12
+ import { Check, Copy, FileCode2 } from "lucide-react";
12
13
  import { focusRing, focusRingInset } from "../lib/focus";
13
14
  import { cn } from "../lib/utils";
14
15
 
15
16
  // Leave variables unresolved: the browser resolves them on each token span,
16
17
  // including nested themes, inline overrides and CSS/media changes. No document
17
18
  // sampling, observers, layout reads or React re-render are needed to recolor code.
18
- const syntaxTheme: { [key: string]: React.CSSProperties } = (() => {
19
- const comment = "var(--syntax-comment, currentColor)";
20
- const keyword = "var(--syntax-keyword, currentColor)";
21
- const string = "var(--syntax-string, currentColor)";
22
- const fn = "var(--syntax-function, currentColor)";
23
- const number = "var(--syntax-number, currentColor)";
24
- const meta = "var(--syntax-meta, currentColor)";
25
- const error = "var(--syntax-error, currentColor)";
26
- const variable = "var(--syntax-variable, currentColor)";
27
- const fg = "var(--syntax-foreground, currentColor)";
28
- return {
29
- "hljs-comment": { color: comment, fontStyle: "italic" },
30
- "hljs-quote": { color: comment, fontStyle: "italic" },
31
- "hljs-doctag": { color: comment },
32
- "hljs-keyword": { color: keyword },
33
- "hljs-selector-tag": { color: keyword },
34
- "hljs-literal": { color: keyword },
35
- "hljs-type": { color: keyword },
36
- "hljs-class": { color: keyword },
37
- "hljs-string": { color: string },
38
- "hljs-template-tag": { color: string },
39
- "hljs-template-variable": { color: string },
40
- "hljs-addition": { color: string },
41
- "hljs-regexp": { color: string },
42
- "hljs-title": { color: fn },
43
- "hljs-section": { color: fn },
44
- "hljs-built_in": { color: fn },
45
- "hljs-name": { color: fn },
46
- "hljs-function": { color: fn },
47
- "hljs-selector-id": { color: fn },
48
- "hljs-selector-class": { color: fn },
49
- "hljs-attribute": { color: fn },
50
- "hljs-number": { color: number },
51
- "hljs-symbol": { color: number },
52
- "hljs-bullet": { color: number },
53
- "hljs-link": { color: number, textDecoration: "underline" },
54
- "hljs-meta": { color: meta },
55
- "hljs-selector-pseudo": { color: meta },
56
- "hljs-deletion": { color: error },
57
- "hljs-params": { color: variable },
58
- "hljs-variable": { color: variable },
59
- "hljs-tag": { color: variable },
60
- "hljs-attr": { color: variable },
61
- "hljs-subst": { color: variable },
62
- "hljs-strong": { fontWeight: "bold" },
63
- "hljs-emphasis": { fontStyle: "italic" },
64
- "hljs": { color: fg, background: "transparent" },
65
- };
66
- })();
19
+ const SYNTAX = {
20
+ comment: "var(--syntax-comment, currentColor)",
21
+ keyword: "var(--syntax-keyword, currentColor)",
22
+ string: "var(--syntax-string, currentColor)",
23
+ function: "var(--syntax-function, currentColor)",
24
+ number: "var(--syntax-number, currentColor)",
25
+ variable: "var(--syntax-variable, currentColor)",
26
+ foreground: "var(--syntax-foreground, currentColor)",
27
+ } as const;
28
+
29
+ const SHIKI_THEME = "tangle-syntax";
30
+ // Shiki's CSS-variables theme names token roles; each maps onto a Brand syntax token.
31
+ const BRAND_COLOR_FOR_SHIKI: Record<string, string> = {
32
+ "var(--shiki-foreground)": SYNTAX.foreground,
33
+ "var(--shiki-token-comment)": SYNTAX.comment,
34
+ "var(--shiki-token-keyword)": SYNTAX.keyword,
35
+ "var(--shiki-token-string)": SYNTAX.string,
36
+ "var(--shiki-token-string-expression)": SYNTAX.string,
37
+ "var(--shiki-token-function)": SYNTAX.function,
38
+ "var(--shiki-token-constant)": SYNTAX.number,
39
+ "var(--shiki-token-link)": SYNTAX.number,
40
+ "var(--shiki-token-parameter)": SYNTAX.variable,
41
+ "var(--shiki-token-punctuation)": SYNTAX.foreground,
42
+ };
43
+
44
+ type GrammarModule = { default: LanguageRegistration[] };
45
+ const GRAMMARS: Record<string, () => Promise<GrammarModule>> = {
46
+ typescript: () => import("shiki/langs/typescript.mjs"),
47
+ tsx: () => import("shiki/langs/tsx.mjs"),
48
+ javascript: () => import("shiki/langs/javascript.mjs"),
49
+ jsx: () => import("shiki/langs/jsx.mjs"),
50
+ json: () => import("shiki/langs/json.mjs"),
51
+ jsonc: () => import("shiki/langs/jsonc.mjs"),
52
+ bash: () => import("shiki/langs/bash.mjs"),
53
+ python: () => import("shiki/langs/python.mjs"),
54
+ html: () => import("shiki/langs/html.mjs"),
55
+ css: () => import("shiki/langs/css.mjs"),
56
+ scss: () => import("shiki/langs/scss.mjs"),
57
+ markdown: () => import("shiki/langs/markdown.mjs"),
58
+ mdx: () => import("shiki/langs/mdx.mjs"),
59
+ yaml: () => import("shiki/langs/yaml.mjs"),
60
+ toml: () => import("shiki/langs/toml.mjs"),
61
+ sql: () => import("shiki/langs/sql.mjs"),
62
+ diff: () => import("shiki/langs/diff.mjs"),
63
+ go: () => import("shiki/langs/go.mjs"),
64
+ rust: () => import("shiki/langs/rust.mjs"),
65
+ solidity: () => import("shiki/langs/solidity.mjs"),
66
+ ruby: () => import("shiki/langs/ruby.mjs"),
67
+ java: () => import("shiki/langs/java.mjs"),
68
+ kotlin: () => import("shiki/langs/kotlin.mjs"),
69
+ swift: () => import("shiki/langs/swift.mjs"),
70
+ php: () => import("shiki/langs/php.mjs"),
71
+ c: () => import("shiki/langs/c.mjs"),
72
+ cpp: () => import("shiki/langs/cpp.mjs"),
73
+ csharp: () => import("shiki/langs/csharp.mjs"),
74
+ graphql: () => import("shiki/langs/graphql.mjs"),
75
+ xml: () => import("shiki/langs/xml.mjs"),
76
+ dockerfile: () => import("shiki/langs/dockerfile.mjs"),
77
+ };
78
+
79
+ const LANGUAGE_ALIASES: Record<string, string> = {
80
+ ts: "typescript", mts: "typescript", cts: "typescript", js: "javascript", mjs: "javascript",
81
+ cjs: "javascript", node: "javascript", sh: "bash", shell: "bash", zsh: "bash", console: "bash",
82
+ shellscript: "bash", py: "python", python3: "python", yml: "yaml", md: "markdown", rs: "rust",
83
+ sol: "solidity", rb: "ruby", kt: "kotlin", cs: "csharp", "c#": "csharp", "c++": "cpp",
84
+ gql: "graphql", svg: "xml", docker: "dockerfile", patch: "diff", htm: "html",
85
+ };
67
86
 
68
- type Highlighter = typeof SyntaxHighlighter;
69
- let highlighterPromise: Promise<Highlighter> | undefined;
87
+ const LANGUAGE_NAMES: Record<string, string> = {
88
+ typescript: "TypeScript", tsx: "TSX", javascript: "JavaScript", jsx: "JSX", json: "JSON",
89
+ jsonc: "JSONC", bash: "Bash", python: "Python", html: "HTML", css: "CSS", scss: "SCSS",
90
+ markdown: "Markdown", mdx: "MDX", yaml: "YAML", toml: "TOML", sql: "SQL", diff: "Diff",
91
+ go: "Go", rust: "Rust", solidity: "Solidity", ruby: "Ruby", java: "Java", kotlin: "Kotlin",
92
+ swift: "Swift", php: "PHP", c: "C", cpp: "C++", csharp: "C#", graphql: "GraphQL", xml: "XML",
93
+ dockerfile: "Dockerfile",
94
+ };
70
95
 
71
- function loadHighlighter(): Promise<Highlighter> {
72
- // Preserve the default renderer's language behavior, including alias inputs
73
- // and unknown-language auto-detection. Its light async variant differs.
74
- return highlighterPromise ??= import("react-syntax-highlighter/dist/esm/default-highlight")
75
- .then((module) => module.default)
96
+ /** The Shiki grammar for a fence language, or undefined for plain text. */
97
+ export function codeBlockGrammar(language: string | undefined): string | undefined {
98
+ if (!language) return undefined;
99
+ const id = language.toLowerCase();
100
+ const resolved = LANGUAGE_ALIASES[id] ?? id;
101
+ return GRAMMARS[resolved] ? resolved : undefined;
102
+ }
103
+
104
+ let highlighterPromise: Promise<HighlighterCore> | undefined;
105
+ const grammarLoads = new Map<string, Promise<void>>();
106
+
107
+ function loadHighlighter(): Promise<HighlighterCore> {
108
+ // The JavaScript regex engine needs no WebAssembly, so it runs under a strict CSP.
109
+ return highlighterPromise ??= Promise.all([import("shiki/core"), import("shiki/engine/javascript")])
110
+ .then(([core, engine]) => core.createHighlighterCore({
111
+ themes: [core.createCssVariablesTheme({ name: SHIKI_THEME, variablePrefix: "--shiki-", fontStyle: true })],
112
+ langs: [],
113
+ engine: engine.createJavaScriptRegexEngine(),
114
+ }))
76
115
  .catch((error: unknown) => {
77
116
  highlighterPromise = undefined;
78
117
  throw error;
79
118
  });
80
119
  }
81
120
 
121
+ async function tokenize(code: string, grammar: string): Promise<ThemedToken[][]> {
122
+ const highlighter = await loadHighlighter();
123
+ let load = grammarLoads.get(grammar);
124
+ if (!load) {
125
+ load = GRAMMARS[grammar]!().then((module) => highlighter.loadLanguage(module.default));
126
+ load.catch(() => grammarLoads.delete(grammar));
127
+ grammarLoads.set(grammar, load);
128
+ }
129
+ await load;
130
+ return highlighter.codeToTokensBase(code, { lang: grammar, theme: SHIKI_THEME });
131
+ }
132
+
133
+ function tokenStyle(token: ThemedToken): CSSProperties | undefined {
134
+ const color = token.color ? BRAND_COLOR_FOR_SHIKI[token.color] ?? SYNTAX.foreground : undefined;
135
+ const style = token.fontStyle ?? 0;
136
+ if (!color && !style) return undefined;
137
+ return {
138
+ ...(color ? { color } : {}),
139
+ ...(style & 1 ? { fontStyle: "italic" } : {}),
140
+ ...(style & 2 ? { fontWeight: "bold" } : {}),
141
+ ...(style & 4 ? { textDecoration: "underline" } : {}),
142
+ };
143
+ }
144
+
82
145
  export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
83
146
  code: string;
84
147
  language?: string;
85
148
  /**
86
- * Header text. Defaults to `language`. Set this when the display name differs
87
- * from the highlight.js language id — e.g. a file extension ("BASHRC") whose
88
- * content highlights as a known language, or none.
149
+ * Header text. Defaults to the language's name. Set this when the display
150
+ * name differs from the language id, e.g. a file extension ("BASHRC").
89
151
  */
90
152
  label?: string;
153
+ /** The file the code belongs to, shown in the header with the language. */
154
+ filename?: string;
91
155
  showLineNumbers?: boolean;
92
156
  /** Force light (true) or dark (false); omitted inherits the nearest theme. */
93
157
  light?: boolean;
94
158
  children?: ReactNode;
95
159
  }
96
160
 
161
+ /**
162
+ * Code with Shiki highlighting in Brand's syntax colors. It renders readable
163
+ * plain code first (and on the server), then highlights once its grammar loads.
164
+ */
97
165
  export const CodeBlock = memo(
98
- ({ code, language, label, showLineNumbers = false, light, className, children, ...props }: CodeBlockProps) => {
99
- const [Highlighter, setHighlighter] = useState<Highlighter | null>(null);
166
+ ({ code, language, label, filename, showLineNumbers = false, light, className, children, ...props }: CodeBlockProps) => {
167
+ const grammar = codeBlockGrammar(language);
168
+ const [highlighted, setHighlighted] = useState<{ code: string; grammar: string; lines: ThemedToken[][] } | null>(null);
100
169
  useEffect(() => {
170
+ if (!grammar) return;
101
171
  let active = true;
102
- void loadHighlighter().then((component) => {
103
- if (active) setHighlighter(() => component);
172
+ void tokenize(code, grammar).then((lines) => {
173
+ if (active) setHighlighted({ code, grammar, lines });
104
174
  }).catch(() => {
105
175
  // A failed chunk leaves the code readable; a later mount can retry.
106
176
  });
107
177
  return () => { active = false; };
108
- }, []);
178
+ }, [code, grammar]);
109
179
 
110
- const headerLabel = label ?? language;
180
+ const languageName = grammar ? LANGUAGE_NAMES[grammar] : language;
181
+ const headerLabel = label ?? (filename ? undefined : languageName);
182
+ const hasHeader = Boolean(filename || headerLabel);
111
183
  const mode = light === undefined ? {} : { "data-theme": light ? "light" : "dark" };
112
- const customStyle = {
184
+ const preStyle: CSSProperties = {
113
185
  margin: 0,
114
186
  padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
115
187
  background: "transparent",
188
+ color: SYNTAX.foreground,
116
189
  fontSize: "var(--code-font-size, 0.8125rem)",
117
190
  lineHeight: "var(--code-line-height, 1.5)",
118
- overflowX: "auto" as const,
191
+ overflowX: "auto",
192
+ };
193
+ const codeStyle: CSSProperties = {
194
+ fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)",
195
+ whiteSpace: "pre",
119
196
  };
120
- const codeTagProps = { style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } };
121
- // A long line scrolls the block sideways, which a keyboard reaches only if
122
- // the block takes focus (axe scrollable-region-focusable). A label on a bare
123
- // <pre> is prohibited ARIA, so the block is a named region, as the terminal
124
- // output regions are.
125
- const scrollRegion = {
126
- tabIndex: 0,
127
- role: "region",
128
- "aria-label": headerLabel ? `${headerLabel} code` : "Code",
129
- // Inset: the rounded, overflow-clipped card would cut an outer ring.
130
- className: focusRingInset,
131
- } as const;
197
+ const isHighlighted = Boolean(highlighted && highlighted.code === code && highlighted.grammar === grammar);
198
+ const lines: Array<Array<{ content: string; style?: CSSProperties }>> = isHighlighted
199
+ ? highlighted!.lines.map((line) => line.map((token) => ({ content: token.content, style: tokenStyle(token) })))
200
+ : code.split("\n").map((line) => [{ content: line }]);
201
+ const regionName = filename ?? headerLabel;
132
202
 
133
203
  return (
134
204
  <div
135
205
  className={cn("group relative overflow-hidden rounded-lg border font-mono", "bg-card border-border", className)}
206
+ data-highlighted={isHighlighted ? "true" : undefined}
136
207
  {...props}
137
208
  {...mode}
138
209
  >
139
- {headerLabel && (
140
- <div className="flex items-center justify-between border-b border-border px-3 py-1 bg-[var(--code-header-bg,hsl(var(--background)))]">
141
- <span className="text-xs font-mono font-medium uppercase tracking-widest text-muted-foreground">
142
- {headerLabel}
143
- </span>
210
+ {hasHeader && (
211
+ <div className="flex items-center justify-between gap-3 border-b border-border px-3 py-1.5 bg-[var(--code-header-bg,hsl(var(--background)))]">
212
+ {filename ? (
213
+ <span className="flex min-w-0 items-center gap-2 font-sans text-sm">
214
+ <FileCode2 aria-hidden className="size-4 shrink-0 text-muted-foreground" />
215
+ <span className="min-w-0 truncate font-mono font-medium text-foreground" title={filename}>{filename}</span>
216
+ {languageName && <span className="shrink-0 text-muted-foreground">{languageName}</span>}
217
+ </span>
218
+ ) : (
219
+ <span className="font-sans text-sm font-medium text-muted-foreground">{headerLabel}</span>
220
+ )}
144
221
  {children}
145
222
  </div>
146
223
  )}
147
- {!headerLabel && children && (
148
- <div className="absolute right-2 top-2 z-10 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
224
+ {!hasHeader && children && (
225
+ <div className="absolute right-2 top-2 z-10 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
149
226
  {children}
150
227
  </div>
151
228
  )}
152
- {Highlighter ? (
153
- <Highlighter
154
- language={language ?? "text"}
155
- style={syntaxTheme}
156
- showLineNumbers={showLineNumbers}
157
- lineNumberStyle={{
158
- color: "var(--syntax-comment, currentColor)",
159
- minWidth: "2.5em",
160
- paddingRight: "1em",
161
- }}
162
- customStyle={customStyle}
163
- codeTagProps={codeTagProps}
164
- wrapLines={false}
165
- {...scrollRegion}
166
- >
167
- {code}
168
- </Highlighter>
169
- ) : (
170
- <pre {...scrollRegion} style={{ ...syntaxTheme.hljs, ...customStyle }}>
171
- <code style={{ ...codeTagProps.style, whiteSpace: "pre" }}>{code}</code>
172
- </pre>
173
- )}
229
+ {/* A long line scrolls the block sideways, which a keyboard reaches only
230
+ if the block takes focus; a label on a bare <pre> is prohibited ARIA,
231
+ so the block is a named region. Inset: the rounded card clips an outer ring. */}
232
+ <pre
233
+ tabIndex={0}
234
+ role="region"
235
+ aria-label={regionName ? `${regionName} code` : "Code"}
236
+ className={focusRingInset}
237
+ style={preStyle}
238
+ >
239
+ <code style={codeStyle}>
240
+ {/* Plain code without line numbers stays one text node, as the server renders it. */}
241
+ {!isHighlighted && !showLineNumbers ? code : lines.map((line, index) => (
242
+ <span key={index}>
243
+ {showLineNumbers && (
244
+ <span
245
+ aria-hidden="true"
246
+ data-line-number
247
+ className="inline-block select-none pr-4 text-right"
248
+ style={{ color: SYNTAX.comment, minWidth: "2.5em" }}
249
+ >
250
+ {index + 1}
251
+ </span>
252
+ )}
253
+ {line.map((token, tokenIndex) => (
254
+ <span key={tokenIndex} style={token.style}>{token.content}</span>
255
+ ))}
256
+ {index < lines.length - 1 ? "\n" : null}
257
+ </span>
258
+ ))}
259
+ </code>
260
+ </pre>
174
261
  </div>
175
262
  );
176
263
  },
@@ -0,0 +1,68 @@
1
+ interface MdastNode {
2
+ type: string;
3
+ lang?: string | null;
4
+ meta?: string | null;
5
+ data?: { hProperties?: Record<string, unknown> } & Record<string, unknown>;
6
+ children?: MdastNode[];
7
+ }
8
+
9
+ /**
10
+ * Carries a fence's info string past the language (```ts title="src/x.ts"```)
11
+ * to the rendered `<code>` as `data-meta`, so the code block can name its file.
12
+ */
13
+ export function remarkCodeMeta() {
14
+ return (tree: MdastNode) => {
15
+ const walk = (node: MdastNode) => {
16
+ if (node.type === "code" && node.meta) {
17
+ node.data = { ...node.data, hProperties: { ...node.data?.hProperties, dataMeta: node.meta } };
18
+ }
19
+ node.children?.forEach(walk);
20
+ };
21
+ walk(tree);
22
+ };
23
+ }
24
+
25
+ const LANGUAGE_BY_EXTENSION: Record<string, string> = {
26
+ ts: "typescript", mts: "typescript", cts: "typescript", tsx: "tsx",
27
+ js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx",
28
+ json: "json", jsonc: "jsonc", md: "markdown", mdx: "mdx", py: "python",
29
+ sh: "bash", bash: "bash", zsh: "bash", yml: "yaml", yaml: "yaml", toml: "toml",
30
+ css: "css", scss: "scss", html: "html", sql: "sql", go: "go", rs: "rust",
31
+ sol: "solidity", rb: "ruby", java: "java", kt: "kotlin", swift: "swift",
32
+ php: "php", c: "c", h: "c", cpp: "cpp", cs: "csharp", graphql: "graphql",
33
+ gql: "graphql", xml: "xml", svg: "xml", diff: "diff", patch: "diff",
34
+ };
35
+
36
+ const FILE_PATH = /^(?:\.{0,2}\/)?(?:[\w@.-]+\/)*[\w@.-]*\.[A-Za-z0-9]+$|^(?:[\w@.-]+\/)+[\w@.-]+$/;
37
+
38
+ /** The language a file's extension implies, if the code block can highlight it. */
39
+ export function languageForFile(filename: string): string | undefined {
40
+ const base = filename.split("/").pop() ?? filename;
41
+ if (/^dockerfile$/i.test(base)) return "dockerfile";
42
+ const extension = base.includes(".") ? base.split(".").pop()?.toLowerCase() : undefined;
43
+ return extension ? LANGUAGE_BY_EXTENSION[extension] : undefined;
44
+ }
45
+
46
+ /**
47
+ * The language and file name of a fenced block, from its class and info
48
+ * string: ```ts title="src/x.ts"```, ```ts filename=src/x.ts```,
49
+ * ```ts src/x.ts```, ```ts:src/x.ts``` and ```src/x.ts``` all name the file.
50
+ */
51
+ export function fenceInfo(className: string | undefined, meta: unknown): { language?: string; filename?: string } {
52
+ const token = /(?:^|\s)language-(\S+)/.exec(className ?? "")?.[1];
53
+ let language = token;
54
+ let filename: string | undefined;
55
+ if (token?.includes(":")) {
56
+ [language, filename] = [token.slice(0, token.indexOf(":")), token.slice(token.indexOf(":") + 1)];
57
+ } else if (token && FILE_PATH.test(token) && (token.includes("/") || languageForFile(token))) {
58
+ filename = token;
59
+ language = undefined;
60
+ }
61
+ if (!filename && typeof meta === "string") {
62
+ const named = /(?:^|\s)(?:title|filename|file|name)=(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(meta);
63
+ const bare = meta.trim().split(/\s+/).find((part) => FILE_PATH.test(part));
64
+ filename = named?.[1] ?? named?.[2] ?? named?.[3] ?? bare;
65
+ }
66
+ if (!language && filename) language = languageForFile(filename);
67
+ return { ...(language ? { language } : {}), ...(filename ? { filename } : {}) };
68
+ }
@@ -1,2 +1,10 @@
1
- export { Markdown, getSanitizedMarkdownHeadingIdFromRawFragment, type MarkdownProps } from "./markdown";
2
- export { CodeBlock, CopyButton, type CodeBlockProps } from "./code-block";
1
+ export {
2
+ Markdown,
3
+ getSanitizedMarkdownHeadingIdFromRawFragment,
4
+ type MarkdownLinkChip,
5
+ type MarkdownLinkChipResolver,
6
+ type MarkdownProps,
7
+ } from "./markdown";
8
+ export { CodeBlock, CopyButton, codeBlockGrammar, type CodeBlockProps } from "./code-block";
9
+ export { fenceInfo } from "./code-meta";
10
+ export { numericColumns, tableCsv } from "./markdown-table";
@@ -0,0 +1,61 @@
1
+ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { Markdown } from "./markdown";
4
+ import { numericColumns, tableCsv } from "./markdown-table";
5
+
6
+ const TABLE = [
7
+ "| Variant | Impressions | CTR % | Spend $ | Note |",
8
+ "| --- | --- | --- | --- | --- |",
9
+ "| Hero A | 12,400 | 2.50% | $182.40 | Wins on \"reach\", barely |",
10
+ "| Hero B | 9,870 | 4.17% | $160.05 | =SUM(A1) |",
11
+ ].join("\n");
12
+
13
+ afterEach(() => {
14
+ cleanup();
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ describe("Markdown tables", () => {
19
+ it("renders a header row and right-aligns figures in tabular numerals", () => {
20
+ render(<Markdown>{TABLE}</Markdown>);
21
+ const table = screen.getByRole("table");
22
+ const headers = within(table).getAllByRole("columnheader");
23
+ expect(headers.map((header) => header.textContent)).toEqual(["Variant", "Impressions", "CTR %", "Spend $", "Note"]);
24
+ expect(headers[0].className).toContain("text-left");
25
+ expect(headers[1].className).toContain("text-right");
26
+ const cells = within(table).getAllByRole("cell");
27
+ expect(cells[1].className).toContain("text-right");
28
+ expect(cells[1].className).toContain("tabular-nums");
29
+ expect(cells[0].className).not.toContain("text-right");
30
+ expect(cells[4].className).not.toContain("tabular-nums");
31
+ });
32
+
33
+ it("scrolls inside a named, focusable region and never uses a tiny font", () => {
34
+ render(<Markdown>{TABLE}</Markdown>);
35
+ const region = screen.getByRole("region", { name: "Table" });
36
+ expect(region.className).toContain("overflow-x-auto");
37
+ expect(region.getAttribute("tabindex")).toBe("0");
38
+ expect(region.closest(".not-prose")).not.toBeNull();
39
+ expect(screen.getByRole("table").className).toContain("text-sm");
40
+ expect(document.body.innerHTML).not.toContain("text-xs");
41
+ });
42
+
43
+ it("copies the table as CSV with quoting and formula-safe cells", async () => {
44
+ const writeText = vi.fn().mockResolvedValue(undefined);
45
+ Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
46
+ render(<Markdown>{TABLE}</Markdown>);
47
+ fireEvent.click(screen.getByRole("button", { name: "Copy CSV" }));
48
+ await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
49
+ expect(writeText.mock.calls[0][0]).toBe([
50
+ "Variant,Impressions,CTR %,Spend $,Note",
51
+ "Hero A,\"12,400\",2.50%,$182.40,\"Wins on \"\"reach\"\", barely\"",
52
+ "Hero B,\"9,870\",4.17%,$160.05,'=SUM(A1)",
53
+ ].join("\n"));
54
+ expect(await screen.findByText("Table copied as CSV")).toBeInTheDocument();
55
+ });
56
+
57
+ it("treats a column as numeric only when every filled cell is a figure", () => {
58
+ expect(numericColumns([["a", "1", "", "$2.50"], ["b", "x", "3", "-4%"]])).toEqual([false, false, true, true]);
59
+ expect(tableCsv([["a,b", " c"]])).toBe('"a,b"," c"');
60
+ });
61
+ });