// @ts-check import { CATEGORIES, TASKS_VALUES } from '../shared/notebook-tags.js'; /** * @typedef {import('../shared/notebook-metadata.ts').INotebookMetadata} INotebookMetadata * @typedef {(v: any) => boolean} isValidFn * @typedef {(v: any) => string | null} ValidatorFn */ /** @type {(_: { key: string, type: string, value: any }) => string} */ const toErrorMessage = ({ key, type, value }) => `'${key}' should be ${type}. Invalid value: ${JSON.stringify(value)}.`; /** @type {(isValid: isValidFn, assertion: { key: string, type: string }) => ValidatorFn} */ const validate = (isValid, { key, type }) => (v) => isValid(v) ? null : toErrorMessage({ key, type, value: v }); // eslint-disable-line @typescript-eslint/no-unsafe-assignment const isString = (/** @type {any} */ v) => typeof v === 'string' || v instanceof String; const isNotEmptyString = (/** @type {any} */ v) => !!v && isString(v); const isUrl = (/** @type {string} */ v) => URL.canParse(v); const isDate = (/** @type {string} */ v) => isString(v) && !isNaN(new Date(v).getTime()); const isStringArray = (/** @type {any[]} */ v) => Array.isArray(v) && v.every(isString); /** @type {(f: isValidFn) => isValidFn} */ const Nullable = (f) => (v) => v === null || f(v); /** * @param {INotebookMetadata['links']} links * @returns {ReturnType} */ const linksValidator = ({ github, docs, colab, binder }) => { const errors = []; if (!isUrl(github)) { errors.push(toErrorMessage({ key: 'links.github', type: 'a valid URL', value: github })); } if (!Nullable(isUrl)(docs)) { errors.push(toErrorMessage({ key: 'links.docs', type: 'a valid URL or null', value: docs })); } if (!Nullable(isUrl)(colab)) { errors.push(toErrorMessage({ key: 'links.colab', type: 'a valid URL or null', value: colab })); } if (!Nullable(isUrl)(binder)) { errors.push(toErrorMessage({ key: 'links.binder', type: 'a valid URL or null', value: binder })); } return errors.length ? errors.join('\n') : null; }; /** * @param {INotebookMetadata['tags']} tags * @returns {ReturnType} */ const tagsValidator = (tags) => { const errors = []; /** @type {(keyof typeof tags)[]} */ const tagsKeys = ['categories', 'tasks', 'libraries', 'other']; for (const key of tagsKeys) { const value = tags[key]; if (!isStringArray(value)) { errors.push(toErrorMessage({ key: `tags.${key}`, type: 'a string array or empty array', value })); } } if (errors.length) { return errors.join('\n'); } const { categories, tasks } = tags; const categoriesError = validateCategoriesTags(categories); if (categoriesError) { errors.push(categoriesError); } const tasksError = validateTasksTags(tasks); if (tasksError) { errors.push(tasksError); } return errors.length ? errors.join('\n') : null; }; /** * @param {INotebookMetadata['tags']['categories']} categories * @returns {ReturnType} */ const validateCategoriesTags = (categories) => { const validTags = Object.values(CATEGORIES); const invalidTags = categories.filter((tag) => !validTags.includes(tag)); if (categories.length && !invalidTags.length) { return null; } return toErrorMessage({ key: 'tags.categories', type: `a subset of ${JSON.stringify(validTags)}`, value: invalidTags, }); }; /** * @param {INotebookMetadata['tags']['tasks']} tasks * @returns {ReturnType} */ const validateTasksTags = (tasks) => { const validTags = TASKS_VALUES; const invalidTags = tasks.filter((tag) => !validTags.includes(tag)); if (tasks.length && !invalidTags.length) { return null; } return toErrorMessage({ key: 'tags.tasks', type: `a subset of ${JSON.stringify(validTags)}`, value: invalidTags, }); }; /** @type {Record} */ const NOTEBOOK_METADATA_VALIDATORS = { title: validate(isNotEmptyString, { key: 'title', type: 'not empty string' }), path: validate(isNotEmptyString, { key: 'path', type: 'not empty string' }), imageUrl: validate(Nullable(isUrl), { key: 'imageUrl', type: 'a valid URL or null' }), createdDate: validate(isDate, { key: 'createdDate', type: 'a valid Date string' }), modifiedDate: validate(isDate, { key: 'modifiedDate', type: 'a valid Date string' }), links: linksValidator, tags: tagsValidator, }; export class NotebookMetadataValidationError extends Error {} /** * Validates notebook metadata object * * @param {INotebookMetadata} metadata * @throws {NotebookMetadataValidationError} Error message containing all metadata invalid properties * @returns {void} */ export function validateNotebookMetadata(metadata) { const errors = []; const entries = /** @type {[keyof INotebookMetadata, any][]} */ (Object.entries(metadata)); for (const [key, value] of entries) { const validator = NOTEBOOK_METADATA_VALIDATORS[key]; if (!validator) { errors.push(`Unknown metadata property "${key}".`); continue; } const error = validator(value); if (error) { errors.push(error); } } if (errors.length) { throw new NotebookMetadataValidationError( `The following notebook metadata properties are not valid:\n${errors.join('\n')}\n` ); } }