markdown2typst 0.1.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/LICENSE +22 -0
- package/README.md +232 -0
- package/dist/markdown2typst.js +20479 -0
- package/dist/markdown2typst.js.map +7 -0
- package/dist/markdown2typst.min.js +103 -0
- package/package.json +74 -0
- package/src/block-renderer.ts +363 -0
- package/src/collectors.ts +138 -0
- package/src/frontmatter.ts +197 -0
- package/src/inline-renderer.ts +266 -0
- package/src/markdown2typst.ts +96 -0
- package/src/old/oldmarkdown2typst.ts +1092 -0
- package/src/output-builder.ts +229 -0
- package/src/parser.ts +53 -0
- package/src/types.ts +156 -0
- package/src/utils.ts +241 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown2typst - Convert Markdown to Typst (OLD SINGLE FILE VERSION)
|
|
3
|
+
*
|
|
4
|
+
* A comprehensive library for converting Markdown documents to Typst markup language.
|
|
5
|
+
* Supports GitHub Flavored Markdown, math equations, tables, footnotes, and more
|
|
6
|
+
*
|
|
7
|
+
* This code is inspired by the original work of zhaoyiqun (Creator of MDXport; 'cosformula' on GitHub).
|
|
8
|
+
* Documented, modified, extended and maintained by Mapaor.
|
|
9
|
+
*
|
|
10
|
+
* @module markdown2typst
|
|
11
|
+
* @license MIT
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { unified } from 'unified';
|
|
15
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
16
|
+
import remarkGfm from 'remark-gfm';
|
|
17
|
+
import remarkMath from 'remark-math';
|
|
18
|
+
import remarkParse from 'remark-parse';
|
|
19
|
+
import { tex2typst } from 'tex2typst';
|
|
20
|
+
import type {
|
|
21
|
+
Blockquote,
|
|
22
|
+
Code,
|
|
23
|
+
Content,
|
|
24
|
+
Delete,
|
|
25
|
+
Definition,
|
|
26
|
+
Emphasis,
|
|
27
|
+
FootnoteDefinition,
|
|
28
|
+
FootnoteReference,
|
|
29
|
+
Heading,
|
|
30
|
+
Html,
|
|
31
|
+
Image,
|
|
32
|
+
InlineCode,
|
|
33
|
+
Link,
|
|
34
|
+
LinkReference,
|
|
35
|
+
List,
|
|
36
|
+
ListItem,
|
|
37
|
+
Literal,
|
|
38
|
+
Paragraph,
|
|
39
|
+
PhrasingContent,
|
|
40
|
+
Root,
|
|
41
|
+
Strong,
|
|
42
|
+
Table,
|
|
43
|
+
TableCell,
|
|
44
|
+
TableRow,
|
|
45
|
+
Text,
|
|
46
|
+
Yaml
|
|
47
|
+
} from 'mdast';
|
|
48
|
+
|
|
49
|
+
/** Extended MDAST node type for marked/highlighted text */
|
|
50
|
+
interface Mark extends Literal {
|
|
51
|
+
type: 'mark';
|
|
52
|
+
children: PhrasingContent[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Extended MDAST node type for superscript text */
|
|
56
|
+
interface SuperScript extends Literal {
|
|
57
|
+
type: 'superscript';
|
|
58
|
+
children: PhrasingContent[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Extended MDAST node type for subscript text */
|
|
62
|
+
interface SubScript extends Literal {
|
|
63
|
+
type: 'subscript';
|
|
64
|
+
children: PhrasingContent[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Extended MDAST node type for block-level math equations */
|
|
68
|
+
interface MathNode extends Literal {
|
|
69
|
+
type: 'math';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Extended MDAST node type for inline math equations */
|
|
73
|
+
interface InlineMathNode extends Literal {
|
|
74
|
+
type: 'inlineMath';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Options for configuring the Markdown to Typst conversion.
|
|
79
|
+
* All fields override corresponding frontmatter values if specified.
|
|
80
|
+
*/
|
|
81
|
+
export type Markdown2TypstOptions = {
|
|
82
|
+
/** Document title (overrides frontmatter) */
|
|
83
|
+
title?: string;
|
|
84
|
+
/** Document author(s) - single string or array (overrides frontmatter) */
|
|
85
|
+
author?: string | string[];
|
|
86
|
+
/** Document authors - alternative field name (overrides frontmatter) */
|
|
87
|
+
authors?: string[];
|
|
88
|
+
/** Document description (overrides frontmatter) */
|
|
89
|
+
description?: string;
|
|
90
|
+
/** Document keywords - array (overrides frontmatter) */
|
|
91
|
+
keywords?: string[];
|
|
92
|
+
/** Document date - string, 'auto', or ISO date (overrides frontmatter) */
|
|
93
|
+
date?: string;
|
|
94
|
+
/** Document abstract - rendered on title page (overrides frontmatter) */
|
|
95
|
+
abstract?: string;
|
|
96
|
+
/** Document language code (zh for Chinese, en for English) (overrides frontmatter) */
|
|
97
|
+
lang?: string;
|
|
98
|
+
/** Alternative language field name (overrides frontmatter) */
|
|
99
|
+
language?: string;
|
|
100
|
+
/** Text region code (overrides frontmatter) */
|
|
101
|
+
region?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Convert Markdown text to Typst markup.
|
|
106
|
+
*
|
|
107
|
+
* This is the main entry point for the library. It parses the Markdown using remark,
|
|
108
|
+
* processes frontmatter and metadata, and converts the AST to Typst syntax.
|
|
109
|
+
*
|
|
110
|
+
* @param markdown - The Markdown text to convert
|
|
111
|
+
* @param options - Optional configuration for the conversion
|
|
112
|
+
* @returns The converted Typst markup as a string
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```typescript
|
|
116
|
+
* const typst = markdown2typst('# Hello\n\nThis is **bold**.');
|
|
117
|
+
* console.log(typst);
|
|
118
|
+
* ```
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```typescript
|
|
122
|
+
* const typst = markdown2typst(markdown, {
|
|
123
|
+
* title: 'My Document',
|
|
124
|
+
* authors: ['John Doe'],
|
|
125
|
+
* lang: 'en'
|
|
126
|
+
* });
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export function markdown2typst(markdown: string, options: Markdown2TypstOptions = {}): string {
|
|
130
|
+
const processor = unified()
|
|
131
|
+
.use(remarkParse)
|
|
132
|
+
.use(remarkFrontmatter, ['yaml'])
|
|
133
|
+
.use(remarkGfm, { singleTilde: false })
|
|
134
|
+
.use(remarkMath)
|
|
135
|
+
//.use(remarkMark)
|
|
136
|
+
|
|
137
|
+
const parsedTree = processor.parse(markdown);
|
|
138
|
+
const tree = processor.runSync(parsedTree) as Root;
|
|
139
|
+
const definitions = collectDefinitions(tree);
|
|
140
|
+
const footnoteDefinitions = collectFootnotes(tree);
|
|
141
|
+
const frontmatter = parseFrontmatter(tree);
|
|
142
|
+
const { title: leadingTitle, index: leadingTitleIndex } = findLeadingH1(tree, definitions) ?? {
|
|
143
|
+
title: null,
|
|
144
|
+
index: null
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const title = options.title ?? frontmatter.title ?? leadingTitle ?? '';
|
|
148
|
+
|
|
149
|
+
// Normalize authors from options and frontmatter (all optional)
|
|
150
|
+
const optionsAuthors = options.authors ??
|
|
151
|
+
(typeof options.author === 'string' ? [options.author] : options.author) ?? [];
|
|
152
|
+
const frontmatterAuthors = frontmatter.authors ??
|
|
153
|
+
(typeof frontmatter.author === 'string' ? [frontmatter.author] : frontmatter.author) ?? [];
|
|
154
|
+
const authors = optionsAuthors.length > 0 ? optionsAuthors : frontmatterAuthors;
|
|
155
|
+
|
|
156
|
+
// Use options as overrides for all other fields
|
|
157
|
+
const lang = coerceLanguage(options.lang ?? options.language ?? frontmatter.lang ?? frontmatter.language);
|
|
158
|
+
const region = options.region ?? frontmatter.region;
|
|
159
|
+
const date = options.date ?? frontmatter.date;
|
|
160
|
+
const description = options.description ?? frontmatter.description;
|
|
161
|
+
const keywords = options.keywords ?? frontmatter.keywords;
|
|
162
|
+
const abstract = options.abstract ?? frontmatter.abstract;
|
|
163
|
+
|
|
164
|
+
const nodesForBody =
|
|
165
|
+
leadingTitleIndex !== null && normalizeText(title) === normalizeText(leadingTitle)
|
|
166
|
+
? tree.children.filter((_, index) => index !== leadingTitleIndex)
|
|
167
|
+
: tree.children;
|
|
168
|
+
|
|
169
|
+
const body = nodesForBody
|
|
170
|
+
.map((node) => renderBlock(node, 0, definitions, footnoteDefinitions))
|
|
171
|
+
.filter(isNonEmpty)
|
|
172
|
+
.join('\n\n');
|
|
173
|
+
|
|
174
|
+
// Build document metadata and configuration
|
|
175
|
+
const parts: string[] = [];
|
|
176
|
+
|
|
177
|
+
// Set document metadata if any metadata is available
|
|
178
|
+
const hasMetadata = title || authors.length > 0 || description || date || (keywords && keywords.length > 0);
|
|
179
|
+
if (hasMetadata) {
|
|
180
|
+
parts.push('// =============== FRONTMATTER ===============');
|
|
181
|
+
parts.push('');
|
|
182
|
+
const docArgs: string[] = [];
|
|
183
|
+
|
|
184
|
+
if (title) docArgs.push(`title: [${title}]`);
|
|
185
|
+
|
|
186
|
+
if (authors.length > 0) {
|
|
187
|
+
docArgs.push(`author: ${renderTypstArray(authors.map(a => `"${escapeTypstString(a)}"`))}`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (description) docArgs.push(`description: [${description}]`);
|
|
191
|
+
|
|
192
|
+
if (keywords && keywords.length > 0) {
|
|
193
|
+
docArgs.push(`keywords: ${renderTypstArray(keywords.map(k => `"${escapeTypstString(k)}"`))}`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (date) {
|
|
197
|
+
const dateTypst = parseDate(date);
|
|
198
|
+
docArgs.push(`date: ${dateTypst}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
parts.push(`#set document(`);
|
|
202
|
+
for (let i = 0; i < docArgs.length; i++) {
|
|
203
|
+
const isLast = i === docArgs.length - 1;
|
|
204
|
+
parts.push(` ${docArgs[i]}${isLast ? '' : ','}`);
|
|
205
|
+
}
|
|
206
|
+
parts.push(`)`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Add abstract variable if present
|
|
210
|
+
if (abstract) {
|
|
211
|
+
if (parts.length > 0) parts.push('');
|
|
212
|
+
parts.push(`#let abstract = [${abstract}]`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Add title page if we have title or authors
|
|
216
|
+
if ((title || authors.length > 0 || date || abstract) && hasMetadata) {
|
|
217
|
+
parts.push('');
|
|
218
|
+
const centerLines: string[] = [];
|
|
219
|
+
|
|
220
|
+
if (title) {
|
|
221
|
+
centerLines.push(`#title() \\ \\`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (authors.length > 0) {
|
|
225
|
+
centerLines.push(`#context document.author.join(", ", last: " & ") \\ \\`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (date) {
|
|
229
|
+
centerLines.push(`#context document.date.display() \\ \\ `);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (abstract) {
|
|
233
|
+
centerLines.push(`\\ *Abstract* \\`);
|
|
234
|
+
centerLines.push(`#abstract`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
parts.push(`#align(center)[`);
|
|
238
|
+
parts.push(` ${centerLines.join(' ')}`);
|
|
239
|
+
parts.push(`]`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Set text language and region if specified
|
|
243
|
+
if (lang || region) {
|
|
244
|
+
if (parts.length > 0) parts.push('');
|
|
245
|
+
const textArgs: string[] = [];
|
|
246
|
+
if (lang) textArgs.push(`lang: "${lang}"`);
|
|
247
|
+
if (region) textArgs.push(`region: "${region}"`);
|
|
248
|
+
parts.push(`#set text(${textArgs.join(', ')})`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Add closing comment for frontmatter section
|
|
252
|
+
if (hasMetadata) {
|
|
253
|
+
parts.push('');
|
|
254
|
+
parts.push('// ============================================');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Add body content
|
|
258
|
+
if (parts.length > 0 && body) {
|
|
259
|
+
parts.push('');
|
|
260
|
+
}
|
|
261
|
+
if (body) {
|
|
262
|
+
parts.push(body);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Return body only if no metadata was set
|
|
266
|
+
return parts.length > 0 ? parts.join('\n') : body;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Render a TypeScript-style tuple array for Typst.
|
|
271
|
+
* Ensures proper syntax for single-element arrays (requires trailing comma).
|
|
272
|
+
*
|
|
273
|
+
* @param items - Array items as strings
|
|
274
|
+
* @returns Formatted Typst array syntax
|
|
275
|
+
*/
|
|
276
|
+
function renderTypstArray(items: string[]): string {
|
|
277
|
+
if (items.length === 1) return `(${items[0]},)`;
|
|
278
|
+
return `(${items.join(', ')})`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Collect all link and image reference definitions from the Markdown AST.
|
|
283
|
+
* These are used to resolve reference-style links like [text][id].
|
|
284
|
+
*
|
|
285
|
+
* @param root - The root node of the MDAST tree
|
|
286
|
+
* @returns Map of identifier to Definition node
|
|
287
|
+
*/
|
|
288
|
+
function collectDefinitions(root: Root): Map<string, Definition> {
|
|
289
|
+
const definitions = new Map<string, Definition>();
|
|
290
|
+
for (const node of root.children) {
|
|
291
|
+
if (node.type !== 'definition') continue;
|
|
292
|
+
const def = node as Definition;
|
|
293
|
+
definitions.set(def.identifier.toLowerCase(), def);
|
|
294
|
+
}
|
|
295
|
+
return definitions;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Collect all footnote definitions from the Markdown AST.
|
|
300
|
+
* These are rendered inline when referenced in the text.
|
|
301
|
+
*
|
|
302
|
+
* @param root - The root node of the MDAST tree
|
|
303
|
+
* @returns Map of identifier to FootnoteDefinition node
|
|
304
|
+
*/
|
|
305
|
+
function collectFootnotes(root: Root): Map<string, FootnoteDefinition> {
|
|
306
|
+
const definitions = new Map<string, FootnoteDefinition>();
|
|
307
|
+
for (const node of root.children) {
|
|
308
|
+
if (node.type !== 'footnoteDefinition') continue;
|
|
309
|
+
const def = node as FootnoteDefinition;
|
|
310
|
+
definitions.set(def.identifier.toLowerCase(), def);
|
|
311
|
+
}
|
|
312
|
+
return definitions;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Metadata extracted from YAML frontmatter.
|
|
317
|
+
* Follows Typst document() parameters specification.
|
|
318
|
+
* All fields are optional. Unknown fields are ignored.
|
|
319
|
+
*/
|
|
320
|
+
type Frontmatter = {
|
|
321
|
+
/** Document title (maps to document.title) */
|
|
322
|
+
title?: string;
|
|
323
|
+
/** Document author(s) - single string or array (maps to document.author) */
|
|
324
|
+
author?: string | string[];
|
|
325
|
+
/** Document authors - alternative field name (maps to document.author) */
|
|
326
|
+
authors?: string[];
|
|
327
|
+
/** Document description (maps to document.description) */
|
|
328
|
+
description?: string;
|
|
329
|
+
/** Document keywords - array (maps to document.keywords) */
|
|
330
|
+
keywords?: string[];
|
|
331
|
+
/** Document date - string, 'auto', or ISO date (maps to document.date) */
|
|
332
|
+
date?: string;
|
|
333
|
+
/** Document abstract - rendered on title page */
|
|
334
|
+
abstract?: string;
|
|
335
|
+
/** Document language code (maps to text.lang, not document parameter) */
|
|
336
|
+
lang?: string;
|
|
337
|
+
/** Alternative language field name */
|
|
338
|
+
language?: string;
|
|
339
|
+
/** Text region code (maps to text.region) */
|
|
340
|
+
region?: string;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Parse YAML frontmatter from the document.
|
|
345
|
+
* Supports title, authors (single or array), and language fields.
|
|
346
|
+
*
|
|
347
|
+
* @param root - The root node of the MDAST tree
|
|
348
|
+
* @returns Parsed frontmatter metadata
|
|
349
|
+
*/
|
|
350
|
+
function parseFrontmatter(root: Root): Frontmatter {
|
|
351
|
+
const yamlNode = root.children.find((node) => node.type === 'yaml') as Yaml | undefined;
|
|
352
|
+
if (!yamlNode?.value) return {};
|
|
353
|
+
return parseFrontmatterYaml(yamlNode.value);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Parse YAML frontmatter string into structured metadata.
|
|
358
|
+
* Handles standard Markdown/Pandoc frontmatter fields.
|
|
359
|
+
*
|
|
360
|
+
* @param yaml - Raw YAML string from frontmatter
|
|
361
|
+
* @returns Parsed frontmatter object
|
|
362
|
+
*/
|
|
363
|
+
function parseFrontmatterYaml(yaml: string): Frontmatter {
|
|
364
|
+
const lines = yaml.split(/\r?\n/);
|
|
365
|
+
const result: Frontmatter = {};
|
|
366
|
+
|
|
367
|
+
for (let i = 0; i < lines.length; i++) {
|
|
368
|
+
const line = lines[i];
|
|
369
|
+
|
|
370
|
+
// Language (lang or language)
|
|
371
|
+
const langMatch = /^\s*lang(?:uage)?\s*:\s*(.+?)\s*$/.exec(line);
|
|
372
|
+
if (langMatch && !result.lang && !result.language) {
|
|
373
|
+
result.lang = stripYamlScalar(langMatch[1]);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Title
|
|
378
|
+
const titleMatch = /^\s*title\s*:\s*(.+?)\s*$/.exec(line);
|
|
379
|
+
if (titleMatch && !result.title) {
|
|
380
|
+
result.title = stripYamlScalar(titleMatch[1]);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Date
|
|
385
|
+
const dateMatch = /^\s*date\s*:\s*(.+?)\s*$/.exec(line);
|
|
386
|
+
if (dateMatch && !result.date) {
|
|
387
|
+
result.date = stripYamlScalar(dateMatch[1]);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Description
|
|
392
|
+
const descriptionMatch = /^\s*description\s*:\s*(.+?)\s*$/.exec(line);
|
|
393
|
+
if (descriptionMatch && !result.description) {
|
|
394
|
+
result.description = stripYamlScalar(descriptionMatch[1]);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Abstract
|
|
399
|
+
const abstractMatch = /^\s*abstract\s*:\s*(.+?)\s*$/.exec(line);
|
|
400
|
+
if (abstractMatch && !result.abstract) {
|
|
401
|
+
result.abstract = stripYamlScalar(abstractMatch[1]);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Region
|
|
406
|
+
const regionMatch = /^\s*region\s*:\s*(.+?)\s*$/.exec(line);
|
|
407
|
+
if (regionMatch && !result.region) {
|
|
408
|
+
result.region = stripYamlScalar(regionMatch[1]);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Single author field (can be single value or array)
|
|
413
|
+
const authorMatch = /^\s*author\s*:\s*(.*?)\s*$/.exec(line);
|
|
414
|
+
if (authorMatch && !result.author && !result.authors) {
|
|
415
|
+
const rest = authorMatch[1].trim();
|
|
416
|
+
if (rest) {
|
|
417
|
+
// Inline value or array
|
|
418
|
+
const parsed = parseInlineYamlList(rest);
|
|
419
|
+
result.author = parsed.length === 1 ? parsed[0] : parsed;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Multi-line author array
|
|
424
|
+
const list: string[] = [];
|
|
425
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
426
|
+
const itemMatch = /^\s*-\s*(.+?)\s*$/.exec(lines[j]);
|
|
427
|
+
if (!itemMatch) break;
|
|
428
|
+
list.push(stripYamlScalar(itemMatch[1]));
|
|
429
|
+
i = j;
|
|
430
|
+
}
|
|
431
|
+
if (list.length > 0) result.author = list.filter(Boolean);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Authors array
|
|
436
|
+
const authorsMatch = /^\s*authors\s*:\s*(.*?)\s*$/.exec(line);
|
|
437
|
+
if (authorsMatch && !result.authors) {
|
|
438
|
+
const rest = authorsMatch[1].trim();
|
|
439
|
+
if (rest) {
|
|
440
|
+
result.authors = parseInlineYamlList(rest);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Multi-line authors array
|
|
445
|
+
const list: string[] = [];
|
|
446
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
447
|
+
const itemMatch = /^\s*-\s*(.+?)\s*$/.exec(lines[j]);
|
|
448
|
+
if (!itemMatch) break;
|
|
449
|
+
list.push(stripYamlScalar(itemMatch[1]));
|
|
450
|
+
i = j;
|
|
451
|
+
}
|
|
452
|
+
if (list.length > 0) result.authors = list.filter(Boolean);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Keywords
|
|
457
|
+
const keywordsMatch = /^\s*keywords\s*:\s*(.*?)\s*$/.exec(line);
|
|
458
|
+
if (keywordsMatch && !result.keywords) {
|
|
459
|
+
const rest = keywordsMatch[1].trim();
|
|
460
|
+
if (rest) {
|
|
461
|
+
result.keywords = parseInlineYamlList(rest);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Multi-line keywords array
|
|
466
|
+
const list: string[] = [];
|
|
467
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
468
|
+
const itemMatch = /^\s*-\s*(.+?)\s*$/.exec(lines[j]);
|
|
469
|
+
if (!itemMatch) break;
|
|
470
|
+
list.push(stripYamlScalar(itemMatch[1]));
|
|
471
|
+
i = j;
|
|
472
|
+
}
|
|
473
|
+
if (list.length > 0) result.keywords = list.filter(Boolean);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Parse inline YAML list values (e.g., [item1, item2] or single value).
|
|
482
|
+
*
|
|
483
|
+
* @param value - Inline YAML list string
|
|
484
|
+
* @returns Array of parsed items
|
|
485
|
+
*/
|
|
486
|
+
function parseInlineYamlList(value: string): string[] {
|
|
487
|
+
const v = value.trim();
|
|
488
|
+
if (!v) return [];
|
|
489
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
490
|
+
const inner = v.slice(1, -1);
|
|
491
|
+
return inner
|
|
492
|
+
.split(',')
|
|
493
|
+
.map((s) => stripYamlScalar(s))
|
|
494
|
+
.filter(Boolean);
|
|
495
|
+
}
|
|
496
|
+
return [stripYamlScalar(v)].filter(Boolean);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Strip quotes and whitespace from a YAML scalar value.
|
|
501
|
+
*
|
|
502
|
+
* @param value - Raw YAML scalar string
|
|
503
|
+
* @returns Cleaned string value
|
|
504
|
+
*/
|
|
505
|
+
function stripYamlScalar(value: string): string {
|
|
506
|
+
let v = value.trim();
|
|
507
|
+
if (
|
|
508
|
+
(v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
|
|
509
|
+
(v.startsWith("'") && v.endsWith("'") && v.length >= 2)
|
|
510
|
+
) {
|
|
511
|
+
v = v.slice(1, -1);
|
|
512
|
+
}
|
|
513
|
+
return v.trim();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Coerce language string to supported language code.
|
|
518
|
+
*
|
|
519
|
+
* @param value - Language string (e.g., 'zh-CN', 'en-US', 'english')
|
|
520
|
+
* @returns Normalized language code ('zh' or 'en') or undefined
|
|
521
|
+
*/
|
|
522
|
+
function coerceLanguage(value: string | undefined): 'zh' | 'en' | undefined {
|
|
523
|
+
const v = (value ?? '').trim().toLowerCase();
|
|
524
|
+
if (v.startsWith('zh')) return 'zh';
|
|
525
|
+
if (v.startsWith('en')) return 'en';
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Parse date string into Typst date format.
|
|
531
|
+
* Supports 'auto', 'none', and ISO date formats (YYYY-MM-DD).
|
|
532
|
+
*
|
|
533
|
+
* @param value - Date string from frontmatter
|
|
534
|
+
* @returns Typst date expression
|
|
535
|
+
*/
|
|
536
|
+
function parseDate(value: string): string {
|
|
537
|
+
const v = value.trim().toLowerCase();
|
|
538
|
+
|
|
539
|
+
// Handle special values
|
|
540
|
+
if (v === 'auto') return 'auto';
|
|
541
|
+
if (v === 'none') return 'none';
|
|
542
|
+
|
|
543
|
+
// Try to parse ISO date format (YYYY-MM-DD)
|
|
544
|
+
const isoMatch = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value.trim());
|
|
545
|
+
if (isoMatch) {
|
|
546
|
+
const year = parseInt(isoMatch[1], 10);
|
|
547
|
+
const month = parseInt(isoMatch[2], 10);
|
|
548
|
+
const day = parseInt(isoMatch[3], 10);
|
|
549
|
+
return `datetime(day: ${day}, month: ${month}, year: ${year})`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Default to auto if we can't parse the date
|
|
553
|
+
return 'auto';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Find the first H1 heading in the document (ignoring frontmatter).
|
|
558
|
+
* Used to extract document title if not specified in frontmatter or options.
|
|
559
|
+
*
|
|
560
|
+
* @param root - The root node of the MDAST tree
|
|
561
|
+
* @param definitions - Map of link reference definitions
|
|
562
|
+
* @returns Title text and node index, or null if no H1 found
|
|
563
|
+
*/
|
|
564
|
+
function findLeadingH1(
|
|
565
|
+
root: Root,
|
|
566
|
+
definitions: Map<string, Definition>
|
|
567
|
+
): { title: string; index: number } | null {
|
|
568
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
569
|
+
const node = root.children[i];
|
|
570
|
+
if (node.type === 'yaml' || node.type === 'definition') continue;
|
|
571
|
+
if (node.type !== 'heading') return null;
|
|
572
|
+
const heading = node as Heading;
|
|
573
|
+
if (heading.depth !== 1) return null;
|
|
574
|
+
const title = plainTextFromPhrasing(heading.children, definitions).trim();
|
|
575
|
+
return title ? { title, index: i } : null;
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Extract plain text content from phrasing nodes (inline content).
|
|
582
|
+
* Strips all formatting and extracts text only.
|
|
583
|
+
*
|
|
584
|
+
* @param nodes - Array of phrasing content nodes
|
|
585
|
+
* @param definitions - Map of link reference definitions
|
|
586
|
+
* @returns Plain text string
|
|
587
|
+
*/
|
|
588
|
+
function plainTextFromPhrasing(nodes: PhrasingContent[], definitions: Map<string, Definition>): string {
|
|
589
|
+
return nodes.map((node) => plainTextFromPhrasingNode(node, definitions)).join('');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function plainTextFromPhrasingNode(node: PhrasingContent, definitions: Map<string, Definition>): string {
|
|
593
|
+
switch (node.type) {
|
|
594
|
+
case 'text':
|
|
595
|
+
return (node as Text).value;
|
|
596
|
+
case 'strong':
|
|
597
|
+
case 'emphasis':
|
|
598
|
+
return plainTextFromPhrasing((node as Strong).children, definitions);
|
|
599
|
+
case 'inlineCode':
|
|
600
|
+
return (node as InlineCode).value;
|
|
601
|
+
case 'link':
|
|
602
|
+
return plainTextFromPhrasing((node as Link).children, definitions);
|
|
603
|
+
case 'linkReference': {
|
|
604
|
+
const lr = node as LinkReference;
|
|
605
|
+
const label = plainTextFromPhrasing(lr.children, definitions);
|
|
606
|
+
if (label.trim()) return label;
|
|
607
|
+
const def = definitions.get(lr.identifier.toLowerCase());
|
|
608
|
+
return def ? def.url : lr.label || lr.identifier;
|
|
609
|
+
}
|
|
610
|
+
case 'break':
|
|
611
|
+
return '\n';
|
|
612
|
+
default:
|
|
613
|
+
return '';
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function normalizeText(value: string | null): string {
|
|
618
|
+
return (value ?? '').trim();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Render a block-level MDAST node to Typst markup.
|
|
623
|
+
* Dispatches to appropriate renderer based on node type.
|
|
624
|
+
*
|
|
625
|
+
* @param node - Block-level content node
|
|
626
|
+
* @param indentLevel - Current indentation level
|
|
627
|
+
* @param definitions - Map of link reference definitions
|
|
628
|
+
* @param footnoteDefinitions - Map of footnote definitions
|
|
629
|
+
* @returns Rendered Typst string or null if node should be skipped
|
|
630
|
+
*/
|
|
631
|
+
function renderBlock(
|
|
632
|
+
node: Content,
|
|
633
|
+
indentLevel: number,
|
|
634
|
+
definitions: Map<string, Definition>,
|
|
635
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
636
|
+
): string | null {
|
|
637
|
+
switch (node.type) {
|
|
638
|
+
case 'yaml':
|
|
639
|
+
case 'definition':
|
|
640
|
+
case 'footnoteDefinition':
|
|
641
|
+
return null;
|
|
642
|
+
case 'heading':
|
|
643
|
+
return renderHeading(node as Heading, indentLevel, definitions, footnoteDefinitions);
|
|
644
|
+
case 'paragraph':
|
|
645
|
+
return indentLines(
|
|
646
|
+
renderParagraph(node as Paragraph, definitions, footnoteDefinitions),
|
|
647
|
+
indentLevel
|
|
648
|
+
);
|
|
649
|
+
case 'list':
|
|
650
|
+
return renderList(node as List, indentLevel, definitions, footnoteDefinitions);
|
|
651
|
+
case 'code':
|
|
652
|
+
return renderCodeBlock(node as Code, indentLevel);
|
|
653
|
+
case 'blockquote':
|
|
654
|
+
return renderBlockquote(node as Blockquote, indentLevel, definitions, footnoteDefinitions);
|
|
655
|
+
case 'thematicBreak':
|
|
656
|
+
return indentLines('#line(length: 100%, stroke: 0.6pt)', indentLevel);
|
|
657
|
+
case 'table':
|
|
658
|
+
return renderTable(node as Table, indentLevel, definitions, footnoteDefinitions);
|
|
659
|
+
case 'math':
|
|
660
|
+
return renderMathBlock(node as MathNode, indentLevel);
|
|
661
|
+
default:
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Render a block-level math equation to Typst.
|
|
668
|
+
* Converts LaTeX math syntax to Typst math using tex2typst.
|
|
669
|
+
*
|
|
670
|
+
* @param node - Math node
|
|
671
|
+
* @param indentLevel - Current indentation level
|
|
672
|
+
* @returns Rendered Typst math block
|
|
673
|
+
*/
|
|
674
|
+
function renderMathBlock(node: MathNode, indentLevel: number): string {
|
|
675
|
+
// Convert LaTeX to Typst math syntax
|
|
676
|
+
try {
|
|
677
|
+
const typstMath = tex2typst(node.value.trim());
|
|
678
|
+
return indentLines(`$ ${typstMath} $`, indentLevel);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
// Fallback: use original LaTeX if conversion fails
|
|
681
|
+
return indentLines(`$ ${node.value.trim()} $`, indentLevel);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Render a heading to Typst markup.
|
|
687
|
+
* Converts Markdown heading syntax (# ## ###) to Typst (= == ===).
|
|
688
|
+
*
|
|
689
|
+
* @param node - Heading node
|
|
690
|
+
* @param indentLevel - Current indentation level
|
|
691
|
+
* @param definitions - Map of link reference definitions
|
|
692
|
+
* @param footnoteDefinitions - Map of footnote definitions
|
|
693
|
+
* @returns Rendered Typst heading
|
|
694
|
+
*/
|
|
695
|
+
function renderHeading(
|
|
696
|
+
node: Heading,
|
|
697
|
+
indentLevel: number,
|
|
698
|
+
definitions: Map<string, Definition>,
|
|
699
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
700
|
+
): string {
|
|
701
|
+
const level = Math.min(Math.max(node.depth, 1), 6);
|
|
702
|
+
return indentLines(
|
|
703
|
+
`${'='.repeat(level)} ${renderInlines(node.children, definitions, footnoteDefinitions)}`,
|
|
704
|
+
indentLevel
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Render a paragraph to Typst.
|
|
710
|
+
* Handles special cases like [toc] for table of contents.
|
|
711
|
+
*
|
|
712
|
+
* @param node - Paragraph node
|
|
713
|
+
* @param definitions - Map of link reference definitions
|
|
714
|
+
* @param footnoteDefinitions - Map of footnote definitions
|
|
715
|
+
* @returns Rendered Typst paragraph
|
|
716
|
+
*/
|
|
717
|
+
function renderParagraph(
|
|
718
|
+
node: Paragraph,
|
|
719
|
+
definitions: Map<string, Definition>,
|
|
720
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
721
|
+
): string {
|
|
722
|
+
// Check for [toc]
|
|
723
|
+
const text = plainTextFromPhrasing(node.children, definitions).trim().toLowerCase();
|
|
724
|
+
if (text === '[toc]') {
|
|
725
|
+
return `#outline(title: auto, indent: auto)`;
|
|
726
|
+
}
|
|
727
|
+
return renderInlines(node.children, definitions, footnoteDefinitions);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function renderList(
|
|
731
|
+
node: List,
|
|
732
|
+
indentLevel: number,
|
|
733
|
+
definitions: Map<string, Definition>,
|
|
734
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
735
|
+
): string {
|
|
736
|
+
const marker = node.ordered ? '+' : '-';
|
|
737
|
+
return node.children
|
|
738
|
+
.map((item) => renderListItem(item, marker, indentLevel, definitions, footnoteDefinitions))
|
|
739
|
+
.filter(isNonEmpty)
|
|
740
|
+
.join('\n');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function renderListItem(
|
|
744
|
+
node: ListItem,
|
|
745
|
+
marker: string,
|
|
746
|
+
indentLevel: number,
|
|
747
|
+
definitions: Map<string, Definition>,
|
|
748
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
749
|
+
): string {
|
|
750
|
+
const baseIndent = ' '.repeat(indentLevel);
|
|
751
|
+
const nestedIndentLevel = indentLevel + 1;
|
|
752
|
+
|
|
753
|
+
const first = node.children[0];
|
|
754
|
+
const lines: string[] = [];
|
|
755
|
+
|
|
756
|
+
if (first?.type === 'paragraph') {
|
|
757
|
+
lines.push(
|
|
758
|
+
`${baseIndent}${marker} ${renderParagraph(first as Paragraph, definitions, footnoteDefinitions)}`
|
|
759
|
+
);
|
|
760
|
+
for (const child of node.children.slice(1)) {
|
|
761
|
+
if (child.type === 'list') {
|
|
762
|
+
lines.push(renderList(child as List, nestedIndentLevel, definitions, footnoteDefinitions));
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const rendered = renderBlock(child as Content, nestedIndentLevel, definitions, footnoteDefinitions);
|
|
766
|
+
if (rendered) lines.push(rendered);
|
|
767
|
+
}
|
|
768
|
+
return lines.join('\n');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
lines.push(`${baseIndent}${marker}`);
|
|
772
|
+
for (const child of node.children) {
|
|
773
|
+
if (child.type === 'list') {
|
|
774
|
+
lines.push(renderList(child as List, nestedIndentLevel, definitions, footnoteDefinitions));
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
const rendered = renderBlock(child as Content, nestedIndentLevel, definitions, footnoteDefinitions);
|
|
778
|
+
if (rendered) lines.push(rendered);
|
|
779
|
+
}
|
|
780
|
+
return lines.join('\n');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function renderCodeBlock(node: Code, indentLevel: number): string {
|
|
784
|
+
const info = node.lang?.trim() ? node.lang.trim() : '';
|
|
785
|
+
const value = node.value.replace(/\n$/, '');
|
|
786
|
+
const fence = '`'.repeat(Math.max(3, maxBacktickRun(value) + 1));
|
|
787
|
+
const open = info ? `${fence}${info}` : fence;
|
|
788
|
+
const indentedCode = indentLines(value, indentLevel);
|
|
789
|
+
return [indentLines(open, indentLevel), indentedCode, indentLines(fence, indentLevel)].join('\n');
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function maxBacktickRun(value: string): number {
|
|
793
|
+
let maxRun = 0;
|
|
794
|
+
let run = 0;
|
|
795
|
+
for (let i = 0; i < value.length; i++) {
|
|
796
|
+
if (value[i] === '`') {
|
|
797
|
+
run++;
|
|
798
|
+
if (run > maxRun) maxRun = run;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
run = 0;
|
|
802
|
+
}
|
|
803
|
+
return maxRun;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function renderTable(
|
|
807
|
+
node: Table,
|
|
808
|
+
indentLevel: number,
|
|
809
|
+
definitions: Map<string, Definition>,
|
|
810
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
811
|
+
): string {
|
|
812
|
+
const rows = node.children as TableRow[];
|
|
813
|
+
if (rows.length === 0) return '';
|
|
814
|
+
|
|
815
|
+
// Get column count from first row
|
|
816
|
+
const headerRow = rows[0];
|
|
817
|
+
const colCount = headerRow.children.length;
|
|
818
|
+
|
|
819
|
+
// Get alignment from node.align
|
|
820
|
+
const alignMap: Record<string, string> = {
|
|
821
|
+
left: 'left',
|
|
822
|
+
right: 'right',
|
|
823
|
+
center: 'center'
|
|
824
|
+
};
|
|
825
|
+
const aligns = (node.align ?? []).map((a) => alignMap[a ?? 'left'] ?? 'left');
|
|
826
|
+
|
|
827
|
+
// Build column specification
|
|
828
|
+
const columns = Array(colCount).fill('1fr').join(', ');
|
|
829
|
+
|
|
830
|
+
// Build table content
|
|
831
|
+
const headerCells: string[] = [];
|
|
832
|
+
for (const cell of headerRow.children as TableCell[]) {
|
|
833
|
+
const content = renderInlines(cell.children, definitions, footnoteDefinitions);
|
|
834
|
+
headerCells.push(`[*${content}*]`);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const dataCells: string[] = [];
|
|
838
|
+
for (let i = 1; i < rows.length; i++) {
|
|
839
|
+
const row = rows[i];
|
|
840
|
+
for (const cell of row.children as TableCell[]) {
|
|
841
|
+
const content = renderInlines(cell.children, definitions, footnoteDefinitions);
|
|
842
|
+
dataCells.push(`[${content}]`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Build align argument
|
|
847
|
+
const alignArgs = aligns.slice(0, colCount).map((a) => a).join(', ');
|
|
848
|
+
|
|
849
|
+
const lines = [
|
|
850
|
+
`#table(`,
|
|
851
|
+
` columns: (${columns}),`,
|
|
852
|
+
` align: (${alignArgs}),`,
|
|
853
|
+
` table.header(${headerCells.join(', ')}),`,
|
|
854
|
+
` ${dataCells.join(', ')}`,
|
|
855
|
+
`)`
|
|
856
|
+
];
|
|
857
|
+
|
|
858
|
+
return indentLines(lines.join('\n'), indentLevel);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function renderBlockquote(
|
|
862
|
+
node: Blockquote,
|
|
863
|
+
indentLevel: number,
|
|
864
|
+
definitions: Map<string, Definition>,
|
|
865
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
866
|
+
): string {
|
|
867
|
+
const body = node.children
|
|
868
|
+
.map((child) => renderBlock(child, 0, definitions, footnoteDefinitions))
|
|
869
|
+
.filter(isNonEmpty)
|
|
870
|
+
.join('\n\n');
|
|
871
|
+
|
|
872
|
+
const open = indentLines('#quote[', indentLevel);
|
|
873
|
+
if (!body.trim()) return `${open}\n${indentLines(']', indentLevel)}`;
|
|
874
|
+
|
|
875
|
+
return [open, indentLines(body, indentLevel + 1), indentLines(']', indentLevel)].join('\n');
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function renderInlines(
|
|
879
|
+
nodes: PhrasingContent[],
|
|
880
|
+
definitions: Map<string, Definition>,
|
|
881
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
882
|
+
): string {
|
|
883
|
+
return nodes
|
|
884
|
+
.map((node) => renderInline(node, definitions, footnoteDefinitions))
|
|
885
|
+
.filter(isNonEmpty)
|
|
886
|
+
.join('');
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Render an inline phrasing node to Typst markup.
|
|
891
|
+
* Handles text, emphasis, strong, code, links, images, math, etc.
|
|
892
|
+
*
|
|
893
|
+
* @param node - Phrasing content node
|
|
894
|
+
* @param definitions - Map of link reference definitions
|
|
895
|
+
* @param footnoteDefinitions - Map of footnote definitions
|
|
896
|
+
* @returns Rendered Typst string or null if node should be skipped
|
|
897
|
+
*/
|
|
898
|
+
function renderInline(
|
|
899
|
+
node: PhrasingContent,
|
|
900
|
+
definitions: Map<string, Definition>,
|
|
901
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
902
|
+
): string | null {
|
|
903
|
+
switch ((node as any).type) {
|
|
904
|
+
case 'text':
|
|
905
|
+
return escapeTypstText((node as Text).value);
|
|
906
|
+
case 'strong':
|
|
907
|
+
// Use #strong[] function form to avoid ambiguity with /* comments and other edge cases
|
|
908
|
+
return `#strong[${renderInlines((node as Strong).children, definitions, footnoteDefinitions)}]`;
|
|
909
|
+
case 'emphasis':
|
|
910
|
+
// Use #emph[] function form to avoid potential parsing issues
|
|
911
|
+
return `#emph[${renderInlines((node as Emphasis).children, definitions, footnoteDefinitions)}]`;
|
|
912
|
+
case 'delete':
|
|
913
|
+
return `#strike[${renderInlines((node as unknown as Delete).children, definitions, footnoteDefinitions)}]`;
|
|
914
|
+
case 'mark':
|
|
915
|
+
return `#highlight[${renderInlines((node as unknown as Mark).children, definitions, footnoteDefinitions)}]`;
|
|
916
|
+
case 'subscript':
|
|
917
|
+
return `#sub[${renderInlines((node as unknown as SubScript).children, definitions, footnoteDefinitions)}]`;
|
|
918
|
+
case 'superscript':
|
|
919
|
+
return `#super[${renderInlines((node as unknown as SuperScript).children, definitions, footnoteDefinitions)}]`;
|
|
920
|
+
case 'footnoteReference': {
|
|
921
|
+
const ref = node as FootnoteReference;
|
|
922
|
+
const def = footnoteDefinitions.get(ref.identifier.toLowerCase());
|
|
923
|
+
if (!def) return ''; // Or render failure?
|
|
924
|
+
// Render footnote content inline
|
|
925
|
+
const content = def.children
|
|
926
|
+
.map((child) => renderBlock(child, 0, definitions, footnoteDefinitions))
|
|
927
|
+
.filter(isNonEmpty)
|
|
928
|
+
.join(' '); // Join blocks with space for inline footnote
|
|
929
|
+
return `#footnote[${content.trim()}]`;
|
|
930
|
+
}
|
|
931
|
+
case 'inlineCode':
|
|
932
|
+
return renderInlineCode(node as InlineCode);
|
|
933
|
+
case 'inlineMath': {
|
|
934
|
+
// Convert LaTeX to Typst math syntax
|
|
935
|
+
// Note: remark-math parses both $...$ (inline) and $$...$$ (block) as inlineMath
|
|
936
|
+
// We distinguish them by checking the source length: $x$ has length value.length+2, $$x$$ has value.length+4
|
|
937
|
+
const mathNode = node as InlineMathNode;
|
|
938
|
+
const value = mathNode.value.trim();
|
|
939
|
+
|
|
940
|
+
try {
|
|
941
|
+
const typstMath = tex2typst(value);
|
|
942
|
+
|
|
943
|
+
// Check if this was originally $$...$$ (display/block math) or $...$ (inline math)
|
|
944
|
+
// by examining the position in the source
|
|
945
|
+
const isDisplayMath = mathNode.position?.end?.offset != null &&
|
|
946
|
+
mathNode.position?.start?.offset != null &&
|
|
947
|
+
(mathNode.position.end.offset - mathNode.position.start.offset) >= value.length + 4;
|
|
948
|
+
|
|
949
|
+
// Display math ($$): use spaces for block-style rendering
|
|
950
|
+
// Inline math ($): no spaces for inline rendering
|
|
951
|
+
return isDisplayMath ? `$ ${typstMath} $` : `$${typstMath}$`;
|
|
952
|
+
} catch (error) {
|
|
953
|
+
// Fallback: use original LaTeX if conversion fails
|
|
954
|
+
const isDisplayMath = mathNode.position?.end?.offset != null &&
|
|
955
|
+
mathNode.position?.start?.offset != null &&
|
|
956
|
+
(mathNode.position.end.offset - mathNode.position.start.offset) >= value.length + 4;
|
|
957
|
+
return isDisplayMath ? `$ ${value} $` : `$${value}$`;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
case 'image':
|
|
961
|
+
return renderImage(node as Image);
|
|
962
|
+
case 'link':
|
|
963
|
+
return renderLink(node as Link, definitions, footnoteDefinitions);
|
|
964
|
+
case 'linkReference':
|
|
965
|
+
return renderLinkReference(node as LinkReference, definitions, footnoteDefinitions);
|
|
966
|
+
case 'break':
|
|
967
|
+
return '\\\n';
|
|
968
|
+
case 'html':
|
|
969
|
+
// Treat inline HTML as literal text, escape it for Typst
|
|
970
|
+
return escapeTypstText((node as Html).value);
|
|
971
|
+
default:
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Render inline code to Typst.
|
|
978
|
+
* Escapes backticks within the code.
|
|
979
|
+
*
|
|
980
|
+
* @param node - InlineCode node
|
|
981
|
+
* @returns Rendered Typst inline code
|
|
982
|
+
*/
|
|
983
|
+
function renderInlineCode(node: InlineCode): string {
|
|
984
|
+
const value = node.value.replace(/`/g, '\\`');
|
|
985
|
+
return `\`${value}\``;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Render an image to Typst.
|
|
990
|
+
* Uses Typst's #image() function with the image URL.
|
|
991
|
+
*
|
|
992
|
+
* @param node - Image node
|
|
993
|
+
* @returns Rendered Typst image function
|
|
994
|
+
*/
|
|
995
|
+
function renderImage(node: Image): string {
|
|
996
|
+
// Basic image support.
|
|
997
|
+
// If alt text exists, we could use it for accessibility or caption, but Typst #image doesn't strictly require it.
|
|
998
|
+
// We'll just output the image function.
|
|
999
|
+
return `#image("${escapeTypstString(node.url)}")`;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Render a link to Typst.
|
|
1004
|
+
* Uses Typst's #link() function with URL and label.
|
|
1005
|
+
*
|
|
1006
|
+
* @param node - Link node
|
|
1007
|
+
* @param definitions - Map of link reference definitions
|
|
1008
|
+
* @param footnoteDefinitions - Map of footnote definitions
|
|
1009
|
+
* @returns Rendered Typst link
|
|
1010
|
+
*/
|
|
1011
|
+
function renderLink(
|
|
1012
|
+
node: Link,
|
|
1013
|
+
definitions: Map<string, Definition>,
|
|
1014
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
1015
|
+
): string {
|
|
1016
|
+
const url = escapeTypstString(node.url);
|
|
1017
|
+
const label = renderInlines(node.children, definitions, footnoteDefinitions);
|
|
1018
|
+
if (!label.trim()) return `#link("${url}")[${escapeTypstText(node.url)}]`;
|
|
1019
|
+
return `#link("${url}")[${label}]`;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Render a reference-style link to Typst.
|
|
1024
|
+
* Resolves the reference to get the URL, then renders like a normal link.
|
|
1025
|
+
*
|
|
1026
|
+
* @param node - LinkReference node
|
|
1027
|
+
* @param definitions - Map of link reference definitions
|
|
1028
|
+
* @param footnoteDefinitions - Map of footnote definitions
|
|
1029
|
+
* @returns Rendered Typst link or fallback text
|
|
1030
|
+
*/
|
|
1031
|
+
function renderLinkReference(
|
|
1032
|
+
node: LinkReference,
|
|
1033
|
+
definitions: Map<string, Definition>,
|
|
1034
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>
|
|
1035
|
+
): string | null {
|
|
1036
|
+
const def = definitions.get(node.identifier.toLowerCase());
|
|
1037
|
+
const label = renderInlines(node.children, definitions, footnoteDefinitions);
|
|
1038
|
+
if (!def) return label || escapeTypstText(node.label || node.identifier);
|
|
1039
|
+
const url = escapeTypstString(def.url);
|
|
1040
|
+
if (!label.trim()) return `#link("${url}")[${escapeTypstText(def.url)}]`;
|
|
1041
|
+
return `#link("${url}")[${label}]`;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Escape special characters in Typst text content.
|
|
1046
|
+
* Escapes characters that have special meaning in Typst markup.
|
|
1047
|
+
*
|
|
1048
|
+
* @param input - Raw text string
|
|
1049
|
+
* @returns Escaped text safe for Typst
|
|
1050
|
+
*/
|
|
1051
|
+
function escapeTypstText(input: string): string {
|
|
1052
|
+
return input.replace(/[\\#*_`\[\]\$<>@]/g, (c) => `\\${c}`);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Escape special characters in Typst string literals.
|
|
1057
|
+
* Used for strings within quotes (URLs, titles, etc.).
|
|
1058
|
+
*
|
|
1059
|
+
* @param input - Raw string
|
|
1060
|
+
* @returns Escaped string safe for Typst string literals
|
|
1061
|
+
*/
|
|
1062
|
+
function escapeTypstString(input: string): string {
|
|
1063
|
+
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Indent all lines of text by a given level.
|
|
1068
|
+
* Each indent level adds 2 spaces.
|
|
1069
|
+
*
|
|
1070
|
+
* @param text - Text to indent
|
|
1071
|
+
* @param indentLevel - Number of indent levels (0 = no indent)
|
|
1072
|
+
* @returns Indented text
|
|
1073
|
+
*/
|
|
1074
|
+
function indentLines(text: string, indentLevel: number): string {
|
|
1075
|
+
if (!indentLevel) return text;
|
|
1076
|
+
const indent = ' '.repeat(indentLevel);
|
|
1077
|
+
return text
|
|
1078
|
+
.split('\n')
|
|
1079
|
+
.map((line) => `${indent}${line}`)
|
|
1080
|
+
.join('\n');
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Type guard to check if a value is a non-empty string.
|
|
1085
|
+
* Useful for filtering arrays.
|
|
1086
|
+
*
|
|
1087
|
+
* @param value - Value to check
|
|
1088
|
+
* @returns True if value is a non-empty string
|
|
1089
|
+
*/
|
|
1090
|
+
function isNonEmpty(value: string | null | undefined): value is string {
|
|
1091
|
+
return typeof value === 'string' && value.length > 0;
|
|
1092
|
+
}
|