diff --git a/.changeset/quiet-lizards-admire.md b/.changeset/quiet-lizards-admire.md new file mode 100644 index 000000000..a483b5f6e --- /dev/null +++ b/.changeset/quiet-lizards-admire.md @@ -0,0 +1,5 @@ +--- +"myst-toc": minor +--- + +Change type aliases for parent-entries diff --git a/.changeset/silly-bats-approve.md b/.changeset/silly-bats-approve.md new file mode 100644 index 000000000..e772ee1aa --- /dev/null +++ b/.changeset/silly-bats-approve.md @@ -0,0 +1,5 @@ +--- +"myst-toc": minor +--- + +Use simple-validators for validation diff --git a/packages/myst-toc/package.json b/packages/myst-toc/package.json index 37808ff81..cbe769b5b 100644 --- a/packages/myst-toc/package.json +++ b/packages/myst-toc/package.json @@ -20,22 +20,18 @@ "url": "git+https://github.com/executablebooks/mystmd.git" }, "scripts": { - "clean": "rimraf dist ./src/schema.json", + "clean": "rimraf dist", "lint": "eslint \"src/**/!(*.spec).ts\" -c ./.eslintrc.cjs", "lint:format": "npx prettier --check \"src/**/*.ts\"", - "test": "npm-run-all build:schema && vitest run", + "test": "vitest run", "test:watch": "vitest watch", "build:esm": "tsc", - "build:schema": "npx ts-json-schema-generator --path src/types.ts --type TOC -o ./src/schema.json", - "build": "npm-run-all -s -l clean build:schema build:esm" + "build": "npm-run-all -s -l clean build:esm" }, "bugs": { "url": "https://github.com/executablebooks/mystmd/issues" }, "dependencies": { - "ajv": "^8.12.0" - }, - "devDependencies": { - "ts-json-schema-generator": "^1.5.0" + "simple-validators": "^1.0.4" } } diff --git a/packages/myst-toc/src/toc.ts b/packages/myst-toc/src/toc.ts index 9cc5dc340..9a3f5b975 100644 --- a/packages/myst-toc/src/toc.ts +++ b/packages/myst-toc/src/toc.ts @@ -1,21 +1,217 @@ -import type { TOC } from './types.js'; -import schema from './schema.json'; -import _Ajv from 'ajv'; +import type { + TOC, + Entry, + FileEntry, + FileParentEntry, + URLEntry, + URLParentEntry, + PatternEntry, + ParentEntry, + CommonEntry, +} from './types.js'; + +import type { ValidationOptions } from 'simple-validators'; +import { + defined, + incrementOptions, + validateBoolean, + validateList, + validateObjectKeys, + validateObject, + validateString, + validationError, +} from 'simple-validators'; + +const COMMON_ENTRY_KEYS = ['title', 'hidden', 'numbering', 'id', 'part', 'class']; + +function validateCommonEntry(entry: Record, opts: ValidationOptions): CommonEntry { + const output: CommonEntry = {}; + if (defined(entry.title)) { + output.title = validateString(entry.title, incrementOptions('title', opts)); + } + + if (defined(entry.hidden)) { + output.hidden = validateBoolean(entry.hidden, incrementOptions('hidden', opts)); + } + + if (defined(entry.numbering)) { + output.numbering = validateString(entry.numbering, incrementOptions('numbering', opts)); + } + + if (defined(entry.id)) { + output.id = validateString(entry.id, incrementOptions('id', opts)); + } + + if (defined(entry.part)) { + output.part = validateString(entry.part, incrementOptions('part', opts)); + } + + if (defined(entry.class)) { + output.class = validateString(entry.class, incrementOptions('class', opts)); + } + + return output; +} + +export function validateFileEntry( + entry: any, + opts: ValidationOptions, +): FileEntry | FileParentEntry | undefined { + const intermediate = validateObjectKeys( + entry, + { + required: ['file'], + optional: [...COMMON_ENTRY_KEYS, 'children'], + }, + opts, + ); + if (!intermediate) { + return undefined; + } + + const file = validateString(intermediate.file, incrementOptions('file', opts)); + if (!file) { + return undefined; + } + + const commonEntry = validateCommonEntry(intermediate, opts); + + let output: FileEntry | FileParentEntry = { file, ...commonEntry }; + if (defined(entry.children)) { + const children = validateList( + intermediate.children, + incrementOptions('children', opts), + (item, ind) => validateEntry(item, incrementOptions(`children.${ind}`, opts)), + ); + output = { children, ...output }; + } + + return output; +} + +export function validateURLEntry( + entry: any, + opts: ValidationOptions, +): URLEntry | URLParentEntry | undefined { + const intermediate = validateObjectKeys( + entry, + { + required: ['url'], + optional: [...COMMON_ENTRY_KEYS, 'children'], + }, + opts, + ); + if (!intermediate) { + return undefined; + } + + const url = validateString(intermediate.url, incrementOptions('url', opts)); + if (!url) { + return undefined; + } + + const commonEntry = validateCommonEntry(intermediate, opts); + + let output: URLEntry | URLParentEntry = { url, ...commonEntry }; + if (defined(entry.children)) { + const children = validateList( + intermediate.children, + incrementOptions('children', opts), + (item, ind) => validateEntry(item, incrementOptions(`children.${ind}`, opts)), + ); + output = { children, ...output }; + } + + return output; +} + +export function validatePatternEntry( + entry: any, + opts: ValidationOptions, +): PatternEntry | undefined { + const intermediate = validateObjectKeys( + entry, + { + required: ['pattern'], + optional: [...COMMON_ENTRY_KEYS, 'children'], + }, + opts, + ); + if (!intermediate) { + return undefined; + } + + const pattern = validateString(intermediate.pattern, incrementOptions('pattern', opts)); + if (!pattern) { + return undefined; + } + + const commonEntry = validateCommonEntry(intermediate, opts); + return { pattern, ...commonEntry }; +} + +export function validateParentEntry(entry: any, opts: ValidationOptions): ParentEntry | undefined { + const intermediate = validateObjectKeys( + entry, + { + required: ['title', 'children'], + optional: [...COMMON_ENTRY_KEYS], + }, + opts, + ); + if (!intermediate) { + return undefined; + } + + const title = validateString(intermediate.title, incrementOptions('title', opts)); + if (!title) { + return undefined; + } + + const children = validateList( + intermediate.children, + incrementOptions('children', opts), + (item, ind) => validateEntry(item, incrementOptions(`children.${ind}`, opts)), + ); + + if (!children) { + return undefined; + } + + const commonEntry = validateCommonEntry(intermediate, opts); + + return { + children, + title, + ...commonEntry, + }; +} + +export function validateEntry(entry: any, opts: ValidationOptions): Entry | undefined { + const intermediate = validateObject(entry, opts); + if (!intermediate) { + return undefined; + } + if (defined(intermediate.file)) { + return validateFileEntry(intermediate, opts); + } else if (defined(intermediate.url)) { + return validateURLEntry(intermediate, opts); + } else if (defined(intermediate.pattern)) { + return validatePatternEntry(intermediate, opts); + } else if (defined(intermediate.title)) { + return validateParentEntry(intermediate, opts); + } else { + return validationError("expected an entry with 'file', 'url', 'pattern', or 'title'", opts); + } +} /** * validate a MyST table of contents * * @param toc: structured TOC data */ -export function validateTOC(toc: Record): TOC { - // eslint-disable-next-line - // @ts-ignore - const Ajv = _Ajv.default; - const ajv = new Ajv(); - const validate = ajv.compile(schema); - if (!validate(toc)) { - throw new Error(`The given contents do not form a valid TOC.`); - } - - return toc as unknown as TOC; +export function validateTOC(toc: any, opts: ValidationOptions): TOC | undefined { + return validateList(toc, opts, (item, ind) => + validateEntry(item, incrementOptions(`${ind}`, opts)), + ) as unknown as TOC | undefined; } diff --git a/packages/myst-toc/src/types.ts b/packages/myst-toc/src/types.ts index 05275a15b..e5fc78cb5 100644 --- a/packages/myst-toc/src/types.ts +++ b/packages/myst-toc/src/types.ts @@ -27,12 +27,24 @@ export type FileEntry = { } & CommonEntry; /** - * Entry with a URL to an external URL + * Entry with a path to a single document with or without the file extension, + * and an array of children + */ +export type FileParentEntry = FileEntry & Omit; + +/** + * Entry with a url to an external resource */ export type URLEntry = { url: string; } & CommonEntry; +/** + * Entry with a url to an external resource, + * and an array of children + */ +export type URLParentEntry = URLEntry & Omit; + /** * Entry representing several documents through a glob */ @@ -40,17 +52,14 @@ export type PatternEntry = { pattern: string; } & CommonEntry; -/** - * Entry representing a single document - */ -export type DocumentEntry = FileEntry | URLEntry; - /** * All possible types of Entry */ export type Entry = - | DocumentEntry - | (DocumentEntry & Omit) + | FileEntry + | URLEntry + | FileParentEntry + | URLParentEntry | PatternEntry | ParentEntry; diff --git a/packages/myst-toc/tests/examples.spec.ts b/packages/myst-toc/tests/examples.spec.ts index cbbfe21ff..34b0edc9a 100644 --- a/packages/myst-toc/tests/examples.spec.ts +++ b/packages/myst-toc/tests/examples.spec.ts @@ -1,49 +1,121 @@ -import { describe, test, expect } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import yaml from 'js-yaml'; +import { describe, beforeEach, test, expect } from 'vitest'; + import { validateTOC } from '../src'; +import type { ValidationOptions } from 'simple-validators'; + +let opts: ValidationOptions; + +beforeEach(() => { + opts = { property: 'test', messages: {} }; +}); + +test('Single file entry passes', () => { + const input = [{ file: 'foo.md' }]; + const toc = validateTOC(input, opts); + expect(opts.messages.errors?.length).toBeFalsy(); + expect(opts.messages.warnings?.length).toBeFalsy(); + expect(toc).toStrictEqual(input); +}); + +describe.each([ + ['file', 'foo.md'], + ['url', 'https://google.com'], + ['pattern', 'main*.md'], +])('Single %s entry', (entryName, entryValue) => { + test.each([ + ['class', 'foo'], + ['part', 'bar'], + ['hidden', true], + ['title', 'document'], + ])('with %s passes', (name, value) => { + const entry = {}; + entry[entryName] = entryValue; + entry[name] = value; + const input = [entry]; + const toc = validateTOC(input, opts); + expect(opts.messages.errors?.length).toBeFalsy(); + expect(opts.messages.warnings?.length).toBeFalsy(); + expect(toc).toStrictEqual(input); + }); -type TestCase = { - title: string; - content: object; - throws?: string; // RegExp pattern - output?: object; - didUpgrade?: boolean; -}; - -type TestCases = { - title: string; - cases: TestCase[]; -}; - -const only = ''; - -const casesList: TestCases[] = fs - .readdirSync(__dirname) - .filter((file) => file.endsWith('.yml')) - .map((file) => { - const content = fs.readFileSync(path.join(__dirname, file), { encoding: 'utf-8' }); - return yaml.load(content) as TestCases; + test.each([ + ['class', 1000, 'string'], + ['part', 1000, 'string'], + ['hidden', 'yes', 'boolean'], + ['title', 1000, 'string'], + ])('with invalid type for %s fails', (name, value, type) => { + const input = [{ file: 'foo.md' }]; + input[0][name] = value; + validateTOC(input, opts); + expect(opts.messages.errors).toStrictEqual([ + { message: `'${name}' must be ${type} (at test.0)`, property: name }, + ]); + expect(opts.messages.warnings).toBeUndefined(); }); -casesList.forEach(({ title, cases }) => { - const filtered = cases.filter((c) => !only || c.title === only); - if (filtered.length === 0) return; - describe(title, () => { - test.each(filtered.map((c): [string, TestCase] => [c.title, c]))( - '%s', - (_, { content, throws, output }) => { - if (output) { - const toc = validateTOC(content); - expect(toc).toEqual(output); - } else if (throws) { - const pattern = new RegExp(throws); - expect(() => validateTOC(content)).toThrowError(pattern); - } else { - validateTOC(content); - } + test('with unknown property fails', () => { + const input = [{ file: 'foo.md', bar: 'baz.rst' }]; + const toc = validateTOC(input, opts); + expect(opts.messages.errors).toBeUndefined(); + expect(opts.messages.warnings).toStrictEqual([ + { + message: "'0' extra key ignored: bar (at test)", + property: '0', }, - ); + ]); + expect(toc).toStrictEqual([ + { + file: 'foo.md', + }, + ]); }); }); + +test('Single file entry with children passes', () => { + const input = [{ file: 'foo.md', children: [{ file: 'bar.md' }] }]; + const toc = validateTOC(input, opts); + expect(opts.messages.errors?.length).toBeFalsy(); + expect(opts.messages.warnings?.length).toBeFalsy(); + expect(toc).toStrictEqual(input); +}); + +test('Single file entry with invalid children fails', () => { + const input = [{ file: 'foo.md', children: [{ utopia: 'bar.md' }] }]; + validateTOC(input, opts); + expect(opts.messages.errors).toStrictEqual([ + { + message: + "'children.0' expected an entry with 'file', 'url', 'pattern', or 'title' (at test.0)", + property: 'children.0', + }, + ]); + expect(opts.messages.warnings).toBeUndefined(); +}); + +test('Single parent entry passes', () => { + const input = [{ title: 'Bar', children: [] }]; + const toc = validateTOC(input, opts); + expect(opts.messages.errors?.length).toBeFalsy(); + expect(opts.messages.warnings?.length).toBeFalsy(); + expect(toc).toStrictEqual(input); +}); + +test('Single parent entry with children passes', () => { + const input = [{ title: 'Bar', children: [{ url: 'https://bbc.co.uk/news' }] }]; + const toc = validateTOC(input, opts); + expect(opts.messages.errors?.length).toBeFalsy(); + expect(opts.messages.warnings?.length).toBeFalsy(); + expect(toc).toStrictEqual(input); +}); + +test('Single parent entry without title', () => { + const input = [{ children: [{ url: 'https://bbc.co.uk/news' }] }]; + validateTOC(input, opts); + expect(opts.messages.errors).toStrictEqual([ + { + message: "'0' expected an entry with 'file', 'url', 'pattern', or 'title' (at test)", + property: '0', + }, + ]); + expect(opts.messages.warnings).toBeUndefined(); +}); diff --git a/packages/myst-toc/tests/test.yml b/packages/myst-toc/tests/test.yml deleted file mode 100644 index a29d9ef6b..000000000 --- a/packages/myst-toc/tests/test.yml +++ /dev/null @@ -1,69 +0,0 @@ -title: Table of Contents -cases: - - title: Single file passes - content: - - file: foo.md - - title: Single file with title passes - content: - - file: foo.md - title: Foo! - - title: Single file with part passes - content: - - file: foo.md - part: abstract - - title: Single file with class passes - content: - - file: foo.md - class: beta - - title: Single file with hidden passes - content: - - file: foo.md - hidden: true - - title: Single file with invalid hidden fails - content: - - file: foo.md - hidden: "yes" - throws: 'The given contents do not form a valid TOC' - - title: Single file with children passes - content: - - file: foo.md - children: - - file: bar.md - - - title: Single URL passes - content: - - url: https://bar.com/foo.md - - title: Single URL with children passes - content: - - url: foo.md - children: - - url: https://bar.com/foo.md - - - title: Single pattern passes - content: - - pattern: foo/*.md - - title: Single pattern with children passes fails - content: - - pattern: foo/*.md - children: - - url: https://bar.com/foo.md - throws: 'The given contents do not form a valid TOC' - - - - title: Single parent passes - content: - - title: Foo - children: - - url: https://bar.com/bar.md - - title: Single parent without title fails - content: - - children: - - url: https://bar.com/bar.md - throws: 'The given contents do not form a valid TOC' - - - - title: Mix of URL and file types fails - content: - - file: foo.md - url: https://bar.com/foo.md - throws: 'The given contents do not form a valid TOC'