@windcoder/astro-theme-orchid 0.0.0-stage → 0.1.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/README.md +311 -2
- package/package.json +68 -3
- package/src/components/AlbumCard.astro +73 -0
- package/src/components/Analytics.astro +22 -0
- package/src/components/BaseLayout.astro +188 -0
- package/src/components/Calendar.astro +379 -0
- package/src/components/CategoryBar.astro +74 -0
- package/src/components/Comment.astro +48 -0
- package/src/components/EncryptedContent.astro +42 -0
- package/src/components/Footer.astro +31 -0
- package/src/components/Header.astro +153 -0
- package/src/components/License.astro +29 -0
- package/src/components/Live2DWidget.astro +262 -0
- package/src/components/MusicPlayer.astro +940 -0
- package/src/components/OutdatedCard.astro +53 -0
- package/src/components/Pagination.astro +94 -0
- package/src/components/PhotoCard.astro +22 -0
- package/src/components/PostCard.astro +89 -0
- package/src/components/PostNav.astro +40 -0
- package/src/components/RecommendedPost.astro +166 -0
- package/src/components/SakuraEffect.astro +152 -0
- package/src/components/Sidebar.astro +334 -0
- package/src/components/Toc.astro +27 -0
- package/src/components/analytics/GoogleAnalytics.astro +22 -0
- package/src/components/analytics/La51Analytics.astro +52 -0
- package/src/components/analytics/MicrosoftClarity.astro +18 -0
- package/src/components/analytics/UmamiAnalytics.astro +86 -0
- package/src/components/comments/Artalk.astro +51 -0
- package/src/components/comments/Disqus.astro +43 -0
- package/src/components/comments/Giscus.astro +50 -0
- package/src/components/comments/Twikoo.astro +65 -0
- package/src/components/comments/Waline.astro +36 -0
- package/src/i18n/en.ts +164 -0
- package/src/i18n/index.ts +18 -0
- package/src/i18n/resolve.ts +49 -0
- package/src/i18n/zh-CN.ts +184 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +406 -0
- package/src/plugins/plantuml-encoder.ts +95 -0
- package/src/plugins/rehype-diagram.ts +218 -0
- package/src/plugins/rehype-email.ts +75 -0
- package/src/plugins/rehype-github-card.ts +114 -0
- package/src/plugins/rehype.ts +284 -0
- package/src/plugins/remark-diagram.ts +78 -0
- package/src/plugins/remark-directives.ts +100 -0
- package/src/plugins/remark-excerpt.ts +29 -0
- package/src/plugins/remark-image-grid.ts +97 -0
- package/src/plugins/remark-reading-time.ts +38 -0
- package/src/plugins/scripts/diagram-panzoom.js +388 -0
- package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
- package/src/routes/404.astro +14 -0
- package/src/routes/_hydrate.ts +34 -0
- package/src/routes/about.astro +25 -0
- package/src/routes/api/allPostMeta.json.ts +26 -0
- package/src/routes/archive.astro +81 -0
- package/src/routes/categories/[category].astro +37 -0
- package/src/routes/categories/index.astro +29 -0
- package/src/routes/friends.astro +83 -0
- package/src/routes/gallery/[album].astro +97 -0
- package/src/routes/gallery/index.astro +100 -0
- package/src/routes/guestbook.astro +37 -0
- package/src/routes/index.astro +47 -0
- package/src/routes/og/[...slug].ts +281 -0
- package/src/routes/page/[page].astro +53 -0
- package/src/routes/posts/[...slug].astro +337 -0
- package/src/routes/robots.txt.ts +18 -0
- package/src/routes/rss.xml.ts +37 -0
- package/src/routes/series/[name].astro +63 -0
- package/src/routes/series/index.astro +64 -0
- package/src/routes/sponsor.astro +84 -0
- package/src/routes/tags/[tag].astro +40 -0
- package/src/routes/tags/index.astro +29 -0
- package/src/schema.ts +1212 -0
- package/src/scripts/app.ts +1041 -0
- package/src/scripts/share-poster.ts +468 -0
- package/src/styles/global.css +4830 -0
- package/src/utils/crypto.ts +46 -0
- package/src/utils/gallery.ts +76 -0
- package/src/utils/lqip.ts +18 -0
- package/src/utils/related.ts +101 -0
- package/src/utils.ts +181 -0
- package/src/vite.ts +105 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rehype 图表插件(M3b-b4,移植自 orchid-blog)
|
|
3
|
+
*
|
|
4
|
+
* - rehypeMermaid:div.mermaid-container → 构建期 beautiful-mermaid 渲染
|
|
5
|
+
* light/dark 双静态 SVG(div.mermaid-svg-light/-dark 包裹,CSS 按主题显隐),
|
|
6
|
+
* 失败降级 .mermaid-error + pre.mermaid-fallback-code 源码展示,零客户端运行时
|
|
7
|
+
* - rehypePlantuml:div.plantuml-wrapper → img.plantuml-image(data-light-src/-dark-src),
|
|
8
|
+
* 客户端 plantuml-theme-switch.js 按主题切换 src、加载失败展示重试
|
|
9
|
+
* - rehypeDiagramPanZoom:为含图表的文章注入 diagram-panzoom.js(控制栏/拖拽/全屏)
|
|
10
|
+
*
|
|
11
|
+
* 偏差(相对 orchid):
|
|
12
|
+
* - 客户端脚本改 readFileSync 模块顶层读入,不依赖 Vite ?raw 管线
|
|
13
|
+
* - panzoom 脚本仅对实际含图表的 tree 注入(orchid 无条件注入)
|
|
14
|
+
* - plantuml wrapper 不再生成随机 id(保证构建产物可复现)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { renderMermaidSVG, THEMES } from "beautiful-mermaid";
|
|
19
|
+
|
|
20
|
+
type Transformer = (tree: any, file: any) => void;
|
|
21
|
+
|
|
22
|
+
/** 深度遍历辅助(与 rehype.ts 内 visit 同风格),fn 返回 'skip' 跳过子树 */
|
|
23
|
+
function visit(node: any, fn: (n: any) => void | "skip") {
|
|
24
|
+
const children = node.children;
|
|
25
|
+
if (!Array.isArray(children)) return;
|
|
26
|
+
for (const child of children) {
|
|
27
|
+
if (fn(child) !== "skip") visit(child, fn);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const panzoomScript = readFileSync(
|
|
32
|
+
new URL("./scripts/diagram-panzoom.js", import.meta.url),
|
|
33
|
+
"utf8",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const plantumlSwitchScript = readFileSync(
|
|
37
|
+
new URL("./scripts/plantuml-theme-switch.js", import.meta.url),
|
|
38
|
+
"utf8",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** 本 rehype tree 已注入过 panzoom 脚本(每篇文档最多一次) */
|
|
42
|
+
const panzoomInjected = new WeakSet<object>();
|
|
43
|
+
|
|
44
|
+
function hasClass(node: any, name: string): boolean {
|
|
45
|
+
const cls = node.properties?.className;
|
|
46
|
+
return Array.isArray(cls) && cls.includes(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function injectScript(tree: any, code: string) {
|
|
50
|
+
tree.children.push({
|
|
51
|
+
type: "element",
|
|
52
|
+
tagName: "script",
|
|
53
|
+
properties: {},
|
|
54
|
+
children: [{ type: "text", value: code }],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function injectPanzoom(tree: any) {
|
|
59
|
+
if (panzoomInjected.has(tree)) return;
|
|
60
|
+
panzoomInjected.add(tree);
|
|
61
|
+
injectScript(tree, panzoomScript);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MermaidOptions {
|
|
65
|
+
lightTheme: string;
|
|
66
|
+
darkTheme: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function rehypeMermaid(options: MermaidOptions): Transformer {
|
|
70
|
+
return (tree) => {
|
|
71
|
+
let found = false;
|
|
72
|
+
visit(tree, (node) => {
|
|
73
|
+
if (node.type !== "element" || node.tagName !== "div") return;
|
|
74
|
+
if (!hasClass(node, "mermaid-container")) return;
|
|
75
|
+
const code = String(node.properties?.["data-mermaid-code"] ?? "");
|
|
76
|
+
const wrapper: any = {
|
|
77
|
+
type: "element",
|
|
78
|
+
tagName: "div",
|
|
79
|
+
properties: { className: ["diagram-wrapper", "mermaid-wrapper"] },
|
|
80
|
+
children: [],
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const lightSvg = renderMermaidSVG(code, {
|
|
84
|
+
...THEMES[options.lightTheme],
|
|
85
|
+
padding: 20,
|
|
86
|
+
});
|
|
87
|
+
const darkSvg = renderMermaidSVG(code, {
|
|
88
|
+
...THEMES[options.darkTheme],
|
|
89
|
+
padding: 20,
|
|
90
|
+
});
|
|
91
|
+
wrapper.children.push(
|
|
92
|
+
{
|
|
93
|
+
type: "element",
|
|
94
|
+
tagName: "div",
|
|
95
|
+
properties: { className: ["mermaid-svg-light"] },
|
|
96
|
+
children: [{ type: "raw", value: lightSvg }],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
type: "element",
|
|
100
|
+
tagName: "div",
|
|
101
|
+
properties: { className: ["mermaid-svg-dark"] },
|
|
102
|
+
children: [{ type: "raw", value: darkSvg }],
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
node.tagName = "div";
|
|
106
|
+
node.properties = {
|
|
107
|
+
className: ["diagram-container", "mermaid-diagram-container"],
|
|
108
|
+
};
|
|
109
|
+
node.children = [wrapper];
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
const preview = code.length > 200 ? `${code.slice(0, 200)}…` : code;
|
|
113
|
+
console.error(`[theme] Mermaid 渲染失败(${message}):\n${preview}`);
|
|
114
|
+
node.properties = {
|
|
115
|
+
className: [
|
|
116
|
+
"diagram-container",
|
|
117
|
+
"mermaid-diagram-container",
|
|
118
|
+
"mermaid-error",
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
node.children = [
|
|
122
|
+
{
|
|
123
|
+
type: "element",
|
|
124
|
+
tagName: "p",
|
|
125
|
+
properties: {},
|
|
126
|
+
children: [
|
|
127
|
+
{ type: "text", value: "Mermaid 图表渲染失败,已展示源码:" },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: "element",
|
|
132
|
+
tagName: "pre",
|
|
133
|
+
properties: { className: ["mermaid-fallback-code"] },
|
|
134
|
+
children: [
|
|
135
|
+
{
|
|
136
|
+
type: "element",
|
|
137
|
+
tagName: "code",
|
|
138
|
+
properties: {},
|
|
139
|
+
children: [{ type: "text", value: code }],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
found = true;
|
|
146
|
+
return "skip";
|
|
147
|
+
});
|
|
148
|
+
if (found) injectPanzoom(tree);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function rehypePlantuml(): Transformer {
|
|
153
|
+
return (tree) => {
|
|
154
|
+
let found = false;
|
|
155
|
+
visit(tree, (node) => {
|
|
156
|
+
if (node.type !== "element" || node.tagName !== "div") return;
|
|
157
|
+
if (!hasClass(node, "plantuml-wrapper")) return;
|
|
158
|
+
const lightSrc = String(node.properties?.["data-light-src"] ?? "");
|
|
159
|
+
const darkSrc = String(node.properties?.["data-dark-src"] ?? lightSrc);
|
|
160
|
+
const alt = String(
|
|
161
|
+
node.properties?.["data-plantuml-alt"] || "PlantUML diagram",
|
|
162
|
+
);
|
|
163
|
+
const img = {
|
|
164
|
+
type: "element",
|
|
165
|
+
tagName: "img",
|
|
166
|
+
properties: {
|
|
167
|
+
className: ["plantuml-image"],
|
|
168
|
+
src: lightSrc,
|
|
169
|
+
alt,
|
|
170
|
+
"data-light-src": lightSrc,
|
|
171
|
+
"data-dark-src": darkSrc,
|
|
172
|
+
loading: "lazy",
|
|
173
|
+
decoding: "async",
|
|
174
|
+
},
|
|
175
|
+
children: [],
|
|
176
|
+
};
|
|
177
|
+
node.tagName = "div";
|
|
178
|
+
node.properties = {
|
|
179
|
+
className: ["diagram-container", "plantuml-diagram-container"],
|
|
180
|
+
};
|
|
181
|
+
node.children = [
|
|
182
|
+
{
|
|
183
|
+
type: "element",
|
|
184
|
+
tagName: "div",
|
|
185
|
+
properties: { className: ["plantuml-wrapper"] },
|
|
186
|
+
children: [img],
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
found = true;
|
|
190
|
+
return "skip";
|
|
191
|
+
});
|
|
192
|
+
if (found) {
|
|
193
|
+
injectPanzoom(tree);
|
|
194
|
+
// PlantUML 主题切换/失败重试脚本(与 panzoom 互相独立,各自防重入)
|
|
195
|
+
injectScript(tree, plantumlSwitchScript);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 单独挂载时的兜底:扫描 tree 中任意图表容器并注入 panzoom 脚本 */
|
|
201
|
+
export function rehypeDiagramPanZoom(): Transformer {
|
|
202
|
+
return (tree) => {
|
|
203
|
+
if (panzoomInjected.has(tree)) return;
|
|
204
|
+
let found = false;
|
|
205
|
+
visit(tree, (node) => {
|
|
206
|
+
if (found) return "skip";
|
|
207
|
+
if (
|
|
208
|
+
node.type === "element" &&
|
|
209
|
+
node.tagName === "div" &&
|
|
210
|
+
hasClass(node, "diagram-container")
|
|
211
|
+
) {
|
|
212
|
+
found = true;
|
|
213
|
+
return "skip";
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
if (found) injectPanzoom(tree);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 邮箱防爬(M3b-b4,移植自 orchid-blog)
|
|
3
|
+
*
|
|
4
|
+
* 构建期把 mailto: 链接的地址编码进 data-encoded-email,href 置为 '#',
|
|
5
|
+
* 并内联一段 onclick 解码脚本:点击时还原地址 → 回写 href → 延时补一次点击。
|
|
6
|
+
* 爬虫不执行点击,拿不到明文邮箱;解码逻辑随属性内联,无需额外客户端资源。
|
|
7
|
+
*
|
|
8
|
+
* 偏差说明:orchid 会向 head 注入占位样式,本主题统一并入 global.css
|
|
9
|
+
* (a[data-encoded-email] 光标提示),插件保持纯 DOM 变换。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type Transformer = (tree: any, file: any) => void;
|
|
13
|
+
|
|
14
|
+
/** 深度遍历辅助(与 rehype.ts 内 visit 同风格),fn 返回 'skip' 跳过子树 */
|
|
15
|
+
function visit(node: any, fn: (n: any) => void | "skip") {
|
|
16
|
+
const children = node.children;
|
|
17
|
+
if (!Array.isArray(children)) return;
|
|
18
|
+
for (const child of children) {
|
|
19
|
+
if (fn(child) !== "skip") visit(child, fn);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EmailProtectionOptions {
|
|
24
|
+
/** base64:兼容性最好(btoa 仅支持 Latin1,超集地址跳过保护保持原样);rot13:零 API 依赖 */
|
|
25
|
+
method: "base64" | "rot13";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const encodeBase64 = (value: string): string | null => {
|
|
29
|
+
try {
|
|
30
|
+
// Buffer 在 Node 构建期可用;btoa 遇非 Latin1 会抛错,统一 try/catch 兜底
|
|
31
|
+
return typeof Buffer !== "undefined"
|
|
32
|
+
? Buffer.from(value, "utf8").toString("base64")
|
|
33
|
+
: btoa(value);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const encodeRot13 = (value: string): string =>
|
|
40
|
+
value.replace(/[a-zA-Z]/g, (c) => {
|
|
41
|
+
const base = c <= "Z" ? 65 : 97;
|
|
42
|
+
return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** 点击时还原地址并补一次点击(压缩成单行内联,this 为链接元素自身) */
|
|
46
|
+
const ONCLICK_TEMPLATE = (method: "base64" | "rot13") => {
|
|
47
|
+
const decode =
|
|
48
|
+
method === "base64"
|
|
49
|
+
? "function(e){return decodeURIComponent(escape(atob(e)))}"
|
|
50
|
+
: 'function(e){return e.replace(/[a-zA-Z]/g,function(c){var b=c.charCodeAt(0),s=c<="Z"?65:97;return String.fromCharCode((b-s+13)%26+s)})}';
|
|
51
|
+
return `(function(){var e=this.getAttribute('data-encoded-email');if(!e)return;this.removeAttribute('data-encoded-email');this.setAttribute('href','mailto:'+(${decode})(e));var a=this;setTimeout(function(){a.click()},0)}).call(this);return false`
|
|
52
|
+
.replace(/\s+/g, " ")
|
|
53
|
+
.trim();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function rehypeEmailProtection(
|
|
57
|
+
options: EmailProtectionOptions,
|
|
58
|
+
): Transformer {
|
|
59
|
+
const encode = options.method === "base64" ? encodeBase64 : encodeRot13;
|
|
60
|
+
const onclick = ONCLICK_TEMPLATE(options.method);
|
|
61
|
+
return (tree) => {
|
|
62
|
+
visit(tree, (node) => {
|
|
63
|
+
if (node.type !== "element" || node.tagName !== "a") return;
|
|
64
|
+
const href = String(node.properties?.href ?? "");
|
|
65
|
+
if (!href.toLowerCase().startsWith("mailto:")) return;
|
|
66
|
+
const address = href.slice("mailto:".length);
|
|
67
|
+
const encoded = encode(address);
|
|
68
|
+
// 编码失败(非 Latin1 的 base64 场景)保持原链接,不做半吊子保护
|
|
69
|
+
if (!encoded) return;
|
|
70
|
+
node.properties["data-encoded-email"] = encoded;
|
|
71
|
+
node.properties.href = "#";
|
|
72
|
+
node.properties.onclick = onclick;
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ::github{repo="owner/repo"} → GitHub 仓库卡片(A2,移植自参考模板
|
|
3
|
+
* rehype-component-github-card)
|
|
4
|
+
*
|
|
5
|
+
* remarkDirectives 已把 leaf 指令转成 <github repo="…"> 标签;本插件将其替换为
|
|
6
|
+
* 卡片结构 + 内联脚本:客户端 fetch api.github.com 填充描述/stars/forks/
|
|
7
|
+
* license/头像;fetch-waiting(骨架 pulsate 动画)→ 完成态 / fetch-error 两态。
|
|
8
|
+
* 数据获取发生在读者浏览器端,构建期零请求。
|
|
9
|
+
*/
|
|
10
|
+
import { h } from "hastscript";
|
|
11
|
+
|
|
12
|
+
function githubCard(properties: any, children: any[]): any {
|
|
13
|
+
if (Array.isArray(children) && children.length !== 0)
|
|
14
|
+
return h(
|
|
15
|
+
"div",
|
|
16
|
+
{ class: "hidden" },
|
|
17
|
+
`Invalid directive. ("github" directive must be leaf type '::github{repo="owner/repo"}')`,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const repo = String(properties.repo ?? "");
|
|
21
|
+
if (!repo.includes("/"))
|
|
22
|
+
return h(
|
|
23
|
+
"div",
|
|
24
|
+
{ class: "hidden" },
|
|
25
|
+
`Invalid repository. ("repo" attribute must be in the format "owner/repo")`,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// 每卡片随机 id 防同页多卡片串数据(碰撞概率可忽略)
|
|
29
|
+
const cardUuid = `GC${Math.random().toString(36).slice(-6)}`;
|
|
30
|
+
|
|
31
|
+
const nAvatar = h(`div#${cardUuid}-avatar`, { class: "gc-avatar" });
|
|
32
|
+
const nLanguage = h(
|
|
33
|
+
`span#${cardUuid}-language`,
|
|
34
|
+
{ class: "gc-language" },
|
|
35
|
+
"Waiting...",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const nTitle = h("div", { class: "gc-titlebar" }, [
|
|
39
|
+
h("div", { class: "gc-titlebar-left" }, [
|
|
40
|
+
h("div", { class: "gc-owner" }, [
|
|
41
|
+
nAvatar,
|
|
42
|
+
h("div", { class: "gc-user" }, repo.split("/")[0]),
|
|
43
|
+
]),
|
|
44
|
+
h("div", { class: "gc-divider" }, "/"),
|
|
45
|
+
h("div", { class: "gc-repo" }, repo.split("/")[1]),
|
|
46
|
+
]),
|
|
47
|
+
h("div", { class: "github-logo" }),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const nDescription = h(
|
|
51
|
+
`div#${cardUuid}-description`,
|
|
52
|
+
{ class: "gc-description" },
|
|
53
|
+
"Waiting for api.github.com...",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const nStars = h(`div#${cardUuid}-stars`, { class: "gc-stars" }, "00K");
|
|
57
|
+
const nForks = h(`div#${cardUuid}-forks`, { class: "gc-forks" }, "0K");
|
|
58
|
+
const nLicense = h(`div#${cardUuid}-license`, { class: "gc-license" }, "0K");
|
|
59
|
+
|
|
60
|
+
const nScript = h(
|
|
61
|
+
`script#${cardUuid}-script`,
|
|
62
|
+
{ type: "text/javascript" },
|
|
63
|
+
`
|
|
64
|
+
fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }).then(response => response.json()).then(data => {
|
|
65
|
+
document.getElementById('${cardUuid}-description').innerText = data.description?.replace(/:[a-zA-Z0-9_]+:/g, '') || "Description not set";
|
|
66
|
+
document.getElementById('${cardUuid}-language').innerText = data.language;
|
|
67
|
+
document.getElementById('${cardUuid}-forks').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\\u202f", '');
|
|
68
|
+
document.getElementById('${cardUuid}-stars').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\\u202f", '');
|
|
69
|
+
const avatarEl = document.getElementById('${cardUuid}-avatar');
|
|
70
|
+
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + '&s=32' + ')';
|
|
71
|
+
avatarEl.style.backgroundColor = 'transparent';
|
|
72
|
+
document.getElementById('${cardUuid}-license').innerText = data.license?.spdx_id || "no-license";
|
|
73
|
+
document.getElementById('${cardUuid}-card').classList.remove("fetch-waiting");
|
|
74
|
+
}).catch(err => {
|
|
75
|
+
document.getElementById('${cardUuid}-card')?.classList.add("fetch-error");
|
|
76
|
+
})
|
|
77
|
+
`,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return h(
|
|
81
|
+
`a#${cardUuid}-card`,
|
|
82
|
+
{
|
|
83
|
+
class: "card-github fetch-waiting",
|
|
84
|
+
href: `https://github.com/${repo}`,
|
|
85
|
+
target: "_blank",
|
|
86
|
+
},
|
|
87
|
+
[
|
|
88
|
+
nTitle,
|
|
89
|
+
nDescription,
|
|
90
|
+
h("div", { class: "gc-infobar" }, [nStars, nForks, nLicense, nLanguage]),
|
|
91
|
+
nScript,
|
|
92
|
+
],
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function rehypeGithubCard(): any {
|
|
97
|
+
return (tree: any) => {
|
|
98
|
+
const walk = (node: any) => {
|
|
99
|
+
if (!Array.isArray(node.children)) return;
|
|
100
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
101
|
+
const child = node.children[i];
|
|
102
|
+
if (child.type === "element" && child.tagName === "github") {
|
|
103
|
+
node.children[i] = githubCard(
|
|
104
|
+
child.properties ?? {},
|
|
105
|
+
child.children ?? [],
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
walk(child);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
walk(tree);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rehype 插件集(M3a,全部零依赖手写 hast 遍历)
|
|
3
|
+
*
|
|
4
|
+
* 挂载顺序(index.ts):externalLinks → callouts → figure → firstImage → anchor
|
|
5
|
+
* 代码块渲染自 §4.61 起由 Expressive Code 接管(index.ts 直连 rehype 内核),
|
|
6
|
+
* 本文件不再包含代码块插件。
|
|
7
|
+
* 注意:hast 节点类名有两种形态——shiki/EC 产出用 HTML 属性名直接写 properties
|
|
8
|
+
* (class 为字符串而非 className 数组);remark-rehype 原始节点则是 className
|
|
9
|
+
* 数组。读取类名务必走 getClassList 兼容两种形态。
|
|
10
|
+
*/
|
|
11
|
+
type Transformer = (tree: any, file: any) => void;
|
|
12
|
+
|
|
13
|
+
/** 浅遍历辅助:对 node.children 逐个调用 fn,fn 可通过返回 'skip' 跳过子树 */
|
|
14
|
+
function visit(node: any, fn: (n: any, parent: any | null) => void | "skip") {
|
|
15
|
+
const children = node.children;
|
|
16
|
+
if (!Array.isArray(children)) return;
|
|
17
|
+
for (const child of children) {
|
|
18
|
+
if (fn(child, node) !== "skip") visit(child, fn);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isBlank = (n: any) => n.type === "text" && !n.value.trim();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 读取 hast 节点类名列表(兼容两种形态):shiki 产出用 HTML 属性名直接写
|
|
26
|
+
* properties(class 为字符串),remark-rehype 原始节点则是 className 数组。
|
|
27
|
+
*/
|
|
28
|
+
function getClassList(props: any): string[] {
|
|
29
|
+
const raw = props?.className ?? props?.class;
|
|
30
|
+
if (Array.isArray(raw)) return raw.map(String);
|
|
31
|
+
if (typeof raw === "string") return raw.split(/\s+/).filter(Boolean);
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ---------------------------------------------------------------- 外链 */
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 外链新窗口打开:http(s) 绝对链接追加 target="_blank" rel="noopener noreferrer"
|
|
39
|
+
*/
|
|
40
|
+
export function rehypeExternalLinks(): Transformer {
|
|
41
|
+
return (tree) => {
|
|
42
|
+
visit(tree, (node) => {
|
|
43
|
+
if (node.type !== "element" || node.tagName !== "a") return;
|
|
44
|
+
const href = String(node.properties?.href ?? "");
|
|
45
|
+
if (!/^https?:\/\//i.test(href)) return;
|
|
46
|
+
node.properties.target = "_blank";
|
|
47
|
+
const rel = String(node.properties.rel ?? "");
|
|
48
|
+
const parts = new Set(rel.split(/\s+/).filter(Boolean));
|
|
49
|
+
parts.add("noopener");
|
|
50
|
+
parts.add("noreferrer");
|
|
51
|
+
node.properties.rel = [...parts].join(" ");
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* -------------------------------------------------------------- callouts */
|
|
57
|
+
|
|
58
|
+
const CALLOUT_META: Record<
|
|
59
|
+
string,
|
|
60
|
+
{ cls: string; title: string; icon: string }
|
|
61
|
+
> = {
|
|
62
|
+
NOTE: { cls: "info", title: "注", icon: "info" },
|
|
63
|
+
INFO: { cls: "info", title: "信息", icon: "info" },
|
|
64
|
+
TIP: { cls: "tip", title: "技巧", icon: "tip" },
|
|
65
|
+
ABSTRACT: { cls: "tip", title: "摘要", icon: "tip" },
|
|
66
|
+
SUMMARY: { cls: "tip", title: "摘要", icon: "tip" },
|
|
67
|
+
TLDR: { cls: "tip", title: "摘要", icon: "tip" },
|
|
68
|
+
TODO: { cls: "tip", title: "待办", icon: "tip" },
|
|
69
|
+
IMPORTANT: { cls: "important", title: "重要", icon: "important" },
|
|
70
|
+
EXAMPLE: { cls: "important", title: "示例", icon: "important" },
|
|
71
|
+
WARNING: { cls: "warn", title: "注意", icon: "warn" },
|
|
72
|
+
CAUTION: { cls: "warn", title: "警告", icon: "warn" },
|
|
73
|
+
ATTENTION: { cls: "warn", title: "注意", icon: "warn" },
|
|
74
|
+
QUESTION: { cls: "warn", title: "问题", icon: "help" },
|
|
75
|
+
HELP: { cls: "warn", title: "帮助", icon: "help" },
|
|
76
|
+
FAQ: { cls: "warn", title: "问答", icon: "help" },
|
|
77
|
+
DANGER: { cls: "danger", title: "危险", icon: "danger" },
|
|
78
|
+
ERROR: { cls: "danger", title: "错误", icon: "x" },
|
|
79
|
+
FAILURE: { cls: "danger", title: "失败", icon: "x" },
|
|
80
|
+
FAIL: { cls: "danger", title: "失败", icon: "x" },
|
|
81
|
+
MISSING: { cls: "danger", title: "缺失", icon: "x" },
|
|
82
|
+
BUG: { cls: "danger", title: "缺陷", icon: "bug" },
|
|
83
|
+
SUCCESS: { cls: "success", title: "成功", icon: "check" },
|
|
84
|
+
CHECK: { cls: "success", title: "成功", icon: "check" },
|
|
85
|
+
DONE: { cls: "success", title: "完成", icon: "check" },
|
|
86
|
+
QUOTE: { cls: "quote", title: "引用", icon: "quote" },
|
|
87
|
+
CITE: { cls: "quote", title: "引用", icon: "quote" },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** 与原型一致的 lucide 风格图标(stroke 1.75) */
|
|
91
|
+
const CALLOUT_ICONS: Record<string, string> = {
|
|
92
|
+
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
93
|
+
tip: '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
|
|
94
|
+
important:
|
|
95
|
+
'<path d="M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41l-7.59-7.59a2.41 2.41 0 0 0-3.41 0z"/><path d="M12 8v4"/><path d="M12 16h.01"/>',
|
|
96
|
+
warn: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
|
97
|
+
danger: '<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/>',
|
|
98
|
+
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
99
|
+
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
|
100
|
+
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
101
|
+
bug: '<path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/>',
|
|
102
|
+
quote:
|
|
103
|
+
'<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function iconSvg(name: string) {
|
|
107
|
+
return {
|
|
108
|
+
type: "element",
|
|
109
|
+
tagName: "svg",
|
|
110
|
+
properties: {
|
|
111
|
+
className: ["co-icon"],
|
|
112
|
+
viewBox: "0 0 24 24",
|
|
113
|
+
fill: "none",
|
|
114
|
+
stroke: "currentColor",
|
|
115
|
+
strokeWidth: "1.75",
|
|
116
|
+
strokeLinecap: "round",
|
|
117
|
+
strokeLinejoin: "round",
|
|
118
|
+
ariaHidden: "true",
|
|
119
|
+
},
|
|
120
|
+
children: [
|
|
121
|
+
{ type: "raw", value: CALLOUT_ICONS[name] ?? CALLOUT_ICONS.info },
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* GitHub 警告块语法:> [!TYPE](26 种别名,GFM alert + Obsidian callout 集合)
|
|
128
|
+
* 转换为 <div class="callout {cls}"><svg class="co-icon"/><div><b>标题</b>…原内容</div></div>
|
|
129
|
+
* 中文默认标题;[!TYPE] 同一行剩余文字作为自定义标题;语义相近的别名共用配色。
|
|
130
|
+
*/
|
|
131
|
+
export function rehypeCallouts(): Transformer {
|
|
132
|
+
return (tree) => {
|
|
133
|
+
visit(tree, (node) => {
|
|
134
|
+
if (node.type !== "element" || node.tagName !== "blockquote") return;
|
|
135
|
+
// 注意:Astro 7 的 mdast→hast 转换会给 blockquote 塞入首尾 "\n" 空白文本节点,
|
|
136
|
+
// 不能假设 children[0] 就是 <p>,须按类型查找首个元素子节点
|
|
137
|
+
const first = (node.children ?? []).find(
|
|
138
|
+
(c: any) => c.type === "element",
|
|
139
|
+
);
|
|
140
|
+
if (!first || first.tagName !== "p") return;
|
|
141
|
+
const firstText = first.children?.find((c: any) => c.type === "text");
|
|
142
|
+
if (!firstText) return;
|
|
143
|
+
const m = String(firstText.value).match(
|
|
144
|
+
/^\[!(NOTE|INFO|TIP|ABSTRACT|SUMMARY|TLDR|TODO|IMPORTANT|EXAMPLE|WARNING|CAUTION|ATTENTION|QUESTION|HELP|FAQ|DANGER|ERROR|BUG|FAILURE|FAIL|MISSING|SUCCESS|CHECK|DONE|QUOTE|CITE)\][ \t]*\n?/i,
|
|
145
|
+
);
|
|
146
|
+
if (!m) return;
|
|
147
|
+
const meta = CALLOUT_META[m[1].toUpperCase()];
|
|
148
|
+
// 移除标记文本;[!TYPE] 同一行剩余文字作为自定义标题(GitHub 语法)。
|
|
149
|
+
// 「无标题」时正则已吃掉换行(m[0] 以 \n 结尾),「有标题」时只吃到空格,
|
|
150
|
+
// 故以 m[0] 是否以 \n 结尾区分,避免把正文首行误当标题
|
|
151
|
+
const rest = String(firstText.value).slice(m[0].length);
|
|
152
|
+
let custom = "";
|
|
153
|
+
let bodyText = rest;
|
|
154
|
+
if (!m[0].endsWith("\n")) {
|
|
155
|
+
const nl = rest.indexOf("\n");
|
|
156
|
+
if (nl === -1) {
|
|
157
|
+
custom = rest.trim();
|
|
158
|
+
bodyText = "";
|
|
159
|
+
} else {
|
|
160
|
+
custom = rest.slice(0, nl).trim();
|
|
161
|
+
bodyText = rest.slice(nl + 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
firstText.value = bodyText;
|
|
165
|
+
const title = custom || meta.title;
|
|
166
|
+
if (!firstText.value && first.children.length === 1) {
|
|
167
|
+
node.children = node.children.filter((c: any) => c !== first);
|
|
168
|
+
}
|
|
169
|
+
const body = {
|
|
170
|
+
type: "element",
|
|
171
|
+
tagName: "div",
|
|
172
|
+
properties: {},
|
|
173
|
+
children: [
|
|
174
|
+
{
|
|
175
|
+
type: "element",
|
|
176
|
+
tagName: "b",
|
|
177
|
+
properties: {},
|
|
178
|
+
children: [{ type: "text", value: title }],
|
|
179
|
+
},
|
|
180
|
+
...node.children,
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
node.tagName = "div";
|
|
184
|
+
node.properties = { className: ["callout", meta.cls] };
|
|
185
|
+
node.children = [iconSvg(meta.icon), body];
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ---------------------------------------------------------------- figure */
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 独立段落中的单图 → <figure>;img 的 title 属性 → <figcaption>。
|
|
194
|
+
* 灯箱归组交给页面级 data-lightbox-gallery 容器(§4.70):figure 不再自带
|
|
195
|
+
* data-lightbox,否则文章容器内嵌套两组容器会被双重绑定点击监听而双开灯箱
|
|
196
|
+
*/
|
|
197
|
+
export function rehypeFigure(): Transformer {
|
|
198
|
+
return (tree) => {
|
|
199
|
+
visit(tree, (node) => {
|
|
200
|
+
if (node.type !== "element" || node.tagName !== "p") return;
|
|
201
|
+
const meaningful = (node.children ?? []).filter((c: any) => !isBlank(c));
|
|
202
|
+
if (
|
|
203
|
+
meaningful.length !== 1 ||
|
|
204
|
+
meaningful[0].type !== "element" ||
|
|
205
|
+
meaningful[0].tagName !== "img"
|
|
206
|
+
)
|
|
207
|
+
return;
|
|
208
|
+
const img = meaningful[0];
|
|
209
|
+
const title = img.properties?.title;
|
|
210
|
+
const figure: any = {
|
|
211
|
+
type: "element",
|
|
212
|
+
tagName: "figure",
|
|
213
|
+
properties: {},
|
|
214
|
+
children: [img],
|
|
215
|
+
};
|
|
216
|
+
if (title) {
|
|
217
|
+
delete img.properties.title;
|
|
218
|
+
figure.children.push({
|
|
219
|
+
type: "element",
|
|
220
|
+
tagName: "figcaption",
|
|
221
|
+
properties: {},
|
|
222
|
+
children: [{ type: "text", value: String(title) }],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// 用 figure 替换 p 内容(p 无其他有意义子节点,直接整体替换)
|
|
226
|
+
node.tagName = "figure";
|
|
227
|
+
node.properties = figure.properties;
|
|
228
|
+
node.children = figure.children;
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* -------------------------------------------------------------- 首图提取 */
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 提取正文第一张图片地址 → frontmatter.firstImage(列表卡片封面兜底,决策 9)
|
|
237
|
+
*/
|
|
238
|
+
export function rehypeFirstImage(): Transformer {
|
|
239
|
+
return (tree, file) => {
|
|
240
|
+
let found = false;
|
|
241
|
+
visit(tree, (node) => {
|
|
242
|
+
if (found) return "skip";
|
|
243
|
+
if (node.type === "element" && node.tagName === "img") {
|
|
244
|
+
const src = node.properties?.src;
|
|
245
|
+
if (typeof src === "string" && src) {
|
|
246
|
+
file.data.astro.frontmatter.firstImage = src;
|
|
247
|
+
found = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* ------------------------------------------------------------ 标题锚点 */
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* h2–h4(Astro 已注入 id)末尾追加 <a class="h-anchor">#</a>
|
|
259
|
+
*/
|
|
260
|
+
export function rehypeAnchor(): Transformer {
|
|
261
|
+
return (tree) => {
|
|
262
|
+
visit(tree, (node) => {
|
|
263
|
+
if (node.type !== "element") return;
|
|
264
|
+
if (!["h2", "h3", "h4"].includes(node.tagName)) return;
|
|
265
|
+
const id = node.properties?.id;
|
|
266
|
+
if (!id) return;
|
|
267
|
+
node.children.push({
|
|
268
|
+
type: "element",
|
|
269
|
+
tagName: "a",
|
|
270
|
+
properties: {
|
|
271
|
+
className: ["h-anchor"],
|
|
272
|
+
href: `#${id}`,
|
|
273
|
+
ariaLabel: "链接到本节",
|
|
274
|
+
},
|
|
275
|
+
children: [{ type: "text", value: "#" }],
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* -------------------------------------------------------------- 代码块 */
|
|
282
|
+
|
|
283
|
+
/* §4.61 起代码块渲染由 Expressive Code 接管(index.ts 直连 rehype 内核),
|
|
284
|
+
自研 rehypeCodeBlock(复制/折叠按钮包装)已退役;此处不再有代码块插件 */
|