md2wechat-mcp 0.1.3 → 0.2.1

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 CHANGED
@@ -1,9 +1,17 @@
1
1
  # 微信 Markdown 转换 MCP
2
2
 
3
- 这个 MCP 服务提供三个工具:
3
+ 这个 MCP 服务提供以下工具:
4
4
  - `convert_markdown_to_wechat_html`: 把 Markdown 转成可用于公众号排版流程的内联样式 HTML。
5
5
  - `list_wechat_themes`: 返回可用主题。
6
- - `open_wechat_html_in_browser`: 接收 `cacheHtmlPath` 直接打开,方便手动 `Cmd+A` / `Cmd+C`。
6
+ - `open_wechat_html_in_browser`: 打开转换后的缓存 HTML(`cacheHtmlPath` 必须来自 `convert_markdown_to_wechat_html` 返回值)。
7
+ - `wechat_get_access_token`: 获取公众号接口调用凭证(读取环境变量)。
8
+ - `wechat_upload_image`: 上传“文章内图片”并返回 URL(不占素材库配额)。
9
+ - `wechat_add_material`: 上传永久素材(`image | voice | video | thumb`)。
10
+ - `wechat_draft_add`: 新增草稿。
11
+ - `wechat_markdown_to_draft`: 一键把 Markdown 转 HTML 并创建草稿。
12
+ - `wechat_draft_update`: 更新草稿指定文章。
13
+ - `wechat_draft_batchget`: 分页查询草稿列表。
14
+ - `wechat_draft_delete`: 删除草稿。
7
15
 
8
16
  当前已支持常见 Markdown 语法(标题、段落、列表、引用、代码块、链接、强调)以及 GFM 风格表格。
9
17
 
@@ -64,14 +72,19 @@ node dist/cli.js ./input.md --theme default
64
72
  - `markdown_path` (string, optional): 传本地 Markdown 文件路径(避免整篇内容走 token)
65
73
  - `theme` (string, optional): `default | tech | warm | apple | wechat-native`
66
74
  - `title` (string, optional)
75
+ - `font_size_preset` (string, optional, default `medium`): `small | medium | large`
76
+ - `access_token` (string, optional): 传入后会自动上传本地图片并替换为微信 URL
67
77
 
68
78
  规则:
69
79
  - `markdown` 和 `markdown_path` 二选一
70
80
  - 如果两者都传,优先使用 `markdown`
81
+ - 顶部一级标题(`# 标题`)会在转换时从正文移除(避免正文重复显示大标题)
71
82
 
72
83
  输出:
73
- - `text` 内容为 HTML 字符串(`<article>...</article>`)
74
- - `meta.cacheHtmlPath` 返回缓存文件路径
84
+ - `content[0].text`:HTML 字符串(`<article>...</article>`)
85
+ - `content[1].text`:可见元信息文本(含 `cacheHtmlPath=...`)
86
+ - `meta.cacheHtmlPath` 返回缓存文件路径(后续 `open_wechat_html_in_browser.cacheHtmlPath` 必须使用这个字段)
87
+ - 兼容说明:若客户端不展示 `meta`,从 `content[1].text` 解析 `cacheHtmlPath=...`
75
88
 
76
89
  ### 2) `list_wechat_themes`
77
90
  输入:空对象
@@ -79,17 +92,303 @@ node dist/cli.js ./input.md --theme default
79
92
 
80
93
  ### 3) `open_wechat_html_in_browser`
81
94
  输入:
82
- - `cacheHtmlPath` (string, required): 已有缓存 HTML 路径,工具会直接打开
95
+ - `cacheHtmlPath` (string, required): 请直接传 `convert_markdown_to_wechat_html` 返回的 `meta.cacheHtmlPath`,不要手猜 `/tmp` 路径
83
96
 
84
97
  输出:
85
98
  - JSON 文本,包含 `ok / opened / cacheHtmlPath`
86
99
  - `meta.cacheHtmlPath` 返回缓存文件路径
87
100
 
101
+ ### 4) `wechat_add_material`
102
+ 对应官方接口:`POST /cgi-bin/material/add_material`
103
+
104
+ 输入:
105
+ - `access_token` (string, required)
106
+ - `type` (string, required): `image | voice | video | thumb`
107
+ - `file_path` (string, required): 本地文件绝对路径
108
+ - `title` (string, optional): `type=video` 时必填
109
+ - `introduction` (string, optional): `type=video` 时必填
110
+
111
+ 输出:
112
+ - `media_id` (string): 新增的永久素材 ID
113
+ - `url` (string, optional): 仅 `image` 类型返回
114
+
115
+ ### 5) `wechat_get_access_token`
116
+ 输入:空对象
117
+
118
+ 依赖环境变量:
119
+ - `WECHAT_APPID`
120
+ - `WECHAT_APPSECRET`
121
+
122
+ 输出:
123
+ - `access_token`
124
+ - `expires_in`
125
+
126
+ ### 6) `wechat_upload_image`
127
+ 对应官方接口:`POST /cgi-bin/media/uploadimg`
128
+
129
+ 输入:
130
+ - `access_token` (string, required)
131
+ - `file_path` (string, required): 本地图片路径,仅支持 `jpg/jpeg/png`
132
+
133
+ 输出:
134
+ - `url` (string): 文章内可用的图片 URL
135
+
136
+ ### 7) `wechat_draft_add`
137
+ 对应官方接口:`POST /cgi-bin/draft/add`
138
+
139
+ 输入:
140
+ - `access_token` (string, required)
141
+ - `articles` (array, required): 至少 1 篇,字段与公众号草稿接口一致,核心字段:
142
+ - `title` (string, required)
143
+ - `content` (string, required): 建议使用 `convert_markdown_to_wechat_html` 生成
144
+ - `author`/`digest`/`content_source_url`/`thumb_media_id`/`need_open_comment`/`only_fans_can_comment` (optional)
145
+
146
+ 输出:
147
+ - `media_id` (string)
148
+
149
+ ### 8) `wechat_draft_update`
150
+ 对应官方接口:`POST /cgi-bin/draft/update`
151
+
152
+ 输入:
153
+ - `access_token` (string, required)
154
+ - `media_id` (string, required)
155
+ - `index` (number, optional, default `0`)
156
+ - `article` (object, required): 同 `wechat_draft_add` 单篇文章结构
157
+
158
+ 输出:
159
+ - `errcode`
160
+ - `errmsg`
161
+
162
+ ### 9) `wechat_markdown_to_draft`
163
+ 一键工具:内部自动执行 `convert_markdown_to_wechat_html` + `wechat_draft_add`。
164
+ 封面自动策略(默认启用,无需传封面参数):
165
+ - 若存在 `title="封面"` 的本地 `jpg/jpeg` 图片:该图上传为封面,并从正文移除
166
+ - 否则:尝试使用首张本地 `jpg/jpeg` 图片作为封面
167
+ - 若无可用图片:不上传封面,草稿仍会创建
168
+ - 顶部一级标题(`# 标题`)会在转换时从正文移除(避免正文重复显示大标题)
169
+
170
+ 输入(核心):
171
+ - `access_token` (string, required)
172
+ - `article_title` (string, optional):草稿标题。未传时按“首个一级标题(H1)→ 文件名(去扩展名)”自动推断
173
+ - `markdown` 或 `markdown_path` (至少一个)
174
+
175
+ 输入(可选):
176
+ - `theme` / `title`
177
+ - `font_size_preset`(可选,`small | medium | large`,默认 `medium`)
178
+ - `thumb_media_id`(可选,显式覆盖自动封面)
179
+ - `author` / `digest` / `content_source_url`
180
+ - `need_open_comment` / `only_fans_can_comment`
181
+
182
+ 说明:
183
+ - `auto_thumb_from_first_image` 参数已移除;封面由 `wechat_markdown_to_draft` 内置策略自动处理。
184
+
185
+ 输出:
186
+ - 草稿创建结果(含 `media_id`)
187
+ - `meta.cacheHtmlPath`(转换缓存路径)
188
+ - `meta.thumbMediaId`(若自动或手动封面可用)
189
+
190
+ ### 10) `wechat_draft_batchget`
191
+ 对应官方接口:`POST /cgi-bin/draft/batchget`
192
+
193
+ 输入:
194
+ - `access_token` (string, required)
195
+ - `offset` (number, optional, default `0`)
196
+ - `count` (number, optional, default `10`, 范围 `1-20`)
197
+ - `no_content` (`0 | 1`, optional, default `0`)
198
+
199
+ 输出:
200
+ - `total_count`
201
+ - `item_count`
202
+ - `item`
203
+
204
+ ### 11) `wechat_draft_delete`
205
+ 对应官方接口:`POST /cgi-bin/draft/delete`
206
+
207
+ 输入:
208
+ - `access_token` (string, required)
209
+ - `media_id` (string, required)
210
+
211
+ 输出:
212
+ - `errcode`
213
+ - `errmsg`
214
+
215
+ ## 典型调用流程
216
+
217
+ 前置:`wechat_markdown_to_draft` 也必须先拿到 `access_token`(可通过 `wechat_get_access_token` 获取)。
218
+
219
+ 1. 推荐直接调用 `wechat_markdown_to_draft` 一步完成转换+建草稿(在已持有 `access_token` 的前提下)
220
+ 2. 或者使用分步模式:`wechat_get_access_token` → `convert_markdown_to_wechat_html` → `wechat_draft_add`
221
+ 3. 封面默认自动处理(`title="封面"` 优先;否则首图);也可手动 `wechat_add_material(type=thumb)` 后在草稿里显式传 `thumb_media_id`
222
+ 4. 使用 `wechat_draft_update / wechat_draft_batchget / wechat_draft_delete` 做后续管理
223
+
224
+ ## 给 AI 的执行规范(建议写入系统提示)
225
+
226
+ 当用户说“把 Markdown 上传到公众号草稿箱”时,必须遵循:
227
+ 1. 必须先调用 `convert_markdown_to_wechat_html`。
228
+ 2. `wechat_draft_add.articles[].content` 必须使用上一步返回的 `content[0].text`。
229
+ 3. 不要把原始 Markdown 直接传给 `wechat_draft_add`。
230
+ 4. `wechat_add_material` 仅用于素材上传(封面/音视频),不能替代 `wechat_draft_add` 创建草稿。
231
+ 5. 需要手动封面时,先调用 `wechat_add_material(type=thumb)`,再将返回 `media_id` 写入 `wechat_draft_add.articles[].thumb_media_id`。
232
+ 6. 调用 `open_wechat_html_in_browser` 时,`cacheHtmlPath` 必须来自 `convert_markdown_to_wechat_html` 返回的 `meta.cacheHtmlPath`。
233
+
234
+ ## MCP 调用示例(可直接复制)
235
+
236
+ ### `wechat_get_access_token`
237
+ ```json
238
+ {
239
+ "name": "wechat_get_access_token",
240
+ "arguments": {}
241
+ }
242
+ ```
243
+
244
+ ### `convert_markdown_to_wechat_html`
245
+ ```json
246
+ {
247
+ "name": "convert_markdown_to_wechat_html",
248
+ "arguments": {
249
+ "markdown_path": "/absolute/path/article.md",
250
+ "theme": "wechat-native",
251
+ "font_size_preset": "small",
252
+ "title": "文章标题",
253
+ "access_token": "ACCESS_TOKEN"
254
+ }
255
+ }
256
+ ```
257
+
258
+ ### `convert_markdown_to_wechat_html` -> `open_wechat_html_in_browser`(字段级映射)
259
+ ```text
260
+ open.arguments.cacheHtmlPath = convert.meta.cacheHtmlPath
261
+ ```
262
+
263
+ 若客户端不展示 `meta`,可从 `convert` 的第二段 `content` 读取:
264
+ ```text
265
+ convert.content[1].text:
266
+ cacheHtmlPath=/abs/path/to/wechat-xxx.html
267
+ ```
268
+
269
+ 提取规则(示例):
270
+ ```text
271
+ cacheHtmlPath = line that starts with "cacheHtmlPath="
272
+ value = text after "="
273
+ ```
274
+
275
+ ```json
276
+ {
277
+ "name": "open_wechat_html_in_browser",
278
+ "arguments": {
279
+ "cacheHtmlPath": "<convert_result.meta.cacheHtmlPath>"
280
+ }
281
+ }
282
+ ```
283
+
284
+ ### `wechat_add_material`
285
+ ```json
286
+ {
287
+ "name": "wechat_add_material",
288
+ "arguments": {
289
+ "access_token": "ACCESS_TOKEN",
290
+ "type": "thumb",
291
+ "file_path": "/absolute/path/cover.jpg"
292
+ }
293
+ }
294
+ ```
295
+
296
+ ### `wechat_upload_image`
297
+ ```json
298
+ {
299
+ "name": "wechat_upload_image",
300
+ "arguments": {
301
+ "access_token": "ACCESS_TOKEN",
302
+ "file_path": "/absolute/path/inline-image.jpg"
303
+ }
304
+ }
305
+ ```
306
+
307
+ ### `wechat_draft_add`
308
+ ```json
309
+ {
310
+ "name": "wechat_draft_add",
311
+ "arguments": {
312
+ "access_token": "ACCESS_TOKEN",
313
+ "articles": [
314
+ {
315
+ "title": "文章标题",
316
+ "author": "作者名",
317
+ "digest": "摘要",
318
+ "content": "<article>...</article>",
319
+ "thumb_media_id": "THUMB_MEDIA_ID",
320
+ "need_open_comment": 1,
321
+ "only_fans_can_comment": 0
322
+ }
323
+ ]
324
+ }
325
+ }
326
+ ```
327
+
328
+ ### `wechat_draft_update`
329
+ ```json
330
+ {
331
+ "name": "wechat_draft_update",
332
+ "arguments": {
333
+ "access_token": "ACCESS_TOKEN",
334
+ "media_id": "DRAFT_MEDIA_ID",
335
+ "index": 0,
336
+ "article": {
337
+ "title": "更新后的标题",
338
+ "content": "<article>...</article>",
339
+ "thumb_media_id": "THUMB_MEDIA_ID"
340
+ }
341
+ }
342
+ }
343
+ ```
344
+
345
+ ### `wechat_markdown_to_draft`
346
+ ```json
347
+ {
348
+ "name": "wechat_markdown_to_draft",
349
+ "arguments": {
350
+ "access_token": "ACCESS_TOKEN",
351
+ "article_title": "文章标题",
352
+ "markdown_path": "/absolute/path/article.md",
353
+ "theme": "wechat-native",
354
+ "font_size_preset": "medium",
355
+ "title": "页面H1标题",
356
+ "author": "作者名",
357
+ "digest": "摘要"
358
+ }
359
+ }
360
+ ```
361
+
362
+ ### `wechat_draft_batchget`
363
+ ```json
364
+ {
365
+ "name": "wechat_draft_batchget",
366
+ "arguments": {
367
+ "access_token": "ACCESS_TOKEN",
368
+ "offset": 0,
369
+ "count": 10,
370
+ "no_content": 1
371
+ }
372
+ }
373
+ ```
374
+
375
+ ### `wechat_draft_delete`
376
+ ```json
377
+ {
378
+ "name": "wechat_draft_delete",
379
+ "arguments": {
380
+ "access_token": "ACCESS_TOKEN",
381
+ "media_id": "DRAFT_MEDIA_ID"
382
+ }
383
+ }
384
+ ```
385
+
88
386
  ## CLI 参数
89
387
 
90
388
  `md2wechat <input> [options]`
91
389
  - `--theme <theme>`
92
390
  - `--title <title>`
391
+ - `--font-size-preset <preset>`
93
392
  - `--out <path>`
94
393
  - `--cache-dir <path>`
95
394
 
package/dist/browser.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { access } from "node:fs/promises";
2
3
  import { promisify } from "node:util";
3
4
  const execFileAsync = promisify(execFile);
4
5
  function getBrowserCommand(filePath) {
@@ -11,6 +12,7 @@ function getBrowserCommand(filePath) {
11
12
  return { command: "xdg-open", args: [filePath] };
12
13
  }
13
14
  export async function openFileInBrowser(filePath, runner = (command, args) => execFileAsync(command, args)) {
15
+ await access(filePath);
14
16
  const { command, args } = getBrowserCommand(filePath);
15
17
  await runner(command, args);
16
18
  }
package/dist/cache.js CHANGED
@@ -16,7 +16,7 @@ export function ensureCacheDir(cwd) {
16
16
  const candidates = [
17
17
  process.env.WECHAT_MCP_CACHE_DIR,
18
18
  join(baseDir, ".cache", "wechat-mcp"),
19
- join(process.cwd(), ".cache", "wechat-mcp"),
19
+ baseDir !== process.cwd() ? join(process.cwd(), ".cache", "wechat-mcp") : undefined,
20
20
  xdgCache ? join(resolve(xdgCache), "wechat-mcp") : undefined,
21
21
  process.platform === "darwin" ? join(homedir(), "Library", "Caches", "wechat-mcp") : join(homedir(), ".cache", "wechat-mcp"),
22
22
  join(tmpdir(), "wechat-mcp")
@@ -37,8 +37,9 @@ export function saveHtmlCache(html, cwd, cacheDir) {
37
37
  const finalDir = cacheDir ? resolve(cacheDir) : ensureCacheDir(cwd);
38
38
  mkdirSync(finalDir, { recursive: true });
39
39
  const now = new Date();
40
- const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
41
- const filePath = join(finalDir, `wechat-${stamp}.html`);
40
+ const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}${String(now.getMilliseconds()).padStart(3, "0")}`;
41
+ const randomSuffix = Math.random().toString(36).slice(2, 8);
42
+ const filePath = join(finalDir, `wechat-${stamp}-${randomSuffix}.html`);
42
43
  writeFileSync(filePath, html, "utf8");
43
44
  return filePath;
44
45
  }
package/dist/cli.js CHANGED
@@ -11,6 +11,12 @@ function validateTheme(theme) {
11
11
  }
12
12
  throw new Error(`Invalid theme: ${theme}. Available: ${[...THEME_NAMES].sort().join(", ")}`);
13
13
  }
14
+ function validateFontSizePreset(value) {
15
+ if (value === "small" || value === "medium" || value === "large") {
16
+ return value;
17
+ }
18
+ throw new Error(`Invalid font size preset: ${value}. Available: small, medium, large`);
19
+ }
14
20
  export async function main(argv = process.argv) {
15
21
  const program = new Command();
16
22
  program
@@ -19,6 +25,7 @@ export async function main(argv = process.argv) {
19
25
  .argument("<input>", "Input markdown file path")
20
26
  .option("--theme <theme>", "Rendering theme", "default")
21
27
  .option("--title <title>", "Optional title override")
28
+ .option("--font-size-preset <preset>", "Font size preset: small | medium | large", "medium")
22
29
  .option("--out <path>", "Optional output html path; if omitted, use .cache/wechat-mcp")
23
30
  .option("--cache-dir <path>", "Override cache directory (or set WECHAT_MCP_CACHE_DIR).")
24
31
  .showHelpAfterError();
@@ -27,7 +34,8 @@ export async function main(argv = process.argv) {
27
34
  const markdown = readFileSync(inputPath, "utf8");
28
35
  const opts = program.opts();
29
36
  const theme = validateTheme(opts.theme);
30
- const html = parseMarkdown(markdown, theme, opts.title);
37
+ const fontSizePreset = validateFontSizePreset(opts.fontSizePreset);
38
+ const html = parseMarkdown(markdown, theme, opts.title, fontSizePreset);
31
39
  let outputPath;
32
40
  if (opts.out) {
33
41
  outputPath = resolve(opts.out);
@@ -39,6 +47,7 @@ export async function main(argv = process.argv) {
39
47
  }
40
48
  process.stdout.write(`Input: ${inputPath}\n`);
41
49
  process.stdout.write(`Theme: ${theme}\n`);
50
+ process.stdout.write(`Font size preset: ${fontSizePreset}\n`);
42
51
  process.stdout.write(`Output: ${outputPath}\n`);
43
52
  return 0;
44
53
  }
@@ -1,3 +1,3 @@
1
- import { type Theme } from "./themes.js";
1
+ import { type FontSizePreset, type Theme } from "./themes.js";
2
2
  export declare function inlineFormat(text: string, theme: Theme): string;
3
- export declare function parseMarkdown(md: string, themeName?: string, title?: string): string;
3
+ export declare function parseMarkdown(md: string, themeName?: string, title?: string, fontSizePreset?: FontSizePreset): string;
package/dist/markdown.js CHANGED
@@ -1,4 +1,4 @@
1
- import { THEMES } from "./themes.js";
1
+ import { resolveTheme } from "./themes.js";
2
2
  function escapeHtml(text) {
3
3
  return text
4
4
  .replaceAll("&", "&amp;")
@@ -18,8 +18,16 @@ export function inlineFormat(text, theme) {
18
18
  escaped = escaped.replace(/`([^`]+)`/g, (_, code) => {
19
19
  return stash(`<code style=\"${theme.code_inline}\">${code}</code>`);
20
20
  });
21
- escaped = escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, href) => {
22
- return `<a href=\"${href}\" style=\"${theme.a}\">${label}</a>`;
21
+ // Images must be processed before links (pattern starts with `!`)
22
+ escaped = escaped.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)(?:\s+(?:"([^"]+)"|'([^']+)'|&quot;([^&]+)&quot;|&#39;([^&]+)&#39;))?\)/g, (_m, alt, src, t1, t2, t3, t4) => {
23
+ const title = t1 ?? t2 ?? t3 ?? t4;
24
+ const titleAttr = title ? ` title=\"${title}\"` : "";
25
+ return stash(`<img src=\"${src}\" alt=\"${alt}\"${titleAttr} style=\"max-width:100%;height:auto;display:block;margin:0.8em auto;\" />`);
26
+ });
27
+ escaped = escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)(?:\s+(?:"([^"]+)"|'([^']+)'|&quot;([^&]+)&quot;|&#39;([^&]+)&#39;))?\)/g, (_m, label, href, t1, t2, t3, t4) => {
28
+ const title = t1 ?? t2 ?? t3 ?? t4;
29
+ const titleAttr = title ? ` title=\"${title}\"` : "";
30
+ return `<a href=\"${href}\"${titleAttr} style=\"${theme.a}\">${label}</a>`;
23
31
  });
24
32
  escaped = escaped.replace(/\*\*([^*]+)\*\*/g, (_m, content) => {
25
33
  return `<strong style=\"${theme.strong}\">${content}</strong>`;
@@ -56,8 +64,8 @@ function parseColumnAlignment(cell) {
56
64
  return "left";
57
65
  return null;
58
66
  }
59
- export function parseMarkdown(md, themeName = "default", title) {
60
- const theme = THEMES[themeName] ?? THEMES.default;
67
+ export function parseMarkdown(md, themeName = "default", title, fontSizePreset = "medium") {
68
+ const theme = resolveTheme(themeName, fontSizePreset);
61
69
  const lines = md.replaceAll("\r\n", "\n").split("\n");
62
70
  const out = [];
63
71
  if (title) {
@@ -68,6 +76,8 @@ export function parseMarkdown(md, themeName = "default", title) {
68
76
  let codeLines = [];
69
77
  let listType;
70
78
  let listItems = [];
79
+ let olStart = 1;
80
+ let olNextExpected;
71
81
  let paragraphBuffer = [];
72
82
  const flushParagraph = () => {
73
83
  if (paragraphBuffer.length > 0) {
@@ -81,7 +91,15 @@ export function parseMarkdown(md, themeName = "default", title) {
81
91
  const flushList = () => {
82
92
  if (listType && listItems.length > 0) {
83
93
  const renderedItems = listItems.map((item) => `<li style=\"${theme.li}\">${item}</li>`).join("");
84
- out.push(`<${listType} style=\"margin: 0.6em 0 0.9em 1.2em; padding: 0;\">${renderedItems}</${listType}>`);
94
+ if (listType === "ol") {
95
+ const startAttr = olStart !== 1 ? ` start=\"${olStart}\"` : "";
96
+ out.push(`<ol${startAttr} style=\"margin: 0.6em 0 0.9em 1.2em; padding: 0;\">${renderedItems}</ol>`);
97
+ olNextExpected = olStart + listItems.length;
98
+ }
99
+ else {
100
+ out.push(`<ul style=\"margin: 0.6em 0 0.9em 1.2em; padding: 0;\">${renderedItems}</ul>`);
101
+ olNextExpected = undefined;
102
+ }
85
103
  }
86
104
  listType = undefined;
87
105
  listItems = [];
@@ -209,14 +227,25 @@ export function parseMarkdown(md, themeName = "default", title) {
209
227
  listItems.push(inlineFormat(ul[1], theme));
210
228
  continue;
211
229
  }
212
- const ol = line.match(/^\s*\d+\.\s+(.+)$/u);
230
+ const ol = line.match(/^\s*(\d+)\.\s+(.+)$/u);
213
231
  if (ol) {
214
232
  flushParagraph();
233
+ const itemNum = parseInt(ol[1], 10);
215
234
  if (listType && listType !== "ol") {
216
235
  flushList();
217
236
  }
237
+ if (listType !== "ol") {
238
+ // Starting a new ol group: check if this continues a previous flushed group
239
+ if (olNextExpected !== undefined && itemNum === olNextExpected) {
240
+ olStart = itemNum;
241
+ }
242
+ else {
243
+ olStart = itemNum;
244
+ olNextExpected = undefined;
245
+ }
246
+ }
218
247
  listType = "ol";
219
- listItems.push(inlineFormat(ol[1], theme));
248
+ listItems.push(inlineFormat(ol[2], theme));
220
249
  continue;
221
250
  }
222
251
  flushList();