@citestyle/core 1.0.0 → 1.0.1
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/package.json +1 -1
- package/src/text.js +49 -77
package/package.json
CHANGED
package/src/text.js
CHANGED
|
@@ -58,95 +58,67 @@ const TITLE_STOP_WORDS = new Set([
|
|
|
58
58
|
])
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Convert text to title case.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* Convert text to title case, as CSL 1.0.2 defines it: in a lowercase or mixed-case string,
|
|
62
|
+
* capitalize the first character of each lowercase word and keep the case of every word that has
|
|
63
|
+
* capitals of its own (an acronym such as "AI", a name such as "iPhone" or "LLMs"); in an
|
|
64
|
+
* all-caps string, capitalize each word and lowercase the rest. Stop words (articles,
|
|
65
|
+
* prepositions, conjunctions) are lowercase unless they are the first or the last word, or
|
|
66
|
+
* follow a colon. Punctuation around a word ("AI:", "(and") is not part of it. Text inside
|
|
67
|
+
* `<span class="nocase">` is kept as written and still counts for first and last.
|
|
64
68
|
*
|
|
65
69
|
* @param {string} str
|
|
66
70
|
* @returns {string}
|
|
67
71
|
*/
|
|
68
72
|
export function titleCase(str) {
|
|
69
73
|
if (!str) return ''
|
|
70
|
-
|
|
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)
|
|
74
|
+
const segments = str.includes('<span class="nocase">') ? parseNocaseSegments(str) : [{ text: str, protect: false }]
|
|
87
75
|
const plainStr = str.replace(NOCASE_RE, '$1')
|
|
88
|
-
|
|
76
|
+
// Detect all-caps across the full string (then its all-caps words are not acronyms)
|
|
77
|
+
const isAllCaps = plainStr === plainStr.toUpperCase() && /\p{L}/u.test(plainStr)
|
|
78
|
+
const totalWords = segments.reduce((n, seg) => n + seg.text.split(/\s+/).filter((w) => w.trim()).length, 0)
|
|
89
79
|
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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('')
|
|
80
|
+
let index = 0
|
|
81
|
+
let afterColon = false
|
|
82
|
+
return segments
|
|
83
|
+
.map((seg) =>
|
|
84
|
+
seg.text
|
|
85
|
+
.split(/(\s+)/)
|
|
86
|
+
.map((word) => {
|
|
87
|
+
if (!word.trim()) return word
|
|
88
|
+
index++
|
|
89
|
+
const out = seg.protect
|
|
90
|
+
? word
|
|
91
|
+
: titleCaseWord(word, { first: index === 1, last: index === totalWords, afterColon, isAllCaps })
|
|
92
|
+
afterColon = ENDS_WITH_COLON.test(word)
|
|
93
|
+
return out
|
|
94
|
+
})
|
|
95
|
+
.join('')
|
|
96
|
+
)
|
|
97
|
+
.join('')
|
|
116
98
|
}
|
|
117
99
|
|
|
118
|
-
|
|
119
|
-
|
|
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++
|
|
100
|
+
/** A word's leading punctuation, the word, and its trailing punctuation (and formatting tokens). */
|
|
101
|
+
const WORD_PARTS = /^([^\p{L}\p{N}]*)(.*?)([^\p{L}\p{N}]*)$/su
|
|
130
102
|
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
103
|
+
/** A word that closes on a colon, maybe before formatting tokens: the next word starts a subtitle. */
|
|
104
|
+
const ENDS_WITH_COLON = /:[--]*$/
|
|
137
105
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
106
|
+
/**
|
|
107
|
+
* One word of a title-cased string.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} word - a word with any punctuation around it
|
|
110
|
+
* @param {{ first: boolean, last: boolean, afterColon: boolean, isAllCaps: boolean }} at
|
|
111
|
+
*/
|
|
112
|
+
function titleCaseWord(word, { first, last, afterColon, isAllCaps }) {
|
|
113
|
+
const [, pre, core, post] = word.match(WORD_PARTS)
|
|
114
|
+
if (!core) return word
|
|
115
|
+
const lower = core.toLowerCase()
|
|
116
|
+
const stop = TITLE_STOP_WORDS.has(lower) && !first && !last && !afterColon
|
|
117
|
+
if (isAllCaps) return pre + (stop ? lower : core.charAt(0).toUpperCase() + core.slice(1).toLowerCase()) + post
|
|
118
|
+
if (core === lower) return pre + (stop ? core : core.charAt(0).toUpperCase() + core.slice(1)) + post
|
|
119
|
+
// A word with capitals of its own keeps them, save a stop word capitalized mid-title ("And")
|
|
120
|
+
if (stop && core === core.charAt(0) + core.slice(1).toLowerCase()) return pre + lower + post
|
|
121
|
+
return word
|
|
150
122
|
}
|
|
151
123
|
|
|
152
124
|
/**
|