Spaces:
Runtime error
Runtime error
// @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<ValidatorFn>} | |
*/ | |
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<ValidatorFn>} | |
*/ | |
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<ValidatorFn>} | |
*/ | |
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<ValidatorFn>} | |
*/ | |
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<keyof INotebookMetadata, ValidatorFn>} */ | |
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` | |
); | |
} | |
} | |