@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/src/parser.js ADDED
@@ -0,0 +1,606 @@
1
+ /**
2
+ * CSL XML Parser — transforms CSL XML into a typed AST.
3
+ *
4
+ * Uses @xmldom/xmldom for DOM parsing (build-time tool, parser weight irrelevant).
5
+ * Walks the DOM into CSL-specific AST nodes.
6
+ */
7
+ import { DOMParser } from '@xmldom/xmldom'
8
+
9
+ const CSL_NS = 'http://purl.org/net/xbiblio/csl'
10
+
11
+ /**
12
+ * Parse a CSL XML string into a CSL AST.
13
+ *
14
+ * @param {string} xml - CSL XML source
15
+ * @returns {CslStyleNode} Parsed AST
16
+ */
17
+ export function parse(xml) {
18
+ const doc = new DOMParser().parseFromString(xml, 'text/xml')
19
+ const styleEl = doc.documentElement
20
+
21
+ if (!styleEl || localName(styleEl) !== 'style') {
22
+ throw new Error('Invalid CSL: root element must be <style>')
23
+ }
24
+
25
+ return parseStyle(styleEl)
26
+ }
27
+
28
+ /**
29
+ * Parse the <style> root element.
30
+ */
31
+ function parseStyle(el) {
32
+ const node = {
33
+ type: 'style',
34
+ class: attr(el, 'class') || 'in-text',
35
+ version: attr(el, 'version'),
36
+ defaultLocale: attr(el, 'default-locale') || 'en-US',
37
+ demoteNonDroppingParticle: attr(el, 'demote-non-dropping-particle') || 'display-and-sort',
38
+ // Inheritable name options (global defaults)
39
+ nameOptions: parseInheritableNameOptions(el),
40
+ pageRangeFormat: attr(el, 'page-range-format'),
41
+ info: null,
42
+ localeOverrides: [],
43
+ macros: {},
44
+ citation: null,
45
+ bibliography: null,
46
+ }
47
+
48
+ for (const child of childElements(el)) {
49
+ const name = localName(child)
50
+ if (name === 'info') {
51
+ node.info = parseInfo(child)
52
+ } else if (name === 'locale') {
53
+ node.localeOverrides.push(parseLocaleOverride(child))
54
+ } else if (name === 'macro') {
55
+ const macroName = attr(child, 'name')
56
+ node.macros[macroName] = {
57
+ type: 'macro',
58
+ name: macroName,
59
+ children: parseRenderingElements(child),
60
+ }
61
+ } else if (name === 'citation') {
62
+ node.citation = parseCitationOrBibliography(child, 'citation')
63
+ } else if (name === 'bibliography') {
64
+ node.bibliography = parseCitationOrBibliography(child, 'bibliography')
65
+ }
66
+ }
67
+
68
+ return node
69
+ }
70
+
71
+ /**
72
+ * Parse <info> element for metadata.
73
+ */
74
+ function parseInfo(el) {
75
+ const info = { title: '', titleShort: '', id: '', links: [], categories: [], rights: '' }
76
+ for (const child of childElements(el)) {
77
+ const name = localName(child)
78
+ if (name === 'title') info.title = textContent(child)
79
+ else if (name === 'title-short') info.titleShort = textContent(child)
80
+ else if (name === 'id') info.id = textContent(child)
81
+ else if (name === 'rights') info.rights = textContent(child)
82
+ else if (name === 'link') {
83
+ info.links.push({ href: attr(child, 'href'), rel: attr(child, 'rel') })
84
+ } else if (name === 'category') {
85
+ const field = attr(child, 'field')
86
+ const citFormat = attr(child, 'citation-format')
87
+ if (field) info.categories.push(field)
88
+ if (citFormat) info.citationFormat = citFormat
89
+ }
90
+ }
91
+ return info
92
+ }
93
+
94
+ /**
95
+ * Parse a locale override within the style.
96
+ */
97
+ function parseLocaleOverride(el) {
98
+ const override = {
99
+ lang: attr(el, 'xml:lang') || attr(el, 'lang'),
100
+ terms: {},
101
+ dateFormats: {},
102
+ styleOptions: {},
103
+ }
104
+
105
+ for (const child of childElements(el)) {
106
+ const name = localName(child)
107
+ if (name === 'terms') {
108
+ for (const termEl of childElements(child)) {
109
+ if (localName(termEl) === 'term') {
110
+ const termName = attr(termEl, 'name')
111
+ const form = attr(termEl, 'form') || 'long'
112
+ const key = form === 'long' ? termName : termName + ':' + form
113
+
114
+ // Check for single/multiple child elements
115
+ const single = findChild(termEl, 'single')
116
+ const multiple = findChild(termEl, 'multiple')
117
+ if (single && multiple) {
118
+ override.terms[key] = {
119
+ single: textContent(single),
120
+ multiple: textContent(multiple),
121
+ }
122
+ } else {
123
+ override.terms[key] = textContent(termEl)
124
+ }
125
+ }
126
+ }
127
+ } else if (name === 'date') {
128
+ const form = attr(child, 'form')
129
+ override.dateFormats[form] = parseDateNode(child)
130
+ } else if (name === 'style-options') {
131
+ override.styleOptions = {
132
+ punctuationInQuote: attr(child, 'punctuation-in-quote'),
133
+ }
134
+ }
135
+ }
136
+
137
+ return override
138
+ }
139
+
140
+ /**
141
+ * Parse <citation> or <bibliography> element.
142
+ */
143
+ function parseCitationOrBibliography(el, type) {
144
+ const node = {
145
+ type,
146
+ nameOptions: parseInheritableNameOptions(el),
147
+ sort: null,
148
+ layout: null,
149
+ // Bibliography-specific
150
+ subsequentAuthorSubstitute: attr(el, 'subsequent-author-substitute'),
151
+ secondFieldAlign: attr(el, 'second-field-align'),
152
+ hangingIndent: attr(el, 'hanging-indent') === 'true',
153
+ entrySpacing: attr(el, 'entry-spacing'),
154
+ lineSpacing: attr(el, 'line-spacing'),
155
+ // Citation-specific
156
+ disambiguateAddYearSuffix: attr(el, 'disambiguate-add-year-suffix') === 'true',
157
+ disambiguateAddNames: attr(el, 'disambiguate-add-names') === 'true',
158
+ disambiguateAddGivenname: attr(el, 'disambiguate-add-givenname') === 'true',
159
+ givennameDisambiguationRule: attr(el, 'givenname-disambiguation-rule'),
160
+ collapseAttr: attr(el, 'collapse'),
161
+ citeGroupDelimiter: attr(el, 'cite-group-delimiter'),
162
+ yearSuffixDelimiter: attr(el, 'year-suffix-delimiter'),
163
+ afterCollapseDelimiter: attr(el, 'after-collapse-delimiter'),
164
+ nearNoteDistance: attr(el, 'near-note-distance'),
165
+ }
166
+
167
+ for (const child of childElements(el)) {
168
+ const name = localName(child)
169
+ if (name === 'sort') {
170
+ node.sort = parseSort(child)
171
+ } else if (name === 'layout') {
172
+ node.layout = parseLayout(child)
173
+ }
174
+ }
175
+
176
+ return node
177
+ }
178
+
179
+ /**
180
+ * Parse <layout> element.
181
+ */
182
+ function parseLayout(el) {
183
+ return {
184
+ type: 'layout',
185
+ ...parseFormatting(el),
186
+ delimiter: attr(el, 'delimiter'),
187
+ verticalAlign: attr(el, 'vertical-align'),
188
+ children: parseRenderingElements(el),
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Parse <sort> element.
194
+ */
195
+ function parseSort(el) {
196
+ const keys = []
197
+ for (const child of childElements(el)) {
198
+ if (localName(child) === 'key') {
199
+ keys.push({
200
+ type: 'sort-key',
201
+ variable: attr(child, 'variable'),
202
+ macro: attr(child, 'macro'),
203
+ sort: attr(child, 'sort') || 'ascending',
204
+ namesMin: attr(child, 'names-min'),
205
+ namesUseFirst: attr(child, 'names-use-first'),
206
+ namesUseLast: attr(child, 'names-use-last'),
207
+ })
208
+ }
209
+ }
210
+ return { type: 'sort', keys }
211
+ }
212
+
213
+ /**
214
+ * Parse all rendering elements within a parent.
215
+ */
216
+ function parseRenderingElements(parent) {
217
+ const children = []
218
+ for (const el of childElements(parent)) {
219
+ const node = parseRenderingElement(el)
220
+ if (node) children.push(node)
221
+ }
222
+ return children
223
+ }
224
+
225
+ /**
226
+ * Parse a single rendering element.
227
+ */
228
+ function parseRenderingElement(el) {
229
+ const name = localName(el)
230
+
231
+ switch (name) {
232
+ case 'text': return parseText(el)
233
+ case 'number': return parseNumber(el)
234
+ case 'label': return parseLabel(el)
235
+ case 'group': return parseGroup(el)
236
+ case 'choose': return parseChoose(el)
237
+ case 'names': return parseNames(el)
238
+ case 'date': return parseDateNode(el)
239
+ default: return null
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Parse <text> element.
245
+ */
246
+ function parseText(el) {
247
+ return {
248
+ type: 'text',
249
+ variable: attr(el, 'variable'),
250
+ macro: attr(el, 'macro'),
251
+ term: attr(el, 'term'),
252
+ value: attr(el, 'value'),
253
+ form: attr(el, 'form'),
254
+ plural: attr(el, 'plural'),
255
+ ...parseFormatting(el),
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Parse <number> element.
261
+ */
262
+ function parseNumber(el) {
263
+ return {
264
+ type: 'number',
265
+ variable: attr(el, 'variable'),
266
+ form: attr(el, 'form') || 'numeric',
267
+ ...parseFormatting(el),
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Parse <label> element.
273
+ */
274
+ function parseLabel(el) {
275
+ return {
276
+ type: 'label',
277
+ variable: attr(el, 'variable'),
278
+ form: attr(el, 'form') || 'long',
279
+ plural: attr(el, 'plural') || 'contextual',
280
+ ...parseFormatting(el),
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Parse <group> element.
286
+ */
287
+ function parseGroup(el) {
288
+ return {
289
+ type: 'group',
290
+ ...parseFormatting(el),
291
+ delimiter: attr(el, 'delimiter'),
292
+ children: parseRenderingElements(el),
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Parse <choose> element.
298
+ */
299
+ function parseChoose(el) {
300
+ const conditions = []
301
+ let elseNode = null
302
+
303
+ for (const child of childElements(el)) {
304
+ const name = localName(child)
305
+ if (name === 'if' || name === 'else-if') {
306
+ conditions.push({
307
+ type: 'condition',
308
+ match: attr(child, 'match') || 'all',
309
+ tests: parseConditionTests(child),
310
+ children: parseRenderingElements(child),
311
+ })
312
+ } else if (name === 'else') {
313
+ elseNode = {
314
+ type: 'else',
315
+ children: parseRenderingElements(child),
316
+ }
317
+ }
318
+ }
319
+
320
+ return {
321
+ type: 'choose',
322
+ conditions,
323
+ else: elseNode,
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Parse condition tests from if/else-if attributes.
329
+ */
330
+ function parseConditionTests(el) {
331
+ const tests = []
332
+
333
+ const typeAttr = attr(el, 'type')
334
+ if (typeAttr) {
335
+ tests.push({ test: 'type', values: typeAttr.split(/\s+/) })
336
+ }
337
+
338
+ const variableAttr = attr(el, 'variable')
339
+ if (variableAttr) {
340
+ tests.push({ test: 'variable', values: variableAttr.split(/\s+/) })
341
+ }
342
+
343
+ const isNumericAttr = attr(el, 'is-numeric')
344
+ if (isNumericAttr) {
345
+ tests.push({ test: 'is-numeric', values: isNumericAttr.split(/\s+/) })
346
+ }
347
+
348
+ const locatorAttr = attr(el, 'locator')
349
+ if (locatorAttr) {
350
+ tests.push({ test: 'locator', values: locatorAttr.split(/\s+/) })
351
+ }
352
+
353
+ const positionAttr = attr(el, 'position')
354
+ if (positionAttr) {
355
+ tests.push({ test: 'position', values: positionAttr.split(/\s+/) })
356
+ }
357
+
358
+ const disambiguateAttr = attr(el, 'disambiguate')
359
+ if (disambiguateAttr) {
360
+ tests.push({ test: 'disambiguate', values: [disambiguateAttr] })
361
+ }
362
+
363
+ const isUncertainDate = attr(el, 'is-uncertain-date')
364
+ if (isUncertainDate) {
365
+ tests.push({ test: 'is-uncertain-date', values: isUncertainDate.split(/\s+/) })
366
+ }
367
+
368
+ return tests
369
+ }
370
+
371
+ /**
372
+ * Parse <names> element.
373
+ */
374
+ function parseNames(el) {
375
+ const node = {
376
+ type: 'names',
377
+ variables: (attr(el, 'variable') || '').split(/\s+/).filter(Boolean),
378
+ ...parseFormatting(el),
379
+ nameNode: null,
380
+ etAlNode: null,
381
+ labelNode: null,
382
+ substitute: null,
383
+ }
384
+
385
+ for (const child of childElements(el)) {
386
+ const name = localName(child)
387
+ if (name === 'name') {
388
+ node.nameNode = parseNameNode(child)
389
+ } else if (name === 'et-al') {
390
+ node.etAlNode = {
391
+ type: 'et-al',
392
+ term: attr(child, 'term') || 'et-al',
393
+ ...parseFormatting(child),
394
+ }
395
+ } else if (name === 'label') {
396
+ node.labelNode = parseLabel(child)
397
+ } else if (name === 'substitute') {
398
+ node.substitute = {
399
+ type: 'substitute',
400
+ children: parseRenderingElements(child),
401
+ }
402
+ // CSL spec: substitute <names> without children inherit from parent
403
+ for (const sub of node.substitute.children) {
404
+ if (sub.type === 'names' && !sub.nameNode) {
405
+ sub.nameNode = node.nameNode
406
+ if (!sub.etAlNode) sub.etAlNode = node.etAlNode
407
+ if (!sub.labelNode) sub.labelNode = node.labelNode
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ return node
414
+ }
415
+
416
+ /**
417
+ * Parse <name> element.
418
+ */
419
+ function parseNameNode(el) {
420
+ const node = {
421
+ type: 'name',
422
+ and: attr(el, 'and'),
423
+ delimiter: attr(el, 'delimiter') ?? ', ',
424
+ delimiterPrecedesEtAl: attr(el, 'delimiter-precedes-et-al'),
425
+ delimiterPrecedesLast: attr(el, 'delimiter-precedes-last'),
426
+ etAlMin: attrInt(el, 'et-al-min'),
427
+ etAlUseFirst: attrInt(el, 'et-al-use-first'),
428
+ etAlUseLast: attr(el, 'et-al-use-last') === 'true',
429
+ etAlSubsequentMin: attrInt(el, 'et-al-subsequent-min'),
430
+ etAlSubsequentUseFirst: attrInt(el, 'et-al-subsequent-use-first'),
431
+ form: attr(el, 'form') || 'long',
432
+ initialize: attr(el, 'initialize') !== 'false',
433
+ initializeWith: attr(el, 'initialize-with'),
434
+ nameAsSortOrder: attr(el, 'name-as-sort-order'),
435
+ sortSeparator: attr(el, 'sort-separator') ?? ', ',
436
+ ...parseFormatting(el),
437
+ nameParts: [],
438
+ }
439
+
440
+ for (const child of childElements(el)) {
441
+ if (localName(child) === 'name-part') {
442
+ node.nameParts.push({
443
+ name: attr(child, 'name'),
444
+ ...parseFormatting(child),
445
+ })
446
+ }
447
+ }
448
+
449
+ return node
450
+ }
451
+
452
+ /**
453
+ * Parse <date> or locale <date> element.
454
+ */
455
+ function parseDateNode(el) {
456
+ const node = {
457
+ type: 'date',
458
+ variable: attr(el, 'variable'),
459
+ form: attr(el, 'form'),
460
+ datePartsAttr: attr(el, 'date-parts'),
461
+ datePartsDelimiter: attr(el, 'delimiter'),
462
+ ...parseFormatting(el),
463
+ dateParts: [],
464
+ }
465
+
466
+ for (const child of childElements(el)) {
467
+ if (localName(child) === 'date-part') {
468
+ node.dateParts.push({
469
+ type: 'date-part',
470
+ name: attr(child, 'name'),
471
+ form: attr(child, 'form'),
472
+ rangeDelimiter: attr(child, 'range-delimiter'),
473
+ ...parseFormatting(child),
474
+ })
475
+ }
476
+ }
477
+
478
+ return node
479
+ }
480
+
481
+ /**
482
+ * Parse inheritable name options from style, citation, or bibliography elements.
483
+ */
484
+ function parseInheritableNameOptions(el) {
485
+ const opts = {}
486
+ const mappings = {
487
+ 'names-delimiter': 'namesDelimiter',
488
+ 'name-form': 'form',
489
+ 'name-delimiter': 'delimiter',
490
+ 'initialize': 'initialize',
491
+ 'initialize-with': 'initializeWith',
492
+ 'name-as-sort-order': 'nameAsSortOrder',
493
+ 'sort-separator': 'sortSeparator',
494
+ 'and': 'and',
495
+ 'delimiter-precedes-last': 'delimiterPrecedesLast',
496
+ 'delimiter-precedes-et-al': 'delimiterPrecedesEtAl',
497
+ 'et-al-min': 'etAlMin',
498
+ 'et-al-use-first': 'etAlUseFirst',
499
+ 'et-al-use-last': 'etAlUseLast',
500
+ 'et-al-subsequent-min': 'etAlSubsequentMin',
501
+ 'et-al-subsequent-use-first': 'etAlSubsequentUseFirst',
502
+ }
503
+
504
+ for (const [xmlAttr, jsName] of Object.entries(mappings)) {
505
+ const val = attr(el, xmlAttr)
506
+ if (val != null) {
507
+ if (xmlAttr.includes('min') || xmlAttr.includes('first')) {
508
+ opts[jsName] = parseInt(val, 10)
509
+ } else if (val === 'true') {
510
+ opts[jsName] = true
511
+ } else if (val === 'false') {
512
+ opts[jsName] = false
513
+ } else {
514
+ opts[jsName] = val
515
+ }
516
+ }
517
+ }
518
+
519
+ return Object.keys(opts).length > 0 ? opts : null
520
+ }
521
+
522
+ /**
523
+ * Parse formatting and affix attributes common to most elements.
524
+ */
525
+ function parseFormatting(el) {
526
+ const f = {}
527
+
528
+ const fontStyle = attr(el, 'font-style')
529
+ if (fontStyle) f.fontStyle = fontStyle
530
+
531
+ const fontWeight = attr(el, 'font-weight')
532
+ if (fontWeight) f.fontWeight = fontWeight
533
+
534
+ const fontVariant = attr(el, 'font-variant')
535
+ if (fontVariant) f.fontVariant = fontVariant
536
+
537
+ const textDecoration = attr(el, 'text-decoration')
538
+ if (textDecoration) f.textDecoration = textDecoration
539
+
540
+ const textCase = attr(el, 'text-case')
541
+ if (textCase) f.textCase = textCase
542
+
543
+ const display = attr(el, 'display')
544
+ if (display) f.display = display
545
+
546
+ const quotes = attr(el, 'quotes')
547
+ if (quotes) f.quotes = quotes === 'true'
548
+
549
+ const stripPeriods = attr(el, 'strip-periods')
550
+ if (stripPeriods) f.stripPeriods = stripPeriods === 'true'
551
+
552
+ const prefix = attr(el, 'prefix')
553
+ if (prefix != null) f.prefix = prefix
554
+
555
+ const suffix = attr(el, 'suffix')
556
+ if (suffix != null) f.suffix = suffix
557
+
558
+ return f
559
+ }
560
+
561
+ // ── DOM helpers ──────────────────────────────────────────────────────────────
562
+
563
+ function localName(el) {
564
+ return el.localName || el.nodeName?.replace(/^.*:/, '')
565
+ }
566
+
567
+ function attr(el, name) {
568
+ if (!el.hasAttribute?.(name)) return null
569
+ return el.getAttribute(name)
570
+ }
571
+
572
+ function attrInt(el, name) {
573
+ const val = attr(el, name)
574
+ return val != null ? parseInt(val, 10) : undefined
575
+ }
576
+
577
+ function textContent(el) {
578
+ let text = ''
579
+ for (let i = 0; i < el.childNodes.length; i++) {
580
+ const child = el.childNodes[i]
581
+ if (child.nodeType === 3 /* TEXT_NODE */ || child.nodeType === 4 /* CDATA */) {
582
+ text += child.nodeValue || ''
583
+ } else if (child.nodeType === 1 /* ELEMENT_NODE */) {
584
+ text += textContent(child)
585
+ }
586
+ }
587
+ return text.trim()
588
+ }
589
+
590
+ function childElements(el) {
591
+ const result = []
592
+ if (!el.childNodes) return result
593
+ for (let i = 0; i < el.childNodes.length; i++) {
594
+ if (el.childNodes[i].nodeType === 1) {
595
+ result.push(el.childNodes[i])
596
+ }
597
+ }
598
+ return result
599
+ }
600
+
601
+ function findChild(el, name) {
602
+ for (const child of childElements(el)) {
603
+ if (localName(child) === name) return child
604
+ }
605
+ return null
606
+ }
package/types.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { compile, parse, resolveLocale, generate, CompileOptions, CompileResult } from '@citestyle/types'