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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontmatter parsing and custom options metadata handling pipeline stage
|
|
3
|
+
* @module frontmatter
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { load as parseYaml } from 'js-yaml';
|
|
7
|
+
import type { Root, Yaml } from 'mdast';
|
|
8
|
+
import type { Frontmatter, Markdown2TypstOptions, DocumentMetadata, ErrorCallback } from './types.js';
|
|
9
|
+
import { ErrorSeverity } from './types.js';
|
|
10
|
+
import { coerceLanguage, coerceRegion } from './utils.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse YAML frontmatter from the document.
|
|
14
|
+
* Supports title, authors (single or array), and language fields.
|
|
15
|
+
*
|
|
16
|
+
* @param root - The root node of the MDAST tree
|
|
17
|
+
* @param onError - Optional error callback for logging parse issues
|
|
18
|
+
* @returns Parsed frontmatter metadata
|
|
19
|
+
*/
|
|
20
|
+
export function parseFrontmatter(root: Root, onError?: ErrorCallback): Frontmatter {
|
|
21
|
+
const yamlNode = root.children.find((node) => node.type === 'yaml') as Yaml | undefined;
|
|
22
|
+
if (!yamlNode?.value) return {};
|
|
23
|
+
return parseFrontmatterYaml(yamlNode.value, onError);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse YAML frontmatter string into structured metadata.
|
|
28
|
+
* Handles standard Markdown/Pandoc frontmatter fields using js-yaml.
|
|
29
|
+
*
|
|
30
|
+
* @param yaml - Raw YAML string from frontmatter
|
|
31
|
+
* @param onError - Optional error callback for logging parse issues
|
|
32
|
+
* @returns Parsed frontmatter object
|
|
33
|
+
*/
|
|
34
|
+
function parseFrontmatterYaml(yaml: string, onError?: ErrorCallback): Frontmatter {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = parseYaml(yaml) as Record<string, any>;
|
|
37
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
38
|
+
if (onError) {
|
|
39
|
+
onError({
|
|
40
|
+
severity: ErrorSeverity.WARNING,
|
|
41
|
+
message: 'YAML frontmatter is empty or not an object',
|
|
42
|
+
context: 'frontmatter parsing'
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result: Frontmatter = {};
|
|
49
|
+
|
|
50
|
+
// Extract and normalize all supported fields
|
|
51
|
+
if ('title' in parsed && typeof parsed.title === 'string') {
|
|
52
|
+
result.title = parsed.title;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle author field (can be string or array)
|
|
56
|
+
if ('author' in parsed) {
|
|
57
|
+
if (typeof parsed.author === 'string') {
|
|
58
|
+
result.author = parsed.author;
|
|
59
|
+
} else if (Array.isArray(parsed.author)) {
|
|
60
|
+
result.author = parsed.author.filter(a => typeof a === 'string');
|
|
61
|
+
} else if (onError) {
|
|
62
|
+
onError({
|
|
63
|
+
severity: ErrorSeverity.WARNING,
|
|
64
|
+
message: 'Frontmatter "author" field must be a string or array of strings',
|
|
65
|
+
context: 'frontmatter parsing',
|
|
66
|
+
details: { fieldType: typeof parsed.author }
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Handle authors field (array)
|
|
72
|
+
if ('authors' in parsed && Array.isArray(parsed.authors)) {
|
|
73
|
+
result.authors = parsed.authors.filter(a => typeof a === 'string');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Description
|
|
77
|
+
if ('description' in parsed && typeof parsed.description === 'string') {
|
|
78
|
+
result.description = parsed.description;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Keywords (array)
|
|
82
|
+
if ('keywords' in parsed && Array.isArray(parsed.keywords)) {
|
|
83
|
+
result.keywords = parsed.keywords.filter(k => typeof k === 'string');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Date (string or Date object)
|
|
87
|
+
if ('date' in parsed) {
|
|
88
|
+
if (typeof parsed.date === 'string') {
|
|
89
|
+
result.date = parsed.date;
|
|
90
|
+
} else if (parsed.date instanceof Date) {
|
|
91
|
+
result.date = parsed.date.toISOString().split('T')[0]; // Convert to YYYY-MM-DD
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Abstract
|
|
96
|
+
if ('abstract' in parsed && typeof parsed.abstract === 'string') {
|
|
97
|
+
result.abstract = parsed.abstract;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Language (lang or language)
|
|
101
|
+
if ('lang' in parsed && typeof parsed.lang === 'string') {
|
|
102
|
+
result.lang = parsed.lang;
|
|
103
|
+
}
|
|
104
|
+
if ('language' in parsed && typeof parsed.language === 'string') {
|
|
105
|
+
result.language = parsed.language;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Region
|
|
109
|
+
if ('region' in parsed && typeof parsed.region === 'string') {
|
|
110
|
+
result.region = parsed.region;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return result;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// If YAML parsing fails, return empty frontmatter
|
|
116
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
117
|
+
if (onError) {
|
|
118
|
+
onError({
|
|
119
|
+
severity: ErrorSeverity.WARNING,
|
|
120
|
+
message: `Failed to parse YAML frontmatter: ${errorMessage}`,
|
|
121
|
+
context: 'frontmatter parsing',
|
|
122
|
+
originalError: error
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse date string into Typst date format.
|
|
131
|
+
* Supports 'auto', 'none', and ISO date formats (YYYY-MM-DD).
|
|
132
|
+
*
|
|
133
|
+
* @param value - Date string from frontmatter
|
|
134
|
+
* @returns Typst date expression
|
|
135
|
+
*/
|
|
136
|
+
export function parseDate(value: string): string {
|
|
137
|
+
const v = value.trim().toLowerCase();
|
|
138
|
+
|
|
139
|
+
// Handle special values
|
|
140
|
+
if (v === 'auto') return 'auto';
|
|
141
|
+
if (v === 'none') return 'none';
|
|
142
|
+
|
|
143
|
+
// Try to parse ISO date format (YYYY-MM-DD)
|
|
144
|
+
const isoMatch = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value.trim());
|
|
145
|
+
if (isoMatch) {
|
|
146
|
+
const year = parseInt(isoMatch[1], 10);
|
|
147
|
+
const month = parseInt(isoMatch[2], 10);
|
|
148
|
+
const day = parseInt(isoMatch[3], 10);
|
|
149
|
+
return `datetime(day: ${day}, month: ${month}, year: ${year})`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Default to auto if we can't parse the date
|
|
153
|
+
return 'auto';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Merge options with frontmatter to create final document metadata.
|
|
158
|
+
* Options take precedence over frontmatter values.
|
|
159
|
+
*
|
|
160
|
+
* @param options - User-provided options
|
|
161
|
+
* @param frontmatter - Parsed frontmatter
|
|
162
|
+
* @param leadingTitle - Title from leading H1, if any
|
|
163
|
+
* @returns Merged document metadata
|
|
164
|
+
*/
|
|
165
|
+
export function mergeMetadata(
|
|
166
|
+
options: Markdown2TypstOptions,
|
|
167
|
+
frontmatter: Frontmatter,
|
|
168
|
+
leadingTitle: string | null
|
|
169
|
+
): DocumentMetadata {
|
|
170
|
+
const title = options.title ?? frontmatter.title ?? leadingTitle ?? '';
|
|
171
|
+
|
|
172
|
+
// Normalize authors from options and frontmatter (all optional)
|
|
173
|
+
const optionsAuthors = options.authors ??
|
|
174
|
+
(typeof options.author === 'string' ? [options.author] : options.author) ?? [];
|
|
175
|
+
const frontmatterAuthors = frontmatter.authors ??
|
|
176
|
+
(typeof frontmatter.author === 'string' ? [frontmatter.author] : frontmatter.author) ?? [];
|
|
177
|
+
const authors = optionsAuthors.length > 0 ? optionsAuthors : frontmatterAuthors;
|
|
178
|
+
|
|
179
|
+
// Use options as overrides for all other fields
|
|
180
|
+
const lang = coerceLanguage(options.lang ?? options.language ?? frontmatter.lang ?? frontmatter.language);
|
|
181
|
+
const region = coerceRegion(options.region ?? frontmatter.region);
|
|
182
|
+
const date = options.date ?? frontmatter.date;
|
|
183
|
+
const description = options.description ?? frontmatter.description;
|
|
184
|
+
const keywords = options.keywords ?? frontmatter.keywords;
|
|
185
|
+
const abstract = options.abstract ?? frontmatter.abstract;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
title,
|
|
189
|
+
authors,
|
|
190
|
+
description,
|
|
191
|
+
keywords,
|
|
192
|
+
date,
|
|
193
|
+
abstract,
|
|
194
|
+
lang,
|
|
195
|
+
region
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline node rendering functions
|
|
3
|
+
* @module inline-renderer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { tex2typst } from 'tex2typst';
|
|
7
|
+
import type {
|
|
8
|
+
PhrasingContent,
|
|
9
|
+
Text,
|
|
10
|
+
Strong,
|
|
11
|
+
Emphasis,
|
|
12
|
+
Delete,
|
|
13
|
+
InlineCode,
|
|
14
|
+
Link,
|
|
15
|
+
LinkReference,
|
|
16
|
+
Image,
|
|
17
|
+
FootnoteReference,
|
|
18
|
+
Html
|
|
19
|
+
} from 'mdast';
|
|
20
|
+
import type { Mark, SuperScript, SubScript, InlineMathNode, RenderContext } from './types.js';
|
|
21
|
+
import { escapeTypstText, escapeTypstString, isNonEmpty } from './utils.js';
|
|
22
|
+
import { ErrorSeverity } from './types.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render inline phrasing nodes to Typst markup.
|
|
26
|
+
*
|
|
27
|
+
* @param nodes - Array of phrasing content nodes
|
|
28
|
+
* @param context - Rendering context with definitions and footnotes
|
|
29
|
+
* @returns Rendered Typst string
|
|
30
|
+
*/
|
|
31
|
+
export function renderInlines(
|
|
32
|
+
nodes: PhrasingContent[],
|
|
33
|
+
context: RenderContext
|
|
34
|
+
): string {
|
|
35
|
+
return nodes
|
|
36
|
+
.map((node) => renderInline(node, context))
|
|
37
|
+
.filter(isNonEmpty)
|
|
38
|
+
.join('');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render an inline phrasing node to Typst markup.
|
|
43
|
+
* Handles text, emphasis, strong, code, links, images, math, etc.
|
|
44
|
+
*
|
|
45
|
+
* @param node - Phrasing content node
|
|
46
|
+
* @param context - Rendering context with definitions and footnotes
|
|
47
|
+
* @returns Rendered Typst string or null if node should be skipped
|
|
48
|
+
*/
|
|
49
|
+
function renderInline(
|
|
50
|
+
node: PhrasingContent,
|
|
51
|
+
context: RenderContext
|
|
52
|
+
): string | null {
|
|
53
|
+
const { definitions, footnoteDefinitions } = context;
|
|
54
|
+
|
|
55
|
+
switch ((node as any).type) {
|
|
56
|
+
case 'text':
|
|
57
|
+
return escapeTypstText((node as Text).value);
|
|
58
|
+
case 'strong':
|
|
59
|
+
return `*${renderInlines((node as Strong).children, context)}*`;
|
|
60
|
+
case 'emphasis':
|
|
61
|
+
return `_${renderInlines((node as Emphasis).children, context)}_`;
|
|
62
|
+
case 'delete':
|
|
63
|
+
return `#strike[${renderInlines((node as unknown as Delete).children, context)}]`;
|
|
64
|
+
case 'mark':
|
|
65
|
+
return `#highlight[${renderInlines((node as unknown as Mark).children, context)}]`;
|
|
66
|
+
case 'subscript':
|
|
67
|
+
return `#sub[${renderInlines((node as unknown as SubScript).children, context)}]`;
|
|
68
|
+
case 'superscript':
|
|
69
|
+
return `#super[${renderInlines((node as unknown as SuperScript).children, context)}]`;
|
|
70
|
+
case 'footnoteReference': {
|
|
71
|
+
const ref = node as FootnoteReference;
|
|
72
|
+
const def = footnoteDefinitions.get(ref.identifier.toLowerCase());
|
|
73
|
+
if (!def) {
|
|
74
|
+
if (context.onError) {
|
|
75
|
+
context.onError({
|
|
76
|
+
severity: ErrorSeverity.WARNING,
|
|
77
|
+
message: `Footnote reference [^${ref.identifier}] has no matching definition`,
|
|
78
|
+
context: 'inline rendering',
|
|
79
|
+
details: { identifier: ref.identifier }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return ''; // Or render as plain text?
|
|
83
|
+
}
|
|
84
|
+
// Render footnote content inline
|
|
85
|
+
try {
|
|
86
|
+
const content = def.children
|
|
87
|
+
.map((child) => {
|
|
88
|
+
// Import renderBlock only when needed to avoid circular dependencies
|
|
89
|
+
const { renderBlock } = require('./block-renderer.js');
|
|
90
|
+
return renderBlock(child, 0, context);
|
|
91
|
+
})
|
|
92
|
+
.filter(isNonEmpty)
|
|
93
|
+
.join(' '); // Join blocks with space for inline footnote
|
|
94
|
+
return `#footnote[${content.trim()}]`;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
97
|
+
if (context.onError) {
|
|
98
|
+
context.onError({
|
|
99
|
+
severity: ErrorSeverity.ERROR,
|
|
100
|
+
message: `Error rendering footnote [^${ref.identifier}]: ${errorMessage}`,
|
|
101
|
+
context: 'inline rendering',
|
|
102
|
+
originalError: error
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
case 'inlineCode':
|
|
109
|
+
return renderInlineCode(node as InlineCode);
|
|
110
|
+
case 'inlineMath': {
|
|
111
|
+
// Convert LaTeX to Typst math syntax
|
|
112
|
+
const mathNode = node as InlineMathNode;
|
|
113
|
+
const value = mathNode.value.trim();
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const typstMath = tex2typst(value);
|
|
117
|
+
|
|
118
|
+
// Check if this was originally $$...$$ (display/block math) or $...$ (inline math)
|
|
119
|
+
const isDisplayMath = mathNode.position?.end?.offset != null &&
|
|
120
|
+
mathNode.position?.start?.offset != null &&
|
|
121
|
+
(mathNode.position.end.offset - mathNode.position.start.offset) >= value.length + 4;
|
|
122
|
+
|
|
123
|
+
// Display math ($$): use spaces for block-style rendering
|
|
124
|
+
// Inline math ($): no spaces for inline rendering
|
|
125
|
+
return isDisplayMath ? `$ ${typstMath} $` : `$${typstMath}$`;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// Fallback: use original LaTeX if conversion fails
|
|
128
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
129
|
+
if (context.onError) {
|
|
130
|
+
context.onError({
|
|
131
|
+
severity: ErrorSeverity.WARNING,
|
|
132
|
+
message: `Failed to convert inline math LaTeX to Typst: ${errorMessage}`,
|
|
133
|
+
context: 'inline math rendering',
|
|
134
|
+
originalError: error,
|
|
135
|
+
details: { latex: value }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const isDisplayMath = mathNode.position?.end?.offset != null &&
|
|
139
|
+
mathNode.position?.start?.offset != null &&
|
|
140
|
+
(mathNode.position.end.offset - mathNode.position.start.offset) >= value.length + 4;
|
|
141
|
+
return isDisplayMath ? `$ ${value} $` : `$${value}$`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
case 'image':
|
|
145
|
+
return renderImage(node as Image, context);
|
|
146
|
+
case 'link':
|
|
147
|
+
return renderLink(node as Link, context);
|
|
148
|
+
case 'linkReference':
|
|
149
|
+
return renderLinkReference(node as LinkReference, context);
|
|
150
|
+
case 'break':
|
|
151
|
+
return '\\\n';
|
|
152
|
+
case 'html':
|
|
153
|
+
// Treat inline HTML as literal text, escape it for Typst
|
|
154
|
+
return escapeTypstText((node as Html).value);
|
|
155
|
+
default:
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Render inline code to Typst.
|
|
162
|
+
* Escapes backticks within the code.
|
|
163
|
+
*
|
|
164
|
+
* @param node - InlineCode node
|
|
165
|
+
* @returns Rendered Typst inline code
|
|
166
|
+
*/
|
|
167
|
+
function renderInlineCode(node: InlineCode): string {
|
|
168
|
+
const value = node.value.replace(/`/g, '\\`');
|
|
169
|
+
return `\`${value}\``;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render an image to Typst.
|
|
174
|
+
* Uses Typst's #image() function for local images,
|
|
175
|
+
* and #external-image() for external (http/https) images.
|
|
176
|
+
*
|
|
177
|
+
* @param node - Image node
|
|
178
|
+
* @param context - Rendering context with warnings tracking
|
|
179
|
+
* @returns Rendered Typst image function
|
|
180
|
+
*/
|
|
181
|
+
function renderImage(node: Image, context: RenderContext): string {
|
|
182
|
+
// Check if image is external (starts with http:// or https://)
|
|
183
|
+
const isExternal = /^https?:\/\//i.test(node.url);
|
|
184
|
+
|
|
185
|
+
if (isExternal) {
|
|
186
|
+
// Mark that external images were detected
|
|
187
|
+
context.warnings.externalImages = true;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
return `#external-image(\n "${escapeTypstString(node.url)}"\n)`;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// Return unescaped URL if escaping fails
|
|
193
|
+
return `#external-image(\n "${node.url}"\n)`;
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
// Local image - use standard #image() function
|
|
197
|
+
try {
|
|
198
|
+
return `#image("${escapeTypstString(node.url)}")`;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// Return unescaped URL if escaping fails
|
|
201
|
+
return `#image("${node.url}")`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Render a link to Typst.
|
|
208
|
+
* Uses Typst's #link() function with URL and label.
|
|
209
|
+
*
|
|
210
|
+
* @param node - Link node
|
|
211
|
+
* @param context - Rendering context with definitions and footnotes
|
|
212
|
+
* @returns Rendered Typst link
|
|
213
|
+
*/
|
|
214
|
+
function renderLink(
|
|
215
|
+
node: Link,
|
|
216
|
+
context: RenderContext
|
|
217
|
+
): string {
|
|
218
|
+
const url = escapeTypstString(node.url);
|
|
219
|
+
const label = renderInlines(node.children, context);
|
|
220
|
+
if (!label.trim()) return `#link("${url}")[${escapeTypstText(node.url)}]`;
|
|
221
|
+
return `#link("${url}")[${label}]`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Render a reference-style link to Typst.
|
|
226
|
+
* Resolves the reference to get the URL, then renders like a normal link.
|
|
227
|
+
*
|
|
228
|
+
* @param node - LinkReference node
|
|
229
|
+
* @param context - Rendering context with definitions and footnotes
|
|
230
|
+
* @returns Rendered Typst link or fallback text
|
|
231
|
+
*/
|
|
232
|
+
function renderLinkReference(
|
|
233
|
+
node: LinkReference,
|
|
234
|
+
context: RenderContext
|
|
235
|
+
): string | null {
|
|
236
|
+
const { definitions } = context;
|
|
237
|
+
const def = definitions.get(node.identifier.toLowerCase());
|
|
238
|
+
const label = renderInlines(node.children, context);
|
|
239
|
+
if (!def) {
|
|
240
|
+
if (context.onError) {
|
|
241
|
+
context.onError({
|
|
242
|
+
severity: ErrorSeverity.WARNING,
|
|
243
|
+
message: `Link reference [${node.identifier}] has no matching definition`,
|
|
244
|
+
context: 'inline rendering',
|
|
245
|
+
details: { identifier: node.identifier }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return label || escapeTypstText(node.label || node.identifier);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const url = escapeTypstString(def.url);
|
|
252
|
+
if (!label.trim()) return `#link("${url}")[${escapeTypstText(def.url)}]`;
|
|
253
|
+
return `#link("${url}")[${label}]`;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
256
|
+
if (context.onError) {
|
|
257
|
+
context.onError({
|
|
258
|
+
severity: ErrorSeverity.ERROR,
|
|
259
|
+
message: `Error rendering link reference [${node.identifier}]: ${errorMessage}`,
|
|
260
|
+
context: 'inline rendering',
|
|
261
|
+
originalError: error
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return label || escapeTypstText(node.label || node.identifier);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown2typst - Convert Markdown to Typst
|
|
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
|
+
* Refactored, documented, modified and extended by Mapaor.
|
|
9
|
+
*
|
|
10
|
+
* @module markdown2typst
|
|
11
|
+
* @license MIT
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ErrorSeverity, type Markdown2TypstOptions } from './types.js';
|
|
15
|
+
import { parseMarkdown } from './parser.js';
|
|
16
|
+
import { collectDefinitions, collectFootnotes, findLeadingH1 } from './collectors.js';
|
|
17
|
+
import { parseFrontmatter, mergeMetadata } from './frontmatter.js';
|
|
18
|
+
import { buildOutput } from './output-builder.js';
|
|
19
|
+
|
|
20
|
+
// Re-export types for public API
|
|
21
|
+
export type { Markdown2TypstOptions, ConversionError, ErrorCallback } from './types.js';
|
|
22
|
+
export { ErrorSeverity } from './types.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert Markdown text to Typst markup.
|
|
26
|
+
*
|
|
27
|
+
* This is the main entry point for the library. It parses the Markdown using remark,
|
|
28
|
+
* processes frontmatter and metadata, and converts the AST to Typst syntax.
|
|
29
|
+
*
|
|
30
|
+
* The conversion pipeline consists of several stages:
|
|
31
|
+
* 1. Parse Markdown into an AST (Abstract Syntax Tree)
|
|
32
|
+
* 2. Collect link/image definitions and footnotes
|
|
33
|
+
* 3. Parse YAML frontmatter and merge with options
|
|
34
|
+
* 4. Render AST nodes to Typst markup
|
|
35
|
+
* 5. Build final output with metadata and body
|
|
36
|
+
*
|
|
37
|
+
* @param markdown - The Markdown text to convert
|
|
38
|
+
* @param options - Optional configuration for the conversion
|
|
39
|
+
* @returns The converted Typst markup as a string
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* const typst = markdown2typst('# Hello\n\nThis is **bold**.');
|
|
44
|
+
* console.log(typst);
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const typst = markdown2typst(markdown, {
|
|
50
|
+
* title: 'My Document',
|
|
51
|
+
* authors: ['John Doe'],
|
|
52
|
+
* lang: 'en'
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function markdown2typst(markdown: string, options: Markdown2TypstOptions = {}): string {
|
|
57
|
+
try {
|
|
58
|
+
// Stage 1: Parse Markdown into AST
|
|
59
|
+
const tree = parseMarkdown(markdown, options.onError);
|
|
60
|
+
|
|
61
|
+
// Stage 2: Collect definitions and footnotes from the AST
|
|
62
|
+
const definitions = collectDefinitions(tree, options.onError);
|
|
63
|
+
const footnoteDefinitions = collectFootnotes(tree, options.onError);
|
|
64
|
+
|
|
65
|
+
// Stage 3: Parse frontmatter and extract leading title (if enabled)
|
|
66
|
+
const frontmatter = parseFrontmatter(tree, options.onError);
|
|
67
|
+
const leadingH1 = options.useH1AsTitle
|
|
68
|
+
? findLeadingH1(tree, definitions, options.onError)
|
|
69
|
+
: null;
|
|
70
|
+
|
|
71
|
+
// Stage 4: Merge metadata from options, frontmatter, and leading H1
|
|
72
|
+
const metadata = mergeMetadata(options, frontmatter, leadingH1?.title ?? null);
|
|
73
|
+
|
|
74
|
+
// Stage 5: Build final Typst output
|
|
75
|
+
const context = {
|
|
76
|
+
definitions,
|
|
77
|
+
footnoteDefinitions,
|
|
78
|
+
onError: options.onError,
|
|
79
|
+
warnings: { externalImages: false }
|
|
80
|
+
};
|
|
81
|
+
return buildOutput(tree, metadata, leadingH1?.index ?? null, context);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Handle unexpected fatal errors
|
|
84
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
85
|
+
if (options.onError) {
|
|
86
|
+
options.onError({
|
|
87
|
+
severity: ErrorSeverity.ERROR,
|
|
88
|
+
message: `Fatal error during conversion: ${errorMessage}`,
|
|
89
|
+
context: 'markdown2typst',
|
|
90
|
+
originalError: error
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// Rethrow fatal errors after logging
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|