markdown2typst 0.1.0 → 0.1.2

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.
@@ -1,1092 +0,0 @@
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
- }