UNPKG

@stencila/jesta

Version:

Stencila plugin for executable documents using JavaScript

292 lines (291 loc) 11.5 kB
"use strict"; 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;