@stencila/jesta
Version:
Stencila plugin for executable documents using JavaScript
292 lines (291 loc) • 11.5 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.parsePerson = exports.parseDate = exports.parseSsi = exports.parseCsi = exports.parse = exports.defaultForType = exports.validate = exports.methodSchema = void 0;
const schema = __importStar(require("@stencila/schema"));
const schema_1 = require("@stencila/schema");
const ajv_1 = __importDefault(require("ajv"));
const ajv_formats_1 = __importDefault(require("ajv-formats"));
const parse_author_1 = __importDefault(require("parse-author"));
const parse_full_name_1 = require("parse-full-name");
exports.methodSchema = {
title: 'validate',
description: 'Validate a node against the Stencila Schema.',
required: ['node'],
properties: {
node: {
description: 'The node to validate.',
},
force: {
description: 'Coerce the node to ensure it is valid (e.g. dropping properties)?',
type: 'boolean',
const: true,
},
},
};
let schemas;
let validators;
let coercers;
async function validate(node, force = true) {
var _a;
if (schemas === undefined) {
schemas = await schema_1.jsonSchemas();
}
if (force && coercers === undefined) {
coercers = ajv_formats_1.default(new ajv_1.default({
strict: false,
schemas,
coerceTypes: 'array',
}));
coercers.addKeyword({
keyword: 'parser',
type: 'string',
modifying: true,
validate: parse,
errors: false,
});
}
else if (validators === undefined) {
validators = ajv_formats_1.default(new ajv_1.default({
strict: false,
schemas,
}));
}
const type = schema.nodeType(node);
const validator = (force ? coercers : validators).getSchema(type);
if (validator === undefined) {
throw new Error(`No schema for node type ${type}`);
}
if (force)
coerce(node);
if (validator(node) !== true) {
const message = (_a = validator.errors) === null || _a === void 0 ? void 0 : _a.map((error) => { var _a; return `${error.instancePath} ${(_a = error.message) !== null && _a !== void 0 ? _a : ''}`; }).join('; ');
throw new Error(message);
}
return node;
}
exports.validate = validate;
validate.schema = exports.methodSchema;
/**
* Recursively walk through the node coercing it towards its schema
*
* This function does several things that Ajv will not
* do for us:
* - rename aliases to canonical property names
*
* - remove additional properties (not in schema);
* Ajv does this but with limitations when `anyOf` etc are used
* https://github.com/epoberezkin/ajv/blob/master/FAQ.md#additional-properties-inside-compound-keywords-anyof-oneof-etc
*
* - coerce an object to an array of objects;
* Ajv does not do that https://github.com/epoberezkin/ajv/issues/992
*
* - coerce an array with length > 1 to a scalar;
* Ajv (understandably) only does this if length == 1
*
* - for required properties, use default values, or
* "empty" values (e.g. `[]` for arrays, `''` for strings)
*/
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
function coerce(node) {
if (Array.isArray(node)) {
for (const child of node)
coerce(child);
}
else if (schema_1.isEntity(node)) {
const schema = schemas[node.type];
const { properties = {}, propertyAliases = {}, required = [] } = schema;
// Coerce properties...
for (const [key, child] of Object.entries(node)) {
// Match key to a property name...
let name;
if (properties[key] !== undefined) {
// `key` is a canonical property name, so just use it
name = key;
}
else {
// Does the key match a property name, or an alias, after
// conversion to lower camel case?
// Replace spaces, hyphens or underscores followed by lowercase
// letter to uppercase letter
const lcc = key.replace(/( |-|_)([a-z])/g, (_match, _separator, letter) => letter.toUpperCase());
name = properties[lcc] !== undefined ? lcc : propertyAliases[lcc];
if (name !== undefined) {
// Rename aliased property
// @ts-ignore
node[name] = child;
// @ts-ignore
delete node[key];
}
else if (properties[key] === undefined) {
// Remove additional property (no need to coerce child, so continue)
console.warn(`Ignoring property ${node.type}.${key}`);
// @ts-ignore
delete node[key];
continue;
}
}
const propertySchema = properties[name];
const isArray = Array.isArray(child);
if (propertySchema.type === 'array' &&
!isArray &&
typeof child === 'object') {
// Coerce a single object to an array
// Do not do this for primitives since Ajv will do that for us
// and to keep strings as strings for possible decoding via
// the `codec` keyword
// @ts-ignore
node[name] = [child];
}
else if (propertySchema.type !== undefined &&
['string', 'number', 'boolean', 'object', 'integer'].includes(propertySchema.type.toString()) &&
isArray) {
// Coerce an array to a scalar by taking the first element
if (child.length > 1)
console.warn(`Ignoring all but first item in ${node.type}.${key}`);
// @ts-ignore
node[name] = child[0];
}
coerce(child);
}
// Add default values for required properties that are missing
for (const name of required) {
if (!(name in node)) {
const { default: def, type } = properties[name];
const value = def !== null && def !== void 0 ? def : defaultForType(type);
// @ts-ignore
node[name] = value;
}
}
}
}
/* eslint-enable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
/**
* Get the default value for a JSON schema type
*
* @param type The JSON Schema type
* @returns A default value for the type
*/
function defaultForType(type) {
switch (type) {
case 'null':
return null;
case 'boolean':
return false;
case 'number':
case 'integer':
return 0;
case 'string':
return '';
case 'array':
return [];
case 'object':
return {};
default:
// Default to empty string because most likely to be
// able to be coerced elsewhere
return '';
}
}
exports.defaultForType = defaultForType;
/**
* Parse a string value
*
* Used for the custom `parser` JSON Schema keyword.
*/
function parse(schema, data, parentSchema, dataCxt) {
const parsed = (() => {
switch (schema) {
case 'csi':
return exports.parseCsi(data);
case 'ssi':
return exports.parseSsi(data);
case 'date':
return exports.parseDate(data);
case 'person':
return exports.parsePerson(data);
}
})();
if (parsed && dataCxt !== undefined) {
const { parentData, parentDataProperty } = dataCxt;
parentData[parentDataProperty] = parsed;
return true;
}
else {
return false;
}
}
exports.parse = parse;
const parseCsi = (data) => data.split(/\s*,\s*/);
exports.parseCsi = parseCsi;
const parseSsi = (data) => data.split(/\s+/);
exports.parseSsi = parseSsi;
const parseDate = (data) => {
// If the content is already valid ISO 8601 then just return it
// as the value of the date. This avoids having to parse the date
// and then generating a concise ISO 8601 string e.g. for 2020-09
// This regex balances permissiveness with
// complexity. More complex, less permissive regexes for this exist
// (see https://github.com/hapi-server/data-specification/issues/54)
// but are probably unnecessary for this use case.
if (/^(-?(?:[1-9][0-9]*)?[0-9]{4})(-(1[0-2]|0[1-9]))?(-(3[01]|0[1-9]|[12][0-9]))?(T(2[0-3]|[01][0-9]))?(:[0-5][0-9])?(:[0-5][0-9])?(\.[0-9]+)?Z?$/.test(data)) {
return schema.date({ value: data });
}
// Date needs parsing
// Add UTC to force parsing as UTC, rather than local.
let date = new Date(data + ' UTC');
// But if that fails, because another timezone specified then
// just parse the raw date.
if (isNaN(date.getTime()))
date = new Date(data);
if (isNaN(date.getTime())) {
return schema.date({ value: '' });
}
// After parsing the date shorten it a much as possible
// Assumes that it the user wanted to specify a date/time as precisely
// being midnight UTC that they would enter it as an ISO string in the
// first place
let value = date.toISOString();
if (value.endsWith('T00:00:00.000Z'))
value = value.substring(0, 10);
return schema.date({ value });
};
exports.parseDate = parseDate;
const parsePerson = (data) => {
const { name = '', email, url } = parse_author_1.default(data);
// These empty string defaults are necessary because @types/parse-full-name is
// wrong in saying that undefineds are returned.
const { title = '', first = '', middle = '', last = '', suffix = '', } = parse_full_name_1.parseFullName(name);
return schema.person({
givenNames: first.length > 0
? [first, ...(middle.length > 0 ? [middle] : [])]
: undefined,
familyNames: last.length > 0 ? [last] : undefined,
honorificPrefix: title.length > 0 ? title : undefined,
honorificSuffix: suffix.length > 0 ? suffix : undefined,
emails: email !== undefined ? [email] : undefined,
url: url,
});
};
exports.parsePerson = parsePerson;