@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/codegen.js ADDED
@@ -0,0 +1,992 @@
1
+ /**
2
+ * CSL AST → JavaScript code generation.
3
+ *
4
+ * Transforms the parsed CSL AST into a JavaScript module string.
5
+ * The generated module exports: meta, bibliography(), citation().
6
+ */
7
+
8
+ /**
9
+ * Generate JavaScript module source from a CSL AST + resolved locale.
10
+ *
11
+ * @param {object} ast - Parsed CSL style AST
12
+ * @param {object} locale - Resolved locale data
13
+ * @param {object} [options]
14
+ * @returns {string} JavaScript module source code
15
+ */
16
+ export function generate(ast, locale, options = {}) {
17
+ const ctx = createCodegenContext(ast, locale)
18
+
19
+ const lines = []
20
+
21
+ // Header
22
+ lines.push(`// Generated by @citestyle/compiler — do not edit`)
23
+ lines.push(`// Source: ${ast.info?.title || 'Unknown CSL style'}`)
24
+ lines.push(`import { formatNames, formatDate, escapeHtml, stripFormatting, toHtml, titleCase, sentenceCase, capitalize, applyTextCase, stripNocaseSpans, ordinal, roman, pageRange } from '@citestyle/core'`)
25
+ lines.push('')
26
+
27
+ // Meta
28
+ lines.push(`export const meta = ${JSON.stringify(buildMeta(ast), null, 2)}`)
29
+ lines.push('')
30
+
31
+ // Locale terms (inlined)
32
+ lines.push(`const T = ${JSON.stringify(buildTermsObject(locale), null, 2)}`)
33
+ lines.push('')
34
+
35
+ // Month terms for date formatting
36
+ lines.push(`const MONTHS = ${JSON.stringify(locale.monthTerms || {})}`)
37
+ lines.push('')
38
+
39
+ // Inheritable name options
40
+ const globalNameOpts = resolveNameOptions(ast, null)
41
+ lines.push(`const NAME_OPTS = ${JSON.stringify(globalNameOpts)}`)
42
+ lines.push('')
43
+
44
+ // _nameConfig: runtime name option resolution (global → section → element)
45
+ lines.push(`function _nameConfig(ctx, el) {
46
+ const c = Object.assign({}, NAME_OPTS, ctx._secOpts || {}, el)
47
+ if (el.initialize === false || (ctx._secOpts || {}).initialize === false || NAME_OPTS.initialize === false) c.initialize = false
48
+ if (c.and === 'text') c.andTerm = T['and'] || 'and'
49
+ else if (c.and === 'symbol') c.andTerm = '&'
50
+ if (!c.etAlTerm) c.etAlTerm = T[el._etAlTerm || 'et-al'] || 'et al.'
51
+ if (ctx._disambig) c._disambig = ctx._disambig
52
+ return c
53
+ }`)
54
+ lines.push('')
55
+
56
+ // Macros — name options resolved at runtime via _nameConfig
57
+ for (const [name, macro] of Object.entries(ast.macros)) {
58
+ lines.push(generateMacro(name, macro, ctx))
59
+ lines.push('')
60
+ }
61
+
62
+ // Bibliography
63
+ if (ast.bibliography) {
64
+ const bibNameOpts = resolveNameOptions(ast, ast.bibliography)
65
+ lines.push(`const BIB_NAME_OPTS = ${JSON.stringify(bibNameOpts)}`)
66
+ lines.push('')
67
+ lines.push(generateBibliography(ast.bibliography, ctx))
68
+ lines.push('')
69
+ }
70
+
71
+ // Citation
72
+ if (ast.citation) {
73
+ const citNameOpts = resolveNameOptions(ast, ast.citation)
74
+ lines.push(`const CIT_NAME_OPTS = ${JSON.stringify(citNameOpts)}`)
75
+ lines.push('')
76
+ lines.push(generateCitation(ast.citation, ctx))
77
+ lines.push('')
78
+ }
79
+
80
+ return lines.join('\n')
81
+ }
82
+
83
+ /**
84
+ * Create a codegen context for tracking state during generation.
85
+ */
86
+ function createCodegenContext(ast, locale) {
87
+ let varCounter = 0
88
+ return {
89
+ ast,
90
+ locale,
91
+ freshVar(prefix = '_v') { return prefix + (varCounter++) },
92
+ macroNames: new Set(Object.keys(ast.macros)),
93
+ pageRangeFormat: ast.pageRangeFormat,
94
+ }
95
+ }
96
+
97
+ // ── Meta ─────────────────────────────────────────────────────────────────────
98
+
99
+ function buildMeta(ast) {
100
+ const info = ast.info || {}
101
+ const id = info.id || ''
102
+ const shortId = id.replace(/^.*\//, '')
103
+
104
+ const meta = {
105
+ id: shortId,
106
+ title: info.title || '',
107
+ class: ast.class,
108
+ version: ast.version,
109
+ compiledWith: '@citestyle/compiler@0.1.0',
110
+ }
111
+
112
+ // Expose bibliography-level settings for registry use
113
+ if (ast.bibliography?.subsequentAuthorSubstitute != null) {
114
+ meta.subsequentAuthorSubstitute = ast.bibliography.subsequentAuthorSubstitute
115
+ }
116
+
117
+ // Expose citation disambiguation settings for registry use
118
+ if (ast.citation?.disambiguateAddYearSuffix) {
119
+ meta.disambiguateAddYearSuffix = true
120
+ }
121
+ if (ast.citation?.disambiguateAddNames) {
122
+ meta.disambiguateAddNames = true
123
+ }
124
+ if (ast.citation?.disambiguateAddGivenname) {
125
+ meta.disambiguateAddGivenname = true
126
+ meta.givennameDisambiguationRule = ast.citation.givennameDisambiguationRule || 'by-cite'
127
+ }
128
+
129
+ // Expose collapse mode for cite collapsing in registry
130
+ if (ast.citation?.collapseAttr) {
131
+ meta.collapse = ast.citation.collapseAttr
132
+ }
133
+
134
+ // Expose citation layout delimiter for collapsed output
135
+ if (ast.citation?.layout?.delimiter) {
136
+ meta.citationLayoutDelimiter = ast.citation.layout.delimiter
137
+ }
138
+
139
+ // Expose collapsing delimiters for author-date collapsing
140
+ if (ast.citation?.citeGroupDelimiter != null) {
141
+ meta.citeGroupDelimiter = ast.citation.citeGroupDelimiter
142
+ }
143
+ if (ast.citation?.yearSuffixDelimiter != null) {
144
+ meta.yearSuffixDelimiter = ast.citation.yearSuffixDelimiter
145
+ }
146
+ if (ast.citation?.afterCollapseDelimiter != null) {
147
+ meta.afterCollapseDelimiter = ast.citation.afterCollapseDelimiter
148
+ }
149
+
150
+ // Expose citation layout prefix/suffix for collapsing reconstruction
151
+ if (ast.citation?.layout?.prefix != null) {
152
+ meta.citationLayoutPrefix = ast.citation.layout.prefix
153
+ }
154
+ if (ast.citation?.layout?.suffix != null) {
155
+ meta.citationLayoutSuffix = ast.citation.layout.suffix
156
+ }
157
+
158
+ return meta
159
+ }
160
+
161
+ function buildTermsObject(locale) {
162
+ const terms = {}
163
+ // Only include terms that are likely used — keep it compact
164
+ for (const [key, val] of Object.entries(locale.terms)) {
165
+ if (key.includes(':gender')) continue // skip gender markers
166
+ terms[key] = val
167
+ }
168
+ return terms
169
+ }
170
+
171
+ // ── Name options resolution ──────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Resolve name options: merge global defaults → citation/bibliography level.
175
+ * These get further merged with per-names-element options at runtime.
176
+ */
177
+ function resolveNameOptions(ast, sectionNode) {
178
+ const opts = {}
179
+ // Global inheritable options from <style>
180
+ if (ast.nameOptions) Object.assign(opts, ast.nameOptions)
181
+ // Section-level overrides
182
+ if (sectionNode?.nameOptions) Object.assign(opts, sectionNode.nameOptions)
183
+ return opts
184
+ }
185
+
186
+ // ── Macro generation ─────────────────────────────────────────────────────────
187
+
188
+ function generateMacro(name, macro, ctx) {
189
+ const safeName = 'macro_' + name.replace(/[^a-zA-Z0-9_]/g, '_')
190
+ const body = generateChildren(macro.children, ctx, ' ')
191
+ return `function ${safeName}(item, ctx) {\n${body.code}\n return ${body.expr}\n}`
192
+ }
193
+
194
+ // ── Bibliography generation ──────────────────────────────────────────────────
195
+
196
+ function generateBibliography(bibNode, ctx) {
197
+ if (!bibNode.layout) return 'export function bibliography(item, ctx = {}) { return { text: "", html: "", parts: {}, links: {} } }'
198
+
199
+ const layout = bibNode.layout
200
+ const body = generateChildren(layout.children, ctx, ' ')
201
+ const delimiter = layout.delimiter ? JSON.stringify(layout.delimiter) : '""'
202
+ const secondFieldAlign = bibNode.secondFieldAlign
203
+
204
+ const sortCode = bibNode.sort ? generateSortFunction(bibNode.sort, ctx) : ''
205
+
206
+ // When second-field-align is set, the first layout element (typically citation-number)
207
+ // is separated from the rest by a space in text output
208
+ let joinCode
209
+ if (secondFieldAlign && body.parts.length > 1) {
210
+ joinCode = ` const _first = ${body.parts[0]}
211
+ const _rest = [${body.parts.slice(1).join(', ')}].filter(v => v !== '').join(${delimiter})
212
+ const _raw = (_first ? _first + (/\\s[\\uE000-\\uE007\\uE020-\\uE022]*$/.test(_first) ? '' : ' ') : '') + _rest`
213
+ } else {
214
+ joinCode = ` const _layout = [${body.parts.join(', ')}].filter(v => v !== '')
215
+ const _raw = _layout.join(${delimiter})`
216
+ }
217
+
218
+ return `export function bibliography(item, ctx = {}) {
219
+ ctx = { ...ctx, _secOpts: BIB_NAME_OPTS }
220
+ ${body.code}
221
+ ${joinCode}${layout.prefix ? ` + ${JSON.stringify(layout.prefix)}` : ''}${layout.suffix ? ` + ${JSON.stringify(layout.suffix)}` : ''}
222
+ const _norm = _normalizePunctuation(_raw)
223
+ const text = stripFormatting(_norm)
224
+ const html = '<div class="csl-entry">' + toHtml(_norm) + '</div>'
225
+ return {
226
+ text,
227
+ html,
228
+ parts: _extractParts(item),
229
+ links: _extractLinks(item),
230
+ }
231
+ }
232
+
233
+ function _extractParts(item) {
234
+ return {
235
+ authors: item.author || [],
236
+ year: item.issued?.['date-parts']?.[0]?.[0]?.toString() || '',
237
+ title: item.title || '',
238
+ container: item['container-title'] || '',
239
+ volume: item.volume?.toString() || '',
240
+ issue: item.issue?.toString() || '',
241
+ pages: item.page || '',
242
+ doi: item.DOI || '',
243
+ url: item.URL || '',
244
+ }
245
+ }
246
+
247
+ function _extractLinks(item) {
248
+ return {
249
+ doi: item.DOI ? 'https://doi.org/' + item.DOI : null,
250
+ url: item.URL || null,
251
+ pdf: item.extended?.pdf_url || null,
252
+ }
253
+ }
254
+
255
+ function _normalizePunctuation(str) {
256
+ if (!str) return ''
257
+ // F matches PUA tokens (formatting U+E000-E007 and semantic U+E020-E022) that may appear between dots
258
+ const F = '[\\uE000-\\uE007\\uE020-\\uE022]*'
259
+ return str
260
+ .replace(new RegExp('([.!?])(' + F + ')\\\\. ', 'g'), '$1$2 ')
261
+ .replace(new RegExp('\\\\.(' + F + ')\\\\.', 'g'), '.$1')
262
+ .replace(/ +/g, ' ')
263
+ .trim()
264
+ }
265
+ ${sortCode}`
266
+ }
267
+
268
+ // ── Citation generation ──────────────────────────────────────────────────────
269
+
270
+ function generateCitation(citNode, ctx) {
271
+ if (!citNode.layout) return 'export function citation(cites, ctx = {}) { return { text: "", html: "" } }'
272
+
273
+ const layout = citNode.layout
274
+ const body = generateChildren(layout.children, ctx, ' ')
275
+ const citeDelimiter = layout.delimiter ? JSON.stringify(layout.delimiter) : '"; "'
276
+ const isSuperscript = layout.verticalAlign === 'sup'
277
+
278
+ return `export function citation(cites, ctx = {}) {
279
+ const _outerCtx = { ...ctx, _secOpts: CIT_NAME_OPTS }
280
+ const rendered = cites.map(cite => {
281
+ let ctx = cite._disambig ? { ..._outerCtx, _disambig: cite._disambig } : _outerCtx
282
+ let item = cite.item || cite
283
+ if (cite.locator != null) { item = { ...item, locator: cite.locator, _locatorLabel: cite.label || 'page' } }
284
+ ${body.code}
285
+ return [${body.parts.join(', ')}].filter(v => v !== '').join('')
286
+ })
287
+ const _raw = ${layout.prefix ? JSON.stringify(layout.prefix) + ' + ' : ''}rendered.join(${citeDelimiter})${layout.suffix ? ' + ' + JSON.stringify(layout.suffix) : ''}
288
+ const text = stripFormatting(_raw)
289
+ const html = '<span class="csl-citation">${isSuperscript ? '<sup>' : ''}' + toHtml(_raw) + '${isSuperscript ? '</sup>' : ''}</span>'
290
+ return { text, html }
291
+ }
292
+
293
+ function _resolveTerm(key) {
294
+ const val = T[key]
295
+ if (val == null) return null
296
+ return typeof val === 'string' ? val : val.single || ''
297
+ }`
298
+ }
299
+
300
+ // ── Sort generation ──────────────────────────────────────────────────────────
301
+
302
+ function generateSortFunction(sort, ctx) {
303
+ if (!sort.keys.length) return ''
304
+
305
+ // Date variables that need special sort key extraction
306
+ const dateVars = new Set(['issued', 'accessed', 'event-date', 'original-date', 'submitted'])
307
+
308
+ const comparisons = sort.keys.map(key => {
309
+ const dir = key.sort === 'descending' ? -1 : 1
310
+ if (key.macro) {
311
+ const safeName = 'macro_' + key.macro.replace(/[^a-zA-Z0-9_]/g, '_')
312
+ return ` { fn: (item) => ${safeName}(item, {}), dir: ${dir} }`
313
+ }
314
+ if (key.variable) {
315
+ // Date variables: extract sortable string from date-parts
316
+ if (dateVars.has(key.variable)) {
317
+ return ` { fn: (item) => { const d = item[${JSON.stringify(key.variable)}]; if (!d || !d['date-parts'] || !d['date-parts'][0]) return ''; const p = d['date-parts'][0]; return String(p[0] || 0).padStart(4, '0') + String(p[1] || 0).padStart(2, '0') + String(p[2] || 0).padStart(2, '0') }, dir: ${dir} }`
318
+ }
319
+ return ` { fn: (item) => String(item[${JSON.stringify(key.variable)}] || ''), dir: ${dir} }`
320
+ }
321
+ return null
322
+ }).filter(Boolean)
323
+
324
+ return `
325
+ export function bibliographySort(a, b) {
326
+ const keys = [
327
+ ${comparisons.join(',\n')}
328
+ ]
329
+ for (const k of keys) {
330
+ const va = k.fn(a)
331
+ const vb = k.fn(b)
332
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0
333
+ if (cmp !== 0) return cmp * k.dir
334
+ }
335
+ return 0
336
+ }`
337
+ }
338
+
339
+ // ── Children rendering ───────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Generate code for an array of rendering elements.
343
+ *
344
+ * Returns { code: string (statements), parts: string[] (expressions), expr: string }
345
+ */
346
+ function generateChildren(children, ctx, indent) {
347
+ const stmts = []
348
+ const parts = []
349
+
350
+ for (const child of children) {
351
+ const result = generateElement(child, ctx, indent)
352
+ if (result.code) stmts.push(result.code)
353
+ parts.push(result.expr)
354
+ }
355
+
356
+ return {
357
+ code: stmts.join('\n'),
358
+ parts,
359
+ expr: parts.length === 1
360
+ ? parts[0]
361
+ : `[${parts.join(', ')}].filter(v => v !== '').join('')`,
362
+ }
363
+ }
364
+
365
+ // ── Element generation ───────────────────────────────────────────────────────
366
+
367
+ function generateElement(node, ctx, indent) {
368
+ switch (node.type) {
369
+ case 'text': return generateTextElement(node, ctx, indent)
370
+ case 'number': return generateNumberElement(node, ctx, indent)
371
+ case 'label': return generateLabelElement(node, ctx, indent)
372
+ case 'group': return generateGroupElement(node, ctx, indent)
373
+ case 'choose': return generateChooseElement(node, ctx, indent)
374
+ case 'names': return generateNamesElement(node, ctx, indent)
375
+ case 'date': return generateDateElement(node, ctx, indent)
376
+ default: return { code: '', expr: "''" }
377
+ }
378
+ }
379
+
380
+ // ── Text ─────────────────────────────────────────────────────────────────────
381
+
382
+ function generateTextElement(node, ctx, indent) {
383
+ const v = ctx.freshVar()
384
+
385
+ let valueExpr
386
+
387
+ if (node.variable) {
388
+ // page-first: derive from page variable (extract first page from range)
389
+ if (node.variable === 'page-first') {
390
+ const pageAcc = variableAccessor('page')
391
+ valueExpr = `(${pageAcc} != null && ${pageAcc} !== '' ? stripNocaseSpans(String(${pageAcc})).split(/[-\u2013,\\s]/)[0] : '')`
392
+ } else if (node.variable === 'page' && ctx.pageRangeFormat) {
393
+ const accessor = variableAccessor(node.variable)
394
+ valueExpr = `(${accessor} != null && ${accessor} !== '' ? pageRange(stripNocaseSpans(String(${accessor})), ${JSON.stringify(ctx.pageRangeFormat)}) : '')`
395
+ } else {
396
+ const accessor = variableAccessor(node.variable)
397
+ valueExpr = `(${accessor} != null && ${accessor} !== '' ? String(${accessor}) : '')`
398
+ }
399
+ } else if (node.macro) {
400
+ const safeName = 'macro_' + node.macro.replace(/[^a-zA-Z0-9_]/g, '_')
401
+ valueExpr = `${safeName}(item, ctx)`
402
+ } else if (node.term) {
403
+ const termKey = node.form && node.form !== 'long'
404
+ ? JSON.stringify(node.term + ':' + node.form)
405
+ : JSON.stringify(node.term)
406
+ valueExpr = `(T[${termKey}] != null ? (typeof T[${termKey}] === 'string' ? T[${termKey}] : ${node.plural === 'true' ? `(T[${termKey}].multiple || T[${termKey}].single || '')` : `(T[${termKey}].single || '')`}) : '')`
407
+ } else if (node.value) {
408
+ valueExpr = JSON.stringify(node.value)
409
+ } else {
410
+ return { code: '', expr: "''" }
411
+ }
412
+
413
+ const code = `${indent}const ${v} = ${valueExpr}`
414
+ let expr = v
415
+
416
+ // Apply text-case (applyTextCase handles nocase span protection + stripping)
417
+ if (node.textCase) {
418
+ expr = applyTextCase(expr, node.textCase)
419
+ } else if (node.variable) {
420
+ // No text-case but variable may contain nocase spans — strip them
421
+ expr = `stripNocaseSpans(${expr})`
422
+ }
423
+ // Apply strip-periods
424
+ if (node.stripPeriods) expr = `${expr}.replace(/\\./g, '')`
425
+ // Apply formatting (for text output, formatting is mostly no-op; for HTML it wraps)
426
+ expr = applyFormatting(expr, node)
427
+ // Apply affixes
428
+ expr = applyAffixes(expr, node)
429
+ // Apply semantic class for variable-based text elements
430
+ if (node.variable) {
431
+ expr = wrapSemantic(expr, node.variable)
432
+ }
433
+
434
+ return { code, expr }
435
+ }
436
+
437
+ // ── Number ───────────────────────────────────────────────────────────────────
438
+
439
+ function generateNumberElement(node, ctx, indent) {
440
+ const v = ctx.freshVar()
441
+ const accessor = variableAccessor(node.variable)
442
+ let code = `${indent}const ${v} = ${accessor} != null ? String(${accessor}) : ''`
443
+
444
+ let expr = v
445
+ if (node.form === 'ordinal') {
446
+ expr = `(${v} && !isNaN(Number(${v})) ? ordinal(Number(${v})) : ${v})`
447
+ } else if (node.form === 'long-ordinal') {
448
+ expr = `(${v} && !isNaN(Number(${v})) ? longOrdinal(Number(${v})) : ${v})`
449
+ } else if (node.form === 'roman') {
450
+ expr = `(${v} && !isNaN(Number(${v})) ? roman(Number(${v})) : ${v})`
451
+ }
452
+
453
+ expr = applyFormatting(expr, node)
454
+ expr = applyAffixes(expr, node)
455
+ expr = wrapSemantic(expr, node.variable)
456
+
457
+ return { code, expr }
458
+ }
459
+
460
+ // ── Label ────────────────────────────────────────────────────────────────────
461
+
462
+ function generateLabelElement(node, ctx, indent) {
463
+ const v = ctx.freshVar()
464
+ const variable = node.variable
465
+
466
+ // Determine term key — locator is special: its term comes from the cite's label property at runtime
467
+ const isLocator = variable === 'locator'
468
+ const termName = variableToTermName(variable)
469
+ const formSuffix = node.form && node.form !== 'long' ? ':' + node.form : ''
470
+ const termKey = isLocator
471
+ ? `(item._locatorLabel || 'page') + ${JSON.stringify(formSuffix)}`
472
+ : JSON.stringify(termName + formSuffix)
473
+
474
+ const accessor = variableAccessor(variable)
475
+ // Labels should produce empty string when their variable is empty
476
+ const emptyCheck = `(${accessor} == null || ${accessor} === ''${
477
+ ['page', 'number-of-pages', 'number-of-volumes', 'volume', 'issue', 'edition', 'chapter-number', 'locator'].includes(variable)
478
+ ? '' : ` || (Array.isArray(${accessor}) && ${accessor}.length === 0)`
479
+ })`
480
+
481
+ let code
482
+ if (node.plural === 'always') {
483
+ code = `${indent}const ${v} = ${emptyCheck} ? '' : (T[${termKey}] != null ? (typeof T[${termKey}] === 'string' ? T[${termKey}] : T[${termKey}].multiple || T[${termKey}].single || '') : '')`
484
+ } else if (node.plural === 'never') {
485
+ code = `${indent}const ${v} = ${emptyCheck} ? '' : (T[${termKey}] != null ? (typeof T[${termKey}] === 'string' ? T[${termKey}] : T[${termKey}].single || '') : '')`
486
+ } else {
487
+ // contextual: determine plural from the variable's value
488
+ code = `${indent}const ${v} = (() => {
489
+ ${indent} const _val = ${accessor}
490
+ ${indent} if (_val == null || _val === '' || (Array.isArray(_val) && _val.length === 0)) return ''
491
+ ${indent} const _term = T[${termKey}]
492
+ ${indent} if (_term == null) return ''
493
+ ${indent} if (typeof _term === 'string') return _term
494
+ ${indent} const _isPlural = typeof _val === 'string' ? /[-,&]/.test(_val) || /\\d+\\s*[-\u2013]\\s*\\d+/.test(_val) : Array.isArray(_val) ? _val.length > 1 : Number(_val) !== 1
495
+ ${indent} return _isPlural ? (_term.multiple || _term.single || '') : (_term.single || '')
496
+ ${indent}})()`
497
+ }
498
+
499
+ let expr = v
500
+ if (node.stripPeriods) expr = `${expr}.replace(/\\./g, '')`
501
+ expr = applyTextCase(expr, node.textCase)
502
+ expr = applyFormatting(expr, node)
503
+ expr = applyAffixes(expr, node)
504
+
505
+ return { code, expr }
506
+ }
507
+
508
+ // ── Group ────────────────────────────────────────────────────────────────────
509
+
510
+ function generateGroupElement(node, ctx, indent) {
511
+ const delimiter = node.delimiter ? JSON.stringify(node.delimiter) : "''"
512
+ const g = ctx.freshVar('_g')
513
+ const partsVar = ctx.freshVar('_gp')
514
+
515
+ // Check if any children are choose elements (need flattening)
516
+ const hasChoose = node.children.some(c => c.type === 'choose')
517
+
518
+ if (hasChoose) {
519
+ // Use parts-array approach: choose branches push multiple items
520
+ return generateGroupWithFlattenedChoose(node, ctx, indent, delimiter, g, partsVar)
521
+ }
522
+
523
+ // Simple path: no choose children
524
+ const children = generateChildren(node.children, ctx, indent)
525
+
526
+ // Determine which children call variables (for group suppression)
527
+ // CSL spec: suppress group if all CALLED VARIABLES are empty
528
+ // We check the variables directly, not the rendered output
529
+ const calledVars = collectCalledVariables(node.children, ctx)
530
+
531
+ let code = children.code
532
+
533
+ if (calledVars.length > 0) {
534
+ const checks = calledVars.map(v => {
535
+ if (v.startsWith('__macro__')) {
536
+ // For macros, check the rendered output (we can't know which vars they use)
537
+ const safeName = 'macro_' + v.slice(9).replace(/[^a-zA-Z0-9_]/g, '_')
538
+ return `!${safeName}(item, ctx)`
539
+ }
540
+ const acc = variableAccessor(v)
541
+ // For name variables, also check array length
542
+ const nameVars = ['author', 'editor', 'translator', 'container-author',
543
+ 'collection-editor', 'composer', 'director', 'illustrator',
544
+ 'interviewer', 'recipient', 'reviewed-author']
545
+ const isNameVar = nameVars.includes(v)
546
+ return `(${acc} == null || ${acc} === ''${isNameVar ? ` || (Array.isArray(${acc}) && ${acc}.length === 0)` : ''})`
547
+ }).join(' && ')
548
+ code += `\n${indent}const ${g} = (${checks}) ? '' : [${children.parts.join(', ')}].filter(v => v !== '').join(${delimiter})`
549
+ } else {
550
+ code += `\n${indent}const ${g} = [${children.parts.join(', ')}].filter(v => v !== '').join(${delimiter})`
551
+ }
552
+
553
+ let expr = g
554
+ expr = applyFormatting(expr, node)
555
+ expr = applyAffixes(expr, node)
556
+
557
+ return { code, expr }
558
+ }
559
+
560
+ /**
561
+ * Generate a group where choose elements are flattened —
562
+ * branch children become individual group members.
563
+ */
564
+ function generateGroupWithFlattenedChoose(node, ctx, indent, delimiter, g, partsVar) {
565
+ const lines = []
566
+ lines.push(`${indent}const ${partsVar} = []`)
567
+
568
+ for (const child of node.children) {
569
+ if (child.type === 'choose') {
570
+ // Flatten: each branch's children push individually to partsVar
571
+ for (let i = 0; i < child.conditions.length; i++) {
572
+ const cond = child.conditions[i]
573
+ const keyword = i === 0 ? 'if' : 'else if'
574
+ const test = generateConditionTest(cond, ctx)
575
+ lines.push(`${indent}${keyword} (${test}) {`)
576
+ for (const branchChild of cond.children) {
577
+ const result = generateElement(branchChild, ctx, indent + ' ')
578
+ if (result.code) lines.push(result.code)
579
+ lines.push(`${indent} ${partsVar}.push(${result.expr})`)
580
+ }
581
+ lines.push(`${indent}}`)
582
+ }
583
+ if (child.else) {
584
+ lines.push(`${indent}else {`)
585
+ for (const branchChild of child.else.children) {
586
+ const result = generateElement(branchChild, ctx, indent + ' ')
587
+ if (result.code) lines.push(result.code)
588
+ lines.push(`${indent} ${partsVar}.push(${result.expr})`)
589
+ }
590
+ lines.push(`${indent}}`)
591
+ }
592
+ } else {
593
+ const result = generateElement(child, ctx, indent)
594
+ if (result.code) lines.push(result.code)
595
+ lines.push(`${indent}${partsVar}.push(${result.expr})`)
596
+ }
597
+ }
598
+
599
+ lines.push(`${indent}const ${g} = ${partsVar}.filter(v => v !== '').join(${delimiter})`)
600
+
601
+ let expr = g
602
+ expr = applyFormatting(expr, node)
603
+ expr = applyAffixes(expr, node)
604
+
605
+ return { code: lines.join('\n'), expr }
606
+ }
607
+
608
+ // ── Choose ───────────────────────────────────────────────────────────────────
609
+
610
+ function generateChooseElement(node, ctx, indent) {
611
+ const v = ctx.freshVar('_ch')
612
+ const lines = []
613
+
614
+ lines.push(`${indent}let ${v} = ''`)
615
+
616
+ for (let i = 0; i < node.conditions.length; i++) {
617
+ const cond = node.conditions[i]
618
+ const keyword = i === 0 ? 'if' : 'else if'
619
+ const test = generateConditionTest(cond, ctx)
620
+ const body = generateChildren(cond.children, ctx, indent + ' ')
621
+
622
+ lines.push(`${indent}${keyword} (${test}) {`)
623
+ if (body.code) lines.push(body.code)
624
+ lines.push(`${indent} ${v} = ${body.expr}`)
625
+ lines.push(`${indent}}`)
626
+ }
627
+
628
+ if (node.else) {
629
+ const body = generateChildren(node.else.children, ctx, indent + ' ')
630
+ lines.push(`${indent}else {`)
631
+ if (body.code) lines.push(body.code)
632
+ lines.push(`${indent} ${v} = ${body.expr}`)
633
+ lines.push(`${indent}}`)
634
+ }
635
+
636
+ return { code: lines.join('\n'), expr: v }
637
+ }
638
+
639
+ function generateConditionTest(cond, ctx) {
640
+ // Flatten all individual checks from all test attributes, then apply
641
+ // the match mode once. This avoids double-application of match="none"
642
+ // when a test attribute has multiple values (e.g., variable="page volume").
643
+ const allChecks = []
644
+
645
+ for (const t of cond.tests) {
646
+ if (t.test === 'type') {
647
+ for (const v of t.values) allChecks.push(`item.type === ${JSON.stringify(v)}`)
648
+ } else if (t.test === 'variable') {
649
+ for (const v of t.values) {
650
+ const acc = variableAccessor(v)
651
+ allChecks.push(`(${acc} != null && ${acc} !== '')`)
652
+ }
653
+ } else if (t.test === 'is-numeric') {
654
+ for (const v of t.values) {
655
+ const acc = variableAccessor(v)
656
+ allChecks.push(`(${acc} != null && /^\\s*\\d/.test(String(${acc})))`)
657
+ }
658
+ } else if (t.test === 'disambiguate') {
659
+ allChecks.push('ctx.disambiguate')
660
+ } else if (t.test === 'is-uncertain-date') {
661
+ for (const v of t.values) {
662
+ const acc = variableAccessor(v)
663
+ allChecks.push(`(${acc} && ${acc}.circa)`)
664
+ }
665
+ } else if (t.test === 'position') {
666
+ for (const v of t.values) allChecks.push(`ctx.position === ${JSON.stringify(v)}`)
667
+ } else if (t.test === 'locator') {
668
+ for (const v of t.values) allChecks.push(`ctx.locator === ${JSON.stringify(v)}`)
669
+ }
670
+ }
671
+
672
+ if (allChecks.length === 0) return 'true'
673
+ if (allChecks.length === 1) {
674
+ if (cond.match === 'none') return `!(${allChecks[0]})`
675
+ return allChecks[0]
676
+ }
677
+ if (cond.match === 'any') return '(' + allChecks.join(' || ') + ')'
678
+ if (cond.match === 'none') return allChecks.map(c => `!(${c})`).join(' && ')
679
+ return '(' + allChecks.join(' && ') + ')'
680
+ }
681
+
682
+ // ── Names ────────────────────────────────────────────────────────────────────
683
+
684
+ function generateNamesElement(node, ctx, indent) {
685
+ const v = ctx.freshVar('_n')
686
+ const lines = []
687
+
688
+ // Build the element-level name options (resolved at runtime via _nameConfig)
689
+ const nameNode = node.nameNode || {}
690
+ const elementOpts = buildElementNameOpts(nameNode, ctx)
691
+
692
+ lines.push(`${indent}let ${v} = ''`)
693
+
694
+ // Try each variable in order (usually "author", or "author editor")
695
+ for (let i = 0; i < node.variables.length; i++) {
696
+ const variable = node.variables[i]
697
+ const accessor = variableAccessor(variable)
698
+ const isFirst = i === 0
699
+
700
+ if (isFirst) {
701
+ lines.push(`${indent}if (${accessor} && ${accessor}.length) {`)
702
+ } else {
703
+ lines.push(`${indent}else if (${accessor} && ${accessor}.length) {`)
704
+ }
705
+
706
+ lines.push(`${indent} ${v} = formatNames(${accessor}, _nameConfig(ctx, ${JSON.stringify(elementOpts)}))`)
707
+
708
+ // Label after names (e.g., "(Ed.)" for editors)
709
+ if (node.labelNode && i > 0) {
710
+ const labelGen = generateLabelElement(
711
+ { ...node.labelNode, variable },
712
+ ctx, indent + ' '
713
+ )
714
+ if (labelGen.code) lines.push(labelGen.code)
715
+ lines.push(`${indent} if (${v}) ${v} += ' ' + ${labelGen.expr}`)
716
+ }
717
+
718
+ // Semantic wrapping for this name variable
719
+ lines.push(`${indent} if (${v}) ${v} = '\\uE020${variable}\\uE021' + ${v} + '\\uE022'`)
720
+
721
+ lines.push(`${indent}}`)
722
+ }
723
+
724
+ // Substitute fallback chain
725
+ if (node.substitute) {
726
+ lines.push(`${indent}if (!${v}) {`)
727
+ for (const sub of node.substitute.children) {
728
+ const subGen = generateElement(sub, ctx, indent + ' ')
729
+ if (subGen.code) lines.push(subGen.code)
730
+ lines.push(`${indent} if (!${v}) ${v} = ${subGen.expr}`)
731
+ }
732
+ lines.push(`${indent}}`)
733
+ }
734
+
735
+ let expr = v
736
+ expr = applyFormatting(expr, node)
737
+ expr = applyAffixes(expr, node)
738
+
739
+ return { code: lines.join('\n'), expr }
740
+ }
741
+
742
+ /**
743
+ * Build element-level name options from a <name> node.
744
+ * Only includes properties explicitly set on the element.
745
+ * Runtime resolution via _nameConfig merges global → section → element.
746
+ */
747
+ function buildElementNameOpts(nameNode, ctx) {
748
+ const opts = {}
749
+
750
+ if (nameNode.and != null) opts.and = nameNode.and
751
+ if (nameNode.delimiter != null) opts.delimiter = nameNode.delimiter
752
+ if (nameNode.delimiterPrecedesLast != null) opts.delimiterPrecedesLast = nameNode.delimiterPrecedesLast
753
+ if (nameNode.etAlMin != null) opts.etAlMin = nameNode.etAlMin
754
+ if (nameNode.etAlUseFirst != null) opts.etAlUseFirst = nameNode.etAlUseFirst
755
+ if (nameNode.etAlUseLast != null) opts.etAlUseLast = nameNode.etAlUseLast
756
+ if (nameNode.initialize === false) opts.initialize = false
757
+ if (nameNode.initializeWith != null) opts.initializeWith = nameNode.initializeWith
758
+ if (nameNode.nameAsSortOrder != null) opts.nameAsSortOrder = nameNode.nameAsSortOrder
759
+ if (nameNode.sortSeparator != null) opts.sortSeparator = nameNode.sortSeparator
760
+ if (nameNode.form && nameNode.form !== 'long') opts.form = nameNode.form
761
+ if (nameNode.delimiterPrecedesEtAl != null) opts.delimiterPrecedesEtAl = nameNode.delimiterPrecedesEtAl
762
+
763
+ // Name-part formatting (text-case, font formatting on family/given)
764
+ if (nameNode.nameParts && nameNode.nameParts.length > 0) {
765
+ opts.nameParts = nameNode.nameParts.map(np => {
766
+ const part = { name: np.name }
767
+ if (np.textCase) part.textCase = np.textCase
768
+ if (np.fontStyle) part.fontStyle = np.fontStyle
769
+ if (np.fontWeight) part.fontWeight = np.fontWeight
770
+ if (np.fontVariant) part.fontVariant = np.fontVariant
771
+ return part
772
+ })
773
+ }
774
+
775
+ // Store et-al term name for runtime resolution
776
+ const etAlNode = nameNode.etAlNode
777
+ if (etAlNode?.term && etAlNode.term !== 'et-al') opts._etAlTerm = etAlNode.term
778
+
779
+ return opts
780
+ }
781
+
782
+ // ── Date ─────────────────────────────────────────────────────────────────────
783
+
784
+ function generateDateElement(node, ctx, indent) {
785
+ const v = ctx.freshVar('_d')
786
+ const variable = node.variable
787
+ const accessor = variableAccessor(variable)
788
+
789
+ // Build date config
790
+ let dateParts = node.dateParts
791
+
792
+ // If form is specified, use locale date format
793
+ if (node.form && (!dateParts || dateParts.length === 0)) {
794
+ const localeFormat = ctx.locale.dateFormats[node.form]
795
+ if (localeFormat) {
796
+ // Locale file uses 'parts', parser locale overrides use 'dateParts'
797
+ const formatParts = localeFormat.parts || localeFormat.dateParts || []
798
+ dateParts = formatParts.map(p => ({
799
+ ...p,
800
+ // Merge any date-part overrides from the style
801
+ ...(node.dateParts?.find(dp => dp.name === p.name) || {}),
802
+ }))
803
+ }
804
+ }
805
+
806
+ // Apply date-parts restriction (e.g., date-parts="year" → only year)
807
+ if (node.datePartsAttr && dateParts.length > 0) {
808
+ const allowed = new Set(node.datePartsAttr.split('-')) // "year-month" → {"year","month"}
809
+ dateParts = dateParts.filter(dp => allowed.has(dp.name))
810
+ }
811
+
812
+ const dateConfig = {
813
+ dateParts: (dateParts || []).map(dp => ({
814
+ name: dp.name,
815
+ form: dp.form,
816
+ prefix: dp.prefix || '',
817
+ suffix: dp.suffix || '',
818
+ rangeDelimiter: dp.rangeDelimiter,
819
+ })),
820
+ monthTerms: ctx.locale.monthTerms,
821
+ seasonTerms: ctx.locale.seasonTerms,
822
+ }
823
+
824
+ const code = `${indent}const ${v} = ${accessor} ? formatDate(${accessor}, ${JSON.stringify(dateConfig)}) : ''`
825
+
826
+ let expr = v
827
+ expr = applyFormatting(expr, node)
828
+ expr = applyAffixes(expr, node)
829
+ if (variable) {
830
+ expr = wrapSemantic(expr, variable)
831
+ }
832
+
833
+ return { code, expr }
834
+ }
835
+
836
+ // ── Formatting helpers ───────────────────────────────────────────────────────
837
+
838
+ function applyTextCase(expr, textCase) {
839
+ if (!textCase) return expr
840
+ // Use the unified applyTextCase runtime function — handles nocase spans
841
+ return `applyTextCase(${expr}, ${JSON.stringify(textCase)})`
842
+ }
843
+
844
+ function applyFormatting(expr, node) {
845
+ // CSL rendering order: quotes → font formatting → display
846
+ if (node.quotes) {
847
+ expr = `(${expr} ? '\u201c' + ${expr} + '\u201d' : '')`
848
+ }
849
+ // Formatting tokens — PUA characters converted to HTML tags by toHtml()
850
+ if (node.fontStyle === 'italic') {
851
+ expr = `(${expr} ? '\\uE000' + ${expr} + '\\uE001' : '')`
852
+ }
853
+ if (node.fontWeight === 'bold') {
854
+ expr = `(${expr} ? '\\uE002' + ${expr} + '\\uE003' : '')`
855
+ }
856
+ if (node.fontVariant === 'small-caps') {
857
+ expr = `(${expr} ? '\\uE004' + ${expr} + '\\uE005' : '')`
858
+ }
859
+ if (node.textDecoration === 'underline') {
860
+ expr = `(${expr} ? '\\uE006' + ${expr} + '\\uE007' : '')`
861
+ }
862
+ return expr
863
+ }
864
+
865
+ function applyAffixes(expr, node) {
866
+ const prefix = node.prefix
867
+ const suffix = node.suffix
868
+ if (!prefix && !suffix) return expr
869
+
870
+ const parts = []
871
+ if (prefix) parts.push(JSON.stringify(prefix))
872
+ parts.push(expr)
873
+ if (suffix) parts.push(JSON.stringify(suffix))
874
+
875
+ if (parts.length === 1) return parts[0]
876
+ return `(${expr} ? ${parts.join(' + ')} : '')`
877
+ }
878
+
879
+ /**
880
+ * Wrap an expression in semantic span tokens for HTML CSS class output.
881
+ * Uses PUA tokens: \uE020 className \uE021 content \uE022
882
+ * toHtml() converts these to <span class="csl-className">content</span>
883
+ * stripFormatting() removes them for plain text.
884
+ */
885
+ function wrapSemantic(expr, className) {
886
+ return `(${expr} ? '\\uE020${className}\\uE021' + ${expr} + '\\uE022' : '')`
887
+ }
888
+
889
+ // ── Utility ──────────────────────────────────────────────────────────────────
890
+
891
+ /**
892
+ * Generate a JS property accessor for a CSL variable name.
893
+ * Handles hyphenated names like "container-title" → item['container-title']
894
+ */
895
+ function variableAccessor(variable) {
896
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(variable)) {
897
+ return `item.${variable}`
898
+ }
899
+ return `item[${JSON.stringify(variable)}]`
900
+ }
901
+
902
+ /**
903
+ * Map variable name to its corresponding term name for labels.
904
+ */
905
+ function variableToTermName(variable) {
906
+ // Most variables map directly to term names
907
+ // Exceptions:
908
+ const map = {
909
+ 'page': 'page',
910
+ 'volume': 'volume',
911
+ 'issue': 'issue',
912
+ 'chapter-number': 'chapter',
913
+ 'edition': 'edition',
914
+ 'number-of-pages': 'number-of-pages',
915
+ 'number-of-volumes': 'number-of-volumes',
916
+ }
917
+ return map[variable] || variable
918
+ }
919
+
920
+ /**
921
+ * Collect all variable names called by an array of nodes.
922
+ * Used for group suppression: if all called variables are empty, suppress.
923
+ */
924
+ function collectCalledVariables(nodes, ctx) {
925
+ const vars = new Set()
926
+ for (const node of nodes) {
927
+ collectVarsFromNode(node, vars, ctx)
928
+ }
929
+ return [...vars]
930
+ }
931
+
932
+ function collectVarsFromNode(node, vars, ctx) {
933
+ if (!node) return
934
+
935
+ switch (node.type) {
936
+ case 'text':
937
+ if (node.variable) vars.add(node.variable)
938
+ // For macros, conservatively treat as calling a variable
939
+ // (we can't easily resolve which vars the macro uses)
940
+ if (node.macro) vars.add('__macro__' + node.macro)
941
+ break
942
+ case 'number':
943
+ case 'date':
944
+ if (node.variable) vars.add(node.variable)
945
+ break
946
+ case 'label':
947
+ if (node.variable) vars.add(node.variable)
948
+ break
949
+ case 'names':
950
+ for (const v of (node.variables || [])) vars.add(v)
951
+ break
952
+ case 'group':
953
+ for (const child of (node.children || [])) collectVarsFromNode(child, vars, ctx)
954
+ break
955
+ case 'choose':
956
+ for (const cond of (node.conditions || [])) {
957
+ for (const child of (cond.children || [])) collectVarsFromNode(child, vars, ctx)
958
+ }
959
+ if (node.else) {
960
+ for (const child of (node.else.children || [])) collectVarsFromNode(child, vars, ctx)
961
+ }
962
+ break
963
+ }
964
+ }
965
+
966
+ /**
967
+ * Check if a rendering element calls a variable (for group suppression).
968
+ */
969
+ function callsVariable(node, ctx) {
970
+ if (!node) return false
971
+
972
+ switch (node.type) {
973
+ case 'text':
974
+ if (node.variable) return true
975
+ if (node.macro) return true // Conservative: treat macros as calling variables
976
+ return false
977
+ case 'number':
978
+ case 'date':
979
+ case 'names':
980
+ return true
981
+ case 'label':
982
+ return !!node.variable
983
+ case 'group':
984
+ return node.children?.some(c => callsVariable(c, ctx)) || false
985
+ case 'choose':
986
+ // Conservative: if any branch calls a variable
987
+ return (node.conditions?.some(c => c.children?.some(ch => callsVariable(ch, ctx))) ||
988
+ node.else?.children?.some(c => callsVariable(c, ctx))) || false
989
+ default:
990
+ return false
991
+ }
992
+ }