@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/LICENSE +21 -0
- package/README.md +138 -0
- package/locales/locales-de-DE.xml +734 -0
- package/locales/locales-en-US.xml +864 -0
- package/locales/locales-pt-BR.xml +746 -0
- package/package.json +36 -0
- package/src/cli.js +322 -0
- package/src/codegen.js +992 -0
- package/src/compile.js +43 -0
- package/src/index.js +9 -0
- package/src/locale.js +235 -0
- package/src/parser.js +606 -0
- package/types.d.ts +1 -0
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
|
+
}
|