@citestyle/compiler 0.0.2

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,36 @@
1
+ {
2
+ "name": "@citestyle/compiler",
3
+ "version": "0.0.2",
4
+ "description": "Build-time compiler: CSL XML → JavaScript modules",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./types.d.ts",
9
+ "import": "./src/index.js"
10
+ }
11
+ },
12
+ "bin": {
13
+ "citestyle": "./src/cli.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "locales",
18
+ "types.d.ts",
19
+ "README.md"
20
+ ],
21
+ "dependencies": {
22
+ "@citestyle/types": "0.0.2",
23
+ "@xmldom/xmldom": "^0.8.11"
24
+ },
25
+ "devDependencies": {
26
+ "vitest": "^3.1.1"
27
+ },
28
+ "engines": {
29
+ "node": ">=20.19.0"
30
+ },
31
+ "license": "MIT",
32
+ "scripts": {
33
+ "test": "vitest run",
34
+ "lint": "echo 'TODO: configure linter'"
35
+ }
36
+ }
package/src/cli.js ADDED
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI entry point for the CSL compiler.
5
+ *
6
+ * Commands:
7
+ * citestyle compile <input.csl> [options] — Compile CSL to JS module
8
+ * citestyle check <input.csl> — Validate CSL without compiling
9
+ *
10
+ * Options:
11
+ * -o, --output <file|dir> Output file or directory (default: stdout)
12
+ * --locale <lang[,lang]> Target locale(s) (default: style's default or en-US)
13
+ * --format <esm|cjs> Output format (default: esm)
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs'
17
+ import { join, basename, extname, resolve, dirname } from 'node:path'
18
+ import { compile } from './compile.js'
19
+ import { parse } from './parser.js'
20
+
21
+ const args = process.argv.slice(2)
22
+
23
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
24
+ printHelp()
25
+ process.exit(0)
26
+ }
27
+
28
+ const command = args[0]
29
+
30
+ if (command === 'compile') {
31
+ runCompile(args.slice(1))
32
+ } else if (command === 'check') {
33
+ runCheck(args.slice(1))
34
+ } else {
35
+ console.error(`Unknown command: ${command}. Use "citestyle compile" or "citestyle check".`)
36
+ process.exit(1)
37
+ }
38
+
39
+ function printHelp() {
40
+ console.log(`Usage: citestyle <command> [options]
41
+
42
+ Commands:
43
+ compile <input...> Compile CSL XML to JavaScript module(s)
44
+ check <input...> Validate CSL XML without compiling
45
+
46
+ Options (compile):
47
+ -o, --output <path> Output file (single input) or directory (batch mode)
48
+ --locale <lang> Target locale, comma-separated for multi-locale (default: en-US)
49
+ --format <esm|cjs> Output format (default: esm)
50
+
51
+ Examples:
52
+ citestyle compile apa.csl -o apa.js
53
+ citestyle compile apa.csl --locale fr-FR -o apa-fr.js
54
+ citestyle compile apa.csl --locale en-US,fr-FR -o dist/
55
+ citestyle compile styles/*.csl -o dist/
56
+ citestyle compile apa.csl --format cjs -o apa.cjs
57
+ citestyle check apa.csl
58
+ citestyle check styles/*.csl`)
59
+ }
60
+
61
+ // ── Compile command ─────────────────────────────────────────────────────────
62
+
63
+ function runCompile(args) {
64
+ const { inputs, output, locales, format } = parseArgs(args)
65
+
66
+ if (inputs.length === 0) {
67
+ console.error('Error: No input file(s) specified.')
68
+ process.exit(1)
69
+ }
70
+
71
+ // Resolve input files (handle globs already expanded by shell)
72
+ const files = resolveInputFiles(inputs)
73
+
74
+ if (files.length === 0) {
75
+ console.error('Error: No .csl files found.')
76
+ process.exit(1)
77
+ }
78
+
79
+ const isBatch = files.length > 1 || (output && isDirectory(output))
80
+ let exitCode = 0
81
+
82
+ for (const file of files) {
83
+ for (const locale of locales) {
84
+ try {
85
+ const cslXml = readFileSync(file, 'utf-8')
86
+ const options = { locale, format }
87
+ const { code, meta } = compile(cslXml, options)
88
+
89
+ let finalCode = code
90
+ if (format === 'cjs') {
91
+ finalCode = esmToCjs(code)
92
+ }
93
+
94
+ if (output) {
95
+ let outPath
96
+ if (isBatch) {
97
+ mkdirSync(output, { recursive: true })
98
+ let outName = basename(file, extname(file))
99
+ if (locales.length > 1) outName += `-${locale}`
100
+ outName += format === 'cjs' ? '.cjs' : '.js'
101
+ outPath = join(output, outName)
102
+ } else {
103
+ mkdirSync(dirname(resolve(output)), { recursive: true })
104
+ outPath = output
105
+ }
106
+
107
+ writeFileSync(outPath, finalCode)
108
+ const size = (finalCode.length / 1024).toFixed(1)
109
+ console.log(`✓ ${meta.title || basename(file)} → ${outPath} (${size}KB)`)
110
+ } else {
111
+ process.stdout.write(finalCode)
112
+ }
113
+ } catch (err) {
114
+ console.error(`✗ ${basename(file)}: ${formatError(err, file)}`)
115
+ exitCode = 1
116
+ }
117
+ }
118
+ }
119
+
120
+ if (exitCode) process.exit(exitCode)
121
+ }
122
+
123
+ // ── Check command ───────────────────────────────────────────────────────────
124
+
125
+ function runCheck(args) {
126
+ const { inputs } = parseArgs(args)
127
+
128
+ if (inputs.length === 0) {
129
+ console.error('Error: No input file(s) specified.')
130
+ process.exit(1)
131
+ }
132
+
133
+ const files = resolveInputFiles(inputs)
134
+
135
+ if (files.length === 0) {
136
+ console.error('Error: No .csl files found.')
137
+ process.exit(1)
138
+ }
139
+
140
+ let exitCode = 0
141
+
142
+ for (const file of files) {
143
+ try {
144
+ const cslXml = readFileSync(file, 'utf-8')
145
+ const ast = parse(cslXml)
146
+
147
+ // Basic validation
148
+ const warnings = validateAst(ast)
149
+
150
+ if (warnings.length === 0) {
151
+ console.log(`✓ ${basename(file)}: valid (${ast.info?.title || 'untitled'})`)
152
+ } else {
153
+ console.log(`⚠ ${basename(file)}: ${warnings.length} warning(s)`)
154
+ for (const w of warnings) console.log(` ${w}`)
155
+ }
156
+ } catch (err) {
157
+ console.error(`✗ ${basename(file)}: ${formatError(err, file)}`)
158
+ exitCode = 1
159
+ }
160
+ }
161
+
162
+ if (exitCode) process.exit(exitCode)
163
+ }
164
+
165
+ // ── Argument parsing ────────────────────────────────────────────────────────
166
+
167
+ function parseArgs(args) {
168
+ const inputs = []
169
+ let output = null
170
+ let localeStr = null
171
+ let format = 'esm'
172
+
173
+ for (let i = 0; i < args.length; i++) {
174
+ if (args[i] === '-o' || args[i] === '--output') {
175
+ output = args[++i]
176
+ } else if (args[i] === '--locale') {
177
+ localeStr = args[++i]
178
+ } else if (args[i] === '--format') {
179
+ format = args[++i]
180
+ if (format !== 'esm' && format !== 'cjs') {
181
+ console.error(`Error: Unknown format "${format}". Use "esm" or "cjs".`)
182
+ process.exit(1)
183
+ }
184
+ } else if (!args[i].startsWith('-')) {
185
+ inputs.push(args[i])
186
+ }
187
+ }
188
+
189
+ const locales = localeStr ? localeStr.split(',').map(s => s.trim()) : ['en-US']
190
+
191
+ return { inputs, output, locales, format }
192
+ }
193
+
194
+ function resolveInputFiles(inputs) {
195
+ const files = []
196
+ for (const input of inputs) {
197
+ try {
198
+ const stat = statSync(input)
199
+ if (stat.isDirectory()) {
200
+ const entries = readdirSync(input).filter(f => f.endsWith('.csl')).sort()
201
+ files.push(...entries.map(f => join(input, f)))
202
+ } else {
203
+ files.push(input)
204
+ }
205
+ } catch {
206
+ // Could be a file that doesn't exist
207
+ files.push(input)
208
+ }
209
+ }
210
+ return files
211
+ }
212
+
213
+ function isDirectory(path) {
214
+ try {
215
+ return statSync(path).isDirectory()
216
+ } catch {
217
+ // If it doesn't exist and ends with / or has no extension, treat as directory
218
+ return path.endsWith('/') || !extname(path)
219
+ }
220
+ }
221
+
222
+ // ── ESM → CJS conversion ───────────────────────────────────────────────────
223
+
224
+ function esmToCjs(code) {
225
+ // Convert import statements
226
+ let result = code.replace(
227
+ /import\s*\{([^}]+)\}\s*from\s*'([^']+)'/g,
228
+ (_, imports, source) => {
229
+ const names = imports.split(',').map(s => s.trim()).filter(Boolean)
230
+ return `const { ${names.join(', ')} } = require('${source}')`
231
+ }
232
+ )
233
+
234
+ // Convert export function to module.exports
235
+ result = result.replace(
236
+ /export\s+function\s+(\w+)/g,
237
+ 'function $1'
238
+ )
239
+
240
+ // Convert export const/let
241
+ result = result.replace(
242
+ /export\s+(const|let)\s+(\w+)/g,
243
+ '$1 $2'
244
+ )
245
+
246
+ // Add module.exports at the end — find all exported names
247
+ const exportedNames = []
248
+ const origExports = code.matchAll(/export\s+(?:function|const|let)\s+(\w+)/g)
249
+ for (const m of origExports) exportedNames.push(m[1])
250
+
251
+ if (exportedNames.length) {
252
+ result += `\nmodule.exports = { ${exportedNames.join(', ')} };\n`
253
+ }
254
+
255
+ return result
256
+ }
257
+
258
+ // ── Validation ──────────────────────────────────────────────────────────────
259
+
260
+ function validateAst(ast, file) {
261
+ const warnings = []
262
+
263
+ if (!ast.class) warnings.push('Missing style class (in-text or note)')
264
+ if (!ast.version) warnings.push('Missing CSL version')
265
+ if (!ast.info?.title) warnings.push('Missing <info><title>')
266
+ if (!ast.info?.id) warnings.push('Missing <info><id>')
267
+
268
+ if (!ast.bibliography && !ast.citation) {
269
+ warnings.push('No <citation> or <bibliography> element found')
270
+ }
271
+
272
+ if (ast.citation && !ast.citation.layout) {
273
+ warnings.push('<citation> has no <layout> child')
274
+ }
275
+
276
+ if (ast.bibliography && !ast.bibliography.layout) {
277
+ warnings.push('<bibliography> has no <layout> child')
278
+ }
279
+
280
+ // Check for undefined macros (ast.macros is an object keyed by name)
281
+ const definedMacros = new Set(Object.keys(ast.macros || {}))
282
+ const usedMacros = findMacroRefs(ast)
283
+ for (const name of usedMacros) {
284
+ if (!definedMacros.has(name)) {
285
+ warnings.push(`Reference to undefined macro "${name}"`)
286
+ }
287
+ }
288
+
289
+ return warnings
290
+ }
291
+
292
+ function findMacroRefs(node, refs = new Set()) {
293
+ if (!node || typeof node !== 'object') return refs
294
+ if (node.type === 'text' && node.macro) refs.add(node.macro)
295
+ if (Array.isArray(node.children)) node.children.forEach(c => findMacroRefs(c, refs))
296
+ if (node.layout) findMacroRefs(node.layout, refs)
297
+ if (node.citation) findMacroRefs(node.citation, refs)
298
+ if (node.bibliography) findMacroRefs(node.bibliography, refs)
299
+ if (node.macros && typeof node.macros === 'object' && !Array.isArray(node.macros)) {
300
+ Object.values(node.macros).forEach(m => findMacroRefs(m, refs))
301
+ }
302
+ if (Array.isArray(node.branches)) node.branches.forEach(b => findMacroRefs(b, refs))
303
+ if (node.else) findMacroRefs(node.else, refs)
304
+ return refs
305
+ }
306
+
307
+ // ── Error formatting ────────────────────────────────────────────────────────
308
+
309
+ function formatError(err, file) {
310
+ const msg = err.message || String(err)
311
+
312
+ // Try to extract line/column info from XML parse errors
313
+ const lineMatch = msg.match(/line[:\s]+(\d+)/i)
314
+ const colMatch = msg.match(/col(?:umn)?[:\s]+(\d+)/i)
315
+
316
+ if (lineMatch) {
317
+ const loc = colMatch ? `${lineMatch[1]}:${colMatch[1]}` : lineMatch[1]
318
+ return `${msg} (at ${basename(file)}:${loc})`
319
+ }
320
+
321
+ return msg
322
+ }