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.
@@ -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
+ }