@jacobbubu/md-to-lark 1.7.0 → 1.8.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 CHANGED
@@ -56,8 +56,8 @@ LARK_DOCUMENT_BASE_URL="https://li.feishu.cn"
56
56
 
57
57
  Notes:
58
58
 
59
- - `--dry-run` still validates Feishu configuration first. It is not a zero-config mode.
60
- - As long as `--doc` is not provided, `LARK_FOLDER_TOKEN` is required for single-file, directory, dry-run, and real publish modes.
59
+ - Pure local `--dry-run` does not require Feishu credentials, `--folder`, or `--doc`.
60
+ - Real publishing requires either `--doc` or `LARK_FOLDER_TOKEN`/`--folder`, plus valid Feishu credentials.
61
61
 
62
62
  The first run should use a built-in sample:
63
63
 
@@ -88,6 +88,18 @@ Progress logs and exceptions are written to stderr.
88
88
 
89
89
  Relative local assets such as `./img-001.png` still resolve against the Markdown file directory by default. If your caller generates a temporary Markdown file elsewhere, use `--resource-base-dir` to keep local asset resolution pinned to the original content directory.
90
90
 
91
+ ## Markdown Syntax Notes
92
+
93
+ Inline `<mark>text</mark>` is supported as a controlled HTML shorthand for Feishu yellow text background highlight.
94
+
95
+ Example:
96
+
97
+ ```md
98
+ This is <mark>important text</mark>.
99
+ ```
100
+
101
+ Nested Markdown marks inside `<mark>...</mark>`, such as `**bold**` and links, are preserved. This does not enable arbitrary raw HTML rendering; unsupported HTML keeps the existing parser behavior.
102
+
91
103
  ## Common Commands
92
104
 
93
105
  Basic publish:
@@ -182,6 +194,7 @@ Guardrails:
182
194
  - Mermaid `text-drawing` and `board` output paths
183
195
  - Opt-in single-dollar inline math parsing for LaTeX-heavy papers
184
196
  - Table width heuristics and numeric-column right alignment
197
+ - Inline `<mark>text</mark>` mapped to Feishu yellow text background
185
198
  - Chinese Markdown formatting preset (`zh-format`)
186
199
  - Ordered preset composition from CLI and programmatic usage
187
200
  - Legacy stage cache output from `00-source` to `05-publish`; protocol mode adds contract and semantic stages through `07-publish`
@@ -39,7 +39,7 @@ function usage() {
39
39
  ' 5) If markdown has exactly one h1 and --title is not provided, that h1 becomes doc title, then removed from content, and remaining headings are promoted by one level.',
40
40
  ' 6) Stage cache layout per markdown: 00-source, 01-prepare, 02-hast, 03-last, 04-btt, 05-publish.',
41
41
  ' 7) Prepare stage can pre-download remote markdown images and optional yt-dlp URL lines.',
42
- ' 8) Leading YAML/TOML frontmatter is rewritten as fenced code block (yaml/toml), so it stays visible and will not be parsed as headings.',
42
+ ' 8) In article-render/v1 protocol mode, leading YAML frontmatter is parsed for article_render configuration and removed from visible output. In legacy mode, unrecognized frontmatter follows the legacy preparation policy.',
43
43
  ' 9) Missing local asset files are skipped/degraded to text fallback; publish will not fail only because a referenced local path is absent.',
44
44
  ' 10) Relative local asset paths resolve against the markdown file directory by default; use --resource-base-dir to override that base.',
45
45
  '',
@@ -285,7 +285,7 @@ export function parsePublishMdArgs(argv, env = process.env) {
285
285
  if (!inputPath.trim()) {
286
286
  throw new Error('Input path is required. Use --input <file.md|dir>.');
287
287
  }
288
- if (!documentId && !folderToken) {
288
+ if (!dryRun && !documentId && !folderToken) {
289
289
  throw new Error('Folder token is required when --doc is not provided. Use --folder or set LARK_FOLDER_TOKEN.');
290
290
  }
291
291
  const normalizedPresetPaths = presetPaths.map((presetPath) => presetPath.trim()).filter(Boolean);
@@ -71,7 +71,7 @@ export async function publishMdToLark(options, env = process.env) {
71
71
  folderToken: options.folderToken?.trim() || env.LARK_FOLDER_TOKEN?.trim() || '',
72
72
  dryRun: options.dryRun ?? false,
73
73
  };
74
- if (!publishOptions.documentId && !publishOptions.folderToken) {
74
+ if (!publishOptions.dryRun && !publishOptions.documentId && !publishOptions.folderToken) {
75
75
  throw new Error('Folder token is required when documentId is not provided.');
76
76
  }
77
77
  const inputSet = await resolvePublishInputSet(publishOptions.inputPath);
@@ -511,6 +511,9 @@ function parseInlineNodes(ctx, nodes, marks = createDefaultMarks()) {
511
511
  if (node.tagName === 'u') {
512
512
  nextMarks.underline = true;
513
513
  }
514
+ if (node.tagName === 'mark') {
515
+ nextMarks.backgroundColor = 'light_yellow';
516
+ }
514
517
  if (node.tagName === 'a' || node.tagName === 'm2l-footnote-reference') {
515
518
  const href = getStringProp(node, 'href');
516
519
  nextMarks.link = href ? { url: href } : null;
@@ -4,12 +4,14 @@ import remarkGfm from 'remark-gfm';
4
4
  import remarkMath from 'remark-math';
5
5
  import remarkRehype from 'remark-rehype';
6
6
  import { normalizeMarkdownBeforeParse } from './normalize-markdown.js';
7
+ import { remarkMarkToHast } from './remark-mark.js';
7
8
  export async function markdownToHast(markdown, options = {}) {
8
9
  const content = normalizeMarkdownBeforeParse(markdown);
9
10
  const processor = unified()
10
11
  .use(remarkParse)
11
12
  .use(remarkGfm)
12
13
  .use(remarkMath, { singleDollarTextMath: options.singleDollarTextMath ?? false })
14
+ .use(remarkMarkToHast)
13
15
  .use(remarkRehype, { allowDangerousHtml: false });
14
16
  const mdast = processor.parse(content);
15
17
  const hast = await processor.run(mdast);
@@ -9,9 +9,11 @@ import { normalizeChineseBoldClosingPunctuation } from './normalize-markdown.js'
9
9
  const SUPPORTED_CONTAINER_DIRECTIVES = new Set(['figure', 'table', 'equation', 'callout', 'footnote']);
10
10
  const SUPPORTED_LEAF_DIRECTIVES = new Set(['caption', 'note', 'source']);
11
11
  const FORBIDDEN_MATH_COMMAND_RE = /\\(?:includegraphics|include|input|cite|documentclass|usepackage|begin\{document\}|end\{document\})\b/;
12
- const CURRENCY_MATH_RE = /^\s*(?:[$€£¥]\s*)?\d[\d,.]*(?:[KMBT])?(?:\s*-\s*[$€£¥]?\s*\d[\d,.]*(?:[KMBT])?)?\s*$/i;
13
12
  const INLINE_CODE_PIPE_PLACEHOLDER = '\uE000';
14
13
  const INLINE_MATH_PIPE_PLACEHOLDER = '\uE001';
14
+ const CURRENCY_DOLLAR_PLACEHOLDER = '\uE002';
15
+ const CURRENCY_TOKEN_RE = /(?<![\\$])\$(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[KMBT])?(?:\/(?:year|yr))?(?=$|[\s)\]}>.,;:!?"'%…—–-])/gi;
16
+ const CURRENCY_MATH_RE = /^\s*(?:[$€£¥]\s*)?\d[\d,.]*(?:[KMBT])?(?:\s*-\s*[$€£¥]?\s*\d[\d,.]*(?:[KMBT])?)?\s*$/i;
15
17
  function normalizeDirectiveTree(node) {
16
18
  if (node.type === 'footnoteReference') {
17
19
  const id = node.identifier ?? node.label ?? '';
@@ -71,9 +73,7 @@ function collectProtectedMdastRanges(tree) {
71
73
  function protectCurrencyDollars(markdown) {
72
74
  const tree = unified().use(remarkParse).parse(markdown);
73
75
  const ranges = collectProtectedMdastRanges(tree);
74
- const protectSegment = (segment) => segment
75
- .replace(/(?<!\\)\$(\d[\d,.]*(?:[KMBT])?)\s*[-–]\s*\$(\d[\d,.]*(?:[KMBT])?)/gi, (_all, left, right) => `\\$${left}-\\$${right}`)
76
- .replace(/(?<!\\)\$(\d[\d,.]*(?:[KMBT])?)\$/gi, (_all, amount) => `\\$${amount}\\$`);
76
+ const protectSegment = (segment) => segment.replace(CURRENCY_TOKEN_RE, (token) => CURRENCY_DOLLAR_PLACEHOLDER + token.slice(1));
77
77
  let output = '';
78
78
  let cursor = 0;
79
79
  for (const range of ranges) {
@@ -84,6 +84,70 @@ function protectCurrencyDollars(markdown) {
84
84
  output += protectSegment(markdown.slice(cursor));
85
85
  return output;
86
86
  }
87
+ function restoreCurrencyPlaceholders(hast) {
88
+ const restore = (value) => value.replaceAll(CURRENCY_DOLLAR_PLACEHOLDER, '$');
89
+ const visit = (node) => {
90
+ if (node.type === 'text') {
91
+ node.value = restore(node.value);
92
+ return;
93
+ }
94
+ if (!isElement(node))
95
+ return;
96
+ for (const [key, value] of Object.entries(node.properties ?? {})) {
97
+ if (typeof value === 'string')
98
+ node.properties[key] = restore(value);
99
+ else if (Array.isArray(value)) {
100
+ node.properties[key] = value.map((item) => (typeof item === 'string' ? restore(item) : item));
101
+ }
102
+ }
103
+ for (const child of node.children)
104
+ visit(child);
105
+ };
106
+ for (const child of hast.children)
107
+ visit(child);
108
+ }
109
+ function isHtmlCommentOnly(value) {
110
+ let remaining = value.trim();
111
+ while (remaining.startsWith('<!--')) {
112
+ const end = remaining.indexOf('-->');
113
+ if (end < 0)
114
+ return false;
115
+ remaining = remaining.slice(end + 3).trim();
116
+ }
117
+ return remaining.length === 0;
118
+ }
119
+ function analyzeRawHtmlMdast(tree, options) {
120
+ const diagnostics = [];
121
+ const referencedFootnoteIds = new Set();
122
+ const visit = (node) => {
123
+ if (node.type === 'html' && typeof node.value === 'string') {
124
+ if (isHtmlCommentOnly(node.value))
125
+ return;
126
+ for (const match of node.value.matchAll(/\[\^([^\]\s]+)\]/g)) {
127
+ if (match[1])
128
+ referencedFootnoteIds.add(match[1].toLowerCase());
129
+ }
130
+ const tag = /<\s*\/?\s*([A-Za-z][\w:-]*)/.exec(node.value)?.[1]?.toLowerCase() ?? 'html';
131
+ diagnostics.push({
132
+ severity: options.strict ? 'error' : 'warning',
133
+ code: 'unsupported-raw-html',
134
+ message: tag === 'table'
135
+ ? 'Raw HTML <table> would be discarded in Lark protocol mode. Convert it to a GFM table before publication.'
136
+ : 'Raw HTML <' +
137
+ tag +
138
+ '> would be discarded in Lark protocol mode. Convert it to Markdown before publication.',
139
+ ...(options.inputPath ? { sourcePath: options.inputPath } : {}),
140
+ ...(typeof node.position?.start?.line === 'number' ? { line: node.position.start.line } : {}),
141
+ ...(typeof node.position?.start?.column === 'number' ? { column: node.position.start.column } : {}),
142
+ });
143
+ return;
144
+ }
145
+ for (const child of node.children ?? [])
146
+ visit(child);
147
+ };
148
+ visit(tree);
149
+ return { diagnostics, referencedFootnoteIds };
150
+ }
87
151
  function protectTableInlinePipes(markdown) {
88
152
  const lines = markdown.match(/[^\n]*\n|[^\n]+/g) ?? [''];
89
153
  let fenceMarker = '';
@@ -402,6 +466,7 @@ function analyzeSemanticHast(hast, options) {
402
466
  code: 'unreferenced-footnote',
403
467
  message: `Footnote definition is not referenced: ${id}.`,
404
468
  ...(options.inputPath ? { sourcePath: options.inputPath } : {}),
469
+ semanticId: id,
405
470
  });
406
471
  }
407
472
  if (footnoteReferences.size > 0) {
@@ -568,14 +633,23 @@ export async function markdownToSemanticHast(markdown, options = {}) {
568
633
  .use(semanticDirectivePlugin)
569
634
  .use(remarkRehype, { allowDangerousHtml: false });
570
635
  const mdast = processor.parse(content);
636
+ const rawHtml = analyzeRawHtmlMdast(mdast, options);
571
637
  const unparsedFootnoteReferences = collectUnparsedFootnoteReferences(mdast);
572
638
  const hast = (await processor.run(mdast));
573
639
  restoreTableInlinePipes(hast);
574
- if (options.target?.math?.currency_policy !== 'parse')
640
+ if (options.target?.math?.currency_policy !== 'parse') {
641
+ restoreCurrencyPlaceholders(hast);
575
642
  restoreCurrencyMath(hast, content);
643
+ }
576
644
  assignAutomaticEquationNumbers(hast);
577
645
  normalizeMathHast(hast, options.target?.math);
578
646
  const semantic = analyzeSemanticHast(hast, options);
647
+ if (rawHtml.referencedFootnoteIds.size > 0) {
648
+ semantic.diagnostics = semantic.diagnostics.filter((diagnostic) => diagnostic.code !== 'unreferenced-footnote' ||
649
+ !diagnostic.semanticId ||
650
+ !rawHtml.referencedFootnoteIds.has(diagnostic.semanticId.toLowerCase()));
651
+ }
652
+ semantic.diagnostics.unshift(...rawHtml.diagnostics);
579
653
  for (const id of unparsedFootnoteReferences) {
580
654
  semantic.counts.footnoteReferences += 1;
581
655
  if (!semantic.diagnostics.some((diagnostic) => diagnostic.code === 'missing-footnote-definition' && diagnostic.message.includes(id))) {
@@ -0,0 +1,83 @@
1
+ const MARK_OPEN_RE = /^<mark(?:\s[^>]*)?>$/i;
2
+ const MARK_CLOSE_RE = /^<\/mark\s*>$/i;
3
+ function isHtmlNode(node) {
4
+ return node?.type === 'html' && typeof node.value === 'string';
5
+ }
6
+ function isMarkOpen(node) {
7
+ return isHtmlNode(node) && MARK_OPEN_RE.test(node.value.trim());
8
+ }
9
+ function isMarkClose(node) {
10
+ return isHtmlNode(node) && MARK_CLOSE_RE.test(node.value.trim());
11
+ }
12
+ function toMarkNode(open, close, children) {
13
+ const markNode = {
14
+ type: 'mark',
15
+ children,
16
+ data: {
17
+ hName: 'mark',
18
+ },
19
+ };
20
+ if (open.position?.start && close.position?.end) {
21
+ markNode.position = {
22
+ start: open.position.start,
23
+ end: close.position.end,
24
+ };
25
+ }
26
+ return markNode;
27
+ }
28
+ function rewriteMarkPairs(children) {
29
+ const rewritten = [];
30
+ let cursor = 0;
31
+ while (cursor < children.length) {
32
+ const node = children[cursor];
33
+ if (!node) {
34
+ cursor += 1;
35
+ continue;
36
+ }
37
+ if (!isMarkOpen(node)) {
38
+ rewritten.push(node);
39
+ cursor += 1;
40
+ continue;
41
+ }
42
+ const markChildren = [];
43
+ let depth = 1;
44
+ let closeIndex = -1;
45
+ for (let index = cursor + 1; index < children.length; index += 1) {
46
+ const child = children[index];
47
+ if (!child)
48
+ continue;
49
+ if (isMarkOpen(child)) {
50
+ depth += 1;
51
+ }
52
+ if (isMarkClose(child)) {
53
+ depth -= 1;
54
+ if (depth === 0) {
55
+ closeIndex = index;
56
+ break;
57
+ }
58
+ }
59
+ markChildren.push(child);
60
+ }
61
+ if (closeIndex === -1) {
62
+ rewritten.push(node);
63
+ cursor += 1;
64
+ continue;
65
+ }
66
+ rewritten.push(toMarkNode(node, children[closeIndex], rewriteMarkPairs(markChildren)));
67
+ cursor = closeIndex + 1;
68
+ }
69
+ return rewritten;
70
+ }
71
+ function visit(node) {
72
+ if (!Array.isArray(node.children))
73
+ return;
74
+ for (const child of node.children) {
75
+ visit(child);
76
+ }
77
+ node.children = rewriteMarkPairs(node.children);
78
+ }
79
+ export const remarkMarkToHast = function remarkMarkToHast() {
80
+ return (tree) => {
81
+ visit(tree);
82
+ };
83
+ };
@@ -53,8 +53,18 @@ function normalizeOptionalPath(value) {
53
53
  }
54
54
  return trimmed;
55
55
  }
56
+ function withDryRunLarkDefaults(env) {
57
+ const tokenType = env.LARK_TOKEN_TYPE?.trim();
58
+ return {
59
+ ...env,
60
+ LARK_APP_ID: env.LARK_APP_ID?.trim() || 'dry-run',
61
+ LARK_APP_SECRET: env.LARK_APP_SECRET?.trim() || 'dry-run',
62
+ LARK_TOKEN_TYPE: tokenType === 'tenant' || tokenType === 'user' ? tokenType : 'tenant',
63
+ LARK_USER_ACCESS_TOKEN: env.LARK_USER_ACCESS_TOKEN?.trim() || 'dry-run',
64
+ };
65
+ }
56
66
  export function buildPublishRuntime(options, env, markdownPresets) {
57
- const config = createLarkClientConfigFromEnv(env);
67
+ const config = createLarkClientConfigFromEnv(options.dryRun ? withDryRunLarkDefaults(env) : env);
58
68
  const sdkClient = new lark.Client({
59
69
  appId: config.appId,
60
70
  appSecret: config.appSecret,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacobbubu/md-to-lark",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Publish Markdown to Feishu docs with a stable pipeline.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",