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/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "markdown2typst",
3
+ "version": "0.1.0",
4
+ "description": "A comprehensive JavaScript library for converting Markdown to Typst code",
5
+ "type": "module",
6
+ "main": "./dist/markdown2typst.js",
7
+ "types": "./src/markdown2typst.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/markdown2typst.ts",
11
+ "import": "./dist/markdown2typst.js",
12
+ "default": "./dist/markdown2typst.min.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "keywords": [
22
+ "markdown",
23
+ "typst",
24
+ "converter",
25
+ "parser",
26
+ "markdown-to-typst",
27
+ "markdown-2-typst",
28
+ "markdown2typst",
29
+ "gfm",
30
+ "github-flavored-markdown",
31
+ "remark",
32
+ "unified",
33
+ "documentation"
34
+ ],
35
+ "scripts": {
36
+ "build": "node build.js",
37
+ "build:dev": "node build.js --dev",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "node --test test/validate.js"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/Mapaor/markdown2typst.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/Mapaor/markdown2typst/issues"
47
+ },
48
+ "homepage": "https://github.com/Mapaor/markdown2typst#readme",
49
+ "author": "Martí Pardo <pardo.marti@gmail.com>",
50
+ "license": "MIT",
51
+ "dependencies": {
52
+ "country-list": "^2.4.1",
53
+ "js-yaml": "^4.1.1",
54
+ "langs": "^2.0.0",
55
+ "remark-frontmatter": "^5.0.0",
56
+ "remark-gfm": "^4.0.0",
57
+ "remark-math": "^6.0.0",
58
+ "remark-parse": "^11.0.0",
59
+ "tex2typst": "^0.5.5",
60
+ "unified": "^11.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@types/country-list": "^2.1.4",
64
+ "@types/js-yaml": "^4.0.9",
65
+ "@types/langs": "^2.0.5",
66
+ "@types/mdast": "^4.0.4",
67
+ "@types/node": "^25.0.8",
68
+ "esbuild": "^0.27.2",
69
+ "typescript": "^5.3.0"
70
+ },
71
+ "engines": {
72
+ "node": ">=18"
73
+ }
74
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Block node rendering functions
3
+ * @module block-renderer
4
+ */
5
+
6
+ import { tex2typst } from 'tex2typst';
7
+ import type {
8
+ Content,
9
+ Heading,
10
+ Paragraph,
11
+ List,
12
+ ListItem,
13
+ Code,
14
+ Blockquote,
15
+ Table,
16
+ TableRow,
17
+ TableCell
18
+ } from 'mdast';
19
+ import type { MathNode, RenderContext } from './types.js';
20
+ import { renderInlines } from './inline-renderer.js';
21
+ import { indentLines, isNonEmpty, plainTextFromPhrasing, maxBacktickRun } from './utils.js';
22
+ import { ErrorSeverity } from './types.js';
23
+
24
+ /**
25
+ * Render a block-level MDAST node to Typst markup.
26
+ * Dispatches to appropriate renderer based on node type.
27
+ *
28
+ * @param node - Block-level content node
29
+ * @param indentLevel - Current indentation level
30
+ * @param context - Rendering context with definitions and footnotes
31
+ * @returns Rendered Typst string or null if node should be skipped
32
+ */
33
+ export function renderBlock(
34
+ node: Content,
35
+ indentLevel: number,
36
+ context: RenderContext
37
+ ): string | null {
38
+ try {
39
+ switch (node.type) {
40
+ case 'yaml':
41
+ case 'definition':
42
+ case 'footnoteDefinition':
43
+ return null;
44
+ case 'heading':
45
+ return renderHeading(node as Heading, indentLevel, context);
46
+ case 'paragraph':
47
+ return indentLines(
48
+ renderParagraph(node as Paragraph, context),
49
+ indentLevel
50
+ );
51
+ case 'list':
52
+ return renderList(node as List, indentLevel, context);
53
+ case 'code':
54
+ return renderCodeBlock(node as Code, indentLevel);
55
+ case 'blockquote':
56
+ return renderBlockquote(node as Blockquote, indentLevel, context);
57
+ case 'thematicBreak':
58
+ return indentLines('#line(length: 100%, stroke: 0.6pt)', indentLevel);
59
+ case 'table':
60
+ return renderTable(node as Table, indentLevel, context);
61
+ case 'math':
62
+ return renderMathBlock(node as MathNode, indentLevel, context);
63
+ default:
64
+ if (context.onError) {
65
+ context.onError({
66
+ severity: ErrorSeverity.WARNING,
67
+ message: `Unknown block node type: ${node.type}`,
68
+ context: 'block rendering',
69
+ details: { nodeType: node.type }
70
+ });
71
+ }
72
+ return null;
73
+ }
74
+ } catch (error) {
75
+ const errorMessage = error instanceof Error ? error.message : String(error);
76
+ if (context.onError) {
77
+ context.onError({
78
+ severity: ErrorSeverity.ERROR,
79
+ message: `Error rendering block node: ${errorMessage}`,
80
+ context: 'block rendering',
81
+ originalError: error,
82
+ details: { nodeType: node.type }
83
+ });
84
+ }
85
+ // Return empty string to continue rendering
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Render a block-level math equation to Typst.
92
+ * Converts LaTeX math syntax to Typst math using tex2typst.
93
+ *
94
+ * @param node - Math node
95
+ * @param indentLevel - Current indentation level
96
+ * @param context - Rendering context for error handling
97
+ * @returns Rendered Typst math block
98
+ */
99
+ function renderMathBlock(node: MathNode, indentLevel: number, context: RenderContext): string {
100
+ // Convert LaTeX to Typst math syntax
101
+ const value = node.value.trim();
102
+ try {
103
+ const typstMath = tex2typst(value);
104
+ return indentLines(`$ ${typstMath} $`, indentLevel);
105
+ } catch (error) {
106
+ // Fallback: use original LaTeX if conversion fails
107
+ const errorMessage = error instanceof Error ? error.message : String(error);
108
+ if (context.onError) {
109
+ context.onError({
110
+ severity: ErrorSeverity.WARNING,
111
+ message: `Failed to convert block math LaTeX to Typst: ${errorMessage}`,
112
+ context: 'block math rendering',
113
+ originalError: error,
114
+ details: { latex: value }
115
+ });
116
+ }
117
+ return indentLines(`$ ${value} $`, indentLevel);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Render a heading to Typst markup.
123
+ * Converts Markdown heading syntax (# ## ###) to Typst (= == ===).
124
+ *
125
+ * @param node - Heading node
126
+ * @param indentLevel - Current indentation level
127
+ * @param context - Rendering context with definitions and footnotes
128
+ * @returns Rendered Typst heading
129
+ */
130
+ function renderHeading(
131
+ node: Heading,
132
+ indentLevel: number,
133
+ context: RenderContext
134
+ ): string {
135
+ const level = Math.min(Math.max(node.depth, 1), 6);
136
+ return indentLines(
137
+ `${'='.repeat(level)} ${renderInlines(node.children, context)}`,
138
+ indentLevel
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Render a paragraph to Typst.
144
+ * Handles special cases like [toc] for table of contents.
145
+ *
146
+ * @param node - Paragraph node
147
+ * @param context - Rendering context with definitions and footnotes
148
+ * @returns Rendered Typst paragraph
149
+ */
150
+ function renderParagraph(
151
+ node: Paragraph,
152
+ context: RenderContext
153
+ ): string {
154
+ // Check for [toc]
155
+ const text = plainTextFromPhrasing(node.children, context.definitions).trim().toLowerCase();
156
+ if (text === '[toc]') {
157
+ return `#outline(title: auto, indent: auto)`;
158
+ }
159
+ return renderInlines(node.children, context);
160
+ }
161
+
162
+ /**
163
+ * Render a list to Typst markup.
164
+ *
165
+ * @param node - List node
166
+ * @param indentLevel - Current indentation level
167
+ * @param context - Rendering context with definitions and footnotes
168
+ * @returns Rendered Typst list
169
+ */
170
+ function renderList(
171
+ node: List,
172
+ indentLevel: number,
173
+ context: RenderContext
174
+ ): string {
175
+ const marker = node.ordered ? '+' : '-';
176
+ return node.children
177
+ .map((item) => renderListItem(item, marker, indentLevel, context))
178
+ .filter(isNonEmpty)
179
+ .join('\n');
180
+ }
181
+
182
+ /**
183
+ * Render a list item to Typst markup.
184
+ *
185
+ * @param node - ListItem node
186
+ * @param marker - List marker ('+' for ordered, '-' for unordered)
187
+ * @param indentLevel - Current indentation level
188
+ * @param context - Rendering context with definitions and footnotes
189
+ * @returns Rendered Typst list item
190
+ */
191
+ function renderListItem(
192
+ node: ListItem,
193
+ marker: string,
194
+ indentLevel: number,
195
+ context: RenderContext
196
+ ): string {
197
+ const baseIndent = ' '.repeat(indentLevel);
198
+ const nestedIndentLevel = indentLevel + 1;
199
+
200
+ const first = node.children[0];
201
+ const lines: string[] = [];
202
+
203
+ if (first?.type === 'paragraph') {
204
+ lines.push(
205
+ `${baseIndent}${marker} ${renderParagraph(first as Paragraph, context)}`
206
+ );
207
+ for (const child of node.children.slice(1)) {
208
+ if (child.type === 'list') {
209
+ lines.push(renderList(child as List, nestedIndentLevel, context));
210
+ continue;
211
+ }
212
+ const rendered = renderBlock(child as Content, nestedIndentLevel, context);
213
+ if (rendered) lines.push(rendered);
214
+ }
215
+ return lines.join('\n');
216
+ }
217
+
218
+ lines.push(`${baseIndent}${marker}`);
219
+ for (const child of node.children) {
220
+ if (child.type === 'list') {
221
+ lines.push(renderList(child as List, nestedIndentLevel, context));
222
+ continue;
223
+ }
224
+ const rendered = renderBlock(child as Content, nestedIndentLevel, context);
225
+ if (rendered) lines.push(rendered);
226
+ }
227
+ return lines.join('\n');
228
+ }
229
+
230
+ /**
231
+ * Render a code block to Typst markup.
232
+ *
233
+ * @param node - Code node
234
+ * @param indentLevel - Current indentation level
235
+ * @returns Rendered Typst code block
236
+ */
237
+ function renderCodeBlock(node: Code, indentLevel: number): string {
238
+ const info = node.lang?.trim() ? node.lang.trim() : '';
239
+ const value = node.value.replace(/\n$/, '');
240
+ const fence = '`'.repeat(Math.max(3, maxBacktickRun(value) + 1));
241
+ const open = info ? `${fence}${info}` : fence;
242
+ const indentedCode = indentLines(value, indentLevel);
243
+ return [indentLines(open, indentLevel), indentedCode, indentLines(fence, indentLevel)].join('\n');
244
+ }
245
+
246
+ /**
247
+ * Render a table to Typst markup.
248
+ *
249
+ * @param node - Table node
250
+ * @param indentLevel - Current indentation level
251
+ * @param context - Rendering context with definitions and footnotes
252
+ * @returns Rendered Typst table
253
+ */
254
+ function renderTable(
255
+ node: Table,
256
+ indentLevel: number,
257
+ context: RenderContext
258
+ ): string {
259
+ try {
260
+ const rows = node.children as TableRow[];
261
+ if (rows.length === 0) {
262
+ if (context.onError) {
263
+ context.onError({
264
+ severity: ErrorSeverity.WARNING,
265
+ message: 'Empty table found (no rows)',
266
+ context: 'table rendering'
267
+ });
268
+ }
269
+ return '';
270
+ }
271
+
272
+ // Get column count from first row
273
+ const headerRow = rows[0];
274
+ const colCount = headerRow.children.length;
275
+
276
+ if (colCount === 0) {
277
+ if (context.onError) {
278
+ context.onError({
279
+ severity: ErrorSeverity.WARNING,
280
+ message: 'Table has no columns',
281
+ context: 'table rendering'
282
+ });
283
+ }
284
+ return '';
285
+ }
286
+
287
+ // Get alignment from node.align
288
+ const alignMap: Record<string, string> = {
289
+ left: 'left',
290
+ right: 'right',
291
+ center: 'center'
292
+ };
293
+ const aligns = (node.align ?? []).map((a) => alignMap[a ?? 'left'] ?? 'left');
294
+
295
+ // Build column specification
296
+ const columns = Array(colCount).fill('1fr').join(', ');
297
+
298
+ // Build table content
299
+ const headerCells: string[] = [];
300
+ for (const cell of headerRow.children as TableCell[]) {
301
+ const content = renderInlines(cell.children, context);
302
+ headerCells.push(`[*${content}*]`);
303
+ }
304
+
305
+ const dataCells: string[] = [];
306
+ for (let i = 1; i < rows.length; i++) {
307
+ const row = rows[i];
308
+ for (const cell of row.children as TableCell[]) {
309
+ const content = renderInlines(cell.children, context);
310
+ dataCells.push(`[${content}]`);
311
+ }
312
+ }
313
+
314
+ // Build align argument
315
+ const alignArgs = aligns.slice(0, colCount).map((a) => a).join(', ');
316
+
317
+ const lines = [
318
+ `#table(`,
319
+ ` columns: (${columns}),`,
320
+ ` align: (${alignArgs}),`,
321
+ ` table.header(${headerCells.join(', ')}),`,
322
+ ` ${dataCells.join(', ')}`,
323
+ `)`
324
+ ];
325
+
326
+ return indentLines(lines.join('\n'), indentLevel);
327
+ } catch (error) {
328
+ const errorMessage = error instanceof Error ? error.message : String(error);
329
+ if (context.onError) {
330
+ context.onError({
331
+ severity: ErrorSeverity.ERROR,
332
+ message: `Error rendering table: ${errorMessage}`,
333
+ context: 'table rendering',
334
+ originalError: error
335
+ });
336
+ }
337
+ return '';
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Render a blockquote to Typst markup.
343
+ *
344
+ * @param node - Blockquote node
345
+ * @param indentLevel - Current indentation level
346
+ * @param context - Rendering context with definitions and footnotes
347
+ * @returns Rendered Typst blockquote
348
+ */
349
+ function renderBlockquote(
350
+ node: Blockquote,
351
+ indentLevel: number,
352
+ context: RenderContext
353
+ ): string {
354
+ const body = node.children
355
+ .map((child) => renderBlock(child, 0, context))
356
+ .filter(isNonEmpty)
357
+ .join('\n\n');
358
+
359
+ const open = indentLines('#quote[', indentLevel);
360
+ if (!body.trim()) return `${open}\n${indentLines(']', indentLevel)}`;
361
+
362
+ return [open, indentLines(body, indentLevel + 1), indentLines(']', indentLevel)].join('\n');
363
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Definition and footnote collection pipeline stage
3
+ * @module collectors
4
+ */
5
+
6
+ import type { Root, Definition, FootnoteDefinition, Heading } from 'mdast';
7
+ import { plainTextFromPhrasing, normalizeText } from './utils.js';
8
+ import type { ErrorCallback } from './types.js';
9
+ import { ErrorSeverity } from './types.js';
10
+
11
+ /**
12
+ * Collect all link and image reference definitions from the Markdown AST.
13
+ * These are used to resolve reference-style links like [text][id].
14
+ *
15
+ * @param root - The root node of the MDAST tree
16
+ * @param onError - Optional error callback for logging collection issues
17
+ * @returns Map of identifier to Definition node
18
+ */
19
+ export function collectDefinitions(root: Root, onError?: ErrorCallback): Map<string, Definition> {
20
+ const definitions = new Map<string, Definition>();
21
+ try {
22
+ for (const node of root.children) {
23
+ if (node.type !== 'definition') continue;
24
+ const def = node as Definition;
25
+ const identifier = def.identifier.toLowerCase();
26
+
27
+ // Warn if duplicate definition found
28
+ if (definitions.has(identifier)) {
29
+ if (onError) {
30
+ onError({
31
+ severity: ErrorSeverity.WARNING,
32
+ message: `Duplicate link/image definition found: [${def.identifier}]`,
33
+ context: 'definition collection',
34
+ details: { identifier: def.identifier, url: def.url }
35
+ });
36
+ }
37
+ }
38
+
39
+ definitions.set(identifier, def);
40
+ }
41
+ } catch (error) {
42
+ const errorMessage = error instanceof Error ? error.message : String(error);
43
+ if (onError) {
44
+ onError({
45
+ severity: ErrorSeverity.ERROR,
46
+ message: `Error collecting definitions: ${errorMessage}`,
47
+ context: 'definition collection',
48
+ originalError: error
49
+ });
50
+ }
51
+ // Don't throw - return partial results
52
+ }
53
+ return definitions;
54
+ }
55
+
56
+ /**
57
+ * Collect all footnote definitions from the Markdown AST.
58
+ * These are rendered inline when referenced in the text.
59
+ *
60
+ * @param root - The root node of the MDAST tree
61
+ * @param onError - Optional error callback for logging collection issues
62
+ * @returns Map of identifier to FootnoteDefinition node
63
+ */
64
+ export function collectFootnotes(root: Root, onError?: ErrorCallback): Map<string, FootnoteDefinition> {
65
+ const definitions = new Map<string, FootnoteDefinition>();
66
+ try {
67
+ for (const node of root.children) {
68
+ if (node.type !== 'footnoteDefinition') continue;
69
+ const def = node as FootnoteDefinition;
70
+ const identifier = def.identifier.toLowerCase();
71
+
72
+ // Warn if duplicate footnote found
73
+ if (definitions.has(identifier)) {
74
+ if (onError) {
75
+ onError({
76
+ severity: ErrorSeverity.WARNING,
77
+ message: `Duplicate footnote definition found: [^${def.identifier}]`,
78
+ context: 'footnote collection',
79
+ details: { identifier: def.identifier }
80
+ });
81
+ }
82
+ }
83
+
84
+ definitions.set(identifier, def);
85
+ }
86
+ } catch (error) {
87
+ const errorMessage = error instanceof Error ? error.message : String(error);
88
+ if (onError) {
89
+ onError({
90
+ severity: ErrorSeverity.ERROR,
91
+ message: `Error collecting footnotes: ${errorMessage}`,
92
+ context: 'footnote collection',
93
+ originalError: error
94
+ });
95
+ }
96
+ // Don't throw - return partial results
97
+ }
98
+ return definitions;
99
+ }
100
+
101
+ /**
102
+ * Find the first H1 heading in the document (ignoring frontmatter).
103
+ * Used to extract document title when useH1AsTitle option is enabled.
104
+ *
105
+ * @param root - The root node of the MDAST tree
106
+ * @param definitions - Map of link reference definitions
107
+ * @param onError - Optional error callback for logging extraction issues
108
+ * @returns Title text and node index, or null if no H1 found
109
+ */
110
+ export function findLeadingH1(
111
+ root: Root,
112
+ definitions: Map<string, Definition>,
113
+ onError?: ErrorCallback
114
+ ): { title: string; index: number } | null {
115
+ try {
116
+ for (let i = 0; i < root.children.length; i++) {
117
+ const node = root.children[i];
118
+ if (node.type === 'yaml' || node.type === 'definition') continue;
119
+ if (node.type !== 'heading') return null;
120
+ const heading = node as Heading;
121
+ if (heading.depth !== 1) return null;
122
+ const title = plainTextFromPhrasing(heading.children, definitions).trim();
123
+ return title ? { title, index: i } : null;
124
+ }
125
+ } catch (error) {
126
+ const errorMessage = error instanceof Error ? error.message : String(error);
127
+ if (onError) {
128
+ onError({
129
+ severity: ErrorSeverity.WARNING,
130
+ message: `Error extracting leading H1 title: ${errorMessage}`,
131
+ context: 'title extraction',
132
+ originalError: error
133
+ });
134
+ }
135
+ // Return null on error
136
+ }
137
+ return null;
138
+ }