@zzclub/pipeline 0.13.2 → 0.16.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 (60) hide show
  1. package/ARTICLE-THEMES.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +182 -788
  4. package/THIRD-PARTY-NOTICES.md +25 -0
  5. package/package.json +6 -2
  6. package/src/adapter-types.ts +3 -0
  7. package/src/adapters/builtin-markdown-renderer.ts +1 -0
  8. package/src/article-theme.test.ts +87 -0
  9. package/src/article-theme.ts +115 -0
  10. package/src/commands/abandon.test.ts +1 -1
  11. package/src/commands/article-theme.ts +16 -0
  12. package/src/commands/checkpoint.test.ts +2 -2
  13. package/src/commands/ingest-handoff.ts +6 -0
  14. package/src/commands/init.ts +10 -0
  15. package/src/commands/prepare.ts +5 -1
  16. package/src/commands/publish.ts +4 -0
  17. package/src/commands/reset.ts +2 -0
  18. package/src/commands/wechat-export.ts +4 -0
  19. package/src/commands/wechat-preview.ts +35 -0
  20. package/src/plugins.ts +2 -0
  21. package/src/providers/index.ts +17 -2
  22. package/src/providers/publish-core.ts +32 -1
  23. package/src/providers/wechat.test.ts +12 -0
  24. package/src/providers/wechat.ts +60 -39
  25. package/src/render-branding.ts +1 -1
  26. package/src/schema/config.ts +12 -0
  27. package/src/schema/state.ts +5 -0
  28. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +21 -1
  29. package/src/wechat-preview/assets/browser-dist/assets/style-C9-YtTIm.css +1 -0
  30. package/src/wechat-preview/assets/browser-dist/assets/wechat-renderer-D1jHoaM2.js +624 -0
  31. package/src/wechat-preview/assets/browser-dist/editor-export.js +1 -512
  32. package/src/wechat-preview/assets/browser-dist/src/wechat-preview/browser/studio/studio.html +293 -0
  33. package/src/wechat-preview/assets/browser-dist/studio.js +332 -0
  34. package/src/wechat-preview/browser/editor-export.ts +17 -2
  35. package/src/wechat-preview/browser/highlight.ts +2 -0
  36. package/src/wechat-preview/browser/studio/color-palettes.test.ts +31 -0
  37. package/src/wechat-preview/browser/studio/color-palettes.ts +115 -0
  38. package/src/wechat-preview/browser/studio/studio.css +638 -0
  39. package/src/wechat-preview/browser/studio/studio.html +292 -0
  40. package/src/wechat-preview/browser/studio/studio.ts +721 -0
  41. package/src/wechat-preview/browser/studio/syntax-presets.test.ts +74 -0
  42. package/src/wechat-preview/browser/studio/syntax-presets.ts +590 -0
  43. package/src/wechat-preview/fixtures/all-nodes.md +38 -0
  44. package/src/wechat-preview/index.ts +36 -5
  45. package/src/wechat-preview/plugins/builtin-badge.ts +152 -0
  46. package/src/wechat-preview/plugins/builtin-callout.ts +261 -0
  47. package/src/wechat-preview/plugins/builtin-highlight.ts +46 -0
  48. package/src/wechat-preview/plugins/builtin-kbd.ts +133 -0
  49. package/src/wechat-preview/plugins/index.test.ts +313 -0
  50. package/src/wechat-preview/plugins/index.ts +6 -0
  51. package/src/wechat-preview/plugins/registry.ts +62 -0
  52. package/src/wechat-preview/plugins/types.ts +31 -0
  53. package/src/wechat-preview/server/http.ts +160 -3
  54. package/src/wechat-preview/server/paths.ts +2 -2
  55. package/src/wechat-preview/server/singleton.ts +14 -12
  56. package/src/wechat-preview/server/studio.test.ts +92 -0
  57. package/src/wechat-preview/server/ui.ts +1 -0
  58. package/src/wechat-preview/wechat-export.integration.test.ts +9 -1
  59. package/src/wechat-preview/wechat-renderer.ts +72 -7
  60. package/src/workflow.test.ts +1 -0
@@ -20,8 +20,10 @@ import {
20
20
  WECHAT_PREVIEW_DIR,
21
21
  } from "../runtime-paths";
22
22
  import { extractFrontmatter } from "./frontmatter-handler";
23
+ import { readArticleTheme } from "../article-theme";
23
24
  import { getWechatPreviewStyleName, getWechatPreviewTheme } from "./themes";
24
25
  import { stripLeadingH1 } from "../text";
26
+ import { buildCombinedPresetsCss } from "./browser/studio/syntax-presets";
25
27
 
26
28
  interface ViteManifestEntry {
27
29
  file: string;
@@ -90,9 +92,17 @@ export interface ExportMarkdownToWechatHtmlInput {
90
92
  title?: string;
91
93
  previewShellOutPath?: string;
92
94
  customCss?: string | null;
95
+ articleThemePath?: string | null;
93
96
  themeOverrides?: {
94
97
  editorVars?: Record<string, string>;
95
98
  exportTheme?: Record<string, string>;
99
+ syntaxPresets?: Record<string, string>;
100
+ structure?: {
101
+ numberedHeadings?: boolean;
102
+ headingLabel?: string;
103
+ quoteLabel?: string;
104
+ calloutPresets?: Record<string, { title?: string; icon?: string }>;
105
+ };
96
106
  };
97
107
  /** 浏览器真实等待上限,默认 15000 毫秒。 */
98
108
  timeoutMs?: number;
@@ -130,6 +140,14 @@ export function resolveMilkdownArticleStylePath(): string {
130
140
  export function getWechatPreviewBundleSources(): string[] {
131
141
  return [
132
142
  join(WECHAT_PREVIEW_DIR, "browser/editor-export.ts"),
143
+ join(WECHAT_PREVIEW_DIR, "browser/highlight.ts"),
144
+ join(WECHAT_PREVIEW_DIR, "browser/studio/studio.ts"),
145
+ join(WECHAT_PREVIEW_DIR, "browser/studio/studio.html"),
146
+ join(WECHAT_PREVIEW_DIR, "browser/studio/studio.css"),
147
+ join(WECHAT_PREVIEW_DIR, "plugins/registry.ts"),
148
+ join(WECHAT_PREVIEW_DIR, "plugins/builtin-callout.ts"),
149
+ join(WECHAT_PREVIEW_DIR, "plugins/builtin-kbd.ts"),
150
+ join(WECHAT_PREVIEW_DIR, "plugins/builtin-badge.ts"),
133
151
  resolveMilkdownArticleStylePath(),
134
152
  join(WECHAT_PREVIEW_DIR, "wechat-renderer.ts"),
135
153
  ];
@@ -377,18 +395,31 @@ export async function exportMarkdownToWechatHtml(
377
395
  const sourceMarkdown = await readFile(input.markdownPath, "utf-8");
378
396
  const stripped = extractFrontmatter(sourceMarkdown).content;
379
397
  const bodyMarkdown = stripLeadingH1(stripped).trimStart();
380
- const markdown = rewriteRelativeImagePaths(bodyMarkdown, dirname(input.markdownPath));
381
- const theme = getWechatPreviewTheme(input.account, input.themeOverrides);
382
- const customCss = input.customCss
383
- ? await readFile(input.customCss, "utf-8")
384
- : undefined;
398
+ const markdown = bodyMarkdown;
399
+ const articleTheme = input.articleThemePath ? await readArticleTheme(input.articleThemePath) : null;
400
+ const theme = getWechatPreviewTheme(input.account, articleTheme ? {
401
+ editorVars: { ...articleTheme.manifest.editorVars, ...input.themeOverrides?.editorVars },
402
+ exportTheme: { footerText: "", ...articleTheme.manifest.exportTheme, ...input.themeOverrides?.exportTheme },
403
+ } : input.themeOverrides);
404
+ const presetsCss = input.themeOverrides?.syntaxPresets
405
+ ? buildCombinedPresetsCss(input.themeOverrides.syntaxPresets)
406
+ : "";
407
+ const customCss = [presetsCss, articleTheme?.css, input.customCss ? await readFile(input.customCss, "utf-8") : ""].filter(Boolean).join("\n\n");
408
+ const structure = articleTheme ? {
409
+ ...articleTheme.manifest.structure,
410
+ ...input.themeOverrides?.structure,
411
+ headerImageUrl: articleTheme.manifest.structure.headerImage
412
+ ? `data:image/${articleTheme.manifest.structure.headerImage.split(".").pop()!.toLowerCase().replace("jpg", "jpeg")};base64,${Buffer.from(articleTheme.files.get(articleTheme.manifest.structure.headerImage)!).toString("base64")}` : null,
413
+ } : (input.themeOverrides?.structure ? { ...input.themeOverrides.structure } : undefined);
385
414
  const shell = readUtf8(TEMPLATE_PATH);
386
415
  const payloadJson = escapeInlineJson(
387
416
  JSON.stringify({
388
417
  markdown,
418
+ assetBaseUrl: pathToFileURL(`${dirname(resolve(input.markdownPath))}/`).href,
389
419
  editorVars: theme.editorVars,
390
420
  exportTheme: theme.exportTheme,
391
421
  customCss,
422
+ structure,
392
423
  }),
393
424
  );
394
425
 
@@ -0,0 +1,152 @@
1
+ import { $markSchema, $remark } from "@milkdown/kit/utils";
2
+ import type { WechatMarkdownPlugin } from "./types";
3
+ import type { WechatElementRenderer } from "../wechat-renderer";
4
+
5
+ /** Remark plugin to match [badge:type|text] syntax. */
6
+ export function remarkBadge() {
7
+ return (tree: any) => {
8
+ function visit(node: any) {
9
+ if (!node || typeof node !== "object") return;
10
+ if (node.type === "paragraph" || node.type === "listItem" || node.type === "heading") {
11
+ if (Array.isArray(node.children)) {
12
+ const newChildren: any[] = [];
13
+ for (const child of node.children) {
14
+ if (child.type === "text" && typeof child.value === "string") {
15
+ const regex = /\[badge:(brand|success|warning|danger|info)\|([^\]]+)\]/gi;
16
+ let lastIndex = 0;
17
+ let match;
18
+ let found = false;
19
+ while ((match = regex.exec(child.value)) !== null) {
20
+ found = true;
21
+ if (match.index > lastIndex) {
22
+ newChildren.push({
23
+ type: "text",
24
+ value: child.value.slice(lastIndex, match.index),
25
+ });
26
+ }
27
+ const badgeType = match[1].toLowerCase();
28
+ const badgeText = match[2];
29
+ newChildren.push({
30
+ type: "badge",
31
+ badgeType,
32
+ children: [{ type: "text", value: badgeText }],
33
+ });
34
+ lastIndex = regex.lastIndex;
35
+ }
36
+ if (found) {
37
+ if (lastIndex < child.value.length) {
38
+ newChildren.push({
39
+ type: "text",
40
+ value: child.value.slice(lastIndex),
41
+ });
42
+ }
43
+ continue;
44
+ }
45
+ }
46
+ newChildren.push(child);
47
+ }
48
+ node.children = newChildren;
49
+ }
50
+ }
51
+ if (Array.isArray(node.children)) {
52
+ for (const child of node.children) {
53
+ visit(child);
54
+ }
55
+ }
56
+ }
57
+ visit(tree);
58
+ };
59
+ }
60
+
61
+ export const badgeMilkdownPlugins = [
62
+ $remark("remarkBadge", () => remarkBadge as any),
63
+ $markSchema("badge", () => ({
64
+ attrs: {
65
+ type: { default: "brand" },
66
+ },
67
+ parseDOM: [
68
+ {
69
+ tag: "span[data-wechat-node='badge']",
70
+ getAttrs: (dom) => ({
71
+ type: (dom as HTMLElement).getAttribute("data-badge-type") || "brand",
72
+ }),
73
+ },
74
+ ],
75
+ toDOM: (mark) => [
76
+ "span",
77
+ {
78
+ class: `wechat-badge wechat-badge-${mark.attrs.type}`,
79
+ "data-wechat-node": "badge",
80
+ "data-badge-type": mark.attrs.type,
81
+ },
82
+ 0,
83
+ ],
84
+ parseMarkdown: {
85
+ match: (node) => node.type === "badge",
86
+ runner: (state, node, markType) => {
87
+ state.openMark(markType, { type: node.badgeType || "brand" });
88
+ state.next(node.children);
89
+ state.closeMark(markType);
90
+ },
91
+ },
92
+ toMarkdown: {
93
+ match: (mark) => mark.type.name === "badge",
94
+ runner: (state, mark) => {
95
+ state.withMark(mark, "badge");
96
+ },
97
+ },
98
+ })),
99
+ ].flat();
100
+
101
+ export const badgeWechatRenderer: WechatElementRenderer = {
102
+ kind: "badge",
103
+ selector: 'span[data-wechat-node="badge"]',
104
+ };
105
+
106
+ export const builtinBadgePlugin: WechatMarkdownPlugin = {
107
+ doc: {
108
+ id: "badge",
109
+ name: "状态徽章 (Badge)",
110
+ category: "inline",
111
+ description: "使用 [badge:类型|文字] 插入圆角状态胶囊标签(支持 brand/success/warning/danger/info)",
112
+ sampleMarkdown: "当前服务状态:[badge:success|运行正常],新版特性:[badge:brand|重点推荐]!",
113
+ syntaxGuide: "[badge:类型|标签文字]",
114
+ cssSelectors: ['span[data-wechat-node="badge"]', ".wechat-badge"],
115
+ },
116
+ milkdownPlugins: badgeMilkdownPlugins,
117
+ wechatRenderer: badgeWechatRenderer,
118
+ defaultCss: `
119
+ .milkdown .editor .wechat-badge,
120
+ .milkdown .editor span[data-wechat-node="badge"] {
121
+ display: inline-block;
122
+ padding: 1px 7px;
123
+ font-size: 0.78em;
124
+ font-weight: 600;
125
+ line-height: 1.4;
126
+ border-radius: 999px;
127
+ margin: 0 3px;
128
+ vertical-align: baseline;
129
+ box-sizing: border-box;
130
+ }
131
+ .milkdown .editor .wechat-badge-brand {
132
+ background-color: rgba(202, 96, 147, 0.12);
133
+ color: #a94473;
134
+ }
135
+ .milkdown .editor .wechat-badge-success {
136
+ background-color: #e8f6ec;
137
+ color: #2f7d4a;
138
+ }
139
+ .milkdown .editor .wechat-badge-warning {
140
+ background-color: #fef3c7;
141
+ color: #b45309;
142
+ }
143
+ .milkdown .editor .wechat-badge-danger {
144
+ background-color: #fee2e2;
145
+ color: #b91c1c;
146
+ }
147
+ .milkdown .editor .wechat-badge-info {
148
+ background-color: #eff6ff;
149
+ color: #1d4ed8;
150
+ }
151
+ `.trim(),
152
+ };
@@ -0,0 +1,261 @@
1
+ import { $nodeSchema, $remark } from "@milkdown/kit/utils";
2
+ import type { WechatMarkdownPlugin } from "./types";
3
+ import type { WechatElementRenderer } from "../wechat-renderer";
4
+
5
+ export interface CalloutMeta {
6
+ title: string;
7
+ icon: string;
8
+ borderColor: string;
9
+ bgColor: string;
10
+ textColor: string;
11
+ }
12
+
13
+ export const CALLOUT_PRESETS: Record<string, CalloutMeta> = {
14
+ note: {
15
+ title: "提示",
16
+ icon: "💡",
17
+ borderColor: "#3b82f6",
18
+ bgColor: "rgba(59, 130, 246, 0.08)",
19
+ textColor: "#1d4ed8",
20
+ },
21
+ tip: {
22
+ title: "建议",
23
+ icon: "✨",
24
+ borderColor: "#10b981",
25
+ bgColor: "rgba(16, 185, 129, 0.08)",
26
+ textColor: "#047857",
27
+ },
28
+ important: {
29
+ title: "重点",
30
+ icon: "📌",
31
+ borderColor: "#8b5cf6",
32
+ bgColor: "rgba(139, 92, 246, 0.08)",
33
+ textColor: "#6d28d9",
34
+ },
35
+ warning: {
36
+ title: "警告",
37
+ icon: "⚠️",
38
+ borderColor: "#f59e0b",
39
+ bgColor: "rgba(245, 158, 11, 0.08)",
40
+ textColor: "#b45309",
41
+ },
42
+ caution: {
43
+ title: "注意",
44
+ icon: "🛑",
45
+ borderColor: "#ef4444",
46
+ bgColor: "rgba(239, 68, 68, 0.08)",
47
+ textColor: "#b91c1c",
48
+ },
49
+ };
50
+
51
+ /** Remark AST plugin: transforms GFM alert blockquote into callout node. */
52
+ export function remarkCallout() {
53
+ return (tree: any) => {
54
+ function visit(node: any) {
55
+ if (!node || typeof node !== "object") return;
56
+ if (node.type === "blockquote" && Array.isArray(node.children) && node.children.length > 0) {
57
+ const firstParagraph = node.children[0];
58
+ if (
59
+ firstParagraph &&
60
+ firstParagraph.type === "paragraph" &&
61
+ Array.isArray(firstParagraph.children) &&
62
+ firstParagraph.children.length > 0
63
+ ) {
64
+ const firstText = firstParagraph.children[0];
65
+ if (firstText && firstText.type === "text" && typeof firstText.value === "string") {
66
+ const match = firstText.value.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:[ \t]+([^\r\n]+))?(?:\r?\n|$)/i);
67
+ if (match) {
68
+ const rawType = match[1].toLowerCase();
69
+ const customTitle = match[2] ? match[2].trim() : "";
70
+ // Strip the [!TAG] marker
71
+ firstText.value = firstText.value.slice(match[0].length);
72
+ if (!firstText.value.trim() && firstParagraph.children.length > 1) {
73
+ firstParagraph.children.shift();
74
+ } else if (!firstText.value.trim() && firstParagraph.children.length === 1) {
75
+ node.children.shift();
76
+ }
77
+ node.type = "callout";
78
+ node.calloutType = rawType;
79
+ node.customTitle = customTitle;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ if (Array.isArray(node.children)) {
85
+ for (const child of node.children) {
86
+ visit(child);
87
+ }
88
+ }
89
+ }
90
+ visit(tree);
91
+ };
92
+ }
93
+
94
+ export const calloutMilkdownPlugins = [
95
+ $remark("remarkCallout", () => remarkCallout as any),
96
+ $nodeSchema("callout", () => ({
97
+ content: "block+",
98
+ group: "block",
99
+ defining: true,
100
+ attrs: {
101
+ type: { default: "note" },
102
+ title: { default: "" },
103
+ },
104
+ parseDOM: [
105
+ {
106
+ tag: "section[data-wechat-node='callout']",
107
+ getAttrs: (dom) => ({
108
+ type: (dom as HTMLElement).getAttribute("data-callout-type") || "note",
109
+ title: (dom as HTMLElement).getAttribute("data-callout-title") || "",
110
+ }),
111
+ },
112
+ ],
113
+ toDOM: (node) => [
114
+ "section",
115
+ {
116
+ class: `wechat-callout wechat-callout-${node.attrs.type}`,
117
+ "data-wechat-node": "callout",
118
+ "data-callout-type": node.attrs.type,
119
+ ...(node.attrs.title ? { "data-callout-title": node.attrs.title } : {}),
120
+ },
121
+ 0,
122
+ ],
123
+ parseMarkdown: {
124
+ match: (node) => node.type === "callout",
125
+ runner: (state, node, type) => {
126
+ state.openNode(type, {
127
+ type: node.calloutType || "note",
128
+ title: node.customTitle || "",
129
+ });
130
+ state.next(node.children);
131
+ state.closeNode();
132
+ },
133
+ },
134
+ toMarkdown: {
135
+ match: (node) => node.type.name === "callout",
136
+ runner: (state, node) => {
137
+ state.openNode("blockquote");
138
+ state.next(node.content);
139
+ state.closeNode();
140
+ },
141
+ },
142
+ })),
143
+ ].flat();
144
+
145
+ export const calloutWechatRenderer: WechatElementRenderer = {
146
+ kind: "callout",
147
+ selector: 'section[data-wechat-node="callout"]',
148
+ prepare(element, context) {
149
+ const type = element.getAttribute("data-callout-type") || "note";
150
+ const customTitleAttr = element.getAttribute("data-callout-title");
151
+ const configuredPreset = context.structure?.calloutPresets?.[type];
152
+ const basePreset = CALLOUT_PRESETS[type] || CALLOUT_PRESETS.note;
153
+ const preset = {
154
+ ...basePreset,
155
+ ...configuredPreset,
156
+ };
157
+
158
+ let iconText = preset.icon;
159
+ let titleText = preset.title;
160
+
161
+ if (customTitleAttr && customTitleAttr.trim()) {
162
+ const trimmed = customTitleAttr.trim();
163
+ const emojiMatch = trimmed.match(/^(\p{Extended_Pictographic}|\p{Emoji_Presentation})\s*(.*)$/u);
164
+ if (emojiMatch) {
165
+ iconText = emojiMatch[1];
166
+ titleText = emojiMatch[2].trim() || preset.title;
167
+ } else {
168
+ titleText = trimmed;
169
+ }
170
+ }
171
+
172
+ // Build callout header section
173
+ const header = context.document.createElement("section");
174
+ header.className = "wechat-callout-header";
175
+ header.setAttribute("data-wechat-node", "callout-header");
176
+
177
+ const icon = context.document.createElement("span");
178
+ icon.className = "wechat-callout-icon";
179
+ icon.textContent = iconText;
180
+ header.appendChild(icon);
181
+
182
+ const title = context.document.createElement("strong");
183
+ title.className = "wechat-callout-title";
184
+ title.textContent = titleText;
185
+ header.appendChild(title);
186
+
187
+ element.prepend(header);
188
+ },
189
+ };
190
+
191
+ export const builtinCalloutPlugin: WechatMarkdownPlugin = {
192
+ doc: {
193
+ id: "callout",
194
+ name: "提示卡片 (Callout)",
195
+ category: "block",
196
+ description: "使用 GFM 规范语法 (> [!NOTE]) 生成带左侧边框与图标的微信高亮卡片",
197
+ sampleMarkdown: `> [!NOTE]
198
+ > 这里是一条标准提示,适用于操作备忘。
199
+
200
+ > [!TIP] 实用技巧
201
+ > 这是带自定义标题的建议卡片。
202
+
203
+ > [!WARNING] ⚠️ 重点防护
204
+ > 这是带自定义 Emoji 与标题的警告卡片。`,
205
+ syntaxGuide: "> [!NOTE] 自定义标题 (可选,可包含 Emoji)\n> 提示内容...",
206
+ cssSelectors: [
207
+ 'section[data-wechat-node="callout"]',
208
+ ".wechat-callout",
209
+ ".wechat-callout-note",
210
+ ".wechat-callout-tip",
211
+ ".wechat-callout-warning",
212
+ ],
213
+ },
214
+ milkdownPlugins: calloutMilkdownPlugins,
215
+ wechatRenderer: calloutWechatRenderer,
216
+ defaultCss: `
217
+ .milkdown .editor section[data-wechat-node="callout"],
218
+ .milkdown .editor .wechat-callout {
219
+ margin: 1.2em 0;
220
+ padding: 12px 16px;
221
+ border-radius: 6px;
222
+ border-left-width: 4px;
223
+ border-left-style: solid;
224
+ box-sizing: border-box;
225
+ }
226
+ .milkdown .editor .wechat-callout-note {
227
+ border-left-color: #3b82f6;
228
+ background-color: rgba(59, 130, 246, 0.08);
229
+ }
230
+ .milkdown .editor .wechat-callout-tip {
231
+ border-left-color: #10b981;
232
+ background-color: rgba(16, 185, 129, 0.08);
233
+ }
234
+ .milkdown .editor .wechat-callout-important {
235
+ border-left-color: #8b5cf6;
236
+ background-color: rgba(139, 92, 246, 0.08);
237
+ }
238
+ .milkdown .editor .wechat-callout-warning {
239
+ border-left-color: #f59e0b;
240
+ background-color: rgba(245, 158, 11, 0.08);
241
+ }
242
+ .milkdown .editor .wechat-callout-caution {
243
+ border-left-color: #ef4444;
244
+ background-color: rgba(239, 68, 68, 0.08);
245
+ }
246
+ .milkdown .editor section[data-wechat-node="callout-header"] {
247
+ font-size: 14px;
248
+ font-weight: bold;
249
+ margin-bottom: 6px;
250
+ line-height: 1.4;
251
+ }
252
+ .milkdown .editor .wechat-callout-icon {
253
+ margin-right: 6px;
254
+ }
255
+ .milkdown .editor .wechat-callout p {
256
+ margin: 0.4em 0;
257
+ font-size: 15px;
258
+ line-height: 1.7;
259
+ }
260
+ `.trim(),
261
+ };
@@ -0,0 +1,46 @@
1
+ import { $markSchema, $remark } from "@milkdown/kit/utils";
2
+ import { remarkHighlightMark } from "remark-highlight-mark";
3
+ import type { WechatMarkdownPlugin } from "./types";
4
+
5
+ export const highlightMilkdownPlugins = [
6
+ $remark("remarkHighlightMark", () => remarkHighlightMark as any),
7
+ $markSchema("highlight", () => ({
8
+ parseDOM: [{ tag: "mark" }],
9
+ toDOM: () => ["mark", 0],
10
+ parseMarkdown: {
11
+ match: (node) => node.type === "highlight",
12
+ runner: (state, node, markType) => {
13
+ state.openMark(markType, {});
14
+ state.next(node.children);
15
+ state.closeMark(markType);
16
+ },
17
+ },
18
+ toMarkdown: {
19
+ match: (mark) => mark.type.name === "highlight",
20
+ runner: (state, mark) => {
21
+ state.withMark(mark, "highlight");
22
+ },
23
+ },
24
+ })),
25
+ ].flat();
26
+
27
+ export const builtinHighlightPlugin: WechatMarkdownPlugin = {
28
+ doc: {
29
+ id: "highlight",
30
+ name: "高亮标记 (Highlight)",
31
+ category: "inline",
32
+ description: "使用 ==文本== 添加荧光笔黄色高亮背景",
33
+ sampleMarkdown: "这里支持 ==重要结论== 与 ==关键观点== 的高亮展示。",
34
+ syntaxGuide: "==高亮文本==",
35
+ cssSelectors: ["mark", ".milkdown .editor mark"],
36
+ },
37
+ milkdownPlugins: highlightMilkdownPlugins,
38
+ defaultCss: `
39
+ .milkdown .editor mark {
40
+ background-color: #fff0a8;
41
+ color: inherit;
42
+ padding: 1px 3px;
43
+ border-radius: 2px;
44
+ }
45
+ `.trim(),
46
+ };
@@ -0,0 +1,133 @@
1
+ import { $markSchema, $remark } from "@milkdown/kit/utils";
2
+ import type { WechatMarkdownPlugin } from "./types";
3
+ import type { WechatElementRenderer } from "../wechat-renderer";
4
+
5
+ /** Remark plugin to match [[kbd:Key]] syntax. */
6
+ export function remarkKbd() {
7
+ return (tree: any) => {
8
+ function visit(node: any) {
9
+ if (!node || typeof node !== "object") return;
10
+ if (node.type === "paragraph" || node.type === "listItem" || node.type === "heading") {
11
+ if (Array.isArray(node.children)) {
12
+ const newChildren: any[] = [];
13
+ for (const child of node.children) {
14
+ if (child.type === "text" && typeof child.value === "string") {
15
+ const regex = /\[\[kbd:([^\]]+)\]\]/g;
16
+ let lastIndex = 0;
17
+ let match;
18
+ let found = false;
19
+ while ((match = regex.exec(child.value)) !== null) {
20
+ found = true;
21
+ if (match.index > lastIndex) {
22
+ newChildren.push({
23
+ type: "text",
24
+ value: child.value.slice(lastIndex, match.index),
25
+ });
26
+ }
27
+ newChildren.push({
28
+ type: "kbd",
29
+ children: [{ type: "text", value: match[1] }],
30
+ });
31
+ lastIndex = regex.lastIndex;
32
+ }
33
+ if (found) {
34
+ if (lastIndex < child.value.length) {
35
+ newChildren.push({
36
+ type: "text",
37
+ value: child.value.slice(lastIndex),
38
+ });
39
+ }
40
+ continue;
41
+ }
42
+ }
43
+ newChildren.push(child);
44
+ }
45
+ node.children = newChildren;
46
+ }
47
+ }
48
+ if (Array.isArray(node.children)) {
49
+ for (const child of node.children) {
50
+ visit(child);
51
+ }
52
+ }
53
+ }
54
+ visit(tree);
55
+ };
56
+ }
57
+
58
+ export const kbdMilkdownPlugins = [
59
+ $remark("remarkKbd", () => remarkKbd as any),
60
+ $markSchema("kbd", () => ({
61
+ parseDOM: [{ tag: "kbd" }, { tag: "span[data-wechat-node='kbd']" }],
62
+ toDOM: () => [
63
+ "span",
64
+ {
65
+ class: "wechat-kbd",
66
+ "data-wechat-node": "kbd",
67
+ },
68
+ 0,
69
+ ],
70
+ parseMarkdown: {
71
+ match: (node) => node.type === "kbd",
72
+ runner: (state, node, markType) => {
73
+ state.openMark(markType, {});
74
+ state.next(node.children);
75
+ state.closeMark(markType);
76
+ },
77
+ },
78
+ toMarkdown: {
79
+ match: (mark) => mark.type.name === "kbd",
80
+ runner: (state, mark) => {
81
+ state.withMark(mark, "kbd");
82
+ },
83
+ },
84
+ })),
85
+ ].flat();
86
+
87
+ export const kbdWechatRenderer: WechatElementRenderer = {
88
+ kind: "kbd",
89
+ selector: 'span[data-wechat-node="kbd"], kbd',
90
+ finalize(element) {
91
+ if (element.tagName.toLowerCase() === "kbd") {
92
+ const replacement = element.ownerDocument.createElement("span");
93
+ for (const attr of Array.from(element.attributes)) {
94
+ replacement.setAttribute(attr.name, attr.value);
95
+ }
96
+ while (element.firstChild) {
97
+ replacement.appendChild(element.firstChild);
98
+ }
99
+ element.replaceWith(replacement);
100
+ }
101
+ },
102
+ };
103
+
104
+ export const builtinKbdPlugin: WechatMarkdownPlugin = {
105
+ doc: {
106
+ id: "kbd",
107
+ name: "按键标记 (Keyboard Key)",
108
+ category: "inline",
109
+ description: "使用 [[kbd:按键]] 输出带轻拟物阴影的键盘按键胶囊",
110
+ sampleMarkdown: "按下快捷键 [[kbd:Cmd]] + [[kbd:K]] 快速唤起全局指令台。",
111
+ syntaxGuide: "[[kbd:按键名称]]",
112
+ cssSelectors: ['span[data-wechat-node="kbd"]', ".wechat-kbd"],
113
+ },
114
+ milkdownPlugins: kbdMilkdownPlugins,
115
+ wechatRenderer: kbdWechatRenderer,
116
+ defaultCss: `
117
+ .milkdown .editor .wechat-kbd,
118
+ .milkdown .editor span[data-wechat-node="kbd"] {
119
+ display: inline-block;
120
+ padding: 2px 6px;
121
+ font-family: -apple-system, SFMono-Regular, Consolas, Menlo, monospace;
122
+ font-size: 0.85em;
123
+ line-height: 1.2;
124
+ color: #374151;
125
+ background-color: #f3f4f6;
126
+ border: 1px solid #d1d5db;
127
+ border-bottom-width: 2px;
128
+ border-radius: 4px;
129
+ margin: 0 2px;
130
+ box-sizing: border-box;
131
+ }
132
+ `.trim(),
133
+ };