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,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output building pipeline stage
|
|
3
|
+
* @module output-builder
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Root, Content } from 'mdast';
|
|
7
|
+
import type { DocumentMetadata, RenderContext } from './types.js';
|
|
8
|
+
import { renderBlock } from './block-renderer.js';
|
|
9
|
+
import { isNonEmpty, normalizeText, renderTypstArray, escapeTypstString } from './utils.js';
|
|
10
|
+
import { parseDate } from './frontmatter.js';
|
|
11
|
+
import { ErrorSeverity } from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the warnings section with custom functions for conversion issues.
|
|
15
|
+
*
|
|
16
|
+
* @param context - Rendering context with warnings tracking
|
|
17
|
+
* @returns Warnings section as string, or null if no warnings
|
|
18
|
+
*/
|
|
19
|
+
function buildWarningsSection(context: RenderContext): string | null {
|
|
20
|
+
const { warnings } = context;
|
|
21
|
+
const sections: string[] = [];
|
|
22
|
+
|
|
23
|
+
// Add header if any warnings exist
|
|
24
|
+
const hasWarnings = warnings.externalImages;
|
|
25
|
+
if (!hasWarnings) return null;
|
|
26
|
+
|
|
27
|
+
sections.push('// ========================= WARNINGS =========================');
|
|
28
|
+
sections.push('');
|
|
29
|
+
sections.push('// ------------------------------------------------------------');
|
|
30
|
+
sections.push('// NOTE: The conversion did not work perfectly due to intrinsic');
|
|
31
|
+
sections.push('// Markdown to Typst limitations. The following custom');
|
|
32
|
+
sections.push('// functions, set or show rules are used to visually display');
|
|
33
|
+
sections.push('// these minor conversion warnings to the user.');
|
|
34
|
+
sections.push('// ------------------------------------------------------------');
|
|
35
|
+
sections.push('');
|
|
36
|
+
|
|
37
|
+
// Add external images function if needed
|
|
38
|
+
if (warnings.externalImages) {
|
|
39
|
+
sections.push('// EXTERNAL IMAGES WERE DETECTED!');
|
|
40
|
+
sections.push('#let external-image(url) = {');
|
|
41
|
+
sections.push(' rect(radius: 4pt, inset: 20pt,)[');
|
|
42
|
+
sections.push(' #align(center)[');
|
|
43
|
+
sections.push(' #text()[');
|
|
44
|
+
sections.push(' External image detected: \\');
|
|
45
|
+
sections.push(' #link(url)');
|
|
46
|
+
sections.push(' ]');
|
|
47
|
+
sections.push(' ]');
|
|
48
|
+
sections.push(' ]');
|
|
49
|
+
sections.push('}');
|
|
50
|
+
sections.push('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
sections.push('// ============================================================');
|
|
54
|
+
|
|
55
|
+
return sections.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the final Typst document output.
|
|
60
|
+
* Combines metadata, frontmatter, and rendered body content.
|
|
61
|
+
*
|
|
62
|
+
* @param tree - The parsed MDAST tree
|
|
63
|
+
* @param metadata - Merged document metadata
|
|
64
|
+
* @param leadingTitleIndex - Index of leading H1 (to skip in body), or null
|
|
65
|
+
* @param context - Rendering context with definitions and footnotes
|
|
66
|
+
* @returns Complete Typst document as string
|
|
67
|
+
*/
|
|
68
|
+
export function buildOutput(
|
|
69
|
+
tree: Root,
|
|
70
|
+
metadata: DocumentMetadata,
|
|
71
|
+
leadingTitleIndex: number | null,
|
|
72
|
+
context: RenderContext
|
|
73
|
+
): string {
|
|
74
|
+
try {
|
|
75
|
+
const { title, authors, description, keywords, date, abstract, lang, region } = metadata;
|
|
76
|
+
|
|
77
|
+
// Filter out leading title if it matches the metadata title
|
|
78
|
+
const nodesForBody =
|
|
79
|
+
leadingTitleIndex !== null && normalizeText(title) !== ''
|
|
80
|
+
? tree.children.filter((_, index) => index !== leadingTitleIndex)
|
|
81
|
+
: tree.children;
|
|
82
|
+
|
|
83
|
+
// Render body content
|
|
84
|
+
const body = nodesForBody
|
|
85
|
+
.map((node) => renderBlock(node, 0, context))
|
|
86
|
+
.filter(isNonEmpty)
|
|
87
|
+
.join('\n\n');
|
|
88
|
+
|
|
89
|
+
// Build document metadata and configuration
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
|
|
92
|
+
// Set document metadata if any metadata is available
|
|
93
|
+
const hasMetadata = title || authors.length > 0 || description || date || (keywords && keywords.length > 0);
|
|
94
|
+
if (hasMetadata) {
|
|
95
|
+
try {
|
|
96
|
+
parts.push('// =============== FRONTMATTER ===============');
|
|
97
|
+
parts.push('');
|
|
98
|
+
const docArgs: string[] = [];
|
|
99
|
+
|
|
100
|
+
if (title) docArgs.push(`title: [${title}]`);
|
|
101
|
+
|
|
102
|
+
if (authors.length > 0) {
|
|
103
|
+
docArgs.push(`author: ${renderTypstArray(authors.map(a => `"${escapeTypstString(a)}"`))}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (description) docArgs.push(`description: [${description}]`);
|
|
107
|
+
|
|
108
|
+
if (keywords && keywords.length > 0) {
|
|
109
|
+
docArgs.push(`keywords: ${renderTypstArray(keywords.map(k => `"${escapeTypstString(k)}"`))}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (date) {
|
|
113
|
+
try {
|
|
114
|
+
const dateTypst = parseDate(date);
|
|
115
|
+
docArgs.push(`date: ${dateTypst}`);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
118
|
+
if (context.onError) {
|
|
119
|
+
context.onError({
|
|
120
|
+
severity: ErrorSeverity.WARNING,
|
|
121
|
+
message: `Failed to parse date "${date}": ${errorMessage}`,
|
|
122
|
+
context: 'output building',
|
|
123
|
+
originalError: error
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// Use 'auto' as fallback
|
|
127
|
+
docArgs.push(`date: auto`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
parts.push(`#set document(`);
|
|
132
|
+
for (let i = 0; i < docArgs.length; i++) {
|
|
133
|
+
const isLast = i === docArgs.length - 1;
|
|
134
|
+
parts.push(` ${docArgs[i]}${isLast ? '' : ','}`);
|
|
135
|
+
}
|
|
136
|
+
parts.push(`)`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
139
|
+
if (context.onError) {
|
|
140
|
+
context.onError({
|
|
141
|
+
severity: ErrorSeverity.ERROR,
|
|
142
|
+
message: `Error building document metadata: ${errorMessage}`,
|
|
143
|
+
context: 'output building',
|
|
144
|
+
originalError: error
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// Continue without metadata
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Add abstract variable if present
|
|
152
|
+
if (abstract) {
|
|
153
|
+
if (parts.length > 0) parts.push('');
|
|
154
|
+
parts.push(`#let abstract = [${abstract}]`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Add title page if we have title or authors
|
|
158
|
+
if ((title || authors.length > 0 || date || abstract) && hasMetadata) {
|
|
159
|
+
parts.push('');
|
|
160
|
+
const centerLines: string[] = [];
|
|
161
|
+
|
|
162
|
+
if (title) {
|
|
163
|
+
centerLines.push(`#title() \\ \\`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (authors.length > 0) {
|
|
167
|
+
centerLines.push(`#context document.author.join(", ", last: " & ") \\ \\`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (date) {
|
|
171
|
+
centerLines.push(`#context document.date.display() \\ \\ `);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (abstract) {
|
|
175
|
+
centerLines.push(`\\ *Abstract* \\`);
|
|
176
|
+
centerLines.push(`#abstract`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
parts.push(`#align(center)[`);
|
|
180
|
+
parts.push(` ${centerLines.join(' ')}`);
|
|
181
|
+
parts.push(`]`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Set text language and region if specified
|
|
185
|
+
if (lang || region) {
|
|
186
|
+
if (parts.length > 0) parts.push('');
|
|
187
|
+
const textArgs: string[] = [];
|
|
188
|
+
if (lang) textArgs.push(`lang: "${lang}"`);
|
|
189
|
+
if (region) textArgs.push(`region: "${region}"`);
|
|
190
|
+
parts.push(`#set text(${textArgs.join(', ')})`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Add closing comment for frontmatter section
|
|
194
|
+
if (hasMetadata) {
|
|
195
|
+
parts.push('');
|
|
196
|
+
parts.push('// ============================================');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Add warnings section if any warnings were detected
|
|
200
|
+
const warningsSection = buildWarningsSection(context);
|
|
201
|
+
if (warningsSection) {
|
|
202
|
+
parts.push('');
|
|
203
|
+
parts.push(warningsSection);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Add body content
|
|
207
|
+
if (parts.length > 0 && body) {
|
|
208
|
+
parts.push('');
|
|
209
|
+
}
|
|
210
|
+
if (body) {
|
|
211
|
+
parts.push(body);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Return body only if no metadata was set
|
|
215
|
+
return parts.length > 0 ? parts.join('\n') : body;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
218
|
+
if (context.onError) {
|
|
219
|
+
context.onError({
|
|
220
|
+
severity: ErrorSeverity.ERROR,
|
|
221
|
+
message: `Fatal error building output: ${errorMessage}`,
|
|
222
|
+
context: 'output building',
|
|
223
|
+
originalError: error
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// Rethrow fatal errors
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown parsing pipeline stage
|
|
3
|
+
* @module parser
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { unified } from 'unified';
|
|
7
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
8
|
+
import remarkGfm from 'remark-gfm';
|
|
9
|
+
import remarkMath from 'remark-math';
|
|
10
|
+
import remarkParse from 'remark-parse';
|
|
11
|
+
import type { Root } from 'mdast';
|
|
12
|
+
import type { ErrorCallback } from './types.js';
|
|
13
|
+
import { ErrorSeverity } from './types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse Markdown text into an MDAST tree.
|
|
17
|
+
*
|
|
18
|
+
* This is the first stage of the conversion pipeline. It uses remark plugins to:
|
|
19
|
+
* - Parse basic Markdown syntax
|
|
20
|
+
* - Extract YAML frontmatter
|
|
21
|
+
* - Support GitHub Flavored Markdown (tables, strikethrough, etc.)
|
|
22
|
+
* - Parse LaTeX math equations
|
|
23
|
+
*
|
|
24
|
+
* @param markdown - The Markdown text to parse
|
|
25
|
+
* @param onError - Optional error callback for logging parse issues
|
|
26
|
+
* @returns The parsed MDAST tree
|
|
27
|
+
*/
|
|
28
|
+
export function parseMarkdown(markdown: string, onError?: ErrorCallback): Root {
|
|
29
|
+
try {
|
|
30
|
+
const processor = unified()
|
|
31
|
+
.use(remarkParse)
|
|
32
|
+
.use(remarkFrontmatter, ['yaml'])
|
|
33
|
+
.use(remarkGfm, { singleTilde: false })
|
|
34
|
+
.use(remarkMath);
|
|
35
|
+
|
|
36
|
+
const parsedTree = processor.parse(markdown);
|
|
37
|
+
const tree = processor.runSync(parsedTree) as Root;
|
|
38
|
+
|
|
39
|
+
return tree;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
42
|
+
if (onError) {
|
|
43
|
+
onError({
|
|
44
|
+
severity: ErrorSeverity.ERROR,
|
|
45
|
+
message: `Failed to parse Markdown: ${errorMessage}`,
|
|
46
|
+
context: 'markdown parsing',
|
|
47
|
+
originalError: error
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Rethrow the error after logging
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the markdown2typst library
|
|
3
|
+
* @module types
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
Definition,
|
|
8
|
+
FootnoteDefinition,
|
|
9
|
+
Literal,
|
|
10
|
+
PhrasingContent
|
|
11
|
+
} from 'mdast';
|
|
12
|
+
|
|
13
|
+
/** Error severity levels for conversion warnings and errors */
|
|
14
|
+
export enum ErrorSeverity {
|
|
15
|
+
/** Warning that doesn't prevent conversion but may affect output quality */
|
|
16
|
+
WARNING = 'warning',
|
|
17
|
+
/** Error that may result in incorrect or incomplete output */
|
|
18
|
+
ERROR = 'error'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Error information passed to error callback */
|
|
22
|
+
export type ConversionError = {
|
|
23
|
+
/** Severity level of the error */
|
|
24
|
+
severity: ErrorSeverity;
|
|
25
|
+
/** Error message describing what went wrong */
|
|
26
|
+
message: string;
|
|
27
|
+
/** Context about where the error occurred (e.g., 'frontmatter parsing', 'math conversion') */
|
|
28
|
+
context: string;
|
|
29
|
+
/** Original error object if available */
|
|
30
|
+
originalError?: unknown;
|
|
31
|
+
/** Additional metadata about the error */
|
|
32
|
+
details?: Record<string, any>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Callback function for handling conversion errors and warnings */
|
|
36
|
+
export type ErrorCallback = (error: ConversionError) => void;
|
|
37
|
+
|
|
38
|
+
/** Extended MDAST node type for marked/highlighted text */
|
|
39
|
+
export interface Mark extends Literal {
|
|
40
|
+
type: 'mark';
|
|
41
|
+
children: PhrasingContent[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extended MDAST node type for superscript text */
|
|
45
|
+
export interface SuperScript extends Literal {
|
|
46
|
+
type: 'superscript';
|
|
47
|
+
children: PhrasingContent[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extended MDAST node type for subscript text */
|
|
51
|
+
export interface SubScript extends Literal {
|
|
52
|
+
type: 'subscript';
|
|
53
|
+
children: PhrasingContent[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Extended MDAST node type for block-level math equations */
|
|
57
|
+
export interface MathNode extends Literal {
|
|
58
|
+
type: 'math';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Extended MDAST node type for inline math equations */
|
|
62
|
+
export interface InlineMathNode extends Literal {
|
|
63
|
+
type: 'inlineMath';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Options for configuring the Markdown to Typst conversion.
|
|
68
|
+
* All fields override corresponding frontmatter values if specified.
|
|
69
|
+
*/
|
|
70
|
+
export type Markdown2TypstOptions = {
|
|
71
|
+
/** Document title (overrides frontmatter) */
|
|
72
|
+
title?: string;
|
|
73
|
+
/** Document author(s) - single string or array (overrides frontmatter) */
|
|
74
|
+
author?: string | string[];
|
|
75
|
+
/** Document authors - alternative field name (overrides frontmatter) */
|
|
76
|
+
authors?: string[];
|
|
77
|
+
/** Document description (overrides frontmatter) */
|
|
78
|
+
description?: string;
|
|
79
|
+
/** Document keywords - array (overrides frontmatter) */
|
|
80
|
+
keywords?: string[];
|
|
81
|
+
/** Document date - string, 'auto', or ISO date (overrides frontmatter) */
|
|
82
|
+
date?: string;
|
|
83
|
+
/** Document abstract - rendered on title page (overrides frontmatter) */
|
|
84
|
+
abstract?: string;
|
|
85
|
+
/** Document language code (ISO 639 standard) (overrides frontmatter) */
|
|
86
|
+
lang?: string;
|
|
87
|
+
/** Alternative language field name (overrides frontmatter) */
|
|
88
|
+
language?: string;
|
|
89
|
+
/** Text region code (overrides frontmatter) */
|
|
90
|
+
region?: string;
|
|
91
|
+
/** Use leading H1 heading as document title (default: false) */
|
|
92
|
+
useH1AsTitle?: boolean;
|
|
93
|
+
/** Callback function for handling errors and warnings during conversion */
|
|
94
|
+
onError?: ErrorCallback;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Metadata extracted from YAML frontmatter.
|
|
99
|
+
* Follows Typst document() parameters specification.
|
|
100
|
+
* All fields are optional. Unknown fields are ignored.
|
|
101
|
+
*/
|
|
102
|
+
export type Frontmatter = {
|
|
103
|
+
/** Document title (maps to document.title) */
|
|
104
|
+
title?: string;
|
|
105
|
+
/** Document author(s) - single string or array (maps to document.author) */
|
|
106
|
+
author?: string | string[];
|
|
107
|
+
/** Document authors - alternative field name (maps to document.author) */
|
|
108
|
+
authors?: string[];
|
|
109
|
+
/** Document description (maps to document.description) */
|
|
110
|
+
description?: string;
|
|
111
|
+
/** Document keywords - array (maps to document.keywords) */
|
|
112
|
+
keywords?: string[];
|
|
113
|
+
/** Document date - string, 'auto', or ISO date (maps to document.date) */
|
|
114
|
+
date?: string;
|
|
115
|
+
/** Document abstract - rendered on title page */
|
|
116
|
+
abstract?: string;
|
|
117
|
+
/** Document language code (maps to text.lang, not document parameter) */
|
|
118
|
+
lang?: string;
|
|
119
|
+
/** Alternative language field name */
|
|
120
|
+
language?: string;
|
|
121
|
+
/** Text region code (maps to text.region) */
|
|
122
|
+
region?: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Merged document metadata after combining options and frontmatter
|
|
127
|
+
*/
|
|
128
|
+
export type DocumentMetadata = {
|
|
129
|
+
title: string;
|
|
130
|
+
authors: string[];
|
|
131
|
+
description?: string;
|
|
132
|
+
keywords?: string[];
|
|
133
|
+
date?: string;
|
|
134
|
+
abstract?: string;
|
|
135
|
+
lang?: string;
|
|
136
|
+
region?: string;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Warnings that can be tracked during conversion
|
|
141
|
+
*/
|
|
142
|
+
export type ConversionWarnings = {
|
|
143
|
+
/** Whether external images were detected */
|
|
144
|
+
externalImages: boolean;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Context for rendering nodes
|
|
149
|
+
*/
|
|
150
|
+
export type RenderContext = {
|
|
151
|
+
definitions: Map<string, Definition>;
|
|
152
|
+
footnoteDefinitions: Map<string, FootnoteDefinition>;
|
|
153
|
+
onError?: ErrorCallback;
|
|
154
|
+
/** Track conversion warnings for generating helper functions */
|
|
155
|
+
warnings: ConversionWarnings;
|
|
156
|
+
};
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for text processing and formatting
|
|
3
|
+
* @module utils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import langs from 'langs';
|
|
7
|
+
import { getCode, getCodes } from 'country-list';
|
|
8
|
+
import type { Definition, PhrasingContent, Text, InlineCode, Strong, Link, LinkReference } from 'mdast';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Escape special characters in Typst text content.
|
|
12
|
+
* Escapes characters that have special meaning in Typst markup.
|
|
13
|
+
*
|
|
14
|
+
* @param input - Raw text string
|
|
15
|
+
* @returns Escaped text safe for Typst
|
|
16
|
+
*/
|
|
17
|
+
export function escapeTypstText(input: string): string {
|
|
18
|
+
try {
|
|
19
|
+
return input.replace(/[\\#*_`\[\]\$<>@]/g, (c) => `\\${c}`);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// Fallback: return original string if escaping fails
|
|
22
|
+
return input;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Escape special characters in Typst string literals.
|
|
28
|
+
* Used for strings within quotes (URLs, titles, etc.).
|
|
29
|
+
*
|
|
30
|
+
* @param input - Raw string
|
|
31
|
+
* @returns Escaped string safe for Typst string literals
|
|
32
|
+
*/
|
|
33
|
+
export function escapeTypstString(input: string): string {
|
|
34
|
+
try {
|
|
35
|
+
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// Fallback: return original string if escaping fails
|
|
38
|
+
return input;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Indent all lines of text by a given level.
|
|
44
|
+
* Each indent level adds 2 spaces.
|
|
45
|
+
*
|
|
46
|
+
* @param text - Text to indent
|
|
47
|
+
* @param indentLevel - Number of indent levels (0 = no indent)
|
|
48
|
+
* @returns Indented text
|
|
49
|
+
*/
|
|
50
|
+
export function indentLines(text: string, indentLevel: number): string {
|
|
51
|
+
if (!indentLevel) return text;
|
|
52
|
+
const indent = ' '.repeat(indentLevel);
|
|
53
|
+
return text
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((line) => `${indent}${line}`)
|
|
56
|
+
.join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Type guard to check if a value is a non-empty string.
|
|
61
|
+
* Useful for filtering arrays.
|
|
62
|
+
*
|
|
63
|
+
* @param value - Value to check
|
|
64
|
+
* @returns True if value is a non-empty string
|
|
65
|
+
*/
|
|
66
|
+
export function isNonEmpty(value: string | null | undefined): value is string {
|
|
67
|
+
return typeof value === 'string' && value.length > 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Normalize text by trimming whitespace.
|
|
72
|
+
*
|
|
73
|
+
* @param value - Text to normalize
|
|
74
|
+
* @returns Trimmed text
|
|
75
|
+
*/
|
|
76
|
+
export function normalizeText(value: string | null): string {
|
|
77
|
+
return (value ?? '').trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract plain text content from phrasing nodes (inline content).
|
|
82
|
+
* Strips all formatting and extracts text only.
|
|
83
|
+
*
|
|
84
|
+
* @param nodes - Array of phrasing content nodes
|
|
85
|
+
* @param definitions - Map of link reference definitions
|
|
86
|
+
* @returns Plain text string
|
|
87
|
+
*/
|
|
88
|
+
export function plainTextFromPhrasing(nodes: PhrasingContent[], definitions: Map<string, Definition>): string {
|
|
89
|
+
return nodes.map((node) => plainTextFromPhrasingNode(node, definitions)).join('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function plainTextFromPhrasingNode(node: PhrasingContent, definitions: Map<string, Definition>): string {
|
|
93
|
+
switch (node.type) {
|
|
94
|
+
case 'text':
|
|
95
|
+
return (node as Text).value;
|
|
96
|
+
case 'strong':
|
|
97
|
+
case 'emphasis':
|
|
98
|
+
return plainTextFromPhrasing((node as Strong).children, definitions);
|
|
99
|
+
case 'inlineCode':
|
|
100
|
+
return (node as InlineCode).value;
|
|
101
|
+
case 'link':
|
|
102
|
+
return plainTextFromPhrasing((node as Link).children, definitions);
|
|
103
|
+
case 'linkReference': {
|
|
104
|
+
const lr = node as LinkReference;
|
|
105
|
+
const label = plainTextFromPhrasing(lr.children, definitions);
|
|
106
|
+
if (label.trim()) return label;
|
|
107
|
+
const def = definitions.get(lr.identifier.toLowerCase());
|
|
108
|
+
return def ? def.url : lr.label || lr.identifier;
|
|
109
|
+
}
|
|
110
|
+
case 'break':
|
|
111
|
+
return '\n';
|
|
112
|
+
default:
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render a Typst-style tuple array.
|
|
119
|
+
* Ensures proper syntax for single-element arrays (requires trailing comma).
|
|
120
|
+
*
|
|
121
|
+
* @param items - Array items as strings
|
|
122
|
+
* @returns Formatted Typst array syntax
|
|
123
|
+
*/
|
|
124
|
+
export function renderTypstArray(items: string[]): string {
|
|
125
|
+
if (items.length === 1) return `(${items[0]},)`;
|
|
126
|
+
return `(${items.join(', ')})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Calculate the maximum consecutive backtick run in a string.
|
|
131
|
+
* Used to determine the fence length for code blocks.
|
|
132
|
+
*
|
|
133
|
+
* @param value - String to analyze
|
|
134
|
+
* @returns Maximum consecutive backtick count
|
|
135
|
+
*/
|
|
136
|
+
export function maxBacktickRun(value: string): number {
|
|
137
|
+
let maxRun = 0;
|
|
138
|
+
let run = 0;
|
|
139
|
+
for (let i = 0; i < value.length; i++) {
|
|
140
|
+
if (value[i] === '`') {
|
|
141
|
+
run++;
|
|
142
|
+
if (run > maxRun) maxRun = run;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
run = 0;
|
|
146
|
+
}
|
|
147
|
+
return maxRun;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Coerce language string to valid ISO 639 language code.
|
|
152
|
+
*
|
|
153
|
+
* Validates and normalizes language codes using ISO 639-1 standard.
|
|
154
|
+
* Supports various input formats:
|
|
155
|
+
* - ISO 639-1 codes (e.g., 'en', 'zh', 'fr')
|
|
156
|
+
* - Locale codes (e.g., 'en-US', 'zh-CN') - extracts language part
|
|
157
|
+
* - Language names (e.g., 'English', 'Chinese') - looks up code
|
|
158
|
+
*
|
|
159
|
+
* @param value - Language string or code
|
|
160
|
+
* @returns Normalized ISO 639-1 language code or undefined if invalid
|
|
161
|
+
*/
|
|
162
|
+
export function coerceLanguage(value: string | undefined): string | undefined {
|
|
163
|
+
if (!value) return undefined;
|
|
164
|
+
|
|
165
|
+
const v = value.trim();
|
|
166
|
+
if (!v) return undefined;
|
|
167
|
+
|
|
168
|
+
// Extract language code from locale format (e.g., 'en-US' -> 'en', 'zh-CN' -> 'zh')
|
|
169
|
+
const langPart = v.split(/[-_]/)[0].toLowerCase();
|
|
170
|
+
|
|
171
|
+
// Try to validate as ISO 639-1 code (2-letter)
|
|
172
|
+
const iso1Codes = langs.codes('1');
|
|
173
|
+
if (iso1Codes.includes(langPart)) {
|
|
174
|
+
return langPart;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Try to look up by language name (case-insensitive)
|
|
178
|
+
const allLangs = langs.all();
|
|
179
|
+
for (const lang of allLangs) {
|
|
180
|
+
if (lang.name && lang.name.toLowerCase() === v.toLowerCase() && lang['1']) {
|
|
181
|
+
return lang['1'];
|
|
182
|
+
}
|
|
183
|
+
// Also check local name if available
|
|
184
|
+
if (lang.local && lang.local.toLowerCase() === v.toLowerCase() && lang['1']) {
|
|
185
|
+
return lang['1'];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Try ISO 639-2 or 639-3 codes and convert to ISO 639-1
|
|
190
|
+
const langBy2 = langs.where('2', v.toLowerCase());
|
|
191
|
+
if (langBy2 && langBy2['1']) {
|
|
192
|
+
return langBy2['1'];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const langBy2B = langs.where('2B', v.toLowerCase());
|
|
196
|
+
if (langBy2B && langBy2B['1']) {
|
|
197
|
+
return langBy2B['1'];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const langBy3 = langs.where('3', v.toLowerCase());
|
|
201
|
+
if (langBy3 && langBy3['1']) {
|
|
202
|
+
return langBy3['1'];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// If no valid code found, return undefined
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Coerce region string to valid ISO 3166 country code.
|
|
211
|
+
*
|
|
212
|
+
* Validates and normalizes region/country codes using ISO 3166-1 alpha-2 standard.
|
|
213
|
+
* Supports various input formats:
|
|
214
|
+
* - ISO 3166-1 alpha-2 codes (e.g., 'US', 'CN', 'GB')
|
|
215
|
+
* - Country names (e.g., 'United States', 'China') - looks up code
|
|
216
|
+
*
|
|
217
|
+
* @param value - Region/country string or code
|
|
218
|
+
* @returns Normalized ISO 3166-1 alpha-2 country code or undefined if invalid
|
|
219
|
+
*/
|
|
220
|
+
export function coerceRegion(value: string | undefined): string | undefined {
|
|
221
|
+
if (!value) return undefined;
|
|
222
|
+
|
|
223
|
+
const v = value.trim();
|
|
224
|
+
if (!v) return undefined;
|
|
225
|
+
|
|
226
|
+
// Check if it's already a valid ISO 3166-1 alpha-2 code
|
|
227
|
+
const upperValue = v.toUpperCase();
|
|
228
|
+
const validCodes = getCodes();
|
|
229
|
+
if (validCodes.includes(upperValue)) {
|
|
230
|
+
return upperValue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Try to look up by country name
|
|
234
|
+
const code = getCode(v);
|
|
235
|
+
if (code) {
|
|
236
|
+
return code.toUpperCase();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// If no valid code found, return undefined
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|