Skip to content

Commit

Permalink
πŸ“‡ Support parsed author names and parse string names (#586)
Browse files Browse the repository at this point in the history
* πŸ“‡ Support parsed author names and parse string names

* πŸ‘©β€πŸŽ“ Align name parsing with CSL-JSON author format, somewhat following citation-js

* πŸ” Move name parse tests to yaml file

* πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ Add real names to name parse tests
  • Loading branch information
fwkoch committed Sep 14, 2023
1 parent ae5411a commit 59b5458
Show file tree
Hide file tree
Showing 14 changed files with 880 additions and 31 deletions.
5 changes: 5 additions & 0 deletions .changeset/wet-jeans-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'myst-frontmatter': patch
---

Support parsed author names and parse string names
137 changes: 120 additions & 17 deletions packages/myst-frontmatter/src/frontmatter/frontmatter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {

const TEST_AUTHOR: Author = {
userId: '',
name: 'test user',
name: 'Test Author',
nameParsed: { literal: 'Test Author', given: 'Test', family: 'Author' },
orcid: '0000-0000-0000-0000',
corresponding: true,
email: 'test@example.com',
Expand Down Expand Up @@ -139,7 +140,13 @@ const TEST_PROJECT_FRONTMATTER: ProjectFrontmatter = {
title: 'frontmatter',
description: 'project frontmatter',
venue: { title: 'test' },
authors: [{ name: 'John Doe', affiliations: ['univa'] }],
authors: [
{
name: 'John Doe',
nameParsed: { literal: 'John Doe', given: 'John', family: 'Doe' },
affiliations: ['univa'],
},
],
affiliations: [{ id: 'univa', name: 'University A' }],
date: '14 Dec 2021',
name: 'example.md',
Expand Down Expand Up @@ -178,7 +185,13 @@ const TEST_PAGE_FRONTMATTER: PageFrontmatter = {
title: 'frontmatter',
description: 'page frontmatter',
venue: { title: 'test' },
authors: [{ name: 'Jane Doe', affiliations: ['univb'] }],
authors: [
{
name: 'Jane Doe',
nameParsed: { literal: 'Jane Doe', given: 'Jane', family: 'Doe' },
affiliations: ['univb'],
},
],
affiliations: [{ id: 'univb', name: 'University B' }],
name: 'example.md',
doi: '10.1000/abcd/efg012',
Expand Down Expand Up @@ -248,6 +261,7 @@ describe('validateAuthor', () => {
it('unknown roles warn', async () => {
expect(validateAuthor({ name: 'my name', roles: ['example'] }, {}, opts)).toEqual({
name: 'my name',
nameParsed: { literal: 'my name', given: 'my', family: 'name' },
roles: ['example'],
});
expect(opts.messages.warnings?.length).toEqual(1);
Expand Down Expand Up @@ -676,11 +690,27 @@ describe('validateAndStashObject', () => {
opts,
);
expect(out).toEqual('Just A. Name');
expect(stash).toEqual({ authors: [{ id: 'Just A. Name', name: 'Just A. Name' }] });
expect(stash).toEqual({
authors: [
{
id: 'Just A. Name',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
});
expect(opts.messages.warnings?.length).toBeFalsy();
});
it('string returns itself when in stash', async () => {
const stash = { authors: [{ id: 'auth1', name: 'Just A. Name' }] };
const stash = {
authors: [
{
id: 'auth1',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
};
const out = validateAndStashObject(
'auth1',
stash,
Expand All @@ -689,7 +719,15 @@ describe('validateAndStashObject', () => {
opts,
);
expect(out).toEqual('auth1');
expect(stash).toEqual({ authors: [{ id: 'auth1', name: 'Just A. Name' }] });
expect(stash).toEqual({
authors: [
{
id: 'auth1',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
});
expect(opts.messages.warnings?.length).toBeFalsy();
});
it('no id creates hashed id', async () => {
Expand All @@ -703,7 +741,13 @@ describe('validateAndStashObject', () => {
);
expect(out).toEqual('authors-test-file-generated-uid-0');
expect(stash).toEqual({
authors: [{ id: 'authors-test-file-generated-uid-0', name: 'Just A. Name' }],
authors: [
{
id: 'authors-test-file-generated-uid-0',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
});
expect(opts.messages.warnings?.length).toBeFalsy();
});
Expand All @@ -725,12 +769,26 @@ describe('validateAndStashObject', () => {
);
expect(out).toEqual('authors-my_file-generated-uid-0');
expect(stash).toEqual({
authors: [{ id: 'authors-my_file-generated-uid-0', name: 'Just A. Name' }],
authors: [
{
id: 'authors-my_file-generated-uid-0',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
});
expect(opts.messages.warnings?.length).toBeFalsy();
});
it('object with id added to stash', async () => {
const stash = { authors: [{ id: 'auth1', name: 'Just A. Name' }] };
const stash = {
authors: [
{
id: 'auth1',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
};
const out = validateAndStashObject(
{ id: 'auth2', name: 'A. Nother Name' },
stash,
Expand All @@ -741,36 +799,81 @@ describe('validateAndStashObject', () => {
expect(out).toEqual('auth2');
expect(stash).toEqual({
authors: [
{ id: 'auth1', name: 'Just A. Name' },
{ id: 'auth2', name: 'A. Nother Name' },
{
id: 'auth1',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
{
id: 'auth2',
name: 'A. Nother Name',
nameParsed: { literal: 'A. Nother Name', given: 'A. Nother', family: 'Name' },
},
],
});
expect(opts.messages.warnings?.length).toBeFalsy();
});
it('object with id replaces simple object', async () => {
const stash = { authors: [{ id: 'auth1', name: 'auth1' }] };
const stash = {
authors: [
{
id: 'auth1',
name: 'auth1',
},
],
};
const out = validateAndStashObject(
{ id: 'auth1', name: 'Just A. Name' },
{
id: 'auth1',
name: 'Just A. Name',
},
stash,
'authors',
(v: any, o: ValidationOptions) => validateAuthor(v, stash, o),
opts,
);
expect(out).toEqual('auth1');
expect(stash).toEqual({ authors: [{ id: 'auth1', name: 'Just A. Name' }] });
expect(stash).toEqual({
authors: [
{
id: 'auth1',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
});
expect(opts.messages.warnings?.length).toBeFalsy();
});
it('object with id warns on duplicate', async () => {
const stash = { authors: [{ id: 'auth1', name: 'Just A. Name' }] };
const stash = {
authors: [
{
id: 'auth1',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
};
const out = validateAndStashObject(
{ id: 'auth1', name: 'A. Nother Name' },
{
id: 'auth1',
name: 'A. Nother Name',
},
stash,
'authors',
(v: any, o: ValidationOptions) => validateAuthor(v, stash, o),
opts,
);
expect(out).toEqual('auth1');
expect(stash).toEqual({ authors: [{ id: 'auth1', name: 'Just A. Name' }] });
expect(stash).toEqual({
authors: [
{
id: 'auth1',
name: 'Just A. Name',
nameParsed: { literal: 'Just A. Name', given: 'Just A.', family: 'Name' },
},
],
});
expect(opts.messages.warnings?.length).toEqual(1);
});
});
16 changes: 15 additions & 1 deletion packages/myst-frontmatter/src/frontmatter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,21 @@ export interface Affiliation {

export type AuthorRoles = CreditRole | string;

export type Name = {
literal?: string;
given?: string;
family?: string;
dropping_particle?: string;
non_dropping_particle?: string;
suffix?: string;
};

/**
* Au
*/
export interface Author {
id?: string;
name?: string; // or Name object?
name?: string; // may be set to Name object
userId?: string;
orcid?: string;
corresponding?: boolean;
Expand All @@ -40,6 +52,8 @@ export interface Author {
note?: string;
phone?: string;
fax?: string;
// Computed property; only 'name' should be set in frontmatter as string or Name object
nameParsed?: Name;
}

/**
Expand Down
83 changes: 82 additions & 1 deletion packages/myst-frontmatter/src/frontmatter/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
validateNumber,
} from 'simple-validators';
import { validateLicenses } from '../licenses/validators.js';
import { formatName, parseName } from '../utils/parseName.js';
import { ExportFormats } from './types.js';
import type {
Author,
Expand All @@ -41,6 +42,7 @@ import type {
JupyterLocalOptions,
ReferenceStash,
Affiliation,
Name,
} from './types.js';

export const SITE_FRONTMATTER_KEYS = [
Expand Down Expand Up @@ -147,6 +149,24 @@ const AUTHOR_ALIASES = {
website: 'url',
};

const NAME_KEYS = [
'literal',
'given',
'family',
'suffix',
'non_dropping_particle',
'dropping_particle',
];
const NAME_ALIASES = {
surname: 'family',
last: 'family',
forename: 'given',
first: 'given',
particle: 'non_dropping_particle',
'non-dropping-particle': 'non_dropping_particle',
'dropping-particle': 'dropping_particle',
};

const AFFILIATION_ALIASES = {
ref: 'id', // Used in QMD to reference an affiliation
region: 'state',
Expand Down Expand Up @@ -432,6 +452,66 @@ export function validateAffiliation(input: any, opts: ValidationOptions) {
return output;
}

/**
* Validate Name object against the schema
*/
export function validateName(input: any, opts: ValidationOptions) {
let output: Name;
if (typeof input === 'string') {
output = parseName(input);
} else {
const value = validateObjectKeys(input, { optional: NAME_KEYS, alias: NAME_ALIASES }, opts);
if (value === undefined) return undefined;
output = {};
if (defined(value.literal)) {
output.literal = validateString(value.literal, incrementOptions('literal', opts));
}
if (defined(value.given)) {
output.given = validateString(value.given, incrementOptions('given', opts));
}
if (defined(value.non_dropping_particle)) {
output.non_dropping_particle = validateString(
value.non_dropping_particle,
incrementOptions('non_dropping_particle', opts),
);
}
if (defined(value.dropping_particle)) {
output.dropping_particle = validateString(
value.dropping_particle,
incrementOptions('dropping_particle', opts),
);
}
if (defined(value.family)) {
output.family = validateString(value.family, incrementOptions('family', opts));
}
if (defined(value.suffix)) {
output.suffix = validateString(value.suffix, incrementOptions('suffix', opts));
}
if (Object.keys(output).length === 1 && output.literal) {
output = { ...output, ...parseName(output.literal) };
} else if (!output.literal) {
output.literal = formatName(output);
}
}
const warnOnComma = (part: string | undefined, o: ValidationOptions) => {
if (part && part.includes(',')) {
validationWarning(`unexpected comma in name part: ${part}`, o);
}
};
warnOnComma(output.given, incrementOptions('given', opts));
warnOnComma(output.family, incrementOptions('family', opts));
warnOnComma(output.non_dropping_particle, incrementOptions('non_dropping_particle', opts));
warnOnComma(output.dropping_particle, incrementOptions('dropping_particle', opts));
warnOnComma(output.suffix, incrementOptions('suffix', opts));
if (!output.family) {
validationWarning(`No family name for name '${output.literal}'`, opts);
}
if (!output.given) {
validationWarning(`No given name for name '${output.literal}'`, opts);
}
return output;
}

/**
* Validate Author object against the schema
*/
Expand All @@ -450,7 +530,8 @@ export function validateAuthor(input: any, stash: ReferenceStash, opts: Validati
output.userId = validateString(value.userId, incrementOptions('userId', opts));
}
if (defined(value.name)) {
output.name = validateString(value.name, incrementOptions('name', opts));
output.nameParsed = validateName(value.name, incrementOptions('name', opts));
output.name = output.nameParsed?.literal;
} else {
validationWarning('author should include name', opts);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/myst-frontmatter/src/utils/edgecases.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
title: Parsing of name edge cases
cases:
- formatted: ',,,,,,'
parsed:
family: ',,,,'
- formatted: ''
parsed: {}
alternatives:
- " \t "
1 change: 1 addition & 0 deletions packages/myst-frontmatter/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './parseName.js';
Loading

0 comments on commit 59b5458

Please sign in to comment.