@tangle-network/ui 11.13.0 → 11.13.1
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 +7 -0
- package/dist/markdown/code-block.js +41 -11
- package/package.json +1 -1
- package/src/markdown/code-block.test.tsx +38 -4
- package/src/markdown/code-block.tsx +56 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.13.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5cbda07: Load the full Highlight.js syntax renderer only when a code block mounts.
|
|
8
|
+
Keep code readable until highlighting arrives, and leave prose-only Markdown free of syntax-engine requests.
|
|
9
|
+
|
|
3
10
|
## 11.13.0
|
|
4
11
|
|
|
5
12
|
### Minor Changes
|
|
@@ -3,7 +3,6 @@ import { cn } from "../lib/utils.js";
|
|
|
3
3
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
import { Check, Copy } from "lucide-react";
|
|
6
|
-
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
7
6
|
//#region src/markdown/code-block.tsx
|
|
8
7
|
const syntaxTheme = (() => {
|
|
9
8
|
const comment = "var(--syntax-comment, currentColor)";
|
|
@@ -65,9 +64,35 @@ const syntaxTheme = (() => {
|
|
|
65
64
|
}
|
|
66
65
|
};
|
|
67
66
|
})();
|
|
67
|
+
let highlighterPromise;
|
|
68
|
+
function loadHighlighter() {
|
|
69
|
+
return highlighterPromise ??= import("react-syntax-highlighter/dist/esm/default-highlight").then((module) => module.default).catch((error) => {
|
|
70
|
+
highlighterPromise = void 0;
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
68
74
|
const CodeBlock = memo(({ code, language, label, showLineNumbers = false, light, className, children, ...props }) => {
|
|
75
|
+
const [Highlighter, setHighlighter] = useState(null);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
let active = true;
|
|
78
|
+
loadHighlighter().then((component) => {
|
|
79
|
+
if (active) setHighlighter(() => component);
|
|
80
|
+
}).catch(() => {});
|
|
81
|
+
return () => {
|
|
82
|
+
active = false;
|
|
83
|
+
};
|
|
84
|
+
}, []);
|
|
69
85
|
const headerLabel = label ?? language;
|
|
70
86
|
const mode = light === void 0 ? {} : { "data-theme": light ? "light" : "dark" };
|
|
87
|
+
const customStyle = {
|
|
88
|
+
margin: 0,
|
|
89
|
+
padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
|
|
90
|
+
background: "transparent",
|
|
91
|
+
fontSize: "var(--code-font-size, 0.8125rem)",
|
|
92
|
+
lineHeight: "var(--code-line-height, 1.5)",
|
|
93
|
+
overflowX: "auto"
|
|
94
|
+
};
|
|
95
|
+
const codeTagProps = { style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } };
|
|
71
96
|
return /* @__PURE__ */ jsxs("div", {
|
|
72
97
|
className: cn("group relative overflow-hidden rounded-lg border font-mono", "bg-card border-border", className),
|
|
73
98
|
...props,
|
|
@@ -84,7 +109,7 @@ const CodeBlock = memo(({ code, language, label, showLineNumbers = false, light,
|
|
|
84
109
|
className: "absolute right-2 top-2 z-10 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100",
|
|
85
110
|
children
|
|
86
111
|
}),
|
|
87
|
-
/* @__PURE__ */ jsx(
|
|
112
|
+
Highlighter ? /* @__PURE__ */ jsx(Highlighter, {
|
|
88
113
|
language: language ?? "text",
|
|
89
114
|
style: syntaxTheme,
|
|
90
115
|
showLineNumbers,
|
|
@@ -93,17 +118,22 @@ const CodeBlock = memo(({ code, language, label, showLineNumbers = false, light,
|
|
|
93
118
|
minWidth: "2.5em",
|
|
94
119
|
paddingRight: "1em"
|
|
95
120
|
},
|
|
96
|
-
customStyle
|
|
97
|
-
|
|
98
|
-
padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
|
|
99
|
-
background: "transparent",
|
|
100
|
-
fontSize: "var(--code-font-size, 0.8125rem)",
|
|
101
|
-
lineHeight: "var(--code-line-height, 1.5)",
|
|
102
|
-
overflowX: "auto"
|
|
103
|
-
},
|
|
104
|
-
codeTagProps: { style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } },
|
|
121
|
+
customStyle,
|
|
122
|
+
codeTagProps,
|
|
105
123
|
wrapLines: false,
|
|
106
124
|
children: code
|
|
125
|
+
}) : /* @__PURE__ */ jsx("pre", {
|
|
126
|
+
style: {
|
|
127
|
+
...syntaxTheme.hljs,
|
|
128
|
+
...customStyle
|
|
129
|
+
},
|
|
130
|
+
children: /* @__PURE__ */ jsx("code", {
|
|
131
|
+
style: {
|
|
132
|
+
...codeTagProps.style,
|
|
133
|
+
whiteSpace: "pre"
|
|
134
|
+
},
|
|
135
|
+
children: code
|
|
136
|
+
})
|
|
107
137
|
})
|
|
108
138
|
]
|
|
109
139
|
});
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { cleanup, render, screen } from "@testing-library/react";
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import { renderToString } from "react-dom/server";
|
|
3
|
+
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
3
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
5
|
import { CodeBlock, CopyButton } from "./code-block";
|
|
5
6
|
|
|
6
7
|
afterEach(cleanup);
|
|
7
8
|
|
|
8
9
|
describe("CodeBlock scoped semantic colors", () => {
|
|
9
|
-
it("
|
|
10
|
+
it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
|
|
10
11
|
const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
|
|
11
|
-
expect(container.
|
|
12
|
+
expect(container.querySelector("pre code")?.textContent).toBe('const message = "hello";');
|
|
13
|
+
await waitFor(() => expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)"));
|
|
12
14
|
expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
|
|
13
15
|
expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
|
|
16
|
+
expect(container.querySelector(".react-syntax-highlighter-line-number")).not.toBeNull();
|
|
17
|
+
expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
it("inherits a named scope by default without inserting its own mode", () => {
|
|
@@ -35,10 +39,12 @@ describe("CodeBlock scoped semantic colors", () => {
|
|
|
35
39
|
expect(screen.getByRole("button", { name: "Copy example" })).toBeTruthy();
|
|
36
40
|
});
|
|
37
41
|
|
|
38
|
-
it("server-renders the same semantic references independent of document theme", () => {
|
|
42
|
+
it("server-renders readable code with the same semantic references independent of document theme", () => {
|
|
39
43
|
const source = <CodeBlock code="const x = 1" language="javascript" />;
|
|
40
44
|
document.documentElement.className = "dark";
|
|
41
45
|
const dark = renderToString(source);
|
|
46
|
+
expect(dark).toContain("const x = 1");
|
|
47
|
+
expect(dark).toContain("var(--syntax-foreground, currentColor)");
|
|
42
48
|
document.documentElement.className = "light";
|
|
43
49
|
expect(renderToString(source)).toBe(dark);
|
|
44
50
|
document.documentElement.removeAttribute("class");
|
|
@@ -52,6 +58,34 @@ describe("CodeBlock", () => {
|
|
|
52
58
|
expect(container.querySelector("code")).not.toBeNull();
|
|
53
59
|
});
|
|
54
60
|
|
|
61
|
+
it.each([
|
|
62
|
+
["javascript", "const answer = 42;"],
|
|
63
|
+
["js", "const answer = 42;"],
|
|
64
|
+
["brainfuck", "++[>+++<-]"],
|
|
65
|
+
["not-a-language", "const answer = 42;"],
|
|
66
|
+
])("keeps the default highlighter's tokens for language %s", async (language, code) => {
|
|
67
|
+
const baseline = document.createElement("div");
|
|
68
|
+
baseline.innerHTML = renderToString(<SyntaxHighlighter language={language}>{code}</SyntaxHighlighter>);
|
|
69
|
+
const expected = Array.from(baseline.querySelectorAll("pre code span"), (span) => span.textContent);
|
|
70
|
+
expect(expected.length).toBeGreaterThan(0);
|
|
71
|
+
|
|
72
|
+
const { container } = render(<CodeBlock code={code} language={language} />);
|
|
73
|
+
await waitFor(() => expect(container.querySelectorAll("pre code span").length).toBe(expected.length));
|
|
74
|
+
expect(Array.from(container.querySelectorAll("pre code span"), (span) => span.textContent)).toEqual(expected);
|
|
75
|
+
expect(container.querySelector("pre code")?.textContent).toBe(code);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("preserves whitespace and line numbering after the renderer loads", async () => {
|
|
79
|
+
const code = "\tfirst \n second\n";
|
|
80
|
+
const baseline = document.createElement("div");
|
|
81
|
+
baseline.innerHTML = renderToString(<SyntaxHighlighter language="text" showLineNumbers>{code}</SyntaxHighlighter>);
|
|
82
|
+
const { container } = render(<CodeBlock code={code} language="text" showLineNumbers />);
|
|
83
|
+
expect(container.querySelector("pre code")?.textContent).toBe(code);
|
|
84
|
+
await waitFor(() => expect(container.querySelectorAll(".react-syntax-highlighter-line-number").length)
|
|
85
|
+
.toBe(baseline.querySelectorAll(".react-syntax-highlighter-line-number").length));
|
|
86
|
+
expect(container.querySelector("pre code")?.textContent).toBe(baseline.querySelector("pre code")?.textContent);
|
|
87
|
+
});
|
|
88
|
+
|
|
55
89
|
it("uses `label` as the header text, overriding `language`", () => {
|
|
56
90
|
const { getByText, queryByText } = render(
|
|
57
91
|
<CodeBlock code={"x"} language="typescript" label="config.ts" />,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type HTMLAttributes,
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
|
-
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
10
|
+
import type SyntaxHighlighter from "react-syntax-highlighter";
|
|
11
11
|
import { Check, Copy } from "lucide-react";
|
|
12
12
|
import { focusRing } from "../lib/focus";
|
|
13
13
|
import { cn } from "../lib/utils";
|
|
@@ -65,6 +65,20 @@ const syntaxTheme: { [key: string]: React.CSSProperties } = (() => {
|
|
|
65
65
|
};
|
|
66
66
|
})();
|
|
67
67
|
|
|
68
|
+
type Highlighter = typeof SyntaxHighlighter;
|
|
69
|
+
let highlighterPromise: Promise<Highlighter> | undefined;
|
|
70
|
+
|
|
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)
|
|
76
|
+
.catch((error: unknown) => {
|
|
77
|
+
highlighterPromise = undefined;
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
|
|
69
83
|
code: string;
|
|
70
84
|
language?: string;
|
|
@@ -82,8 +96,28 @@ export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
82
96
|
|
|
83
97
|
export const CodeBlock = memo(
|
|
84
98
|
({ code, language, label, showLineNumbers = false, light, className, children, ...props }: CodeBlockProps) => {
|
|
99
|
+
const [Highlighter, setHighlighter] = useState<Highlighter | null>(null);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
let active = true;
|
|
102
|
+
void loadHighlighter().then((component) => {
|
|
103
|
+
if (active) setHighlighter(() => component);
|
|
104
|
+
}).catch(() => {
|
|
105
|
+
// A failed chunk leaves the code readable; a later mount can retry.
|
|
106
|
+
});
|
|
107
|
+
return () => { active = false; };
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
85
110
|
const headerLabel = label ?? language;
|
|
86
111
|
const mode = light === undefined ? {} : { "data-theme": light ? "light" : "dark" };
|
|
112
|
+
const customStyle = {
|
|
113
|
+
margin: 0,
|
|
114
|
+
padding: "var(--code-padding-y, 0.625rem) var(--code-padding-x, 0.75rem)",
|
|
115
|
+
background: "transparent",
|
|
116
|
+
fontSize: "var(--code-font-size, 0.8125rem)",
|
|
117
|
+
lineHeight: "var(--code-line-height, 1.5)",
|
|
118
|
+
overflowX: "auto" as const,
|
|
119
|
+
};
|
|
120
|
+
const codeTagProps = { style: { fontFamily: "var(--font-mono, 'JetBrains Mono', ui-monospace, monospace)" } };
|
|
87
121
|
|
|
88
122
|
return (
|
|
89
123
|
<div
|
|
@@ -104,28 +138,27 @@ export const CodeBlock = memo(
|
|
|
104
138
|
{children}
|
|
105
139
|
</div>
|
|
106
140
|
)}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
</SyntaxHighlighter>
|
|
141
|
+
{Highlighter ? (
|
|
142
|
+
<Highlighter
|
|
143
|
+
language={language ?? "text"}
|
|
144
|
+
style={syntaxTheme}
|
|
145
|
+
showLineNumbers={showLineNumbers}
|
|
146
|
+
lineNumberStyle={{
|
|
147
|
+
color: "var(--syntax-comment, currentColor)",
|
|
148
|
+
minWidth: "2.5em",
|
|
149
|
+
paddingRight: "1em",
|
|
150
|
+
}}
|
|
151
|
+
customStyle={customStyle}
|
|
152
|
+
codeTagProps={codeTagProps}
|
|
153
|
+
wrapLines={false}
|
|
154
|
+
>
|
|
155
|
+
{code}
|
|
156
|
+
</Highlighter>
|
|
157
|
+
) : (
|
|
158
|
+
<pre style={{ ...syntaxTheme.hljs, ...customStyle }}>
|
|
159
|
+
<code style={{ ...codeTagProps.style, whiteSpace: "pre" }}>{code}</code>
|
|
160
|
+
</pre>
|
|
161
|
+
)}
|
|
129
162
|
</div>
|
|
130
163
|
);
|
|
131
164
|
},
|