Skip to content

Commit 52d4032

Browse files
authored
Merge pull request #485 from easyops-cn/steve/markdown-link
feat(): support external links
2 parents 6ee515a + 04bd1c4 commit 52d4032

File tree

8 files changed

+302
-17
lines changed

8 files changed

+302
-17
lines changed

bricks/markdown/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
"build:main": "cross-env NODE_ENV=production build-next-bricks",
3232
"build:types": "tsc --emitDeclarationOnly --declaration --declarationDir dist-types --project tsconfig.json",
3333
"build:manifest": "cross-env NODE_ENV=production build-next-bricks --manifest-only",
34+
"test": "cross-env NODE_ENV='test' test-next",
35+
"test:ci": "cross-env NODE_ENV='test' CI=true test-next",
3436
"prepublishOnly": "cp package.json package.json.bak && npm pkg delete dependencies",
3537
"postpublish": "mv package.json.bak package.json"
3638
},
@@ -53,6 +55,7 @@
5355
"@next-api-sdk/object-store-sdk": "1.1.0",
5456
"@next-core/element": "^1.2.19",
5557
"@next-core/react-element": "^1.0.38",
58+
"@next-core/runtime": "^1.71.4",
5659
"@next-core/theme": "^1.6.1",
5760
"@next-shared/form": "^0.10.1",
5861
"@next-shared/markdown": "^0.7.4",
Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,158 @@
1-
import React from "react";
2-
import { describe, test, expect, jest } from "@jest/globals";
1+
import { describe, test, expect } from "@jest/globals";
32
import { act } from "react-dom/test-utils";
43
import "./";
54
import type { MarkdownDisplay } from "./index.js";
65

7-
jest.mock("@next-shared/markdown", () => ({
8-
MarkdownComponent: jest.fn((props: { content?: string }) => (
9-
<div>{props.content}</div>
10-
)),
11-
}));
126
jest.mock("@next-core/theme", () => ({}));
137

8+
async function waitForContent(
9+
element: MarkdownDisplay,
10+
selector: string,
11+
timeout = 5000
12+
): Promise<Element> {
13+
const startTime = Date.now();
14+
while (Date.now() - startTime < timeout) {
15+
await act(async () => {
16+
await new Promise((resolve) => setTimeout(resolve, 50));
17+
});
18+
const found = element.shadowRoot?.querySelector(selector);
19+
if (found) {
20+
return found;
21+
}
22+
}
23+
throw new Error(`Timeout waiting for selector: ${selector}`);
24+
}
25+
1426
describe("eo-markdown-display", () => {
15-
test("basic usage", async () => {
27+
test("should render with content", async () => {
1628
const element = document.createElement(
1729
"eo-markdown-display"
1830
) as MarkdownDisplay;
31+
element.content = "# Hello World";
32+
33+
act(() => {
34+
document.body.appendChild(element);
35+
});
36+
37+
const h1 = await waitForContent(element, "h1");
38+
expect(h1?.textContent).toBe("Hello World");
39+
40+
act(() => {
41+
document.body.removeChild(element);
42+
});
43+
});
44+
45+
test("should render external links with target blank and icon", async () => {
46+
const element = document.createElement(
47+
"eo-markdown-display"
48+
) as MarkdownDisplay;
49+
element.content = "[External Link](https://example.com)";
50+
51+
act(() => {
52+
document.body.appendChild(element);
53+
});
54+
55+
const link = await waitForContent(element, "a");
56+
expect(link.getAttribute("target")).toBe("_blank");
57+
expect(link.getAttribute("rel")).toBe("nofollow noopener noreferrer");
1958

20-
expect(element.shadowRoot).toBeFalsy();
59+
// Should have external link icon
60+
const icon = link.querySelector("eo-icon");
61+
expect(icon?.getAttribute("lib")).toBe("lucide");
62+
expect(icon?.getAttribute("icon")).toBe("external-link");
63+
64+
act(() => {
65+
document.body.removeChild(element);
66+
});
67+
});
68+
69+
test("should render internal links without target blank", async () => {
70+
const element = document.createElement(
71+
"eo-markdown-display"
72+
) as MarkdownDisplay;
73+
element.content = "[Internal Link](/some/path)";
2174

2275
act(() => {
2376
document.body.appendChild(element);
2477
});
25-
expect(element.shadowRoot?.childNodes.length).toBeGreaterThan(1);
78+
79+
const link = await waitForContent(element, "a");
80+
expect(link.getAttribute("target")).toBeNull();
81+
expect(link.getAttribute("rel")).toBeNull();
82+
83+
// Should not have external link icon
84+
const icon = link.querySelector("eo-icon");
85+
expect(icon).toBeNull();
86+
87+
act(() => {
88+
document.body.removeChild(element);
89+
});
90+
});
91+
92+
test("should not add icon to external links with images", async () => {
93+
const element = document.createElement(
94+
"eo-markdown-display"
95+
) as MarkdownDisplay;
96+
element.content =
97+
"[![Image](https://example.com/image.png)](https://example.com)";
98+
99+
act(() => {
100+
document.body.appendChild(element);
101+
});
102+
103+
const link = await waitForContent(element, "a");
104+
expect(link.getAttribute("target")).toBe("_blank");
105+
106+
// Should not have external link icon since it contains an image
107+
const icon = link.querySelector("eo-icon");
108+
expect(icon).toBeNull();
109+
110+
act(() => {
111+
document.body.removeChild(element);
112+
});
113+
});
114+
115+
test("should render code blocks with syntax highlighting", async () => {
116+
const element = document.createElement(
117+
"eo-markdown-display"
118+
) as MarkdownDisplay;
119+
element.content = "```js\nconst a = 1;\n```";
120+
121+
act(() => {
122+
document.body.appendChild(element);
123+
});
124+
125+
// Should use presentational.code-wrapper for code blocks
126+
const codeWrapper = await waitForContent(
127+
element,
128+
"presentational\\.code-wrapper"
129+
);
130+
expect(codeWrapper).toBeTruthy();
131+
132+
act(() => {
133+
document.body.removeChild(element);
134+
});
135+
});
136+
137+
test("should pass themeVariant to code wrapper", async () => {
138+
const element = document.createElement(
139+
"eo-markdown-display"
140+
) as MarkdownDisplay;
141+
element.content = "```js\nconst a = 1;\n```";
142+
element.themeVariant = "elevo";
143+
144+
act(() => {
145+
document.body.appendChild(element);
146+
});
147+
148+
const codeWrapper = await waitForContent(
149+
element,
150+
"presentational\\.code-wrapper"
151+
);
152+
expect(codeWrapper.getAttribute("themeVariant")).toBe("elevo");
26153

27154
act(() => {
28155
document.body.removeChild(element);
29156
});
30-
expect(element.shadowRoot?.childNodes.length).toBe(0);
31157
});
32158
});

bricks/markdown/src/markdown-display/index.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import React, { useMemo } from "react";
22
import { createDecorators } from "@next-core/element";
33
import { ReactNextElement, wrapBrick } from "@next-core/react-element";
4+
import { getBasePath } from "@next-core/runtime";
45
import { useCurrentTheme } from "@next-core/react-runtime";
56
import "@next-core/theme";
67
import {
78
MarkdownComponent,
9+
type Element,
810
type MarkdownComponentProps,
11+
type RehypeExternalLinksOptions,
912
} from "@next-shared/markdown";
1013
import {
1114
CodeWrapper,
@@ -25,6 +28,31 @@ export interface MarkdownDisplayProps {
2528
themeVariant?: "default" | "elevo";
2629
}
2730

31+
const externalLinks: RehypeExternalLinksOptions = {
32+
target: "_blank",
33+
rel: ["nofollow", "noopener", "noreferrer"],
34+
test: (element: Element) => {
35+
return isExternalLink(element.properties.href);
36+
},
37+
content(element) {
38+
if (containsImg(element)) {
39+
return;
40+
}
41+
return {
42+
type: "element",
43+
tagName: "eo-icon",
44+
properties: {
45+
lib: "lucide",
46+
icon: "external-link",
47+
},
48+
children: [],
49+
};
50+
},
51+
contentProperties: {
52+
className: "external-link-icon",
53+
},
54+
};
55+
2856
/**
2957
* 用于展示 markdown 内容的构件。
3058
*/
@@ -85,6 +113,28 @@ function MarkdownDisplayComponent({
85113
content={content}
86114
components={components}
87115
shiki={shikiOptions}
116+
externalLinks={externalLinks}
88117
/>
89118
);
90119
}
120+
121+
function containsImg(element: Element): boolean {
122+
return element.children.some((child) => {
123+
if (child.type === "element") {
124+
return child.tagName === "img" || containsImg(child);
125+
}
126+
return false;
127+
});
128+
}
129+
130+
function isExternalLink(href: unknown): boolean {
131+
if (typeof href !== "string") {
132+
return false;
133+
}
134+
try {
135+
const url = new URL(href, `${location.origin}${getBasePath()}`);
136+
return url.origin !== location.origin;
137+
} catch {
138+
return true;
139+
}
140+
}

bricks/markdown/src/markdown-display/styles.shadow.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,17 @@ blockquote {
2424
margin-left: 0;
2525
margin-right: 0;
2626
}
27+
28+
a {
29+
color: var(--antd-btn-link-color);
30+
text-decoration: none;
31+
32+
&:hover {
33+
color: var(--antd-btn-link-hover-color);
34+
}
35+
}
36+
37+
.external-link-icon {
38+
margin-left: 3px;
39+
color: var(--text-color-disabled);
40+
}

bricks/markdown/test.config.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// @ts-check
2+
/** @type {import("@next-core/test-next").TestNextConfig} */
3+
export default {
4+
transformModulePatterns: [
5+
"(?:hast|unist|mdast|estree)-util-(?:[^/]+)/",
6+
"unified/",
7+
"bail/",
8+
"devlop/",
9+
"is-plain-obj/",
10+
"trough/",
11+
"vfile/",
12+
"vfile-message/",
13+
"decode-named-character-reference/",
14+
"ccount/",
15+
"escape-string-regexp/",
16+
"markdown-table/",
17+
"longest-streak/",
18+
"zwitch/",
19+
"trim-lines/",
20+
"property-information/",
21+
"is-absolute-url/",
22+
"space-separated-tokens/",
23+
"comma-separated-tokens/",
24+
"micromark/",
25+
"micromark-(?:[^/]+)/",
26+
"remark-(?:[^/]+)/",
27+
"rehype-(?:[^/]+)/",
28+
"@shikijs/",
29+
"shiki/",
30+
"html-void-elements/",
31+
"hastscript/",
32+
"web-namespaces/",
33+
"stringify-entities/",
34+
"character-entities-(?:[^/]+)/",
35+
],
36+
};

shared/markdown/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"hast-util-to-string": "^3.0.0",
4141
"mermaid": "^11.9.0",
4242
"react": "0.0.0-experimental-ee8509801-20230117",
43+
"rehype-external-links": "^3.0.0",
4344
"rehype-react": "^8.0.0",
4445
"remark-gfm": "^4.0.1",
4546
"remark-parse": "^11.0.0",

shared/markdown/src/MarkdownComponent.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { unified } from "unified";
44
import remarkParse from "remark-parse";
55
import remarkGfm from "remark-gfm";
66
import remarkToRehype from "remark-rehype";
7+
import rehypeExternalLinks, {
8+
type Options as RehypeExternalLinksOptions,
9+
} from "rehype-external-links";
710
import rehypeReact, { Options as RehypeReactOptions } from "rehype-react";
811
import type { Components } from "hast-util-to-jsx-runtime";
912
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
@@ -15,13 +18,16 @@ import { getCodeLanguage } from "./utils.js";
1518

1619
const production = { Fragment, jsx, jsxs };
1720

21+
export type { RehypeExternalLinksOptions, Element };
22+
1823
export interface MarkdownComponentProps {
1924
content?: string;
2025
components?: Partial<Components>;
2126
shiki?: {
2227
/** @default "dark-plus" */
2328
theme?: "light-plus" | "dark-plus";
2429
};
30+
externalLinks?: RehypeExternalLinksOptions;
2531
}
2632

2733
export async function preloadHighlighter(
@@ -62,6 +68,7 @@ export function MarkdownComponent({
6268
content,
6369
components,
6470
shiki,
71+
externalLinks,
6572
}: MarkdownComponentProps): JSX.Element | null {
6673
const [reactContent, setReactContent] = useState<JSX.Element | null>(null);
6774
const theme = shiki?.theme ?? "dark-plus";
@@ -80,6 +87,7 @@ export function MarkdownComponent({
8087
.use(remarkParse)
8188
.use(remarkGfm)
8289
.use(remarkToRehype)
90+
.use(rehypeExternalLinks, externalLinks)
8391
.use(rehypeMermaid)
8492
.use(rehypeFallbackLanguage)
8593
.use(rehypeShikiFromHighlighter, highlighter as any, {
@@ -111,7 +119,7 @@ export function MarkdownComponent({
111119
return () => {
112120
ignore = true;
113121
};
114-
}, [components, content, theme]);
122+
}, [components, content, externalLinks, theme]);
115123

116124
return reactContent;
117125
}

0 commit comments

Comments
 (0)