@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 +21 -0
- package/README.md +119 -0
- package/package.json +29 -0
- package/src/dates.js +119 -0
- package/src/html.js +131 -0
- package/src/index.js +14 -0
- package/src/names.js +268 -0
- package/src/numbers.js +97 -0
- package/src/pages.js +78 -0
- package/src/text.js +311 -0
- package/types.d.ts +18 -0
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, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, ''')
|
|
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'
|