@citestyle/core 0.0.2 → 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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @citestyle/core
2
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).
3
+ Shared runtime library for the Citestyle ecosystem. Every compiled CSL style imports from this package — it provides the formatting functions that styles call at runtime (name formatting, date formatting, text-case transforms, number formatting, page ranges, and HTML output).
4
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.
5
+ **You probably don't need to install this directly.** It's automatically pulled in as a dependency of compiled styles and `@citestyle/registry`. This documentation is for contributors, advanced use cases, and anyone curious about what's under the hood.
6
6
 
7
7
  ## Installation
8
8
 
@@ -10,110 +10,144 @@ Most consumers do not need to use this package directly -- compiled styles impor
10
10
  npm install @citestyle/core
11
11
  ```
12
12
 
13
- ## API Reference
13
+ ~6-8KB. Zero dependencies. Works in Node.js and browsers.
14
14
 
15
- ### Name Formatting
15
+ ## What it does
16
+
17
+ When the Citestyle compiler transforms a `.csl` file into JavaScript, the generated code calls functions from this package instead of inlining the logic. This keeps each compiled style small (~3-5KB) while sharing the heavier formatting logic (~6-8KB) across all styles.
18
+
19
+ For example, a compiled APA style might generate:
20
+
21
+ ```javascript
22
+ // Generated code in apa.js — calls into @citestyle/core
23
+ import { formatNames, formatDate, applyTextCase, toHtml } from '@citestyle/core'
24
+
25
+ // The style bakes in the *configuration*, core provides the *implementation*
26
+ formatNames(item.author, { and: 'symbol', initializeWith: '. ', nameAsSortOrder: 'first' })
27
+ ```
28
+
29
+ ## API
30
+
31
+ ### Name formatting
16
32
 
17
33
  #### `formatNames(names, config?)`
18
34
 
19
- Format a list of CSL name objects according to CSL name options.
35
+ Format CSL name objects according to style-specific rules. Handles et-al truncation, particles (von, de la), sort order, initialization, and all five CSL `disambiguate-add-givenname` rules.
20
36
 
21
- ```js
37
+ ```javascript
22
38
  import { formatNames } from '@citestyle/core'
23
39
 
24
40
  formatNames(
25
41
  [{ family: 'Smith', given: 'John' }, { family: 'Doe', given: 'Jane' }],
26
42
  { and: 'text', delimiter: ', ', initializeWith: '. ' }
27
43
  )
28
- // "J. Smith and J. Doe"
44
+ // → "J. Smith and J. Doe"
45
+
46
+ formatNames(
47
+ [{ family: 'de la Cruz', given: 'Maria', 'non-dropping-particle': 'de la' }],
48
+ { nameAsSortOrder: 'all', sortSeparator: ', ' }
49
+ )
50
+ // → "de la Cruz, Maria"
29
51
  ```
30
52
 
31
- Config options: `and`, `delimiter`, `delimiterPrecedesLast`, `etAlMin`, `etAlUseFirst`, `etAlUseLast`, `initialize`, `initializeWith`, `nameAsSortOrder`, `sortSeparator`, `nameParts`, `form`.
53
+ **Config options**: `and`, `delimiter`, `delimiterPrecedesLast`, `etAlMin`, `etAlUseFirst`, `etAlUseLast`, `initialize`, `initializeWith`, `nameAsSortOrder`, `sortSeparator`, `nameParts`, `form`.
32
54
 
33
- ### Date Formatting
55
+ ### Date formatting
34
56
 
35
57
  #### `formatDate(date, config?)`
36
58
 
37
- Format a CSL date object with configurable parts, month terms, and range support.
59
+ Format CSL date objects with configurable parts, localized month terms, ranges, and seasons.
38
60
 
39
- ```js
61
+ ```javascript
40
62
  import { formatDate } from '@citestyle/core'
41
63
 
42
64
  formatDate(
43
65
  { 'date-parts': [[2024, 3, 15]] },
44
- { dateParts: [{ name: 'year' }, { name: 'month', prefix: ', ' }, { name: 'day', prefix: ' ' }] }
66
+ { dateParts: [{ name: 'year' }, { name: 'month', form: 'long', prefix: ', ' }, { name: 'day', prefix: ' ' }] }
45
67
  )
46
- // "2024, March 15"
68
+ // → "2024, March 15"
47
69
  ```
48
70
 
49
- ### Text-Case Transforms
71
+ ### Text-case transforms
50
72
 
51
- All transforms respect `<span class="nocase">` protection in CSL-JSON values.
73
+ All transforms respect `<span class="nocase">` protection in CSL-JSON values — brand names like "iPhone" and chemical terms like "pH" are preserved.
52
74
 
53
75
  #### `applyTextCase(str, textCase)`
54
76
 
55
- Unified entry point for all CSL text-case values: `'lowercase'`, `'uppercase'`, `'capitalize-first'`, `'capitalize-all'`, `'title'`, `'sentence'`.
77
+ Unified entry point for all CSL text-case values.
56
78
 
57
- ```js
79
+ ```javascript
58
80
  import { applyTextCase } from '@citestyle/core'
59
81
 
60
- applyTextCase('the art of war', 'title') // "The Art of War"
61
- applyTextCase('A STUDY OF DNA', 'sentence') // "A study of DNA"
82
+ applyTextCase('the art of war', 'title') // "The Art of War"
83
+ applyTextCase('A STUDY OF DNA', 'sentence') // "A study of DNA"
84
+ applyTextCase('proceedings of the IEEE', 'title') // "Proceedings of the IEEE"
85
+ applyTextCase('<span class="nocase">iPhone</span> sales', 'uppercase')
86
+ // "IPHONE SALES" → no: "iPhone SALES"
62
87
  ```
63
88
 
64
- #### `titleCase(str)`
89
+ **Supported values**: `'lowercase'`, `'uppercase'`, `'capitalize-first'`, `'capitalize-all'`, `'title'`, `'sentence'`.
65
90
 
66
- Title-case with stop word handling. Preserves acronyms and intentional casing.
91
+ #### Individual transforms
67
92
 
68
- #### `sentenceCase(str)`
93
+ - **`titleCase(str)`** — Title case with stop-word handling. Preserves acronyms and nocase spans.
94
+ - **`sentenceCase(str)`** — Lowercase all but first word. Preserves all-caps acronyms (DNA, USA) in mixed-case input.
95
+ - **`capitalize(str)`** — Capitalize the first character.
96
+ - **`stripNocaseSpans(str)`** — Remove `<span class="nocase">` tags without changing case. Used when a value passes through without any text-case transform.
69
97
 
70
- Capitalize first word only. Preserves all-caps acronyms (DNA, USA) in mixed-case input.
98
+ ### Number formatting
71
99
 
72
- #### `capitalize(str)`
100
+ ```javascript
101
+ import { ordinal, longOrdinal, roman } from '@citestyle/core'
73
102
 
74
- Capitalize the first character of the string.
75
-
76
- #### `stripNocaseSpans(str)`
77
-
78
- Remove `<span class="nocase">` tags without applying any case transform.
103
+ ordinal(3) // "3rd"
104
+ ordinal(1, 'fr') // "1re" (locale-aware)
105
+ longOrdinal(2) // "second"
106
+ roman(14) // "xiv"
107
+ ```
79
108
 
80
- ### Number Formatting
109
+ ### Page ranges
81
110
 
82
- #### `ordinal(n, locale?)`
111
+ #### `pageRange(pages, format?)`
83
112
 
84
- Format as ordinal: `ordinal(3)` returns `"3rd"`. Supports locale-specific suffixes.
113
+ Format page ranges per CSL `page-range-format` rules.
85
114
 
86
- #### `longOrdinal(n, locale?)`
115
+ ```javascript
116
+ import { pageRange } from '@citestyle/core'
87
117
 
88
- Format as long ordinal: `longOrdinal(2)` returns `"second"`. Falls back to numeric ordinal for n > 10.
118
+ pageRange('321-328', 'chicago') // "321–28" (en-dash, Chicago-style abbreviation)
119
+ pageRange('321-328', 'minimal') // "321–8" (en-dash, minimal abbreviation)
120
+ pageRange('321-328', 'expanded') // "321–328" (en-dash, no abbreviation)
121
+ ```
89
122
 
90
- #### `roman(n)`
123
+ ### HTML output
91
124
 
92
- Format as lowercase Roman numeral: `roman(14)` returns `"xiv"`.
125
+ #### `toHtml(str)`
93
126
 
94
- ### Page Ranges
127
+ Convert internal formatting tokens to semantic HTML. Also auto-links DOIs and URLs. This is the final output step — call it on the raw output from a compiled style function.
95
128
 
96
- #### `pageRange(pages, format?)`
129
+ #### `stripFormatting(str)`
97
130
 
98
- Format page ranges per CSL `page-range-format`: `'expanded'`, `'chicago'`, `'minimal'`, `'minimal-two'`.
131
+ Remove all formatting tokens for clean plain text output.
99
132
 
100
- ```js
101
- import { pageRange } from '@citestyle/core'
133
+ #### `escapeHtml(str)`
102
134
 
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
- ```
135
+ Escape `&`, `<`, `>`, `"`, `'` for safe HTML interpolation. Used internally by compiled styles for all variable output.
106
136
 
107
- ### HTML Output
137
+ ### Validation
108
138
 
109
- #### `escapeHtml(str)`
139
+ #### `validateItem(item)`
110
140
 
111
- Escape `&`, `<`, `>`, `"`, `'` for safe HTML output.
141
+ Validate a CSL-JSON item and return warnings for common mistakes.
112
142
 
113
- #### `toHtml(str)`
143
+ ```javascript
144
+ import { validateItem } from '@citestyle/core'
114
145
 
115
- Convert PUA formatting tokens to HTML tags. Also auto-links DOIs and URLs. This is the final output step for HTML rendering.
146
+ validateItem({ id: '1', type: 'article-journal', title: 'My Paper', doi: '10.1234/x' })
147
+ // { valid: true, warnings: ['Use uppercase "DOI" instead of "doi"'] }
116
148
 
117
- #### `stripFormatting(str)`
149
+ validateItem({ title: 'Missing required fields' })
150
+ // { valid: false, warnings: ['Missing required field: id', 'Missing required field: type'] }
151
+ ```
118
152
 
119
- Remove all PUA formatting tokens, returning clean plain text.
153
+ Checks for: missing `id`/`type`, invalid type values, malformed name fields (should be arrays of objects with `family` or `literal`), malformed date fields, and common field-name mistakes (`doi` → `DOI`, `url` → `URL`, `isbn` → `ISBN`).
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "@citestyle/core",
3
- "version": "0.0.2",
3
+ "version": "1.0.1",
4
4
  "description": "Shared runtime helpers for compiled CSL styles (~6-8KB)",
5
+ "author": "Uniweb",
6
+ "homepage": "https://github.com/uniweb/csl/tree/main/packages/core",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/uniweb/csl.git",
10
+ "directory": "packages/core"
11
+ },
12
+ "keywords": [
13
+ "csl",
14
+ "citation",
15
+ "formatting",
16
+ "names",
17
+ "dates",
18
+ "text-case"
19
+ ],
5
20
  "type": "module",
6
21
  "exports": {
7
22
  ".": {
@@ -23,7 +38,6 @@
23
38
  },
24
39
  "license": "MIT",
25
40
  "scripts": {
26
- "test": "vitest run",
27
- "lint": "echo 'TODO: configure linter'"
41
+ "test": "vitest run"
28
42
  }
29
43
  }
package/src/index.js CHANGED
@@ -12,3 +12,4 @@ export { titleCase, sentenceCase, capitalize, applyTextCase, stripNocaseSpans }
12
12
  export { ordinal, longOrdinal, roman } from './numbers.js'
13
13
  export { pageRange } from './pages.js'
14
14
  export { escapeHtml, stripFormatting, toHtml } from './html.js'
15
+ export { validateItem } from './validate.js'
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
  /**
@@ -0,0 +1,110 @@
1
+ /**
2
+ * CSL-JSON item validation.
3
+ *
4
+ * Checks that a CSL-JSON item has required fields and correct shapes.
5
+ * Returns an array of warning strings (empty = valid).
6
+ */
7
+
8
+ const VALID_TYPES = new Set([
9
+ 'article', 'article-journal', 'article-magazine', 'article-newspaper',
10
+ 'bill', 'book', 'broadcast', 'chapter', 'dataset', 'entry',
11
+ 'entry-dictionary', 'entry-encyclopedia', 'figure', 'graphic',
12
+ 'interview', 'legal_case', 'legislation', 'manuscript', 'map',
13
+ 'motion_picture', 'musical_score', 'pamphlet', 'paper-conference',
14
+ 'patent', 'personal_communication', 'post', 'post-weblog',
15
+ 'regulation', 'report', 'review', 'review-book', 'software',
16
+ 'song', 'speech', 'standard', 'thesis', 'treaty', 'webpage',
17
+ ])
18
+
19
+ const NAME_FIELDS = [
20
+ 'author', 'editor', 'translator', 'container-author', 'collection-editor',
21
+ 'composer', 'director', 'interviewer', 'reviewed-author', 'recipient',
22
+ 'illustrator', 'original-author',
23
+ ]
24
+
25
+ const DATE_FIELDS = [
26
+ 'issued', 'accessed', 'original-date', 'submitted', 'event-date',
27
+ ]
28
+
29
+ /**
30
+ * Validate a CSL-JSON item.
31
+ *
32
+ * @param {object} item - CSL-JSON item to validate
33
+ * @returns {{ valid: boolean, warnings: string[] }}
34
+ */
35
+ export function validateItem(item) {
36
+ const warnings = []
37
+
38
+ if (!item || typeof item !== 'object') {
39
+ return { valid: false, warnings: ['Item must be an object'] }
40
+ }
41
+
42
+ // Required: id
43
+ if (item.id == null || item.id === '') {
44
+ warnings.push('Missing required field "id"')
45
+ }
46
+
47
+ // Required: type
48
+ if (!item.type) {
49
+ warnings.push('Missing required field "type"')
50
+ } else if (!VALID_TYPES.has(item.type)) {
51
+ warnings.push(`Unknown type "${item.type}"`)
52
+ }
53
+
54
+ // Validate name fields are arrays of objects
55
+ for (const field of NAME_FIELDS) {
56
+ if (item[field] != null) {
57
+ if (!Array.isArray(item[field])) {
58
+ warnings.push(`"${field}" must be an array of name objects`)
59
+ } else {
60
+ for (let i = 0; i < item[field].length; i++) {
61
+ const name = item[field][i]
62
+ if (!name || typeof name !== 'object') {
63
+ warnings.push(`${field}[${i}] must be a name object`)
64
+ } else if (!name.family && !name.literal) {
65
+ warnings.push(`${field}[${i}] must have "family" or "literal"`)
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ // Validate date fields
73
+ for (const field of DATE_FIELDS) {
74
+ if (item[field] != null) {
75
+ const date = item[field]
76
+ if (typeof date !== 'object' || Array.isArray(date)) {
77
+ warnings.push(`"${field}" must be a date object`)
78
+ } else if (!date['date-parts'] && !date.literal && !date.raw) {
79
+ warnings.push(`"${field}" must have "date-parts", "literal", or "raw"`)
80
+ } else if (date['date-parts']) {
81
+ if (!Array.isArray(date['date-parts'])) {
82
+ warnings.push(`${field}.date-parts must be an array`)
83
+ } else {
84
+ for (let i = 0; i < date['date-parts'].length; i++) {
85
+ const dp = date['date-parts'][i]
86
+ if (!Array.isArray(dp) || dp.length === 0) {
87
+ warnings.push(`${field}.date-parts[${i}] must be a non-empty array [year, month?, day?]`)
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // Helpful warnings for common mistakes
96
+ if (item.doi && !item.DOI) {
97
+ warnings.push('"doi" should be "DOI" (uppercase)')
98
+ }
99
+ if (item.url && !item.URL) {
100
+ warnings.push('"url" should be "URL" (uppercase)')
101
+ }
102
+ if (item.isbn && !item.ISBN) {
103
+ warnings.push('"isbn" should be "ISBN" (uppercase)')
104
+ }
105
+ if (item.issn && !item.ISSN) {
106
+ warnings.push('"issn" should be "ISSN" (uppercase)')
107
+ }
108
+
109
+ return { valid: warnings.length === 0, warnings }
110
+ }
package/types.d.ts CHANGED
@@ -13,6 +13,8 @@ export {
13
13
  escapeHtml,
14
14
  stripFormatting,
15
15
  toHtml,
16
+ validateItem,
17
+ ValidationResult,
16
18
  NameFormatConfig,
17
19
  DateFormatConfig,
18
20
  } from '@citestyle/types'