@tangle-network/ui 11.30.1 → 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.
- package/CHANGELOG.md +17 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +7 -2
- package/dist/markdown/code-block.d.ts +11 -4
- package/dist/markdown/code-block.js +239 -107
- package/dist/markdown/code-meta.d.ts +11 -0
- package/dist/markdown/code-meta.js +98 -0
- package/dist/markdown/markdown-table.d.ts +8 -0
- package/dist/markdown/markdown-table.js +168 -0
- package/dist/markdown/markdown.d.ts +17 -4
- package/dist/markdown/markdown.js +96 -34
- package/dist/markdown.d.ts +5 -3
- package/dist/markdown.js +4 -2
- package/dist/primitives/toolbar.d.ts +10 -10
- package/dist/primitives/toolbar.js +2 -2
- package/dist/run/approval-card.d.ts +46 -0
- package/dist/run/approval-card.js +162 -0
- package/dist/run/run-phase-list.d.ts +18 -0
- package/dist/run/run-phase-list.js +56 -0
- package/dist/run/task-list.d.ts +33 -0
- package/dist/run/task-list.js +138 -0
- package/dist/run.d.ts +4 -1
- package/dist/run.js +4 -1
- package/package.json +3 -4
- package/src/markdown/code-block.test.tsx +35 -26
- package/src/markdown/code-block.tsx +197 -110
- package/src/markdown/code-meta.ts +68 -0
- package/src/markdown/index.ts +10 -2
- package/src/markdown/markdown-table.test.tsx +61 -0
- package/src/markdown/markdown-table.tsx +191 -0
- package/src/markdown/markdown.test.tsx +27 -0
- package/src/markdown/markdown.tsx +126 -48
- package/src/primitives/toolbar.test.tsx +9 -10
- package/src/primitives/toolbar.tsx +12 -12
- package/src/run/approval-card.test.tsx +41 -0
- package/src/run/approval-card.tsx +146 -0
- package/src/run/index.ts +17 -0
- package/src/run/run-phase-list.test.tsx +25 -0
- package/src/run/run-phase-list.tsx +56 -0
- package/src/run/task-list.test.tsx +35 -0
- 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
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
87
|
-
* from the
|
|
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
|
|
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
|
|
103
|
-
if (active)
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
{!
|
|
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
|
-
{
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}
|
package/src/markdown/index.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
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
|
+
});
|