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,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
+ }