@windcoder/astro-theme-orchid 0.0.0-stage → 0.2.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.
Files changed (83) hide show
  1. package/README.md +118 -2
  2. package/package.json +70 -5
  3. package/src/components/AlbumCard.astro +73 -0
  4. package/src/components/Analytics.astro +22 -0
  5. package/src/components/BaseLayout.astro +188 -0
  6. package/src/components/Calendar.astro +379 -0
  7. package/src/components/CategoryBar.astro +74 -0
  8. package/src/components/Comment.astro +48 -0
  9. package/src/components/EncryptedContent.astro +42 -0
  10. package/src/components/Footer.astro +31 -0
  11. package/src/components/Header.astro +153 -0
  12. package/src/components/License.astro +29 -0
  13. package/src/components/Live2DWidget.astro +262 -0
  14. package/src/components/MusicPlayer.astro +940 -0
  15. package/src/components/OutdatedCard.astro +53 -0
  16. package/src/components/Pagination.astro +94 -0
  17. package/src/components/PhotoCard.astro +22 -0
  18. package/src/components/PostCard.astro +89 -0
  19. package/src/components/PostNav.astro +40 -0
  20. package/src/components/RecommendedPost.astro +166 -0
  21. package/src/components/SakuraEffect.astro +152 -0
  22. package/src/components/Sidebar.astro +334 -0
  23. package/src/components/Toc.astro +27 -0
  24. package/src/components/analytics/GoogleAnalytics.astro +22 -0
  25. package/src/components/analytics/La51Analytics.astro +52 -0
  26. package/src/components/analytics/MicrosoftClarity.astro +18 -0
  27. package/src/components/analytics/UmamiAnalytics.astro +86 -0
  28. package/src/components/comments/Artalk.astro +51 -0
  29. package/src/components/comments/Disqus.astro +43 -0
  30. package/src/components/comments/Giscus.astro +50 -0
  31. package/src/components/comments/Twikoo.astro +65 -0
  32. package/src/components/comments/Waline.astro +36 -0
  33. package/src/i18n/en.ts +164 -0
  34. package/src/i18n/index.ts +18 -0
  35. package/src/i18n/resolve.ts +49 -0
  36. package/src/i18n/zh-CN.ts +184 -0
  37. package/src/icons.ts +71 -0
  38. package/src/index.ts +411 -0
  39. package/src/plugins/plantuml-encoder.ts +95 -0
  40. package/src/plugins/rehype-diagram.ts +218 -0
  41. package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
  42. package/src/plugins/rehype-email.ts +75 -0
  43. package/src/plugins/rehype-github-card.ts +114 -0
  44. package/src/plugins/rehype.ts +284 -0
  45. package/src/plugins/remark-diagram.ts +78 -0
  46. package/src/plugins/remark-directives.ts +100 -0
  47. package/src/plugins/remark-excerpt.ts +29 -0
  48. package/src/plugins/remark-image-grid.ts +97 -0
  49. package/src/plugins/remark-reading-time.ts +38 -0
  50. package/src/plugins/scripts/diagram-panzoom.js +388 -0
  51. package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
  52. package/src/routes/404.astro +14 -0
  53. package/src/routes/_hydrate.ts +34 -0
  54. package/src/routes/about.astro +25 -0
  55. package/src/routes/api/allPostMeta.json.ts +26 -0
  56. package/src/routes/archive.astro +81 -0
  57. package/src/routes/categories/[category].astro +37 -0
  58. package/src/routes/categories/index.astro +29 -0
  59. package/src/routes/friends.astro +83 -0
  60. package/src/routes/gallery/[album].astro +97 -0
  61. package/src/routes/gallery/index.astro +100 -0
  62. package/src/routes/guestbook.astro +37 -0
  63. package/src/routes/index.astro +47 -0
  64. package/src/routes/og/[...slug].ts +281 -0
  65. package/src/routes/page/[page].astro +53 -0
  66. package/src/routes/posts/[...slug].astro +350 -0
  67. package/src/routes/robots.txt.ts +18 -0
  68. package/src/routes/rss.xml.ts +37 -0
  69. package/src/routes/series/[name].astro +66 -0
  70. package/src/routes/series/index.astro +64 -0
  71. package/src/routes/sponsor.astro +84 -0
  72. package/src/routes/tags/[tag].astro +40 -0
  73. package/src/routes/tags/index.astro +29 -0
  74. package/src/schema.ts +1218 -0
  75. package/src/scripts/app.ts +1041 -0
  76. package/src/scripts/share-poster.ts +468 -0
  77. package/src/styles/global.css +4864 -0
  78. package/src/utils/crypto.ts +46 -0
  79. package/src/utils/gallery.ts +76 -0
  80. package/src/utils/lqip.ts +18 -0
  81. package/src/utils/related.ts +101 -0
  82. package/src/utils.ts +233 -0
  83. package/src/vite.ts +105 -0
@@ -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(复制/折叠按钮包装)已退役;此处不再有代码块插件 */
@@ -0,0 +1,78 @@
1
+ /**
2
+ * remark 图表标记插件(M3b-b4,移植自 orchid-blog)
3
+ *
4
+ * - remarkMermaid:```mermaid 代码块 → 自定义节点 type='mermaid',
5
+ * data.hName/hProperties 落为 <div class="mermaid-container" data-mermaid-code="…">,
6
+ * hChildren 保留源码文本(构建期渲染失败时可降级展示),value 置空避免重复输出
7
+ * - remarkPlantuml:```plantuml 代码块 → <div class="plantuml-wrapper">,
8
+ * 双主题 URL(injectTheme → pako deflate → PlantUML base64url)预编码进 data 属性,
9
+ * 构建期零请求,主题切换由客户端换 src 完成
10
+ */
11
+
12
+ import { buildUrl, encodePlantUML, injectTheme } from "./plantuml-encoder.js";
13
+
14
+ type Transformer = (tree: any, file: any) => void;
15
+
16
+ /** 深度遍历辅助(与 rehype.ts 内 visit 同风格),fn 返回 'skip' 跳过子树 */
17
+ function visit(node: any, fn: (n: any) => void | "skip") {
18
+ const children = node.children;
19
+ if (!Array.isArray(children)) return;
20
+ for (const child of children) {
21
+ if (fn(child) !== "skip") visit(child, fn);
22
+ }
23
+ }
24
+
25
+ export function remarkMermaid(): Transformer {
26
+ return (tree) => {
27
+ visit(tree, (node) => {
28
+ if (node.type !== "code") return;
29
+ if (String(node.lang ?? "").toLowerCase() !== "mermaid") return;
30
+ const code = String(node.value ?? "");
31
+ node.type = "mermaid";
32
+ node.data = { ...node.data };
33
+ node.data.hName = "div";
34
+ node.data.hProperties = {
35
+ className: ["mermaid-container"],
36
+ "data-mermaid-code": code,
37
+ };
38
+ node.data.hChildren = [{ type: "text", value: code }];
39
+ node.value = undefined;
40
+ });
41
+ };
42
+ }
43
+
44
+ export interface PlantumlOptions {
45
+ /** PlantUML 渲染服务地址,如 https://www.plantuml.com/plantuml */
46
+ server: string;
47
+ /** 浅色主题名(空 = 不注入 !theme,尊重图内已有声明) */
48
+ lightTheme?: string;
49
+ /** 深色主题名(空 = 复用浅色 URL) */
50
+ darkTheme?: string;
51
+ }
52
+
53
+ export function remarkPlantuml(options: PlantumlOptions): Transformer {
54
+ return (tree) => {
55
+ visit(tree, (node) => {
56
+ if (node.type !== "code") return;
57
+ if (String(node.lang ?? "").toLowerCase() !== "plantuml") return;
58
+ const code = String(node.value ?? "");
59
+ const lightSource = injectTheme(code, options.lightTheme ?? "");
60
+ const darkSource = injectTheme(code, options.darkTheme ?? "");
61
+ const lightUrl = buildUrl(options.server, encodePlantUML(lightSource));
62
+ const darkUrl =
63
+ darkSource === lightSource
64
+ ? lightUrl
65
+ : buildUrl(options.server, encodePlantUML(darkSource));
66
+ node.type = "plantuml";
67
+ node.data = { ...node.data };
68
+ node.data.hName = "div";
69
+ node.data.hProperties = {
70
+ className: ["plantuml-wrapper"],
71
+ "data-light-src": lightUrl,
72
+ "data-dark-src": darkUrl,
73
+ "data-plantuml-alt": code.slice(0, 200),
74
+ };
75
+ node.value = undefined;
76
+ });
77
+ };
78
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * remark-directive 指令分发(A2/A3,移植自参考模板 remark-directive-rehype)
3
+ *
4
+ * 官方 remark-directive 将 :::container / ::leaf / :text 指令解析为三类
5
+ * directive 节点,本插件统一改写:
6
+ * - containerDirective + 26 种 admonition 类型 → blockquote,并在首段注入
7
+ * "[!TYPE] " 标记,复用既有 rehypeCallouts 渲染(自定义 label 融入标题)
8
+ * - 其余指令 → hastscript 转成同名自定义标签(如 ::github{repo=…} →
9
+ * <github repo="…">),交由对应 rehype 插件替换(rehypeGithubCard)
10
+ */
11
+ import { h } from "hastscript";
12
+
13
+ /** 参考模板的 26 种 admonition 类型(GitHub 6 种 + Obsidian 扩充别名) */
14
+ const ADMONITION_TYPES = new Set([
15
+ "note",
16
+ "tip",
17
+ "important",
18
+ "warning",
19
+ "caution",
20
+ "abstract",
21
+ "summary",
22
+ "tldr",
23
+ "info",
24
+ "todo",
25
+ "success",
26
+ "check",
27
+ "done",
28
+ "question",
29
+ "help",
30
+ "faq",
31
+ "attention",
32
+ "failure",
33
+ "missing",
34
+ "fail",
35
+ "danger",
36
+ "error",
37
+ "bug",
38
+ "example",
39
+ "quote",
40
+ "cite",
41
+ ]);
42
+
43
+ export function remarkDirectives(): any {
44
+ const transform = (node: any) => {
45
+ if (
46
+ node.type !== "containerDirective" &&
47
+ node.type !== "leafDirective" &&
48
+ node.type !== "textDirective"
49
+ )
50
+ return;
51
+ const name = String(node.name ?? "").toLowerCase();
52
+
53
+ if (node.type === "containerDirective" && ADMONITION_TYPES.has(name)) {
54
+ const type = name.toUpperCase();
55
+ const firstChild = node.children[0];
56
+ if (firstChild?.data?.directiveLabel) {
57
+ // 自定义标题:标记注入 label 首文本,由 rehypeCallouts 提为标题
58
+ if (
59
+ firstChild.children.length > 0 &&
60
+ firstChild.children[0].type === "text"
61
+ ) {
62
+ firstChild.children[0].value = `[!${type}] ${firstChild.children[0].value}`;
63
+ } else {
64
+ firstChild.children.unshift({
65
+ type: "text",
66
+ value: `[!${type}] `,
67
+ });
68
+ }
69
+ } else {
70
+ // 无标题:插入仅含标记的新段落
71
+ node.children.unshift({
72
+ type: "paragraph",
73
+ children: [{ type: "text", value: `[!${type}]` }],
74
+ });
75
+ }
76
+ // 关键:转 blockquote 且清除 hProperties,防止变成自定义标签
77
+ node.type = "blockquote";
78
+ node.data = node.data ?? {};
79
+ node.data.hName = "blockquote";
80
+ delete node.data.hProperties;
81
+ } else {
82
+ // 其他指令:转同名自定义标签(attributes 原样带入),样式缺省不可见,
83
+ // 由对应 rehype 插件负责替换
84
+ node.data = node.data ?? {};
85
+ const hast = h(name, node.attributes ?? {});
86
+ node.data.hName = hast.tagName;
87
+ node.data.hProperties = hast.properties;
88
+ }
89
+ };
90
+
91
+ const walk = (node: any) => {
92
+ if (!Array.isArray(node.children)) return;
93
+ for (const child of node.children) {
94
+ transform(child);
95
+ walk(child);
96
+ }
97
+ };
98
+
99
+ return (tree: any) => walk(tree);
100
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * remark 插件:自动摘要(写入 frontmatter.excerpt)
3
+ *
4
+ * 取正文第一个 paragraph 的纯文本(与 orchid-blog 的 remark-excerpt 行为一致)。
5
+ * description 字段仍是手动维护的展示摘要;excerpt 仅作列表兜底。
6
+ */
7
+ export function remarkExcerpt() {
8
+ return (tree: any, { data }: any) => {
9
+ let excerpt = "";
10
+ for (const node of tree.children || []) {
11
+ if (node.type !== "paragraph") continue;
12
+ excerpt = mdastToString(node).trim();
13
+ break;
14
+ }
15
+ data.astro.frontmatter.excerpt = excerpt;
16
+ };
17
+ }
18
+
19
+ /** 近似 mdast-util-to-string:递归取 text / inlineCode 的 value,忽略 html 与 image */
20
+ function mdastToString(node: any): string {
21
+ if (!node) return "";
22
+ if (node.type === "text" || node.type === "inlineCode")
23
+ return node.value || "";
24
+ if (node.type === "html" || node.type === "image" || node.type === "code")
25
+ return "";
26
+ let out = "";
27
+ for (const child of node.children || []) out += mdastToString(child);
28
+ return out;
29
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * [grid]…[/grid] 图片网格(A1,移植自参考模板 remark-image-grid)
3
+ *
4
+ * 支持两种形态:
5
+ * 1. 同段:[grid] ![a](…) ![b](…) [/grid](标签与图片同段)
6
+ * 2. 跨段:[grid] 独行、图片段落若干、[/grid] 独行;未闭合时原样输出不转换
7
+ *
8
+ * 列数 = 网格内图片数(上限 4,无图缺省 2)。输出 div.image-grid[data-cols="N"],
9
+ * 布局由主题 CSS 生成——不用参考模板的 tailwind 动态类(md:grid-cols-N 这类
10
+ * 拼接类名在 node_modules symlink 源码中不可靠扫描,见 §4.10)。
11
+ */
12
+
13
+ const countImages = (node: any): number => {
14
+ let count = node.type === "image" ? 1 : 0;
15
+ if (Array.isArray(node.children)) {
16
+ for (const child of node.children) count += countImages(child);
17
+ }
18
+ return count;
19
+ };
20
+
21
+ const gridNode = (children: any[]) => {
22
+ const cols = Math.min(4, countImages({ children }) || 2);
23
+ return {
24
+ type: "paragraph",
25
+ data: {
26
+ hName: "div",
27
+ hProperties: {
28
+ className: ["image-grid"],
29
+ "data-cols": String(cols),
30
+ },
31
+ },
32
+ children,
33
+ };
34
+ };
35
+
36
+ export function remarkImageGrid(): any {
37
+ return (tree: any) => {
38
+ if (tree.type !== "root") return;
39
+ const newChildren: any[] = [];
40
+ let inGrid = false;
41
+ let gridChildren: any[] = [];
42
+
43
+ for (const node of tree.children) {
44
+ if (node.type === "paragraph" && node.children?.length > 0) {
45
+ const first = node.children[0];
46
+ const last = node.children[node.children.length - 1];
47
+ const hasStart =
48
+ first.type === "text" && first.value.trim().startsWith("[grid]");
49
+ const hasEnd =
50
+ last.type === "text" && last.value.trim().endsWith("[/grid]");
51
+
52
+ // 形态 1:[grid]…[/grid] 同段
53
+ if (hasStart && hasEnd && !inGrid) {
54
+ first.value = first.value.replace(/^\s*\[grid\]\s*/, "");
55
+ last.value = last.value.replace(/\s*\[\/grid\]\s*$/, "");
56
+ newChildren.push(
57
+ gridNode(
58
+ node.children.filter(
59
+ (n: any) => n.type !== "text" || n.value.trim() !== "",
60
+ ),
61
+ ),
62
+ );
63
+ continue;
64
+ }
65
+
66
+ // 形态 2:跨段开始([grid] 独行则丢弃该段落)
67
+ if (!inGrid && hasStart) {
68
+ inGrid = true;
69
+ first.value = first.value.replace(/^\s*\[grid\]\s*/, "");
70
+ if (!(node.children.length === 1 && first.value.trim() === "")) {
71
+ gridChildren.push(node);
72
+ }
73
+ continue;
74
+ }
75
+
76
+ // 形态 2:跨段结束,汇总图片数定列数
77
+ if (inGrid && hasEnd) {
78
+ inGrid = false;
79
+ last.value = last.value.replace(/\s*\[\/grid\]\s*$/, "");
80
+ if (!(node.children.length === 1 && last.value.trim() === "")) {
81
+ gridChildren.push(node);
82
+ }
83
+ newChildren.push(gridNode(gridChildren));
84
+ gridChildren = [];
85
+ continue;
86
+ }
87
+ }
88
+
89
+ if (inGrid) gridChildren.push(node);
90
+ else newChildren.push(node);
91
+ }
92
+
93
+ // 未闭合:原样输出已收集内容
94
+ if (inGrid) newChildren.push(...gridChildren);
95
+ tree.children = newChildren;
96
+ };
97
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * remark 插件:阅读时长与字数统计(写入 frontmatter.minutes / frontmatter.words)
3
+ *
4
+ * 口径(自实现,零依赖,对中文博客更准确):
5
+ * - words = CJK 字符数 + 非 CJK 单词数
6
+ * - minutes = max(1, round(words / 275)),275 wpm 为 reading-time 默认速度
7
+ * 值在渲染期由 remark 链写入,不经过 content collection schema 校验,
8
+ * 组件通过 `const { remarkPluginFrontmatter } = await render(post)` 读取。
9
+ */
10
+ export function remarkReadingTime() {
11
+ return (tree: any, { data }: any) => {
12
+ const text = mdastToString(tree);
13
+ const words = countWords(text);
14
+ data.astro.frontmatter.words = words;
15
+ data.astro.frontmatter.minutes = Math.max(1, Math.round(words / 275));
16
+ };
17
+ }
18
+
19
+ /** 近似 mdast-util-to-string:递归取 text / inlineCode 的 value,忽略 html 与 image */
20
+ function mdastToString(node: any): string {
21
+ if (!node) return "";
22
+ if (node.type === "text" || node.type === "inlineCode")
23
+ return node.value || "";
24
+ if (node.type === "html" || node.type === "image" || node.type === "code")
25
+ return "";
26
+ let out = "";
27
+ for (const child of node.children || []) out += mdastToString(child);
28
+ return out;
29
+ }
30
+
31
+ const CJK = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g;
32
+
33
+ function countWords(text: string): number {
34
+ const cjk = (text.match(CJK) || []).length;
35
+ const rest = text.replace(CJK, " ");
36
+ const latin = rest.split(/\s+/).filter(Boolean).length;
37
+ return cjk + latin;
38
+ }