@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 +85 -51
- package/package.json +17 -3
- package/src/index.js +1 -0
- package/src/text.js +49 -77
- package/src/validate.js +110 -0
- package/types.d.ts +2 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @citestyle/core
|
|
2
2
|
|
|
3
|
-
Shared runtime
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
~6-8KB. Zero dependencies. Works in Node.js and browsers.
|
|
14
14
|
|
|
15
|
-
|
|
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
|
|
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
|
-
```
|
|
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
|
|
53
|
+
**Config options**: `and`, `delimiter`, `delimiterPrecedesLast`, `etAlMin`, `etAlUseFirst`, `etAlUseLast`, `initialize`, `initializeWith`, `nameAsSortOrder`, `sortSeparator`, `nameParts`, `form`.
|
|
32
54
|
|
|
33
|
-
### Date
|
|
55
|
+
### Date formatting
|
|
34
56
|
|
|
35
57
|
#### `formatDate(date, config?)`
|
|
36
58
|
|
|
37
|
-
Format
|
|
59
|
+
Format CSL date objects with configurable parts, localized month terms, ranges, and seasons.
|
|
38
60
|
|
|
39
|
-
```
|
|
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-
|
|
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
|
|
77
|
+
Unified entry point for all CSL text-case values.
|
|
56
78
|
|
|
57
|
-
```
|
|
79
|
+
```javascript
|
|
58
80
|
import { applyTextCase } from '@citestyle/core'
|
|
59
81
|
|
|
60
|
-
applyTextCase('the art of war', 'title')
|
|
61
|
-
applyTextCase('A STUDY OF DNA', 'sentence')
|
|
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
|
-
|
|
89
|
+
**Supported values**: `'lowercase'`, `'uppercase'`, `'capitalize-first'`, `'capitalize-all'`, `'title'`, `'sentence'`.
|
|
65
90
|
|
|
66
|
-
|
|
91
|
+
#### Individual transforms
|
|
67
92
|
|
|
68
|
-
|
|
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
|
-
|
|
98
|
+
### Number formatting
|
|
71
99
|
|
|
72
|
-
|
|
100
|
+
```javascript
|
|
101
|
+
import { ordinal, longOrdinal, roman } from '@citestyle/core'
|
|
73
102
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
103
|
+
ordinal(3) // "3rd"
|
|
104
|
+
ordinal(1, 'fr') // "1re" (locale-aware)
|
|
105
|
+
longOrdinal(2) // "second"
|
|
106
|
+
roman(14) // "xiv"
|
|
107
|
+
```
|
|
79
108
|
|
|
80
|
-
###
|
|
109
|
+
### Page ranges
|
|
81
110
|
|
|
82
|
-
#### `
|
|
111
|
+
#### `pageRange(pages, format?)`
|
|
83
112
|
|
|
84
|
-
Format
|
|
113
|
+
Format page ranges per CSL `page-range-format` rules.
|
|
85
114
|
|
|
86
|
-
|
|
115
|
+
```javascript
|
|
116
|
+
import { pageRange } from '@citestyle/core'
|
|
87
117
|
|
|
88
|
-
|
|
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
|
-
|
|
123
|
+
### HTML output
|
|
91
124
|
|
|
92
|
-
|
|
125
|
+
#### `toHtml(str)`
|
|
93
126
|
|
|
94
|
-
|
|
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
|
-
#### `
|
|
129
|
+
#### `stripFormatting(str)`
|
|
97
130
|
|
|
98
|
-
|
|
131
|
+
Remove all formatting tokens for clean plain text output.
|
|
99
132
|
|
|
100
|
-
|
|
101
|
-
import { pageRange } from '@citestyle/core'
|
|
133
|
+
#### `escapeHtml(str)`
|
|
102
134
|
|
|
103
|
-
|
|
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
|
-
###
|
|
137
|
+
### Validation
|
|
108
138
|
|
|
109
|
-
#### `
|
|
139
|
+
#### `validateItem(item)`
|
|
110
140
|
|
|
111
|
-
|
|
141
|
+
Validate a CSL-JSON item and return warnings for common mistakes.
|
|
112
142
|
|
|
113
|
-
|
|
143
|
+
```javascript
|
|
144
|
+
import { validateItem } from '@citestyle/core'
|
|
114
145
|
|
|
115
|
-
|
|
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
|
-
|
|
149
|
+
validateItem({ title: 'Missing required fields' })
|
|
150
|
+
// { valid: false, warnings: ['Missing required field: id', 'Missing required field: type'] }
|
|
151
|
+
```
|
|
118
152
|
|
|
119
|
-
|
|
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": "
|
|
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
|
-
*
|
|
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
|
/**
|
package/src/validate.js
ADDED
|
@@ -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
|
+
}
|