Skip to content

Commit 1229c25

Browse files
committed
重新设计外部链接样式,使用 google服务自动获取外链 icon
1 parent 10af080 commit 1229c25

File tree

7 files changed

+119
-98
lines changed

7 files changed

+119
-98
lines changed

astro.config.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import AstroPureIntegration from './packages/pure/index.ts';
1919
// Local integrations
2020
// Local rehype & remark plugins
2121
import rehypeAutolinkHeadings from './src/plugins/rehype-auto-link-headings.ts';
22-
import { remarkAiNotice } from './src/plugins/remark-ai-notice.mjs';
22+
// import { remarkAiNotice } from './src/plugins/remark-ai-notice.mjs';
2323
import { remarkMermaid } from './src/plugins/remark-mermaid';
2424
// Shiki
2525
// import { addCopyButton, addLanguage, addTitle, transformerNotationDiff, transformerNotationHighlight, updateStyle } from './src/plugins/shiki-transformers.ts';
@@ -197,7 +197,7 @@ export default defineConfig({
197197
],
198198
remarkBreaks,
199199
remarkMermaid,
200-
remarkAiNotice
200+
// remarkAiNotice
201201
],
202202
rehypePlugins: [
203203
[rehypeKatex, {}],

packages/pure/index.ts

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
1-
import { spawn } from 'node:child_process'
2-
import { dirname, relative } from 'node:path'
3-
import { fileURLToPath } from 'node:url'
1+
import { spawn } from 'node:child_process';
2+
import { dirname, relative } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
44
// Astro
5-
import type { AstroIntegration, RehypePlugins, RemarkPlugins } from 'astro'
5+
import type { AstroIntegration, RehypePlugins, RemarkPlugins } from 'astro';
66
// Integrations
7-
import mdx from '@astrojs/mdx'
8-
import sitemap from '@astrojs/sitemap'
9-
import rehypeExternalLinks from 'rehype-external-links'
10-
import UnoCSS from 'unocss/astro'
11-
import rehypeTable from './plugins/rehype-table'
7+
import mdx from '@astrojs/mdx';
8+
import sitemap from '@astrojs/sitemap';
9+
import UnoCSS from 'unocss/astro';
10+
11+
12+
13+
import rehypeExternalLinks from './plugins/rehype-external-links';
14+
import rehypeTable from './plugins/rehype-table';
15+
import { remarkAddZoomable, remarkReadingTime } from './plugins/remark-plugins';
16+
import { vitePluginUserConfig } from './plugins/virtual-user-config';
17+
import { UserConfigSchema, type UserInputConfig } from './types/user-config';
18+
import { parseWithFriendlyErrors } from './utils/error-map';
1219

13-
import { remarkAddZoomable, remarkReadingTime } from './plugins/remark-plugins'
14-
import { vitePluginUserConfig } from './plugins/virtual-user-config'
15-
import { UserConfigSchema, type UserInputConfig } from './types/user-config'
16-
import { parseWithFriendlyErrors } from './utils/error-map'
1720

1821
export default function AstroPureIntegration(opts: UserInputConfig): AstroIntegration {
19-
let integrations: AstroIntegration[] = []
20-
let remarkPlugins: RemarkPlugins = []
21-
let rehypePlugins: RehypePlugins = []
22+
const integrations: AstroIntegration[] = []
23+
const remarkPlugins: RemarkPlugins = []
24+
const rehypePlugins: RehypePlugins = []
2225
return {
2326
name: 'astro-pure',
2427
hooks: {
2528
'astro:config:setup': async ({ config, updateConfig }) => {
26-
let userConfig = parseWithFriendlyErrors(
29+
const userConfig = parseWithFriendlyErrors(
2730
// @ts-ignore
2831
UserConfigSchema,
2932
opts,
@@ -52,18 +55,12 @@ export default function AstroPureIntegration(opts: UserInputConfig): AstroIntegr
5255
rehypePlugins.push([
5356
rehypeExternalLinks,
5457
{
55-
content: {
56-
type: 'text',
57-
value: userConfig.content.externalLinks.content || ''
58-
},
59-
contentProperties: {
60-
className: ['external-link-icon']
61-
},
6258
properties: {
63-
className: ['external-link','not-prose']
59+
className: ['external-link', 'not-prose']
6460
},
6561
target: '_blank',
6662
rel: ['nofollow', 'noopener', 'noreferrer'],
63+
customIcons: userConfig.content.externalLinks.customIcons
6764
}
6865
])
6966
rehypePlugins.push(rehypeTable)
@@ -111,4 +108,4 @@ export default function AstroPureIntegration(opts: UserInputConfig): AstroIntegr
111108
}
112109
}
113110
}
114-
}
111+
}

packages/pure/plugins/rehype-external-links.ts

Lines changed: 69 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,95 @@
11
// https://github.com/rehypejs/rehype-external-links
2-
3-
import type { Element, ElementContent, Root } from 'hast'
2+
import type { Element, Root } from 'hast'
43
import { visit } from 'unist-util-visit'
54

5+
import { Icons } from '../libs/icons'
66
import isAbsoluteUrl from '../utils/is-absolute-url'
77

88
export interface ExternalLinkOptions {
9-
content?: ElementContent | ElementContent[]
10-
contentProperties?: Record<string, unknown>
119
protocols?: string[]
10+
rel?: string | string[]
11+
target?: string
12+
properties?: Record<string, unknown>
13+
customIcons?: Record<string, string> // hostname -> icon key
1214
}
1315

14-
const defaultProtocols = ['http', 'https']
16+
const defaultProtocols = ['http', 'https'];
1517

16-
/**
17-
* Automatically add `rel` (and `target`?) to external links.
18-
*
19-
* ###### Notes
20-
*
21-
* You should [likely not configure `target`][css-tricks].
22-
*
23-
* You should at least set `rel` to `['nofollow']`.
24-
* When using a `target`, add `noopener` and `noreferrer` to avoid exploitation
25-
* of the `window.opener` API.
26-
*
27-
* When using a `target`, you should set `content` to adhere to accessibility
28-
* guidelines by giving users advanced warning when opening a new window.
29-
*
30-
* [css-tricks]: https://css-tricks.com/use-target_blank/
31-
*
32-
* @param {Readonly<Options> | null | undefined} [options]
33-
* Configuration (optional).
34-
* @returns
35-
* Transform.
36-
*/
3718
export default function rehypeExternalLinks(options: ExternalLinkOptions = {}) {
38-
const { content, contentProperties = {}, protocols = defaultProtocols } = options
19+
const {
20+
protocols = defaultProtocols,
21+
rel = ['nofollow', 'noopener', 'noreferrer'],
22+
target = '_blank',
23+
properties = {},
24+
customIcons = {}
25+
} = options
3926

4027
return function transformer(tree: Root): void {
4128
visit(tree, 'element', (node: Element) => {
4229
if (node.tagName === 'a' && typeof node.properties?.href === 'string') {
4330
const href = node.properties.href
44-
const protocol = href.startsWith('//')
45-
? 'http' // treat protocol-relative as http
46-
: href.slice(0, href.indexOf(':'))
31+
const protocolRelative = href.startsWith('//')
32+
const protocol = protocolRelative ? 'http' : href.slice(0, href.indexOf(':'))
4733

48-
if (href.startsWith('//') || (isAbsoluteUrl(href) && protocols.includes(protocol))) {
34+
if (protocolRelative || (isAbsoluteUrl(href) && protocols.includes(protocol))) {
4935
node.properties = {
5036
...node.properties,
51-
rel: 'nofollow noopener noreferrer',
52-
target: '_blank'
37+
...properties,
38+
rel,
39+
target
5340
}
5441

55-
if (content) {
56-
const spanNode: Element = {
57-
type: 'element',
58-
tagName: 'span',
59-
properties: {
60-
...(contentProperties as Record<
61-
string,
62-
string | number | boolean | (string | number)[] | null | undefined
63-
>)
64-
},
65-
children: Array.isArray(content) ? content : [content]
42+
const url = protocolRelative ? `http:${href}` : href
43+
try {
44+
const hostname = new URL(url).hostname
45+
46+
let iconNode: Element | undefined
47+
let svgString: string | undefined
48+
49+
const customIconKey = customIcons?.[hostname]
50+
if (customIconKey) {
51+
svgString = Icons[customIconKey as keyof typeof Icons]
6652
}
6753

68-
node.children.push(spanNode)
54+
if (svgString) {
55+
// If the icon is an SVG fragment (e.g. <g>), wrap it in an <svg> tag.
56+
if (!svgString.trim().startsWith('<svg')) {
57+
svgString = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">${svgString}</svg>`
58+
}
59+
60+
const dataUri = `data:image/svg+xml;base64,${Buffer.from(svgString).toString('base64')}`
61+
iconNode = {
62+
type: 'element',
63+
tagName: 'img',
64+
properties: {
65+
src: dataUri,
66+
className: ['external-link-icon'],
67+
alt: '', // Decorative
68+
width: 16,
69+
height: 16
70+
},
71+
children: []
72+
}
73+
} else {
74+
iconNode = {
75+
type: 'element',
76+
tagName: 'img',
77+
properties: {
78+
src: `https://www.google.com/s2/favicons?domain=${hostname}&size=16`,
79+
className: ['external-link-icon'],
80+
alt: '', // Decorative
81+
width: 16,
82+
height: 16
83+
},
84+
children: []
85+
}
86+
}
87+
88+
if (iconNode) {
89+
node.children.unshift(iconNode)
90+
}
91+
} catch (e) {
92+
// Ignore invalid URLs
6993
}
7094
}
7195
}

packages/pure/types/theme-config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,12 @@ export const ThemeConfigSchema = () =>
173173
properties: z
174174
.record(z.string())
175175
.optional()
176-
.describe('Properties for the external links element')
176+
.describe('Properties for the external links element'),
177+
/** Custom icons for external links */
178+
customIcons: z
179+
.record(z.string())
180+
.optional()
181+
.describe('Custom icons for external links. Key is hostname, value is SVG string.')
177182
}),
178183

179184
/** Blog page size for pagination */

src/assets/styles/external-link.css

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,7 @@
1515
border-bottom-width: 1px;
1616
}
1717

18-
/* 不再需要 after 图标 */
19-
/* .external-link::after {
20-
display: none !important;
21-
} */
22-
23-
/* 特定网站的图标样式保持不变 */
24-
/* .external-link[href*="github.com"]::after {
25-
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 2C6.477 2 2 6.477 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2z'/%3E%3C/svg%3E");
26-
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 2C6.477 2 2 6.477 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2z'/%3E%3C/svg%3E");
27-
}
28-
29-
.external-link[href*="twitter.com"]::after,
30-
.external-link[href*="x.com"]::after {
31-
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z'/%3E%3C/svg%3E");
32-
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z'/%3E%3C/svg%3E");
33-
} */
34-
3518
.external-link-icon {
36-
display: none;
37-
}
19+
display: inline-block;
20+
vertical-align: text-bottom;
21+
}

src/assets/styles/inner-link.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
text-decoration: none;
33
position: relative;
44
padding-right: 1.2em;
5-
border-bottom: 1px dashed rgba(120, 120, 120, 0.4); /* 虚线,淡灰色 */
5+
border-bottom: 1px dashed rgba(120, 120, 120, 0.4);
66
transition:
77
border-bottom-color 0.3s cubic-bezier(0.4,0,0.2,1),
88
border-bottom-width 0.3s cubic-bezier(0.4,0,0.2,1);
99
}
1010

1111

1212
.inner-link:hover {
13-
border-bottom-color: rgba(120, 120, 120, 1); /* hover更明显 */
13+
border-bottom-color: rgba(120, 120, 120, 1);
1414
border-bottom-width: 1px;
1515
}
1616

@@ -32,4 +32,4 @@
3232
-webkit-mask-size: 100% 100%;
3333
mask-size: 100% 100%;
3434
opacity: 0.6;
35-
}
35+
} */

src/site.config.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import type { Config, IntegrationUserConfig, ThemeUserConfig } from 'packages/pure/types';
2+
3+
4+
25
import type { CardListData } from 'astro-pure/types';
6+
7+
8+
9+
10+
311
export const theme: ThemeUserConfig = {
412
// === Basic configuration ===
513
/** Title for your website. Will be used in metadata and as browser tab title. */
6-
title: 'CCM\'blog',
14+
title: "CCM'blog",
715
/** Will be used in index page & copyright declaration */
816
author: 'CCM',
917
/** Description metadata for your website. Can be used in page metadata. */
@@ -54,7 +62,7 @@ export const theme: ThemeUserConfig = {
5462
{ title: 'Cats', link: '/cats' },
5563
// { title: 'Links', link: '/links' },
5664
{ title: 'About', link: '/about' },
57-
{ title: 'GusetBook', link: '/guestbook' },
65+
{ title: 'GusetBook', link: '/guestbook' }
5866
]
5967
},
6068

@@ -82,6 +90,9 @@ export const theme: ThemeUserConfig = {
8290
content: {
8391
externalLinks: {
8492
content: '',
93+
customIcons: {
94+
'github.com': 'pin'
95+
}
8596
},
8697
/** Blog page size for pagination (optional) */
8798
blogPageSize: 8,

0 commit comments

Comments
 (0)