@citestyle/core 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Proximify
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @citestyle/core
2
+
3
+ Shared runtime helpers imported by all compiled CSL styles. Keeps individual style modules small (~3-5KB each) by centralizing formatting logic here (~6-8KB shared).
4
+
5
+ Most consumers do not need to use this package directly -- compiled styles import from it automatically. This reference is for contributors and advanced use cases.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @citestyle/core
11
+ ```
12
+
13
+ ## API Reference
14
+
15
+ ### Name Formatting
16
+
17
+ #### `formatNames(names, config?)`
18
+
19
+ Format a list of CSL name objects according to CSL name options.
20
+
21
+ ```js
22
+ import { formatNames } from '@citestyle/core'
23
+
24
+ formatNames(
25
+ [{ family: 'Smith', given: 'John' }, { family: 'Doe', given: 'Jane' }],
26
+ { and: 'text', delimiter: ', ', initializeWith: '. ' }
27
+ )
28
+ // "J. Smith and J. Doe"
29
+ ```
30
+
31
+ Config options: `and`, `delimiter`, `delimiterPrecedesLast`, `etAlMin`, `etAlUseFirst`, `etAlUseLast`, `initialize`, `initializeWith`, `nameAsSortOrder`, `sortSeparator`, `nameParts`, `form`.
32
+
33
+ ### Date Formatting
34
+
35
+ #### `formatDate(date, config?)`
36
+
37
+ Format a CSL date object with configurable parts, month terms, and range support.
38
+
39
+ ```js
40
+ import { formatDate } from '@citestyle/core'
41
+
42
+ formatDate(
43
+ { 'date-parts': [[2024, 3, 15]] },
44
+ { dateParts: [{ name: 'year' }, { name: 'month', prefix: ', ' }, { name: 'day', prefix: ' ' }] }
45
+ )
46
+ // "2024, March 15"
47
+ ```
48
+
49
+ ### Text-Case Transforms
50
+
51
+ All transforms respect `<span class="nocase">` protection in CSL-JSON values.
52
+
53
+ #### `applyTextCase(str, textCase)`
54
+
55
+ Unified entry point for all CSL text-case values: `'lowercase'`, `'uppercase'`, `'capitalize-first'`, `'capitalize-all'`, `'title'`, `'sentence'`.
56
+
57
+ ```js
58
+ import { applyTextCase } from '@citestyle/core'
59
+
60
+ applyTextCase('the art of war', 'title') // "The Art of War"
61
+ applyTextCase('A STUDY OF DNA', 'sentence') // "A study of DNA"
62
+ ```
63
+
64
+ #### `titleCase(str)`
65
+
66
+ Title-case with stop word handling. Preserves acronyms and intentional casing.
67
+
68
+ #### `sentenceCase(str)`
69
+
70
+ Capitalize first word only. Preserves all-caps acronyms (DNA, USA) in mixed-case input.
71
+
72
+ #### `capitalize(str)`
73
+
74
+ Capitalize the first character of the string.
75
+
76
+ #### `stripNocaseSpans(str)`
77
+
78
+ Remove `<span class="nocase">` tags without applying any case transform.
79
+
80
+ ### Number Formatting
81
+
82
+ #### `ordinal(n, locale?)`
83
+
84
+ Format as ordinal: `ordinal(3)` returns `"3rd"`. Supports locale-specific suffixes.
85
+
86
+ #### `longOrdinal(n, locale?)`
87
+
88
+ Format as long ordinal: `longOrdinal(2)` returns `"second"`. Falls back to numeric ordinal for n > 10.
89
+
90
+ #### `roman(n)`
91
+
92
+ Format as lowercase Roman numeral: `roman(14)` returns `"xiv"`.
93
+
94
+ ### Page Ranges
95
+
96
+ #### `pageRange(pages, format?)`
97
+
98
+ Format page ranges per CSL `page-range-format`: `'expanded'`, `'chicago'`, `'minimal'`, `'minimal-two'`.
99
+
100
+ ```js
101
+ import { pageRange } from '@citestyle/core'
102
+
103
+ pageRange('321-328', 'chicago') // "321\u201328" (321-28 with en-dash)
104
+ pageRange('321-328', 'minimal') // "321\u20138" (321-8 with en-dash)
105
+ ```
106
+
107
+ ### HTML Output
108
+
109
+ #### `escapeHtml(str)`
110
+
111
+ Escape `&`, `<`, `>`, `"`, `'` for safe HTML output.
112
+
113
+ #### `toHtml(str)`
114
+
115
+ Convert PUA formatting tokens to HTML tags. Also auto-links DOIs and URLs. This is the final output step for HTML rendering.
116
+
117
+ #### `stripFormatting(str)`
118
+
119
+ Remove all PUA formatting tokens, returning clean plain text.
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@citestyle/core",
3
+ "version": "0.0.2",
4
+ "description": "Shared runtime helpers for compiled CSL styles (~6-8KB)",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./types.d.ts",
9
+ "import": "./src/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "src",
14
+ "types.d.ts",
15
+ "README.md"
16
+ ],
17
+ "dependencies": {},
18
+ "devDependencies": {
19
+ "vitest": "^3.1.1"
20
+ },
21
+ "engines": {
22
+ "node": ">=20.19.0"
23
+ },
24
+ "license": "MIT",
25
+ "scripts": {
26
+ "test": "vitest run",
27
+ "lint": "echo 'TODO: configure linter'"
28
+ }
29
+ }
package/src/dates.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Date formatting engine.
3
+ *
4
+ * Handles: localized and non-localized date forms, date-part formatting,
5
+ * date ranges, seasons.
6
+ *
7
+ * @param {import('@citestyle/types').CslDate} date
8
+ * @param {object} config - Style-specific config baked in by compiler
9
+ * @param {Array<{name: string, form?: string, prefix?: string, suffix?: string, rangeDelimiter?: string}>} [config.dateParts]
10
+ * - Date parts to render, in order
11
+ * @param {object} [config.monthTerms] - Locale month terms { '1': 'January', ... }
12
+ * @param {object} [config.seasonTerms] - Locale season terms { '1': 'Spring', ... }
13
+ * @returns {string} Formatted date string
14
+ */
15
+ export function formatDate(date, config = {}) {
16
+ if (!date) return ''
17
+
18
+ // Handle literal dates
19
+ if (date.literal) return date.literal
20
+
21
+ const parts = date['date-parts']
22
+ if (!parts || !parts.length || !parts[0].length) return ''
23
+
24
+ const {
25
+ dateParts = [{ name: 'year' }],
26
+ monthTerms = DEFAULT_MONTHS,
27
+ seasonTerms = DEFAULT_SEASONS,
28
+ } = config
29
+
30
+ const dp = parts[0]
31
+ const year = dp[0]
32
+ const month = dp.length > 1 ? dp[1] : undefined
33
+ const day = dp.length > 2 ? dp[2] : undefined
34
+
35
+ // Check for date range (two date-parts arrays)
36
+ const hasRange = parts.length > 1 && parts[1].length > 0
37
+ const dp2 = hasRange ? parts[1] : null
38
+ const year2 = dp2 ? dp2[0] : undefined
39
+ const month2 = dp2 && dp2.length > 1 ? dp2[1] : undefined
40
+ const day2 = dp2 && dp2.length > 2 ? dp2[2] : undefined
41
+
42
+ // Format each requested date part
43
+ const result = []
44
+ for (const partCfg of dateParts) {
45
+ const val = formatDatePart(partCfg, { year, month, day, date }, { monthTerms, seasonTerms })
46
+ if (val === '') continue
47
+
48
+ let out = val
49
+ if (hasRange) {
50
+ const val2 = formatDatePart(partCfg, {
51
+ year: year2, month: month2, day: day2, date,
52
+ }, { monthTerms, seasonTerms })
53
+ if (val2 && val2 !== val) {
54
+ const rangeDelim = partCfg.rangeDelimiter || '\u2013'
55
+ out = val + rangeDelim + val2
56
+ }
57
+ }
58
+
59
+ if (partCfg.prefix) out = partCfg.prefix + out
60
+ if (partCfg.suffix) out = out + partCfg.suffix
61
+ result.push(out)
62
+ }
63
+
64
+ return result.join('')
65
+ }
66
+
67
+ /**
68
+ * Format a single date part.
69
+ */
70
+ function formatDatePart(cfg, vals, terms) {
71
+ const { name, form } = cfg
72
+ const { year, month, day, date } = vals
73
+
74
+ if (name === 'year') {
75
+ if (year == null) return ''
76
+ let y = String(year)
77
+ if (form === 'short') {
78
+ y = y.slice(-2)
79
+ }
80
+ return y
81
+ }
82
+
83
+ if (name === 'month') {
84
+ if (date && date.season) {
85
+ return terms.seasonTerms[String(date.season)] || ''
86
+ }
87
+ if (month == null) return ''
88
+ if (form === 'numeric') return String(month)
89
+ if (form === 'numeric-leading-zeros') return String(month).padStart(2, '0')
90
+ if (form === 'short') return (terms.monthTerms[String(month)] || '').slice(0, 3)
91
+ // Default: long form
92
+ return terms.monthTerms[String(month)] || ''
93
+ }
94
+
95
+ if (name === 'day') {
96
+ if (day == null) return ''
97
+ if (form === 'numeric-leading-zeros') return String(day).padStart(2, '0')
98
+ if (form === 'ordinal') return ordinalDay(day)
99
+ return String(day)
100
+ }
101
+
102
+ return ''
103
+ }
104
+
105
+ function ordinalDay(n) {
106
+ const s = ['th', 'st', 'nd', 'rd']
107
+ const v = n % 100
108
+ return n + (s[(v - 20) % 10] || s[v] || s[0])
109
+ }
110
+
111
+ const DEFAULT_MONTHS = {
112
+ '1': 'January', '2': 'February', '3': 'March', '4': 'April',
113
+ '5': 'May', '6': 'June', '7': 'July', '8': 'August',
114
+ '9': 'September', '10': 'October', '11': 'November', '12': 'December',
115
+ }
116
+
117
+ const DEFAULT_SEASONS = {
118
+ '1': 'Spring', '2': 'Summer', '3': 'Autumn', '4': 'Winter',
119
+ }
package/src/html.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * HTML escaping for safe output.
3
+ *
4
+ * Every variable interpolation in the structured HTML output must be
5
+ * escaped to prevent XSS. This is the single function for that.
6
+ *
7
+ * @param {string} str - Raw string to escape
8
+ * @returns {string} HTML-safe string
9
+ */
10
+ export function escapeHtml(str) {
11
+ if (!str) return ''
12
+ return str
13
+ .replace(/&/g, '&amp;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&#x27;')
18
+ }
19
+
20
+ /**
21
+ * Formatting tokens — Unicode Private Use Area characters embedded in
22
+ * text output by compiled styles to mark formatting spans. These are
23
+ * invisible in plain text and get converted to HTML tags by toHtml().
24
+ *
25
+ * Font formatting tokens (U+E000-E007):
26
+ * U+E000/E001 = italic start/end
27
+ * U+E002/E003 = bold start/end
28
+ * U+E004/E005 = small-caps start/end
29
+ * U+E006/E007 = underline start/end
30
+ *
31
+ * Semantic span tokens (U+E020-E022):
32
+ * U+E020 = semantic span start (followed by class name until U+E021)
33
+ * U+E021 = separator between class name and content
34
+ * U+E022 = semantic span end
35
+ *
36
+ * Example: \uE020title\uE021My Book Title\uE022
37
+ * → stripFormatting: "My Book Title"
38
+ * → toHtml: '<span class="csl-title">My Book Title</span>'
39
+ */
40
+ const FMT_REGEX = /[\uE000-\uE007]/g
41
+ const SEM_OPEN_REGEX = /\uE020[^\uE021]*\uE021/g
42
+ const SEM_CLOSE = /\uE022/g
43
+
44
+ /**
45
+ * Strip formatting tokens from text output.
46
+ *
47
+ * @param {string} str - Text with embedded formatting tokens
48
+ * @returns {string} Clean plain text
49
+ */
50
+ export function stripFormatting(str) {
51
+ if (!str) return ''
52
+ return str
53
+ .replace(SEM_OPEN_REGEX, '') // strip semantic open markers + class names
54
+ .replace(FMT_REGEX, '') // strip font formatting tokens
55
+ .replace(SEM_CLOSE, '') // strip semantic close markers
56
+ }
57
+
58
+ /**
59
+ * Convert a formatted text string to semantic HTML.
60
+ *
61
+ * Processing order matters — auto-linking must happen while PUA tokens
62
+ * are still in the string (before they become HTML tags that would
63
+ * interfere with URL matching), then tokens are converted to HTML.
64
+ *
65
+ * 1. Escapes HTML entities (XSS protection)
66
+ * 2. Auto-links DOI URLs (while PUA tokens still present)
67
+ * 3. Auto-links other URLs (while PUA tokens still present)
68
+ * 4. Converts formatting tokens to HTML tags (italic, bold, etc.)
69
+ * 5. Converts semantic tokens to CSS-classed spans
70
+ *
71
+ * @param {string} str - Text with optional formatting tokens
72
+ * @returns {string} HTML string
73
+ */
74
+ export function toHtml(str) {
75
+ if (!str) return ''
76
+
77
+ // Escape HTML entities first (tokens are PUA chars, unaffected)
78
+ let html = escapeHtml(str)
79
+
80
+ // Auto-link DOI URLs (before token conversion — PUA chars used as URL boundaries)
81
+ // The [^\s<)\uE000-\uE022] excludes PUA tokens from URL matching
82
+ html = html.replace(
83
+ /https:\/\/doi\.org\/[^\s<)\uE000-\uE022]+/g,
84
+ match => {
85
+ const cleaned = match.replace(/[.,;:]+$/, '')
86
+ const trailing = match.slice(cleaned.length)
87
+ return `<a class="csl-doi" href="${cleaned}">${cleaned}</a>${trailing}`
88
+ }
89
+ )
90
+
91
+ // Auto-link bare DOIs (e.g., "doi:10.1038/nn.2024" or "doi: 10.1038/nn.2024")
92
+ html = html.replace(
93
+ /(?<!href="|">)doi:\s*(10\.[^\s<)\uE000-\uE022]+)/gi,
94
+ (match, doi) => {
95
+ const cleaned = doi.replace(/[.,;:]+$/, '')
96
+ const trailing = doi.slice(cleaned.length)
97
+ const prefix = match.slice(0, match.indexOf('10.'))
98
+ return `${prefix}<a class="csl-doi" href="https://doi.org/${cleaned}">${cleaned}</a>${trailing}`
99
+ }
100
+ )
101
+
102
+ // Auto-link remaining URLs (exclude PUA tokens from URL boundaries)
103
+ html = html.replace(
104
+ /(?<!href="|">)https?:\/\/[^\s<)\uE000-\uE022]+/g,
105
+ match => {
106
+ if (match.includes('doi.org/')) return match
107
+ const cleaned = match.replace(/[.,;:]+$/, '')
108
+ const trailing = match.slice(cleaned.length)
109
+ return `<a class="csl-url" href="${cleaned}">${cleaned}</a>${trailing}`
110
+ }
111
+ )
112
+
113
+ // Convert formatting tokens to HTML tags
114
+ html = html
115
+ .replace(/\uE000/g, '<i>')
116
+ .replace(/\uE001/g, '</i>')
117
+ .replace(/\uE002/g, '<b>')
118
+ .replace(/\uE003/g, '</b>')
119
+ .replace(/\uE004/g, '<span class="csl-sc">')
120
+ .replace(/\uE005/g, '</span>')
121
+ .replace(/\uE006/g, '<span class="csl-ul">')
122
+ .replace(/\uE007/g, '</span>')
123
+
124
+ // Convert semantic span tokens to CSS-classed spans
125
+ html = html.replace(/\uE020([^\uE021]*)\uE021/g, (_, cls) =>
126
+ `<span class="csl-${cls}">`
127
+ )
128
+ html = html.replace(/\uE022/g, '</span>')
129
+
130
+ return html
131
+ }
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @citestyle/core
3
+ *
4
+ * Shared runtime helpers imported by all compiled CSL styles.
5
+ * Keeps individual style modules small (~3-5KB) by centralizing
6
+ * complex formatting logic here (~6-8KB shared).
7
+ */
8
+
9
+ export { formatNames } from './names.js'
10
+ export { formatDate } from './dates.js'
11
+ export { titleCase, sentenceCase, capitalize, applyTextCase, stripNocaseSpans } from './text.js'
12
+ export { ordinal, longOrdinal, roman } from './numbers.js'
13
+ export { pageRange } from './pages.js'
14
+ export { escapeHtml, stripFormatting, toHtml } from './html.js'
package/src/names.js ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Name formatting engine.
3
+ *
4
+ * Handles: et-al truncation, delimiter-precedes-last, name-as-sort-order,
5
+ * initialize-with, name particles (von, de la), name-part formatting,
6
+ * substitute fallback chains.
7
+ *
8
+ * @param {import('@citestyle/types').CslName[]} names
9
+ * @param {object} config - Style-specific config baked in by compiler
10
+ * @param {string} [config.and] - 'text'|'symbol' → "and"|"&"
11
+ * @param {string} [config.delimiter=', '] - Delimiter between names
12
+ * @param {string} [config.delimiterPrecedesLast='contextual'] - 'contextual'|'always'|'never'|'after-inverted-name'
13
+ * @param {string} [config.delimiterPrecedesEtAl='contextual'] - 'contextual'|'always'|'never'|'after-inverted-name'
14
+ * @param {number} [config.etAlMin] - Min names to trigger et-al
15
+ * @param {number} [config.etAlUseFirst] - Names to show before et-al
16
+ * @param {boolean} [config.etAlUseLast] - Show "…, Last" pattern (no "and")
17
+ * @param {boolean} [config.initialize=true] - Abbreviate given names
18
+ * @param {string} [config.initializeWith] - String after initials (e.g. '. ')
19
+ * @param {string} [config.nameAsSortOrder] - 'first'|'all' → "Family, Given" ordering
20
+ * @param {string} [config.sortSeparator=', '] - Separator in inverted names
21
+ * @param {string} [config.andTerm='and'] - Resolved locale "and" term
22
+ * @param {string} [config.etAlTerm='et al.'] - Resolved locale "et-al" term
23
+ * @param {Array} [config.nameParts] - Per-part formatting [{name:'family', textCase:'uppercase', ...}]
24
+ * @returns {string} Formatted name string
25
+ */
26
+ export function formatNames(names, config = {}) {
27
+ if (!names || !names.length) return ''
28
+
29
+ const {
30
+ and,
31
+ delimiter = ', ',
32
+ delimiterPrecedesLast = 'contextual',
33
+ delimiterPrecedesEtAl = 'contextual',
34
+ etAlMin,
35
+ etAlUseFirst,
36
+ etAlUseLast = false,
37
+ initialize = true,
38
+ initializeWith,
39
+ nameAsSortOrder,
40
+ sortSeparator = ', ',
41
+ andTerm = 'and',
42
+ etAlTerm = 'et al.',
43
+ form = 'long',
44
+ nameParts = [],
45
+ } = config
46
+
47
+ // Determine if et-al truncation applies
48
+ // Disambiguation override: registry may increase etAlUseFirst to show more names
49
+ const effectiveEtAlMin = config._disambig?.etAlMin ?? etAlMin
50
+ const effectiveEtAlUseFirst = config._disambig?.etAlUseFirst ?? etAlUseFirst
51
+ let truncated = false
52
+ let displayNames = names
53
+ if (effectiveEtAlMin && effectiveEtAlUseFirst && names.length >= effectiveEtAlMin) {
54
+ truncated = true
55
+ if (etAlUseLast && names.length > effectiveEtAlUseFirst + 1) {
56
+ // Show first N, then last (ellipsis added during join)
57
+ displayNames = [
58
+ ...names.slice(0, effectiveEtAlUseFirst),
59
+ names[names.length - 1],
60
+ ]
61
+ } else {
62
+ displayNames = names.slice(0, effectiveEtAlUseFirst)
63
+ }
64
+ }
65
+
66
+ // Handle form="count" — return number of names
67
+ if (form === 'count') return String(displayNames.length)
68
+
69
+ // Disambiguation config (set by registry for name disambiguation)
70
+ const disambig = config._disambig
71
+
72
+ // Format individual names
73
+ const formatted = displayNames.map((name, i) => {
74
+ // Determine effective form/initialize for this name position
75
+ let nameForm = form
76
+ let nameInit = initialize
77
+ let nameInitWith = initializeWith
78
+
79
+ if (disambig && nameForm === 'short') {
80
+ // Check if this name index should be expanded
81
+ const expand = disambig.expandAll || (disambig.expandIndices && disambig.expandIndices.includes(i))
82
+ if (expand) {
83
+ nameForm = 'long'
84
+ if (disambig.withInitials) {
85
+ nameInit = true
86
+ nameInitWith = disambig.initializeWith ?? initializeWith ?? '. '
87
+ } else {
88
+ nameInit = false // show full given names
89
+ }
90
+ }
91
+ }
92
+
93
+ // form="short" — family name only
94
+ if (nameForm === 'short') {
95
+ if (name.literal) return name.literal
96
+ let family = buildFamilyWithParticles(name)
97
+ family = applyNamePartFormatting(family, 'family', nameParts)
98
+ return family
99
+ }
100
+
101
+ return formatSingleName(name, {
102
+ initialize: nameInit,
103
+ initializeWith: nameInitWith,
104
+ nameAsSortOrder,
105
+ sortSeparator,
106
+ index: i,
107
+ nameParts,
108
+ })
109
+ })
110
+
111
+ // Join names with delimiters
112
+ if (formatted.length === 0) return ''
113
+ if (formatted.length === 1 && !truncated) {
114
+ return formatted[0]
115
+ }
116
+
117
+ // Handle et-al-use-last: "A, B, … Z" pattern (no "and" connector)
118
+ if (truncated && etAlUseLast) {
119
+ const init = formatted.slice(0, -1) // first N names
120
+ const last = formatted[formatted.length - 1]
121
+ return (init.length > 0 ? init.join(delimiter) + delimiter : '') + '\u2026 ' + last
122
+ }
123
+
124
+ // Handle et-al case (no "and" connector, just "et al." after names)
125
+ if (truncated) {
126
+ const joined = formatted.join(delimiter)
127
+ // delimiter-precedes-et-al controls separator before "et al."
128
+ const dpea = delimiterPrecedesEtAl || 'contextual'
129
+ let useDelim
130
+ if (dpea === 'always') useDelim = true
131
+ else if (dpea === 'never') useDelim = false
132
+ else if (dpea === 'after-inverted-name') useDelim = !!nameAsSortOrder
133
+ else useDelim = formatted.length > 1 // contextual
134
+ return joined + (useDelim ? delimiter : ' ') + etAlTerm
135
+ }
136
+
137
+ // Normal multi-name join
138
+ const andStr = and === 'symbol' ? '&' : and === 'text' ? andTerm : null
139
+
140
+ if (!andStr) {
141
+ return formatted.join(delimiter)
142
+ }
143
+
144
+ if (formatted.length === 2) {
145
+ // Two names: "A & B" or "A, & B"
146
+ const useDelim = delimiterPrecedesLast === 'always' ||
147
+ (delimiterPrecedesLast === 'after-inverted-name' && nameAsSortOrder)
148
+ const sep = useDelim ? delimiter + andStr + ' ' : ' ' + andStr + ' '
149
+ return formatted[0] + sep + formatted[1]
150
+ }
151
+
152
+ // Three or more names
153
+ const init = formatted.slice(0, -1)
154
+ const last = formatted[formatted.length - 1]
155
+
156
+ const useDelimBeforeLast = delimiterPrecedesLast === 'always' ||
157
+ delimiterPrecedesLast === 'after-inverted-name' ||
158
+ (delimiterPrecedesLast === 'contextual' && formatted.length > 2)
159
+
160
+ const sep = useDelimBeforeLast ? delimiter + andStr + ' ' : ' ' + andStr + ' '
161
+ return init.join(delimiter) + sep + last
162
+ }
163
+
164
+ /**
165
+ * Format a single name.
166
+ */
167
+ function formatSingleName(name, opts) {
168
+ const { initialize, initializeWith, nameAsSortOrder, sortSeparator = ', ', index, nameParts = [] } = opts
169
+
170
+ // Literal names (institutional, etc.)
171
+ if (name.literal) return name.literal
172
+
173
+ let family = buildFamilyWithParticles(name)
174
+ let given = name.given || ''
175
+
176
+ // Initialize given names (e.g., "John Andrew" → "J. A.")
177
+ // initializeWith can be "" (empty string) for styles like Vancouver → "JA"
178
+ if (initialize && initializeWith != null && given) {
179
+ given = initializeGiven(given, initializeWith)
180
+ }
181
+
182
+ // Apply name-part formatting (text-case, font formatting)
183
+ family = applyNamePartFormatting(family, 'family', nameParts)
184
+ given = applyNamePartFormatting(given, 'given', nameParts)
185
+
186
+ if (!given) return family
187
+
188
+ // Determine sort order (inverted: "Family, Given" vs. normal: "Given Family")
189
+ const invert = nameAsSortOrder === 'all' ||
190
+ (nameAsSortOrder === 'first' && index === 0)
191
+
192
+ if (invert) {
193
+ return family + sortSeparator + given
194
+ }
195
+ return given + ' ' + family
196
+ }
197
+
198
+ /**
199
+ * Build family name with particles.
200
+ * E.g., { family: 'Beethoven', 'non-dropping-particle': 'van' } → 'van Beethoven'
201
+ */
202
+ function buildFamilyWithParticles(name) {
203
+ let family = name.family || ''
204
+ const ndp = name['non-dropping-particle']
205
+ const dp = name['dropping-particle']
206
+ const suffix = name.suffix
207
+
208
+ if (ndp) family = ndp + ' ' + family
209
+ if (dp) family = dp + ' ' + family
210
+ if (suffix) family = family + ' ' + suffix
211
+
212
+ return family
213
+ }
214
+
215
+ /**
216
+ * Apply name-part formatting (text-case and font formatting) to a name part.
217
+ *
218
+ * @param {string} str - The name part string
219
+ * @param {string} partName - 'family' or 'given'
220
+ * @param {Array} nameParts - Array of name-part configs
221
+ * @returns {string} Formatted name part
222
+ */
223
+ function applyNamePartFormatting(str, partName, nameParts) {
224
+ if (!str || !nameParts || nameParts.length === 0) return str
225
+
226
+ const cfg = nameParts.find(np => np.name === partName)
227
+ if (!cfg) return str
228
+
229
+ // Apply text-case
230
+ if (cfg.textCase === 'uppercase') str = str.toUpperCase()
231
+ else if (cfg.textCase === 'lowercase') str = str.toLowerCase()
232
+ else if (cfg.textCase === 'capitalize-first') str = str.charAt(0).toUpperCase() + str.slice(1)
233
+ else if (cfg.textCase === 'capitalize-all') str = str.split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
234
+
235
+ // Apply font formatting using PUA tokens
236
+ if (cfg.fontStyle === 'italic') str = '\uE000' + str + '\uE001'
237
+ if (cfg.fontWeight === 'bold') str = '\uE002' + str + '\uE003'
238
+ if (cfg.fontVariant === 'small-caps') str = '\uE004' + str + '\uE005'
239
+
240
+ return str
241
+ }
242
+
243
+ /**
244
+ * Initialize given names.
245
+ * "John Andrew" with ". " → "J. A."
246
+ * "Jean-Pierre" with ". " → "J.-P."
247
+ */
248
+ function initializeGiven(given, initWith) {
249
+ const trimmedInit = initWith.trimEnd()
250
+ // When initializeWith is empty (""), join initials without space (e.g., "JA")
251
+ // When it has content (". "), join with space (e.g., "J. A.")
252
+ const joinWith = initWith === '' ? '' : ' '
253
+
254
+ return given
255
+ .split(/\s+/)
256
+ .filter(Boolean)
257
+ .map(part => {
258
+ // Handle hyphenated names: "Jean-Pierre" → "J.-P."
259
+ if (part.includes('-')) {
260
+ return part
261
+ .split('-')
262
+ .map(sub => sub ? sub.charAt(0).toUpperCase() + trimmedInit : '')
263
+ .join('-')
264
+ }
265
+ return part.charAt(0).toUpperCase() + trimmedInit
266
+ })
267
+ .join(joinWith)
268
+ }
package/src/numbers.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Number formatting: ordinal, long-ordinal, roman numerals.
3
+ */
4
+
5
+ /**
6
+ * Format number as ordinal (1st, 2nd, 3rd, 4th, ...).
7
+ *
8
+ * @param {number} n
9
+ * @param {object} [locale] - Locale ordinal config (for non-English)
10
+ * @param {object} [locale.terms] - Ordinal terms from locale
11
+ * @returns {string}
12
+ */
13
+ export function ordinal(n, locale) {
14
+ if (n == null) return ''
15
+ n = Number(n)
16
+ if (isNaN(n)) return String(n)
17
+
18
+ // Use locale-specific ordinal terms if provided
19
+ if (locale && locale.terms) {
20
+ const suffix = resolveOrdinalTerm(n, locale.terms)
21
+ if (suffix != null) return n + suffix
22
+ }
23
+
24
+ // English default
25
+ const s = ['th', 'st', 'nd', 'rd']
26
+ const v = Math.abs(n) % 100
27
+ return n + (s[(v - 20) % 10] || s[v] || s[0])
28
+ }
29
+
30
+ /**
31
+ * Resolve ordinal suffix from locale terms.
32
+ * CSL locales can define: ordinal, ordinal-00 through ordinal-99
33
+ */
34
+ function resolveOrdinalTerm(n, terms) {
35
+ const abs = Math.abs(n)
36
+ // Try specific: ordinal-01, ordinal-02, ...
37
+ const padded = String(abs % 100).padStart(2, '0')
38
+ if (terms['ordinal-' + padded] != null) return terms['ordinal-' + padded]
39
+ // Try last digit: ordinal-01, ordinal-02, ordinal-03, ordinal-04
40
+ const lastDigit = String(abs % 10).padStart(2, '0')
41
+ if (terms['ordinal-' + lastDigit] != null) return terms['ordinal-' + lastDigit]
42
+ // Fallback to generic ordinal
43
+ if (terms['ordinal'] != null) return terms['ordinal']
44
+ return null
45
+ }
46
+
47
+ /**
48
+ * Format number as long ordinal (first, second, third, ...).
49
+ * Falls back to ordinal for numbers > 10.
50
+ *
51
+ * @param {number} n
52
+ * @param {object} [locale]
53
+ * @returns {string}
54
+ */
55
+ export function longOrdinal(n, locale) {
56
+ if (n == null) return ''
57
+ n = Number(n)
58
+
59
+ // Check locale long-ordinal terms
60
+ if (locale && locale.terms) {
61
+ const padded = String(Math.abs(n) % 100).padStart(2, '0')
62
+ const term = locale.terms['long-ordinal-' + padded]
63
+ if (term) return term
64
+ }
65
+
66
+ // English defaults for 1-10
67
+ const LONG = ['', 'first', 'second', 'third', 'fourth', 'fifth',
68
+ 'sixth', 'seventh', 'eighth', 'ninth', 'tenth']
69
+ if (n >= 1 && n <= 10) return LONG[n]
70
+
71
+ // Fall back to numeric ordinal
72
+ return ordinal(n, locale)
73
+ }
74
+
75
+ /**
76
+ * Format number as roman numeral.
77
+ *
78
+ * @param {number} n
79
+ * @returns {string}
80
+ */
81
+ export function roman(n) {
82
+ if (n == null) return ''
83
+ n = Number(n)
84
+ if (isNaN(n) || n <= 0) return String(n)
85
+
86
+ const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]
87
+ const symbols = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I']
88
+
89
+ let result = ''
90
+ for (let i = 0; i < values.length; i++) {
91
+ while (n >= values[i]) {
92
+ result += symbols[i]
93
+ n -= values[i]
94
+ }
95
+ }
96
+ return result.toLowerCase()
97
+ }
package/src/pages.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Page range formatting.
3
+ *
4
+ * Supports expanded, chicago, minimal, and minimal-two algorithms
5
+ * per the CSL page-range-format attribute.
6
+ *
7
+ * @param {string} pages - Raw page string (e.g., "321-328")
8
+ * @param {string} [format] - 'chicago'|'expanded'|'minimal'|'minimal-two'
9
+ * @returns {string} Formatted page range
10
+ */
11
+ export function pageRange(pages, format) {
12
+ if (!pages) return ''
13
+
14
+ // Normalize hyphens to en-dash for output
15
+ const normalized = pages.replace(/\s*[-\u2013]\s*/, '\u2013')
16
+
17
+ if (!format || format === 'expanded') {
18
+ // Expanded: show full numbers (321–328)
19
+ return normalized
20
+ }
21
+
22
+ // Try to parse as numeric range
23
+ const match = pages.match(/^(\d+)\s*[-\u2013]\s*(\d+)$/)
24
+ if (!match) return normalized // Non-numeric range, pass through
25
+
26
+ const first = match[1]
27
+ const last = match[2]
28
+
29
+ if (format === 'minimal') {
30
+ return first + '\u2013' + minimalEnd(first, last, 1)
31
+ }
32
+
33
+ if (format === 'minimal-two') {
34
+ return first + '\u2013' + minimalEnd(first, last, 2)
35
+ }
36
+
37
+ if (format === 'chicago') {
38
+ return first + '\u2013' + chicagoEnd(first, last)
39
+ }
40
+
41
+ return normalized
42
+ }
43
+
44
+ /**
45
+ * Minimal format: remove shared leading digits, keep at least `minDigits` digits.
46
+ */
47
+ function minimalEnd(first, last, minDigits) {
48
+ let i = 0
49
+ while (i < first.length && i < last.length - minDigits && first[i] === last[i]) {
50
+ i++
51
+ }
52
+ return last.slice(i)
53
+ }
54
+
55
+ /**
56
+ * Chicago page range algorithm (16th ed. 9.64):
57
+ * - 1-99: use all digits (3-10, 71-72, 96-117)
58
+ * - 100+: if second number ≤ 109 (in its hundred), use changed part only (100-104 → 100-104)
59
+ * - For x01-x09: use changed part only (1496-1504 → 1496–504)
60
+ * - Otherwise: use at least 2 digits (321-328 → 321–28, 1087-1089 → 1087–89)
61
+ */
62
+ function chicagoEnd(first, last) {
63
+ const f = parseInt(first, 10)
64
+ const l = parseInt(last, 10)
65
+
66
+ // Less than 100: use all digits
67
+ if (f < 100) return last
68
+
69
+ // 100s: if both in same hundred and last ends in 00-09, use all digits
70
+ const fHundred = Math.floor(f / 100)
71
+ const lHundred = Math.floor(l / 100)
72
+ if (fHundred === lHundred && (l % 100) >= 0 && (l % 100) <= 9) {
73
+ return last
74
+ }
75
+
76
+ // Use at least 2 digits
77
+ return minimalEnd(first, last, 2)
78
+ }
package/src/text.js ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Text-case transforms.
3
+ *
4
+ * CSL text-case values: lowercase, uppercase, capitalize-first,
5
+ * capitalize-all, title, sentence.
6
+ *
7
+ * Nocase span support: CSL-JSON values may contain
8
+ * `<span class="nocase">...</span>` to protect text from case transforms.
9
+ * Protected regions are preserved as-is during transformation, then the
10
+ * span tags are stripped from the final output. Protected regions may
11
+ * contain PUA formatting tokens (U+E000-E007, U+E020-E022).
12
+ */
13
+
14
+ // Regex to split on nocase spans — captures the protected content
15
+ const NOCASE_RE = /<span class="nocase">([\s\S]*?)<\/span>/g
16
+
17
+ /**
18
+ * Apply a case transform while respecting nocase spans.
19
+ * Splits the string on nocase boundaries, applies the transform to
20
+ * unprotected segments only, then joins everything back together.
21
+ *
22
+ * @param {string} str - Input string (may contain nocase spans)
23
+ * @param {function} transform - Case transform function for unprotected text
24
+ * @returns {string} Transformed string with nocase spans stripped
25
+ */
26
+ function withNocaseProtection(str, transform) {
27
+ if (!str) return ''
28
+ if (!str.includes('<span class="nocase">')) return transform(str)
29
+
30
+ const parts = []
31
+ let lastIndex = 0
32
+ let match
33
+ const re = new RegExp(NOCASE_RE.source, 'g')
34
+
35
+ while ((match = re.exec(str)) !== null) {
36
+ // Unprotected text before this span
37
+ if (match.index > lastIndex) {
38
+ parts.push({ text: str.slice(lastIndex, match.index), protect: false })
39
+ }
40
+ // Protected content (strip the span tags, keep content)
41
+ parts.push({ text: match[1], protect: true })
42
+ lastIndex = re.lastIndex
43
+ }
44
+
45
+ // Remaining unprotected text after last span
46
+ if (lastIndex < str.length) {
47
+ parts.push({ text: str.slice(lastIndex), protect: false })
48
+ }
49
+
50
+ return parts.map(p => p.protect ? p.text : transform(p.text)).join('')
51
+ }
52
+
53
+ // Words that should not be capitalized in title case (unless first/last word)
54
+ const TITLE_STOP_WORDS = new Set([
55
+ 'a', 'an', 'and', 'as', 'at', 'but', 'by', 'down', 'for', 'from',
56
+ 'in', 'into', 'nor', 'of', 'on', 'onto', 'or', 'over', 'so',
57
+ 'the', 'till', 'to', 'up', 'via', 'with', 'yet',
58
+ ])
59
+
60
+ /**
61
+ * Convert text to title case.
62
+ * Capitalize all words except stop words (articles, prepositions, conjunctions).
63
+ * Always capitalize first and last word.
64
+ *
65
+ * @param {string} str
66
+ * @returns {string}
67
+ */
68
+ export function titleCase(str) {
69
+ if (!str) return ''
70
+ if (!str.includes('<span class="nocase">')) return _titleCaseInner(str)
71
+
72
+ // Nocase-aware title case: parse into segments (protected vs unprotected),
73
+ // then process all words as a single sequence for correct first/last detection
74
+ const segments = parseNocaseSegments(str)
75
+
76
+ // Collect all word tokens across segments for first/last word detection
77
+ const allWords = []
78
+ for (const seg of segments) {
79
+ const words = seg.text.split(/(\s+)/)
80
+ for (const w of words) {
81
+ if (w.trim()) allWords.push({ word: w, protect: seg.protect })
82
+ }
83
+ }
84
+
85
+ const totalWords = allWords.length
86
+ // Detect all-caps across full string (strip nocase spans for this check)
87
+ const plainStr = str.replace(NOCASE_RE, '$1')
88
+ const isAllCaps = plainStr === plainStr.toUpperCase() && /[a-zA-Z]/.test(plainStr)
89
+
90
+ let globalWordIdx = 0
91
+ return segments.map(seg => {
92
+ if (seg.protect) {
93
+ // Count protected words but don't transform them
94
+ const words = seg.text.split(/(\s+)/)
95
+ for (const w of words) { if (w.trim()) globalWordIdx++ }
96
+ return seg.text
97
+ }
98
+
99
+ const words = seg.text.split(/(\s+)/)
100
+ return words.map(word => {
101
+ if (!word.trim()) return word
102
+ globalWordIdx++
103
+
104
+ if (/[a-z][A-Z]|[A-Z][a-z].*[A-Z]/.test(word)) return word
105
+ if (/^[A-Z]{2,}$/.test(word) && !isAllCaps) return word
106
+
107
+ if (globalWordIdx === 1 || globalWordIdx === totalWords) {
108
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
109
+ }
110
+ if (TITLE_STOP_WORDS.has(word.toLowerCase())) {
111
+ return word.toLowerCase()
112
+ }
113
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
114
+ }).join('')
115
+ }).join('')
116
+ }
117
+
118
+ function _titleCaseInner(str) {
119
+ if (!str) return ''
120
+ const words = str.split(/(\s+)/)
121
+ let wordIndex = 0
122
+ const totalWords = words.filter(w => w.trim()).length
123
+ // Detect if the entire string is all-caps (then individual all-caps words aren't abbreviations)
124
+ const isAllCaps = str === str.toUpperCase() && /[a-zA-Z]/.test(str)
125
+
126
+ return words
127
+ .map(word => {
128
+ if (!word.trim()) return word // preserve whitespace
129
+ wordIndex++
130
+
131
+ // CSL spec: words with internal capitals (e.g., "OpenAI", "JavaScript")
132
+ // or all-caps words in a mixed-case string are treated as intentional
133
+ // casing and preserved as-is.
134
+ if (/[a-z][A-Z]|[A-Z][a-z].*[A-Z]/.test(word)) return word
135
+ // Preserve all-caps words (2+ chars) when the whole string isn't all-caps
136
+ if (/^[A-Z]{2,}$/.test(word) && !isAllCaps) return word
137
+
138
+ // Always capitalize first and last word
139
+ if (wordIndex === 1 || wordIndex === totalWords) {
140
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
141
+ }
142
+ // Don't capitalize stop words
143
+ if (TITLE_STOP_WORDS.has(word.toLowerCase())) {
144
+ return word.toLowerCase()
145
+ }
146
+ // Capitalize first letter, lowercase rest
147
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
148
+ })
149
+ .join('')
150
+ }
151
+
152
+ /**
153
+ * Parse a string into nocase-protected and unprotected segments.
154
+ */
155
+ function parseNocaseSegments(str) {
156
+ const parts = []
157
+ let lastIndex = 0
158
+ let match
159
+ const re = new RegExp(NOCASE_RE.source, 'g')
160
+ while ((match = re.exec(str)) !== null) {
161
+ if (match.index > lastIndex) {
162
+ parts.push({ text: str.slice(lastIndex, match.index), protect: false })
163
+ }
164
+ parts.push({ text: match[1], protect: true })
165
+ lastIndex = re.lastIndex
166
+ }
167
+ if (lastIndex < str.length) {
168
+ parts.push({ text: str.slice(lastIndex), protect: false })
169
+ }
170
+ return parts
171
+ }
172
+
173
+ /**
174
+ * Convert text to sentence case.
175
+ * Lowercase all words except the first word and proper nouns.
176
+ * (We can't reliably detect proper nouns, so we only capitalize
177
+ * the first word and words after ": ".)
178
+ *
179
+ * @param {string} str
180
+ * @returns {string}
181
+ */
182
+ export function sentenceCase(str) {
183
+ if (!str) return ''
184
+ if (!str.includes('<span class="nocase">')) return _sentenceCaseInner(str)
185
+
186
+ // Nocase-aware: parse into segments, process as single sequence
187
+ const segments = parseNocaseSegments(str)
188
+ // Detect all-caps from unprotected segments only (nocase content shouldn't affect detection)
189
+ const unprotectedText = segments.filter(s => !s.protect).map(s => s.text).join('')
190
+ const isAllCaps = unprotectedText === unprotectedText.toUpperCase() && /[a-zA-Z]/.test(unprotectedText)
191
+
192
+ let globalWordIdx = 0
193
+ let afterColon = false
194
+ return segments.map(seg => {
195
+ if (seg.protect) {
196
+ // Count words in protected segments but don't transform
197
+ const words = seg.text.split(/(\s+)/)
198
+ for (const w of words) {
199
+ if (w.trim()) {
200
+ globalWordIdx++
201
+ afterColon = w.endsWith(':')
202
+ }
203
+ }
204
+ return seg.text
205
+ }
206
+
207
+ const words = seg.text.split(/(\s+)/)
208
+ return words.map(word => {
209
+ if (!word.trim()) return word
210
+ globalWordIdx++
211
+
212
+ // First word or after colon: capitalize
213
+ if (globalWordIdx === 1 || afterColon) {
214
+ afterColon = word.endsWith(':')
215
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
216
+ }
217
+
218
+ if (word.endsWith(':')) afterColon = true
219
+
220
+ // Preserve all-caps acronyms when string isn't entirely uppercase
221
+ if (/^[A-Z]{2,}$/.test(word) && !isAllCaps) return word
222
+ // Preserve mixed-case words
223
+ if (/[a-z][A-Z]|[A-Z][a-z].*[A-Z]/.test(word)) return word
224
+
225
+ return word.toLowerCase()
226
+ }).join('')
227
+ }).join('')
228
+ }
229
+
230
+ function _sentenceCaseInner(str) {
231
+ if (!str) return ''
232
+ const isAllCaps = str === str.toUpperCase() && /[a-zA-Z]/.test(str)
233
+ const words = str.split(/(\s+)/)
234
+ let wordIndex = 0
235
+ let afterColon = false
236
+
237
+ const result = words.map(word => {
238
+ if (!word.trim()) return word
239
+ wordIndex++
240
+
241
+ // Preserve all-caps words (acronyms) when string isn't entirely uppercase
242
+ if (/^[A-Z]{2,}$/.test(word) && !isAllCaps && wordIndex > 1) return word
243
+ // Preserve mixed-case words (e.g., "iPhone", "McDonald")
244
+ if (/[a-z][A-Z]|[A-Z][a-z].*[A-Z]/.test(word) && wordIndex > 1) return word
245
+
246
+ // First word or after colon: capitalize
247
+ if (wordIndex === 1 || afterColon) {
248
+ afterColon = false
249
+ // First word all-caps in non-all-caps string: capitalize normally
250
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
251
+ }
252
+
253
+ if (word.endsWith(':')) {
254
+ afterColon = true
255
+ }
256
+
257
+ return word.toLowerCase()
258
+ }).join('')
259
+
260
+ return result
261
+ }
262
+
263
+ /**
264
+ * Capitalize first character of string.
265
+ *
266
+ * @param {string} str
267
+ * @returns {string}
268
+ */
269
+ export function capitalize(str) {
270
+ return withNocaseProtection(str, _capitalizeInner)
271
+ }
272
+
273
+ function _capitalizeInner(str) {
274
+ if (!str) return ''
275
+ return str.charAt(0).toUpperCase() + str.slice(1)
276
+ }
277
+
278
+ /**
279
+ * Apply any CSL text-case transform with nocase protection.
280
+ * This is the single entry point used by compiled styles — routes to
281
+ * the appropriate transform function with nocase span handling.
282
+ *
283
+ * @param {string} str - Input string (may contain nocase spans)
284
+ * @param {string} textCase - CSL text-case value
285
+ * @returns {string}
286
+ */
287
+ export function applyTextCase(str, textCase) {
288
+ if (!str || !textCase) return str || ''
289
+ switch (textCase) {
290
+ case 'lowercase': return withNocaseProtection(str, s => s.toLowerCase())
291
+ case 'uppercase': return withNocaseProtection(str, s => s.toUpperCase())
292
+ case 'capitalize-first': return capitalize(str)
293
+ case 'capitalize-all': return withNocaseProtection(str, s => s.split(' ').map(w => _capitalizeInner(w)).join(' '))
294
+ case 'title': return titleCase(str)
295
+ case 'sentence': return sentenceCase(str)
296
+ default: return str
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Strip nocase spans from a string without applying any case transform.
302
+ * Used for values that pass through without text-case but may contain
303
+ * nocase spans from CSL-JSON input.
304
+ *
305
+ * @param {string} str
306
+ * @returns {string}
307
+ */
308
+ export function stripNocaseSpans(str) {
309
+ if (!str || typeof str !== 'string') return str || ''
310
+ return str.replace(NOCASE_RE, '$1')
311
+ }
package/types.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ export {
2
+ formatNames,
3
+ formatDate,
4
+ applyTextCase,
5
+ titleCase,
6
+ sentenceCase,
7
+ capitalize,
8
+ stripNocaseSpans,
9
+ ordinal,
10
+ longOrdinal,
11
+ roman,
12
+ pageRange,
13
+ escapeHtml,
14
+ stripFormatting,
15
+ toHtml,
16
+ NameFormatConfig,
17
+ DateFormatConfig,
18
+ } from '@citestyle/types'