myst-frontmatter
Advanced tools
Comparing version
import type { CreditRole } from 'credit-roles'; | ||
import type { Licenses } from '../licenses/types.js'; | ||
export interface Affiliation { | ||
id?: string; | ||
name?: string; | ||
institution?: string; | ||
department?: string; | ||
address?: string; | ||
city?: string; | ||
state?: string; | ||
postal_code?: string; | ||
country?: string; | ||
collaboration?: boolean; | ||
isni?: string; | ||
ringgold?: number; | ||
ror?: string; | ||
url?: string; | ||
email?: string; | ||
phone?: string; | ||
fax?: string; | ||
} | ||
export type AuthorRoles = CreditRole | string; | ||
export interface Author { | ||
id?: string; | ||
name?: string; | ||
@@ -9,10 +29,23 @@ userId?: string; | ||
corresponding?: boolean; | ||
equal_contributor?: boolean; | ||
deceased?: boolean; | ||
email?: string; | ||
roles?: AuthorRoles[]; | ||
affiliations?: string[]; | ||
collaborations?: string[]; | ||
twitter?: string; | ||
github?: string; | ||
website?: string; | ||
url?: string; | ||
note?: string; | ||
phone?: string; | ||
fax?: string; | ||
} | ||
/** | ||
* Object to hold items referenced in multiple parts of frontmatter | ||
* | ||
* These will be normalized to the top level and replaced with ids elsewhere | ||
*/ | ||
export type ReferenceStash = { | ||
affiliations?: Affiliation[]; | ||
authors?: Author[]; | ||
}; | ||
export type Biblio = { | ||
@@ -115,2 +148,3 @@ volume?: string | number; | ||
authors?: Author[]; | ||
affiliations?: Affiliation[]; | ||
venue?: Venue; | ||
@@ -117,0 +151,0 @@ github?: string; |
import type { ValidationOptions } from 'simple-validators'; | ||
import type { Author, Biblio, Export, Jupytext, KernelSpec, Numbering, PageFrontmatter, ProjectFrontmatter, SiteFrontmatter, Venue, Thebe, BinderHubOptions, JupyterServerOptions, JupyterLocalOptions } from './types.js'; | ||
import type { Author, Biblio, Export, Jupytext, KernelSpec, Numbering, PageFrontmatter, ProjectFrontmatter, SiteFrontmatter, Venue, Thebe, BinderHubOptions, JupyterServerOptions, JupyterLocalOptions, ReferenceStash, Affiliation } from './types.js'; | ||
export declare const SITE_FRONTMATTER_KEYS: string[]; | ||
@@ -9,2 +9,20 @@ export declare const PROJECT_FRONTMATTER_KEYS: string[]; | ||
/** | ||
* Update stash of authors/affiliations based on input value | ||
* | ||
* Input may be: | ||
* - string name | ||
* - string id | ||
* - object without id | ||
* - object with id | ||
* | ||
* This function will normalize all of the above to an id and if a corresponding | ||
* object does not yet exist in the stash, it will be added. The id is returned. | ||
* | ||
* This function will warn if two objects are explicitly defined with the same id. | ||
*/ | ||
export declare function validateAndStashObject<T extends { | ||
id?: string; | ||
name?: string; | ||
}>(input: any, stash: ReferenceStash, kind: keyof ReferenceStash, validateFn: (v: any, o: ValidationOptions) => T | undefined, opts: ValidationOptions): string | undefined; | ||
/** | ||
* Validate Venue object against the schema | ||
@@ -16,5 +34,12 @@ * | ||
/** | ||
* Validate Affiliation object against the schema | ||
*/ | ||
export declare function validateAffiliation(input: any, opts: ValidationOptions): Affiliation | { | ||
id: string; | ||
name: string; | ||
} | undefined; | ||
/** | ||
* Validate Author object against the schema | ||
*/ | ||
export declare function validateAuthor(input: any, opts: ValidationOptions): Author | undefined; | ||
export declare function validateAuthor(input: any, stash: ReferenceStash, opts: ValidationOptions): Author | undefined; | ||
/** | ||
@@ -70,3 +95,3 @@ * Validate Biblio object | ||
*/ | ||
export declare function fillPageFrontmatter(pageFrontmatter: PageFrontmatter, projectFrontmatter: ProjectFrontmatter): PageFrontmatter; | ||
export declare function fillPageFrontmatter(pageFrontmatter: PageFrontmatter, projectFrontmatter: ProjectFrontmatter, opts: ValidationOptions): PageFrontmatter; | ||
/** | ||
@@ -73,0 +98,0 @@ * Unnest `kernelspec` from `jupyter.kernelspec` |
import { doi } from 'doi-utils'; | ||
import { credit } from 'credit-roles'; | ||
import { orcid } from 'orcid'; | ||
import { defined, incrementOptions, fillMissingKeys, filterKeys, validateBoolean, validateDate, validateEmail, validateEnum, validateKeys, validateList, validateObject, validateObjectKeys, validateString, validateUrl, validationError, validationWarning, } from 'simple-validators'; | ||
import { defined, incrementOptions, fillMissingKeys, filterKeys, validateBoolean, validateDate, validateEmail, validateEnum, validateKeys, validateList, validateObject, validateObjectKeys, validateString, validateUrl, validationError, validationWarning, validateNumber, } from 'simple-validators'; | ||
import { validateLicenses } from '../licenses/validators.js'; | ||
import { BinderProviders, ExportFormats } from './types.js'; | ||
import { createHash } from 'node:crypto'; | ||
export const SITE_FRONTMATTER_KEYS = [ | ||
@@ -20,2 +21,3 @@ 'title', | ||
'keywords', | ||
'affiliations', | ||
]; | ||
@@ -60,3 +62,23 @@ export const PROJECT_FRONTMATTER_KEYS = [ | ||
]; | ||
const AFFILIATION_KEYS = [ | ||
'id', | ||
'address', | ||
'city', | ||
'state', | ||
'postal_code', | ||
'country', | ||
'name', | ||
'institution', | ||
'department', | ||
'collaboration', | ||
'isni', | ||
'ringgold', | ||
'ror', | ||
'url', | ||
'email', | ||
'phone', | ||
'fax', | ||
]; | ||
const AUTHOR_KEYS = [ | ||
'id', | ||
'userId', | ||
@@ -66,2 +88,4 @@ 'name', | ||
'corresponding', | ||
'equal_contributor', | ||
'deceased', | ||
'email', | ||
@@ -73,3 +97,6 @@ 'roles', | ||
'github', | ||
'website', | ||
'url', | ||
'note', | ||
'phone', | ||
'fax', | ||
]; | ||
@@ -79,3 +106,10 @@ const AUTHOR_ALIASES = { | ||
affiliation: 'affiliations', | ||
website: 'url', | ||
}; | ||
const AFFILIATION_ALIASES = { | ||
ref: 'id', | ||
region: 'state', | ||
province: 'state', | ||
website: 'url', | ||
}; | ||
const BIBLIO_KEYS = ['volume', 'issue', 'first_page', 'last_page']; | ||
@@ -145,3 +179,68 @@ const THEBE_KEYS = [ | ||
} | ||
function stashPlaceholder(value) { | ||
return { id: value, name: value }; | ||
} | ||
/** | ||
* Return true if object: | ||
* - has 2 keys and only 2 keys: id and name | ||
* - the values for id and name are the same | ||
*/ | ||
function isStashPlaceholder(object) { | ||
return Object.keys(object).length === 2 && object.name && object.id && object.name === object.id; | ||
} | ||
function normalizedString(value) { | ||
return JSON.stringify(Object.entries(value).sort()); | ||
} | ||
/** | ||
* Update stash of authors/affiliations based on input value | ||
* | ||
* Input may be: | ||
* - string name | ||
* - string id | ||
* - object without id | ||
* - object with id | ||
* | ||
* This function will normalize all of the above to an id and if a corresponding | ||
* object does not yet exist in the stash, it will be added. The id is returned. | ||
* | ||
* This function will warn if two objects are explicitly defined with the same id. | ||
*/ | ||
export function validateAndStashObject(input, stash, kind, validateFn, opts) { | ||
var _a; | ||
const lookup = {}; | ||
(_a = stash[kind]) === null || _a === void 0 ? void 0 : _a.forEach((item) => { | ||
if (item.id) | ||
lookup[item.id] = item; | ||
}); | ||
if (typeof input === 'string' && Object.keys(lookup).includes(input)) { | ||
// Handle case where input is id and object already exists | ||
return input; | ||
} | ||
const value = validateFn(input, opts); | ||
if (!value) | ||
return; | ||
// Only warn on duplicate if the new object is not a placeholder | ||
let warnOnDuplicate = !isStashPlaceholder(value); | ||
if (!value.id) { | ||
// If object is defined without an id, generate a unique id | ||
value.id = createHash('md5').update(normalizedString(value)).digest('hex'); | ||
// Do not warn on duplicates for hash ids; any duplicates here are identical | ||
warnOnDuplicate = false; | ||
} | ||
if (!Object.keys(lookup).includes(value.id)) { | ||
// Handle case of new id - add stash value | ||
lookup[value.id] = value; | ||
} | ||
else if (isStashPlaceholder(lookup[value.id])) { | ||
// Handle case of existing placeholder { id: value, name: value } - replace stash value | ||
lookup[value.id] = value; | ||
} | ||
else if (warnOnDuplicate) { | ||
// Warn on duplicate id - lose new object | ||
validationWarning(`duplicate id for ${kind} found in frontmatter: ${value.id}`, opts); | ||
} | ||
stash[kind] = Object.values(lookup); | ||
return value.id; | ||
} | ||
/** | ||
* Validate Venue object against the schema | ||
@@ -174,7 +273,84 @@ * | ||
/** | ||
* Validate Affiliation object against the schema | ||
*/ | ||
export function validateAffiliation(input, opts) { | ||
if (typeof input === 'string') { | ||
input = stashPlaceholder(input); | ||
} | ||
const value = validateObjectKeys(input, { optional: AFFILIATION_KEYS, alias: AFFILIATION_ALIASES }, opts); | ||
if (value === undefined) | ||
return undefined; | ||
const output = {}; | ||
if (defined(value.id)) { | ||
output.id = validateString(value.id, incrementOptions('id', opts)); | ||
} | ||
if (defined(value.name)) { | ||
output.name = validateString(value.name, incrementOptions('name', opts)); | ||
} | ||
if (defined(value.institution)) { | ||
output.institution = validateString(value.institution, incrementOptions('institution', opts)); | ||
} | ||
if (defined(value.department)) { | ||
output.department = validateString(value.department, incrementOptions('department', opts)); | ||
} | ||
if (defined(value.address)) { | ||
output.address = validateString(value.address, incrementOptions('address', opts)); | ||
} | ||
if (defined(value.city)) { | ||
output.city = validateString(value.city, incrementOptions('city', opts)); | ||
} | ||
if (defined(value.state)) { | ||
output.state = validateString(value.state, incrementOptions('state', opts)); | ||
} | ||
if (defined(value.postal_code)) { | ||
output.postal_code = validateString(value.postal_code, incrementOptions('postal_code', opts)); | ||
} | ||
if (defined(value.country)) { | ||
output.country = validateString(value.country, incrementOptions('country', opts)); | ||
} | ||
// Both ISNI and ROR validation should occur similar to orcid (maybe in that same lib?) | ||
if (defined(value.isni)) { | ||
output.isni = validateString(value.isni, incrementOptions('isni', opts)); | ||
} | ||
if (defined(value.ror)) { | ||
output.ror = validateString(value.ror, incrementOptions('ror', opts)); | ||
} | ||
if (defined(value.ringgold)) { | ||
output.ringgold = validateNumber(value.ringgold, { | ||
min: 1000, | ||
max: 999999, | ||
...incrementOptions('ringgold', opts), | ||
}); | ||
} | ||
if (defined(value.collaboration)) { | ||
output.collaboration = validateBoolean(value.collaboration, incrementOptions('collaboration', opts)); | ||
} | ||
if (defined(value.email)) { | ||
output.email = validateEmail(value.email, incrementOptions('email', opts)); | ||
} | ||
if (defined(value.url)) { | ||
output.url = validateUrl(value.url, incrementOptions('url', opts)); | ||
} | ||
if (defined(value.phone)) { | ||
output.phone = validateString(value.phone, incrementOptions('phone', opts)); | ||
} | ||
if (defined(value.fax)) { | ||
output.fax = validateString(value.fax, incrementOptions('fax', opts)); | ||
} | ||
// If affiliation only has an id, give it a matching name; this is equivalent to the case | ||
// where a simple string is provided as an affiliation. | ||
if (Object.keys(output).length === 1 && output.id) { | ||
return stashPlaceholder(output.id); | ||
} | ||
else if (!output.name && !output.institution) { | ||
validationWarning('affiliation should include name or institution', opts); | ||
} | ||
return output; | ||
} | ||
/** | ||
* Validate Author object against the schema | ||
*/ | ||
export function validateAuthor(input, opts) { | ||
export function validateAuthor(input, stash, opts) { | ||
if (typeof input === 'string') { | ||
input = { name: input }; | ||
input = { id: input, name: input }; | ||
} | ||
@@ -185,2 +361,5 @@ const value = validateObjectKeys(input, { optional: AUTHOR_KEYS, alias: AUTHOR_ALIASES }, opts); | ||
const output = {}; | ||
if (defined(value.id)) { | ||
output.id = validateString(value.id, incrementOptions('id', opts)); | ||
} | ||
if (defined(value.userId)) { | ||
@@ -193,2 +372,5 @@ // TODO: Better userId validation - length? regex? | ||
} | ||
else { | ||
validationWarning('author should include name', opts); | ||
} | ||
if (defined(value.orcid)) { | ||
@@ -212,2 +394,8 @@ const orcidOpts = incrementOptions('orcid', opts); | ||
} | ||
if (defined(value.equal_contributor)) { | ||
output.equal_contributor = validateBoolean(value.equal_contributor, incrementOptions('equal_contributor', opts)); | ||
} | ||
if (defined(value.deceased)) { | ||
output.deceased = validateBoolean(value.deceased, incrementOptions('deceased', opts)); | ||
} | ||
if (defined(value.email)) { | ||
@@ -234,2 +422,5 @@ output.email = validateEmail(value.email, incrementOptions('email', opts)); | ||
} | ||
if (defined(value.collaborations)) { | ||
validationError('collaborations must be defined in frontmatter as affiliations with "collaboration: true"', incrementOptions('collaborations', opts)); | ||
} | ||
if (defined(value.affiliations)) { | ||
@@ -239,20 +430,8 @@ const affiliationsOpts = incrementOptions('affiliations', opts); | ||
if (typeof affiliations === 'string') { | ||
affiliations = affiliations.split(';'); | ||
affiliations = affiliations.split(';').map((aff) => aff.trim()); | ||
} | ||
output.affiliations = validateList(affiliations, affiliationsOpts, (aff) => { | ||
var _a; | ||
return (_a = validateString(aff, affiliationsOpts)) === null || _a === void 0 ? void 0 : _a.trim(); | ||
return validateAndStashObject(aff, stash, 'affiliations', validateAffiliation, affiliationsOpts); | ||
}); | ||
} | ||
if (defined(value.collaborations)) { | ||
const collaborationsOpts = incrementOptions('collaborations', opts); | ||
let collaborations = value.collaborations; | ||
if (typeof collaborations === 'string') { | ||
collaborations = collaborations.split(';'); | ||
} | ||
output.collaborations = validateList(collaborations, collaborationsOpts, (col) => { | ||
var _a; | ||
return (_a = validateString(col, collaborationsOpts)) === null || _a === void 0 ? void 0 : _a.trim(); | ||
}); | ||
} | ||
if (defined(value.twitter)) { | ||
@@ -264,5 +443,14 @@ output.twitter = validateString(value.twitter, incrementOptions('twitter', opts)); | ||
} | ||
if (defined(value.website)) { | ||
output.website = validateUrl(value.website, incrementOptions('website', opts)); | ||
if (defined(value.url)) { | ||
output.url = validateUrl(value.url, incrementOptions('url', opts)); | ||
} | ||
if (defined(value.phone)) { | ||
output.phone = validateString(value.phone, incrementOptions('phone', opts)); | ||
} | ||
if (defined(value.fax)) { | ||
output.fax = validateString(value.fax, incrementOptions('fax', opts)); | ||
} | ||
if (defined(value.note)) { | ||
output.note = validateString(value.note, incrementOptions('note', opts)); | ||
} | ||
return output; | ||
@@ -623,2 +811,13 @@ } | ||
} | ||
const stash = {}; | ||
if (defined(value.affiliations)) { | ||
const affiliationsOpts = incrementOptions('affiliations', opts); | ||
let affiliations = value.affiliations; | ||
if (typeof affiliations === 'string') { | ||
affiliations = affiliations.split(';').map((aff) => aff.trim()); | ||
} | ||
validateList(affiliations, affiliationsOpts, (aff) => { | ||
return validateAndStashObject(aff, stash, 'affiliations', validateAffiliation, affiliationsOpts); | ||
}); | ||
} | ||
if (defined(value.authors)) { | ||
@@ -631,3 +830,3 @@ let authors = value.authors; | ||
output.authors = validateList(authors, incrementOptions('authors', opts), (author, index) => { | ||
return validateAuthor(author, incrementOptions(`authors.${index}`, opts)); | ||
return validateAuthor(author, stash, incrementOptions(`authors.${index}`, opts)); | ||
}); | ||
@@ -656,2 +855,5 @@ // Ensure there is a corresponding author if an email is provided | ||
} | ||
if (stash.affiliations) { | ||
output.affiliations = stash.affiliations; | ||
} | ||
return output; | ||
@@ -838,3 +1040,3 @@ } | ||
*/ | ||
export function fillPageFrontmatter(pageFrontmatter, projectFrontmatter) { | ||
export function fillPageFrontmatter(pageFrontmatter, projectFrontmatter, opts) { | ||
var _a, _b, _c, _d; | ||
@@ -859,2 +1061,37 @@ const frontmatter = fillMissingKeys(pageFrontmatter, projectFrontmatter, USE_PROJECT_FALLBACK); | ||
} | ||
// Replace affiliation placeholders with extra affiliations available on the project/page | ||
let affiliations; | ||
let extraAffiliations; | ||
// Currently, affiliations are connected only to authors, so we look at | ||
// which frontmatter (project or page) has authors defined, and use the | ||
// affiliations from there. However, we still use the other affiliations | ||
// to fill out any placeholders where affiliations have id only. | ||
if (projectFrontmatter.authors && !pageFrontmatter.authors) { | ||
affiliations = projectFrontmatter.affiliations; | ||
extraAffiliations = pageFrontmatter.affiliations; | ||
} | ||
else { | ||
affiliations = pageFrontmatter.affiliations; | ||
extraAffiliations = projectFrontmatter.affiliations; | ||
} | ||
if (affiliations) { | ||
const projectAffLookup = {}; | ||
extraAffiliations === null || extraAffiliations === void 0 ? void 0 : extraAffiliations.forEach((aff) => { | ||
if (aff.id && !isStashPlaceholder(aff)) { | ||
projectAffLookup[aff.id] = aff; | ||
} | ||
}); | ||
frontmatter.affiliations = affiliations.map((aff) => { | ||
if (!aff.id || !projectAffLookup[aff.id]) { | ||
return aff; | ||
} | ||
else if (isStashPlaceholder(aff)) { | ||
return projectAffLookup[aff.id]; | ||
} | ||
else if (normalizedString(aff) !== normalizedString(projectAffLookup[aff.id])) { | ||
validationWarning(`Duplicate affiliation id within project: ${aff.id}`, incrementOptions('affiliations', opts)); | ||
} | ||
return aff; | ||
}); | ||
} | ||
return frontmatter; | ||
@@ -861,0 +1098,0 @@ } |
{ | ||
"name": "myst-frontmatter", | ||
"version": "1.1.1", | ||
"version": "1.1.2", | ||
"sideEffects": false, | ||
@@ -35,3 +35,3 @@ "license": "MIT", | ||
"dependencies": { | ||
"credit-roles": "^2.0.0", | ||
"credit-roles": "^2.1.0", | ||
"doi-utils": "^2.0.0", | ||
@@ -38,0 +38,0 @@ "orcid": "^1.0.0", |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
115531
11.44%3456
9.37%1
Infinity%Updated