@jacobbubu/md-to-lark 1.6.0 → 1.7.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 +6 -3
- package/dist/commands/publish-md/args.js +65 -3
- package/dist/commands/publish-md/command.js +35 -9
- package/dist/index.js +1 -0
- package/dist/pipeline/hast-to-last.js +293 -10
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/markdown/md-to-semantic-hast.js +665 -0
- package/dist/protocol/capabilities.js +31 -0
- package/dist/protocol/contract.js +253 -0
- package/dist/protocol/frontmatter.js +43 -0
- package/dist/protocol/index.js +3 -0
- package/dist/protocol/types.js +1 -0
- package/dist/publish/asset-adapter.js +4 -1
- package/dist/publish/image-size-manifest.js +133 -0
- package/dist/publish/lark-tree-validator.js +31 -0
- package/dist/publish/process-file.js +171 -10
- package/dist/publish/render-report.js +45 -0
- package/dist/publish/runtime.js +11 -1
- package/dist/publish/stage-cache.js +29 -6
- package/package.json +4 -1
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import katex from 'katex';
|
|
2
|
+
import remarkDirective from 'remark-directive';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import remarkMath from 'remark-math';
|
|
5
|
+
import remarkParse from 'remark-parse';
|
|
6
|
+
import remarkRehype from 'remark-rehype';
|
|
7
|
+
import { unified } from 'unified';
|
|
8
|
+
import { normalizeChineseBoldClosingPunctuation } from './normalize-markdown.js';
|
|
9
|
+
const SUPPORTED_CONTAINER_DIRECTIVES = new Set(['figure', 'table', 'equation', 'callout', 'footnote']);
|
|
10
|
+
const SUPPORTED_LEAF_DIRECTIVES = new Set(['caption', 'note', 'source']);
|
|
11
|
+
const FORBIDDEN_MATH_COMMAND_RE = /\\(?:includegraphics|include|input|cite|documentclass|usepackage|begin\{document\}|end\{document\})\b/;
|
|
12
|
+
const INLINE_CODE_PIPE_PLACEHOLDER = '\uE000';
|
|
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;
|
|
17
|
+
function normalizeDirectiveTree(node) {
|
|
18
|
+
if (node.type === 'footnoteReference') {
|
|
19
|
+
const id = node.identifier ?? node.label ?? '';
|
|
20
|
+
node.type = 'link';
|
|
21
|
+
node.url = `#fn-${id}`;
|
|
22
|
+
node.data = {
|
|
23
|
+
...(node.data ?? {}),
|
|
24
|
+
hName: 'm2l-footnote-reference',
|
|
25
|
+
hProperties: { footnoteId: id, href: `#fn-${id}` },
|
|
26
|
+
};
|
|
27
|
+
node.children = [{ type: 'text', value: `[${node.label ?? node.identifier ?? ''}]` }];
|
|
28
|
+
}
|
|
29
|
+
else if (node.type === 'footnoteDefinition') {
|
|
30
|
+
const id = node.identifier ?? node.label ?? '';
|
|
31
|
+
node.type = 'containerDirective';
|
|
32
|
+
node.name = 'footnote';
|
|
33
|
+
node.attributes = { id };
|
|
34
|
+
}
|
|
35
|
+
if (node.type === 'containerDirective' || node.type === 'leafDirective') {
|
|
36
|
+
const name = node.name ?? '';
|
|
37
|
+
const supported = node.type === 'containerDirective'
|
|
38
|
+
? SUPPORTED_CONTAINER_DIRECTIVES.has(name)
|
|
39
|
+
: SUPPORTED_LEAF_DIRECTIVES.has(name);
|
|
40
|
+
const attributes = node.attributes ?? {};
|
|
41
|
+
node.data = {
|
|
42
|
+
...(node.data ?? {}),
|
|
43
|
+
hName: supported ? `m2l-${name}` : 'm2l-unknown-directive',
|
|
44
|
+
hProperties: {
|
|
45
|
+
...attributes,
|
|
46
|
+
directiveName: name,
|
|
47
|
+
...(typeof attributes.id === 'string' ? { semanticId: attributes.id } : {}),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
for (const child of node.children ?? [])
|
|
52
|
+
normalizeDirectiveTree(child);
|
|
53
|
+
}
|
|
54
|
+
const semanticDirectivePlugin = function semanticDirectivePlugin() {
|
|
55
|
+
return (tree) => normalizeDirectiveTree(tree);
|
|
56
|
+
};
|
|
57
|
+
function collectProtectedMdastRanges(tree) {
|
|
58
|
+
const ranges = [];
|
|
59
|
+
const visit = (node) => {
|
|
60
|
+
if (node.type === 'code' || node.type === 'inlineCode' || node.type === 'html') {
|
|
61
|
+
const start = node.position?.start?.offset;
|
|
62
|
+
const end = node.position?.end?.offset;
|
|
63
|
+
if (typeof start === 'number' && typeof end === 'number')
|
|
64
|
+
ranges.push({ start, end });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const child of node.children ?? [])
|
|
68
|
+
visit(child);
|
|
69
|
+
};
|
|
70
|
+
visit(tree);
|
|
71
|
+
return ranges.sort((left, right) => left.start - right.start);
|
|
72
|
+
}
|
|
73
|
+
function protectCurrencyDollars(markdown) {
|
|
74
|
+
const tree = unified().use(remarkParse).parse(markdown);
|
|
75
|
+
const ranges = collectProtectedMdastRanges(tree);
|
|
76
|
+
const protectSegment = (segment) => segment.replace(CURRENCY_TOKEN_RE, (token) => CURRENCY_DOLLAR_PLACEHOLDER + token.slice(1));
|
|
77
|
+
let output = '';
|
|
78
|
+
let cursor = 0;
|
|
79
|
+
for (const range of ranges) {
|
|
80
|
+
output += protectSegment(markdown.slice(cursor, range.start));
|
|
81
|
+
output += markdown.slice(range.start, range.end);
|
|
82
|
+
cursor = range.end;
|
|
83
|
+
}
|
|
84
|
+
output += protectSegment(markdown.slice(cursor));
|
|
85
|
+
return output;
|
|
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
|
+
}
|
|
151
|
+
function protectTableInlinePipes(markdown) {
|
|
152
|
+
const lines = markdown.match(/[^\n]*\n|[^\n]+/g) ?? [''];
|
|
153
|
+
let fenceMarker = '';
|
|
154
|
+
let fenceLength = 0;
|
|
155
|
+
return lines
|
|
156
|
+
.map((line) => {
|
|
157
|
+
const fence = line.match(/^ {0,3}([`~]{3,})/);
|
|
158
|
+
if (fenceMarker) {
|
|
159
|
+
if (fence?.[1]?.startsWith(fenceMarker) && fence[1].length >= fenceLength) {
|
|
160
|
+
fenceMarker = '';
|
|
161
|
+
fenceLength = 0;
|
|
162
|
+
}
|
|
163
|
+
return line;
|
|
164
|
+
}
|
|
165
|
+
if (fence?.[1]) {
|
|
166
|
+
fenceMarker = fence[1][0];
|
|
167
|
+
fenceLength = fence[1].length;
|
|
168
|
+
return line;
|
|
169
|
+
}
|
|
170
|
+
if (!line.includes('|'))
|
|
171
|
+
return line;
|
|
172
|
+
let output = '';
|
|
173
|
+
let codeDelimiterLength = 0;
|
|
174
|
+
let inMath = false;
|
|
175
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
176
|
+
const char = line[index];
|
|
177
|
+
if (char === '`' && !inMath) {
|
|
178
|
+
let runLength = 1;
|
|
179
|
+
while (line[index + runLength] === '`')
|
|
180
|
+
runLength += 1;
|
|
181
|
+
if (codeDelimiterLength === 0)
|
|
182
|
+
codeDelimiterLength = runLength;
|
|
183
|
+
else if (runLength === codeDelimiterLength)
|
|
184
|
+
codeDelimiterLength = 0;
|
|
185
|
+
output += '`'.repeat(runLength);
|
|
186
|
+
index += runLength - 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (char === '$' && codeDelimiterLength === 0 && line[index - 1] !== '\\' && line[index + 1] !== '$') {
|
|
190
|
+
inMath = !inMath;
|
|
191
|
+
output += char;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (char === '|' && codeDelimiterLength > 0) {
|
|
195
|
+
output += INLINE_CODE_PIPE_PLACEHOLDER;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (char === '|' && inMath) {
|
|
199
|
+
output += INLINE_MATH_PIPE_PLACEHOLDER;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
output += char;
|
|
203
|
+
}
|
|
204
|
+
return output;
|
|
205
|
+
})
|
|
206
|
+
.join('');
|
|
207
|
+
}
|
|
208
|
+
function restoreTableInlinePipes(hast) {
|
|
209
|
+
const visit = (node) => {
|
|
210
|
+
if (node.type === 'text') {
|
|
211
|
+
node.value = node.value
|
|
212
|
+
.replaceAll(INLINE_CODE_PIPE_PLACEHOLDER, '|')
|
|
213
|
+
.replaceAll(INLINE_MATH_PIPE_PLACEHOLDER, '|');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (isElement(node))
|
|
217
|
+
for (const child of node.children)
|
|
218
|
+
visit(child);
|
|
219
|
+
};
|
|
220
|
+
for (const child of hast.children)
|
|
221
|
+
visit(child);
|
|
222
|
+
}
|
|
223
|
+
function collectUnparsedFootnoteReferences(tree) {
|
|
224
|
+
const references = [];
|
|
225
|
+
const visit = (node) => {
|
|
226
|
+
if (node.type === 'code' || node.type === 'inlineCode' || node.type === 'html')
|
|
227
|
+
return;
|
|
228
|
+
if (node.type === 'text' && typeof node.value === 'string') {
|
|
229
|
+
for (const match of node.value.matchAll(/\[\^([^\]\s]+)\]/g)) {
|
|
230
|
+
if (match[1])
|
|
231
|
+
references.push(match[1].toLowerCase());
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const child of node.children ?? [])
|
|
235
|
+
visit(child);
|
|
236
|
+
};
|
|
237
|
+
visit(tree);
|
|
238
|
+
return references;
|
|
239
|
+
}
|
|
240
|
+
function isElement(node) {
|
|
241
|
+
return node.type === 'element';
|
|
242
|
+
}
|
|
243
|
+
function getStringProperty(element, key) {
|
|
244
|
+
const value = element.properties?.[key];
|
|
245
|
+
return value === undefined || value === null ? undefined : String(value);
|
|
246
|
+
}
|
|
247
|
+
function sourceDiagnostic(node, severity, code, message, inputPath, semanticId) {
|
|
248
|
+
return {
|
|
249
|
+
severity,
|
|
250
|
+
code,
|
|
251
|
+
message,
|
|
252
|
+
...(inputPath ? { sourcePath: inputPath } : {}),
|
|
253
|
+
...(typeof node.position?.start.line === 'number' ? { line: node.position.start.line } : {}),
|
|
254
|
+
...(typeof node.position?.start.column === 'number' ? { column: node.position.start.column } : {}),
|
|
255
|
+
...(semanticId ? { semanticId } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function findDescendants(element, predicate) {
|
|
259
|
+
const matches = [];
|
|
260
|
+
const visit = (node) => {
|
|
261
|
+
if (!isElement(node))
|
|
262
|
+
return;
|
|
263
|
+
if (predicate(node))
|
|
264
|
+
matches.push(node);
|
|
265
|
+
for (const child of node.children)
|
|
266
|
+
visit(child);
|
|
267
|
+
};
|
|
268
|
+
for (const child of element.children)
|
|
269
|
+
visit(child);
|
|
270
|
+
return matches;
|
|
271
|
+
}
|
|
272
|
+
function analyzeSemanticHast(hast, options) {
|
|
273
|
+
const counts = {
|
|
274
|
+
figures: 0,
|
|
275
|
+
figureCaptions: 0,
|
|
276
|
+
tables: 0,
|
|
277
|
+
tableCaptions: 0,
|
|
278
|
+
equations: 0,
|
|
279
|
+
footnoteReferences: 0,
|
|
280
|
+
footnoteDefinitions: 0,
|
|
281
|
+
callouts: 0,
|
|
282
|
+
};
|
|
283
|
+
const diagnostics = [];
|
|
284
|
+
const ids = new Map();
|
|
285
|
+
const footnoteReferences = new Map();
|
|
286
|
+
const footnoteDefinitions = new Map();
|
|
287
|
+
const anchorReferences = [];
|
|
288
|
+
const capabilityLosses = new Set();
|
|
289
|
+
const strictSeverity = options.strict ? 'error' : 'warning';
|
|
290
|
+
const mathTarget = options.target?.math;
|
|
291
|
+
const registerId = (element, kind, required) => {
|
|
292
|
+
const id = getStringProperty(element, 'semanticId') ?? getStringProperty(element, 'id');
|
|
293
|
+
if (!id) {
|
|
294
|
+
if (required) {
|
|
295
|
+
diagnostics.push(sourceDiagnostic(element, 'error', 'semantic-id-required', `${kind} requires a unique id.`, options.inputPath));
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const previous = ids.get(id);
|
|
300
|
+
if (previous) {
|
|
301
|
+
diagnostics.push(sourceDiagnostic(element, 'error', 'duplicate-semantic-id', `Duplicate semantic id "${id}" (${previous}, ${kind}).`, options.inputPath, id));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
ids.set(id, kind);
|
|
305
|
+
};
|
|
306
|
+
const validateAnnotations = (element, kind) => {
|
|
307
|
+
for (const annotation of ['caption', 'note', 'source']) {
|
|
308
|
+
const matches = findDescendants(element, (node) => node.tagName === `m2l-${annotation}`);
|
|
309
|
+
if (matches.length > 1) {
|
|
310
|
+
diagnostics.push(sourceDiagnostic(element, 'error', `duplicate-${kind}-${annotation}`, `${kind} may contain at most one ${annotation}.`, options.inputPath));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const validateMath = (element) => {
|
|
315
|
+
const latex = element.children.map((child) => (child.type === 'text' ? child.value : '')).join('');
|
|
316
|
+
const forbidden = FORBIDDEN_MATH_COMMAND_RE.exec(latex);
|
|
317
|
+
if (forbidden) {
|
|
318
|
+
diagnostics.push(sourceDiagnostic(element, 'error', 'forbidden-math-command', `Forbidden document command in math: ${forbidden[0]}.`, options.inputPath));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
for (const match of latex.matchAll(/\\begin\{([^}]+)\}/g)) {
|
|
322
|
+
const environment = match[1] ?? '';
|
|
323
|
+
if (mathTarget?.environments && !mathTarget.environments.includes(environment)) {
|
|
324
|
+
diagnostics.push(sourceDiagnostic(element, 'error', 'unsupported-math-environment', `Unsupported KaTeX environment: ${environment}.`, options.inputPath));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
katex.renderToString(latex, {
|
|
329
|
+
throwOnError: true,
|
|
330
|
+
displayMode: element.properties?.className?.toString().includes('math-display') ?? false,
|
|
331
|
+
...(mathTarget?.macros ? { macros: mathTarget.macros } : {}),
|
|
332
|
+
strict: 'error',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
diagnostics.push(sourceDiagnostic(element, mathTarget?.unsupported_command === 'warning' ? 'warning' : 'error', 'katex-validation', error instanceof Error ? error.message : String(error), options.inputPath));
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
const visit = (node) => {
|
|
340
|
+
if (!isElement(node))
|
|
341
|
+
return;
|
|
342
|
+
if (node.tagName === 'm2l-unknown-directive') {
|
|
343
|
+
const name = getStringProperty(node, 'directiveName') ?? '';
|
|
344
|
+
diagnostics.push(sourceDiagnostic(node, strictSeverity, 'unknown-directive', `Unknown semantic directive: ${name}.`, options.inputPath));
|
|
345
|
+
}
|
|
346
|
+
else if (node.tagName === 'm2l-figure') {
|
|
347
|
+
counts.figures += 1;
|
|
348
|
+
registerId(node, 'figure', true);
|
|
349
|
+
validateAnnotations(node, 'figure');
|
|
350
|
+
const images = findDescendants(node, (element) => element.tagName === 'img');
|
|
351
|
+
if (images.length === 0) {
|
|
352
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'figure-image-required', 'figure requires at least one Markdown image.', options.inputPath));
|
|
353
|
+
}
|
|
354
|
+
const width = getStringProperty(node, 'width');
|
|
355
|
+
if (width && !parseDirectiveWidth(width)) {
|
|
356
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'invalid-figure-width', `Invalid figure width: ${width}.`, options.inputPath));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else if (node.tagName === 'm2l-table') {
|
|
360
|
+
counts.tables += 1;
|
|
361
|
+
registerId(node, 'table', true);
|
|
362
|
+
validateAnnotations(node, 'table');
|
|
363
|
+
const tables = findDescendants(node, (element) => element.tagName === 'table');
|
|
364
|
+
if (tables.length !== 1) {
|
|
365
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'invalid-semantic-table', `table requires exactly one GFM table; received ${tables.length}.`, options.inputPath));
|
|
366
|
+
}
|
|
367
|
+
if (tables.length === 1 && options.target?.tables?.invalid_table === 'error') {
|
|
368
|
+
const rows = findDescendants(tables[0], (element) => element.tagName === 'tr');
|
|
369
|
+
const widths = rows.map((row) => row.children.filter((child) => isElement(child) && (child.tagName === 'th' || child.tagName === 'td'))
|
|
370
|
+
.length);
|
|
371
|
+
if (new Set(widths).size > 1) {
|
|
372
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'invalid-table-row-width', `Malformed table row widths: ${widths.join(', ')}.`, options.inputPath));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else if (node.tagName === 'm2l-equation') {
|
|
377
|
+
registerId(node, 'equation', true);
|
|
378
|
+
validateAnnotations(node, 'equation');
|
|
379
|
+
const displayMath = findDescendants(node, (element) => {
|
|
380
|
+
const classes = Array.isArray(element.properties?.className)
|
|
381
|
+
? element.properties.className.map(String)
|
|
382
|
+
: [String(element.properties?.className ?? '')];
|
|
383
|
+
return element.tagName === 'code' && classes.includes('math-display');
|
|
384
|
+
});
|
|
385
|
+
if (displayMath.length !== 1) {
|
|
386
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'invalid-semantic-equation', `equation requires exactly one display math node; received ${displayMath.length}.`, options.inputPath));
|
|
387
|
+
}
|
|
388
|
+
if (getStringProperty(node, 'number') === 'auto') {
|
|
389
|
+
if (typeof node.properties.equationNumber !== 'number') {
|
|
390
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'equation-numbering', 'Unable to assign automatic equation number.', options.inputPath));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if (node.tagName === 'm2l-callout') {
|
|
395
|
+
counts.callouts += 1;
|
|
396
|
+
registerId(node, 'callout', true);
|
|
397
|
+
const type = getStringProperty(node, 'type');
|
|
398
|
+
if (type && !['note', 'info', 'tip', 'warning', 'danger', 'important'].includes(type)) {
|
|
399
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'invalid-callout-type', `Invalid callout type: ${type}.`, options.inputPath));
|
|
400
|
+
}
|
|
401
|
+
const unsupported = findDescendants(node, (element) => ['img', 'table'].includes(element.tagName));
|
|
402
|
+
if (unsupported.length > 0 && options.target?.callouts?.unsupported_child_policy !== 'sibling-after') {
|
|
403
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'unsupported-callout-child', 'Callout contains a child block unsupported by Lark.', options.inputPath));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
else if (node.tagName === 'm2l-footnote') {
|
|
407
|
+
counts.footnoteDefinitions += 1;
|
|
408
|
+
const id = getStringProperty(node, 'semanticId') ?? getStringProperty(node, 'id') ?? '';
|
|
409
|
+
footnoteDefinitions.set(id, (footnoteDefinitions.get(id) ?? 0) + 1);
|
|
410
|
+
const images = findDescendants(node, (element) => element.tagName === 'img');
|
|
411
|
+
if (images.length > 0 && options.target?.footnotes?.image_policy !== 'sibling-after') {
|
|
412
|
+
diagnostics.push(sourceDiagnostic(node, 'error', 'unsupported-footnote-image', 'Footnote image requires footnotes.image_policy: sibling-after.', options.inputPath));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else if (node.tagName === 'm2l-footnote-reference') {
|
|
416
|
+
counts.footnoteReferences += 1;
|
|
417
|
+
const id = getStringProperty(node, 'footnoteId') ?? '';
|
|
418
|
+
footnoteReferences.set(id, (footnoteReferences.get(id) ?? 0) + 1);
|
|
419
|
+
}
|
|
420
|
+
else if (node.tagName === 'm2l-caption') {
|
|
421
|
+
const parentTag = undefined;
|
|
422
|
+
void parentTag;
|
|
423
|
+
}
|
|
424
|
+
else if (node.tagName === 'a') {
|
|
425
|
+
const href = getStringProperty(node, 'href');
|
|
426
|
+
if (href?.startsWith('#'))
|
|
427
|
+
anchorReferences.push({ id: href.slice(1), node });
|
|
428
|
+
}
|
|
429
|
+
if (node.tagName === 'code') {
|
|
430
|
+
const className = node.properties?.className;
|
|
431
|
+
const classes = Array.isArray(className) ? className.map(String) : [String(className ?? '')];
|
|
432
|
+
if (classes.includes('math-inline') || classes.includes('math-display')) {
|
|
433
|
+
validateMath(node);
|
|
434
|
+
counts.equations += 1;
|
|
435
|
+
if (getStringProperty(node, 'mathLabel'))
|
|
436
|
+
registerId(node, 'equation', false);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (node.tagName !== 'code' && node.tagName !== 'pre') {
|
|
440
|
+
for (const child of node.children) {
|
|
441
|
+
if (child.type !== 'text')
|
|
442
|
+
continue;
|
|
443
|
+
if (/(?:^|\n)\s*:{3,}/.test(child.value) || /\[table\]/i.test(child.value) || child.value.includes('$$')) {
|
|
444
|
+
diagnostics.push(sourceDiagnostic(child, strictSeverity, 'visible-semantic-residue', `Visible unrendered semantic residue: ${child.value.trim().slice(0, 80)}`, options.inputPath));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const child of node.children)
|
|
449
|
+
visit(child);
|
|
450
|
+
};
|
|
451
|
+
for (const child of hast.children)
|
|
452
|
+
visit(child);
|
|
453
|
+
counts.figureCaptions = countTag(hast, 'm2l-figure', 'm2l-caption');
|
|
454
|
+
counts.tableCaptions = countTag(hast, 'm2l-table', 'm2l-caption');
|
|
455
|
+
for (const [id, count] of footnoteDefinitions) {
|
|
456
|
+
if (count > 1)
|
|
457
|
+
diagnostics.push({
|
|
458
|
+
severity: 'error',
|
|
459
|
+
code: 'duplicate-footnote-definition',
|
|
460
|
+
message: `Duplicate footnote definition: ${id}.`,
|
|
461
|
+
...(options.inputPath ? { sourcePath: options.inputPath } : {}),
|
|
462
|
+
});
|
|
463
|
+
if (!footnoteReferences.has(id))
|
|
464
|
+
diagnostics.push({
|
|
465
|
+
severity: 'warning',
|
|
466
|
+
code: 'unreferenced-footnote',
|
|
467
|
+
message: `Footnote definition is not referenced: ${id}.`,
|
|
468
|
+
...(options.inputPath ? { sourcePath: options.inputPath } : {}),
|
|
469
|
+
semanticId: id,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (footnoteReferences.size > 0) {
|
|
473
|
+
capabilityLosses.add('Lark output preserves footnote reference labels but not Markdown anchor navigation.');
|
|
474
|
+
}
|
|
475
|
+
for (const id of footnoteReferences.keys()) {
|
|
476
|
+
if (!footnoteDefinitions.has(id))
|
|
477
|
+
diagnostics.push({
|
|
478
|
+
severity: 'error',
|
|
479
|
+
code: 'missing-footnote-definition',
|
|
480
|
+
message: `Footnote reference has no definition: ${id}.`,
|
|
481
|
+
...(options.inputPath ? { sourcePath: options.inputPath } : {}),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
for (const reference of anchorReferences) {
|
|
485
|
+
const normalized = reference.id.replace(/^fn-/, '');
|
|
486
|
+
if (!ids.has(reference.id) && !footnoteDefinitions.has(normalized)) {
|
|
487
|
+
diagnostics.push(sourceDiagnostic(reference.node, strictSeverity, 'broken-semantic-reference', `Broken semantic reference: #${reference.id}.`, options.inputPath));
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
capabilityLosses.add('Lark output preserves semantic reference labels but not Markdown anchor navigation.');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return { counts, diagnostics, ids: Object.fromEntries(ids), capabilityLosses: [...capabilityLosses] };
|
|
494
|
+
}
|
|
495
|
+
function countTag(root, ancestorTag, targetTag) {
|
|
496
|
+
let count = 0;
|
|
497
|
+
const visit = (node, insideAncestor) => {
|
|
498
|
+
if (!isElement(node))
|
|
499
|
+
return;
|
|
500
|
+
const nextInside = insideAncestor || node.tagName === ancestorTag;
|
|
501
|
+
if (nextInside && node.tagName === targetTag)
|
|
502
|
+
count += 1;
|
|
503
|
+
for (const child of node.children)
|
|
504
|
+
visit(child, nextInside);
|
|
505
|
+
};
|
|
506
|
+
for (const child of root.children)
|
|
507
|
+
visit(child, false);
|
|
508
|
+
return count;
|
|
509
|
+
}
|
|
510
|
+
export function parseDirectiveWidth(raw) {
|
|
511
|
+
const value = raw.trim().toLowerCase();
|
|
512
|
+
const percent = /^(\d+(?:\.\d+)?)%$/.exec(value);
|
|
513
|
+
if (percent) {
|
|
514
|
+
const ratio = Number(percent[1]) / 100;
|
|
515
|
+
return ratio > 0 && ratio <= 1 ? { widthRatio: ratio } : null;
|
|
516
|
+
}
|
|
517
|
+
const pixels = /^(\d+(?:\.\d+)?)px$/.exec(value);
|
|
518
|
+
if (pixels) {
|
|
519
|
+
const widthPx = Number(pixels[1]);
|
|
520
|
+
return widthPx > 0 ? { widthPx } : null;
|
|
521
|
+
}
|
|
522
|
+
const ratio = Number(value);
|
|
523
|
+
return Number.isFinite(ratio) && ratio > 0 && ratio <= 1 ? { widthRatio: ratio } : null;
|
|
524
|
+
}
|
|
525
|
+
function restoreCurrencyMath(hast, markdown) {
|
|
526
|
+
const visitChildren = (children) => {
|
|
527
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
528
|
+
const node = children[index];
|
|
529
|
+
if (!node || !isElement(node))
|
|
530
|
+
continue;
|
|
531
|
+
const className = node.properties?.className;
|
|
532
|
+
const classes = Array.isArray(className) ? className.map(String) : [String(className ?? '')];
|
|
533
|
+
if (node.tagName === 'code' && classes.includes('math-inline')) {
|
|
534
|
+
const latex = node.children.map((child) => (child.type === 'text' ? child.value : '')).join('');
|
|
535
|
+
if (CURRENCY_MATH_RE.test(latex)) {
|
|
536
|
+
const start = node.position?.start.offset;
|
|
537
|
+
const end = node.position?.end.offset;
|
|
538
|
+
children[index] = {
|
|
539
|
+
type: 'text',
|
|
540
|
+
value: typeof start === 'number' && typeof end === 'number' ? markdown.slice(start, end) : `$${latex}$`,
|
|
541
|
+
position: node.position,
|
|
542
|
+
};
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
visitChildren(node.children);
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
visitChildren(hast.children);
|
|
550
|
+
}
|
|
551
|
+
function assignAutomaticEquationNumbers(hast) {
|
|
552
|
+
let number = 0;
|
|
553
|
+
const visit = (node) => {
|
|
554
|
+
if (!isElement(node))
|
|
555
|
+
return;
|
|
556
|
+
if (node.tagName === 'm2l-equation' && getStringProperty(node, 'number') === 'auto') {
|
|
557
|
+
number += 1;
|
|
558
|
+
node.properties.equationNumber = number;
|
|
559
|
+
}
|
|
560
|
+
for (const child of node.children)
|
|
561
|
+
visit(child);
|
|
562
|
+
};
|
|
563
|
+
for (const child of hast.children)
|
|
564
|
+
visit(child);
|
|
565
|
+
}
|
|
566
|
+
function normalizeMathHast(hast, target) {
|
|
567
|
+
const macros = Object.entries(target?.macros ?? {}).sort(([left], [right]) => right.length - left.length);
|
|
568
|
+
const equationNumbers = new Map();
|
|
569
|
+
const collectNumbers = (node) => {
|
|
570
|
+
if (!isElement(node))
|
|
571
|
+
return;
|
|
572
|
+
if (node.tagName === 'm2l-equation') {
|
|
573
|
+
const id = getStringProperty(node, 'semanticId');
|
|
574
|
+
const number = getStringProperty(node, 'equationNumber');
|
|
575
|
+
if (number) {
|
|
576
|
+
if (id)
|
|
577
|
+
equationNumbers.set(id, number);
|
|
578
|
+
for (const math of findDescendants(node, (element) => element.tagName === 'code')) {
|
|
579
|
+
const latex = math.children.map((child) => (child.type === 'text' ? child.value : '')).join('');
|
|
580
|
+
for (const label of latex.matchAll(/\\label\{([^}]+)\}/g)) {
|
|
581
|
+
if (label[1])
|
|
582
|
+
equationNumbers.set(label[1], number);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
for (const child of node.children)
|
|
588
|
+
collectNumbers(child);
|
|
589
|
+
};
|
|
590
|
+
for (const child of hast.children)
|
|
591
|
+
collectNumbers(child);
|
|
592
|
+
const visit = (node) => {
|
|
593
|
+
if (!isElement(node))
|
|
594
|
+
return;
|
|
595
|
+
const className = node.properties?.className;
|
|
596
|
+
const classes = Array.isArray(className) ? className.map(String) : [String(className ?? '')];
|
|
597
|
+
if (node.tagName === 'code' && (classes.includes('math-inline') || classes.includes('math-display'))) {
|
|
598
|
+
let latex = node.children.map((child) => (child.type === 'text' ? child.value : '')).join('');
|
|
599
|
+
const labels = [...latex.matchAll(/\\label\{([^}]+)\}/g)].map((match) => match[1]).filter(Boolean);
|
|
600
|
+
if (labels[0]) {
|
|
601
|
+
node.properties.mathLabel = labels[0];
|
|
602
|
+
node.properties.semanticId = labels[0];
|
|
603
|
+
}
|
|
604
|
+
latex = latex
|
|
605
|
+
.replace(/\\label\{[^}]+\}/g, '')
|
|
606
|
+
.replace(/\\begin\{equation\*?\}/g, '')
|
|
607
|
+
.replace(/\\end\{equation\*?\}/g, '')
|
|
608
|
+
.replace(/\\eqref\{([^}]+)\}/g, (_all, id) => '\\text{(' + (equationNumbers.get(id) ?? id) + ')}')
|
|
609
|
+
.replace(/\\ref\{([^}]+)\}/g, (_all, id) => '\\text{' + (equationNumbers.get(id) ?? id) + '}');
|
|
610
|
+
for (const [macro, replacement] of macros) {
|
|
611
|
+
const escaped = macro.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
612
|
+
latex = latex.replace(new RegExp(`${escaped}(?![A-Za-z])`, 'g'), replacement);
|
|
613
|
+
}
|
|
614
|
+
node.children = [{ type: 'text', value: latex }];
|
|
615
|
+
}
|
|
616
|
+
for (const child of node.children)
|
|
617
|
+
visit(child);
|
|
618
|
+
};
|
|
619
|
+
for (const child of hast.children)
|
|
620
|
+
visit(child);
|
|
621
|
+
}
|
|
622
|
+
export async function markdownToSemanticHast(markdown, options = {}) {
|
|
623
|
+
const normalized = normalizeChineseBoldClosingPunctuation(markdown);
|
|
624
|
+
const withProtectedTablePipes = protectTableInlinePipes(normalized);
|
|
625
|
+
const content = options.target?.math?.currency_policy === 'parse'
|
|
626
|
+
? withProtectedTablePipes
|
|
627
|
+
: protectCurrencyDollars(withProtectedTablePipes);
|
|
628
|
+
const processor = unified()
|
|
629
|
+
.use(remarkParse)
|
|
630
|
+
.use(remarkGfm)
|
|
631
|
+
.use(remarkMath, { singleDollarTextMath: options.singleDollarTextMath ?? true })
|
|
632
|
+
.use(remarkDirective)
|
|
633
|
+
.use(semanticDirectivePlugin)
|
|
634
|
+
.use(remarkRehype, { allowDangerousHtml: false });
|
|
635
|
+
const mdast = processor.parse(content);
|
|
636
|
+
const rawHtml = analyzeRawHtmlMdast(mdast, options);
|
|
637
|
+
const unparsedFootnoteReferences = collectUnparsedFootnoteReferences(mdast);
|
|
638
|
+
const hast = (await processor.run(mdast));
|
|
639
|
+
restoreTableInlinePipes(hast);
|
|
640
|
+
if (options.target?.math?.currency_policy !== 'parse') {
|
|
641
|
+
restoreCurrencyPlaceholders(hast);
|
|
642
|
+
restoreCurrencyMath(hast, content);
|
|
643
|
+
}
|
|
644
|
+
assignAutomaticEquationNumbers(hast);
|
|
645
|
+
normalizeMathHast(hast, options.target?.math);
|
|
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);
|
|
653
|
+
for (const id of unparsedFootnoteReferences) {
|
|
654
|
+
semantic.counts.footnoteReferences += 1;
|
|
655
|
+
if (!semantic.diagnostics.some((diagnostic) => diagnostic.code === 'missing-footnote-definition' && diagnostic.message.includes(id))) {
|
|
656
|
+
semantic.diagnostics.push({
|
|
657
|
+
severity: 'error',
|
|
658
|
+
code: 'missing-footnote-definition',
|
|
659
|
+
message: `Footnote reference has no definition: ${id}.`,
|
|
660
|
+
...(options.inputPath ? { sourcePath: options.inputPath } : {}),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return { hast, semantic };
|
|
665
|
+
}
|