@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/text.js +49 -77
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citestyle/core",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Shared runtime helpers for compiled CSL styles (~6-8KB)",
5
5
  "author": "Uniweb",
6
6
  "homepage": "https://github.com/uniweb/csl/tree/main/packages/core",
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
- * Capitalize all words except stop words (articles, prepositions, conjunctions).
63
- * Always capitalize first and last word.
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
- 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)
74
+ const segments = str.includes('<span class="nocase">') ? parseNocaseSegments(str) : [{ text: str, protect: false }]
87
75
  const plainStr = str.replace(NOCASE_RE, '$1')
88
- const isAllCaps = plainStr === plainStr.toUpperCase() && /[a-zA-Z]/.test(plainStr)
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 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('')
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
- 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++
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
- // 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
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
- // 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('')
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
  /**