From 4754ebd964877ad14fbf43554fcb00415eb1114e Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Tue, 5 Mar 2024 14:48:57 +0000 Subject: [PATCH 1/5] feat: add sphinx-external-toc package --- .gitignore | 1 + package-lock.json | 148 ++++++++++++++- package.json | 5 +- packages/sphinx-external-toc/.eslintrc.cjs | 4 + packages/sphinx-external-toc/.gitignore | 1 + packages/sphinx-external-toc/README.md | 3 + packages/sphinx-external-toc/package.json | 42 +++++ packages/sphinx-external-toc/src/index.ts | 2 + packages/sphinx-external-toc/src/toc.ts | 168 +++++++++++++++++ packages/sphinx-external-toc/src/types.ts | 177 ++++++++++++++++++ .../tests/examples.spec.ts | 50 +++++ packages/sphinx-external-toc/tests/test.yml | 132 +++++++++++++ packages/sphinx-external-toc/tsconfig.json | 9 + 13 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 packages/sphinx-external-toc/.eslintrc.cjs create mode 100644 packages/sphinx-external-toc/.gitignore create mode 100644 packages/sphinx-external-toc/README.md create mode 100644 packages/sphinx-external-toc/package.json create mode 100644 packages/sphinx-external-toc/src/index.ts create mode 100644 packages/sphinx-external-toc/src/toc.ts create mode 100644 packages/sphinx-external-toc/src/types.ts create mode 100644 packages/sphinx-external-toc/tests/examples.spec.ts create mode 100644 packages/sphinx-external-toc/tests/test.yml create mode 100644 packages/sphinx-external-toc/tsconfig.json diff --git a/.gitignore b/.gitignore index 141bea125..71d99b0f1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ _build yalc.lock *.docx +schemas/ diff --git a/package-lock.json b/package-lock.json index 829d6e2e7..720a546e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "workspaces": [ "packages/*" ], + "dependencies": { + "json2ts": "^0.0.7" + }, "devDependencies": { "@changesets/cli": "^2.26.1", "@types/node": "^20.2.5", @@ -7498,6 +7501,14 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json2ts": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/json2ts/-/json2ts-0.0.7.tgz", + "integrity": "sha512-7g41Foq7xRPmZ+4o8HGCsNFBe9ar/egOpuktCdlI3OHzAY34WJh28LKrc3523sLV4wPdgZk2LjOPDs5Za1HzAg==", + "dependencies": { + "underscore": "^1.8.3" + } + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -10859,6 +10870,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -11347,6 +11367,10 @@ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==" }, + "node_modules/sphinx-external-toc": { + "resolved": "packages/sphinx-external-toc", + "link": true + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -11884,6 +11908,88 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-json-schema-generator": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-1.5.0.tgz", + "integrity": "sha512-RkiaJ6YxGc5EWVPfyHxszTmpGxX8HC2XBvcFlAl1zcvpOG4tjjh+eXioStXJQYTvr9MoK8zCOWzAUlko3K0DiA==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.12", + "commander": "^11.0.0", + "glob": "^8.0.3", + "json5": "^2.2.3", + "normalize-path": "^3.0.0", + "safe-stable-stringify": "^2.4.3", + "typescript": "~5.3.2" + }, + "bin": { + "ts-json-schema-generator": "bin/ts-json-schema-generator" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ts-json-schema-generator/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ts-json-schema-generator/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/ts-json-schema-generator/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-json-schema-generator/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-json-schema-generator/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tsconfig": { "resolved": "packages/tsconfig", "link": true @@ -12212,9 +12318,9 @@ } }, "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -12250,6 +12356,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + }, "node_modules/unified": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", @@ -14637,6 +14748,37 @@ "moment": "^2.29.4" } }, + "packages/sphinx-external-toc": { + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "ts-json-schema-generator": "^1.5.0" + } + }, + "packages/sphinx-external-toc/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/sphinx-external-toc/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "packages/tex-to-myst": { "version": "1.0.22", "license": "MIT", diff --git a/package.json b/package.json index 1d33f7154..e67f936ee 100644 --- a/package.json +++ b/package.json @@ -34,5 +34,8 @@ "npm": ">=7.0.0", "node": ">=14.0.0" }, - "packageManager": "npm@8.10.0" + "packageManager": "npm@8.10.0", + "dependencies": { + "json2ts": "^0.0.7" + } } diff --git a/packages/sphinx-external-toc/.eslintrc.cjs b/packages/sphinx-external-toc/.eslintrc.cjs new file mode 100644 index 000000000..76787609a --- /dev/null +++ b/packages/sphinx-external-toc/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ['curvenote'], +}; diff --git a/packages/sphinx-external-toc/.gitignore b/packages/sphinx-external-toc/.gitignore new file mode 100644 index 000000000..a46a3fe48 --- /dev/null +++ b/packages/sphinx-external-toc/.gitignore @@ -0,0 +1 @@ +/src/schema.json diff --git a/packages/sphinx-external-toc/README.md b/packages/sphinx-external-toc/README.md new file mode 100644 index 000000000..74f6c3404 --- /dev/null +++ b/packages/sphinx-external-toc/README.md @@ -0,0 +1,3 @@ +# sphinx-external-toc + +Utilities to parse a JupyterBook \_toc.yml diff --git a/packages/sphinx-external-toc/package.json b/packages/sphinx-external-toc/package.json new file mode 100644 index 000000000..4436bb47d --- /dev/null +++ b/packages/sphinx-external-toc/package.json @@ -0,0 +1,42 @@ +{ + "name": "sphinx-external-toc", + "version": "0.0.0", + "sideEffects": false, + "license": "MIT", + "description": "sphinx-external-toc Table of Contents types and validation", + "author": "Angus Hollands ", + "homepage": "https://github.com/executablebooks/mystmd/tree/main/packages/sphinx-external-toc", + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/executablebooks/mystmd.git" + }, + "scripts": { + "clean": "rimraf dist ./src/schema.json", + "lint": "eslint \"src/**/!(*.spec).ts\" -c ./.eslintrc.cjs", + "lint:format": "npx prettier --check \"src/**/*.ts\"", + "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" + }, + "bugs": { + "url": "https://github.com/executablebooks/mystmd/issues" + }, + "dependencies": { + "ajv": "^8.12.0", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "ts-json-schema-generator": "^1.5.0" + } +} diff --git a/packages/sphinx-external-toc/src/index.ts b/packages/sphinx-external-toc/src/index.ts new file mode 100644 index 000000000..5567a73b4 --- /dev/null +++ b/packages/sphinx-external-toc/src/index.ts @@ -0,0 +1,2 @@ +export * from './toc.js'; +export * from './types.js'; diff --git a/packages/sphinx-external-toc/src/toc.ts b/packages/sphinx-external-toc/src/toc.ts new file mode 100644 index 000000000..aa7d8042c --- /dev/null +++ b/packages/sphinx-external-toc/src/toc.ts @@ -0,0 +1,168 @@ +import yaml from 'js-yaml'; +import type { TOC, ArticleTOC, BookTOC, BasicTOC } from './types.js'; +import schema from './schema.json'; +import Ajv from 'ajv'; + +// See https://executablebooks.org/en/latest/blog/2021-06-18-update-toc/ +// Implementation transpiled from https://github.com/executablebooks/sphinx-external-toc/blob/v1.0.1/sphinx_external_toc/tools.py#L277 +function upgradeOldJupyterBookTOC(oldTOC: any[]) { + const tocUpdated = oldTOC[0] as Record; + + let firstItems: Record[] = []; + let topItemsKey = 'sections'; + + if ('sections' in tocUpdated && 'chapters' in tocUpdated) { + throw new Error("First list item contains both 'chapters' and 'sections' keys"); + } + + // Identify whether we have "sections" or "chapters" in our first key + // And pull them into "firstItems" if so + for (const key of ['sections', 'chapters']) { + if (key in tocUpdated) { + topItemsKey = key; + const items = tocUpdated[key] as Record[]; + delete tocUpdated[key]; + + if (!Array.isArray(items)) { + throw new Error(); + } + + firstItems = [...firstItems, ...items]; + break; + } + } + // Fuse first key's items, and remaining TOC entries + firstItems = [...firstItems, ...oldTOC.slice(1)]; + + const containsPart = firstItems.some((item) => 'part' in item || 'chapter' in item); + const containsFile = firstItems.some((item) => 'file' in item); + + // Ensure we don't mix key types + if (containsPart && containsFile) { + throw new Error("top-level contains mixed 'part' and 'file' keys"); + } + + // Respect top-level "parts", "sections", or "chapters". + // Only group under "parts" if any array-items have a "part" key + tocUpdated[containsPart ? 'parts' : topItemsKey] = firstItems; + + // Write root + const { file: root, ...toc } = tocUpdated; + if (root === undefined) { + throw new Error("no top-level 'file' key found"); + } + toc['root'] = root; + + // Ensure we don't mix top-level key types + const topLevelKeys = ['parts', 'chapters', 'sections'].filter((item) => item in toc); + if (topLevelKeys.length > 1) { + throw new Error(`There is more than one top-level key: ${topLevelKeys}`); + } + + // Deduce the TOC format + if (topLevelKeys.length) { + const fileFormat = { + parts: 'jb-book', + chapters: 'jb-book', + sections: 'jb-article', + }[topLevelKeys[0]]; + toc['format'] = fileFormat; + } + + // Do we have a singular "default" subtree (indicated by "entries" key) + const hasDefaultSubtree = 'entries' in toc || 'sections' in toc || 'chapters' in toc; + + // Lower numbering + const numbered = toc['numbered']; + delete toc['numbered']; + if (numbered !== undefined) { + // Default subtree + if (hasDefaultSubtree) { + toc['options'] = { numbered: numbered }; + } + // Child subtrees + else { + const subtrees = (toc['subtrees'] ?? toc['parts'] ?? []) as Record[]; + for (const subtree of subtrees) { + subtree['numbered'] = numbered; + } + } + } + + // Lower title + const title = toc['title']; + delete toc['title']; + if (title !== undefined) { + // Only set title for single default subtree + if (hasDefaultSubtree) { + const options = (toc['options'] ?? (toc['options'] = {})) as Record; + options['caption'] = title; + } + } + + // Rename "part" to "caption" + const adjustConfig = (obj: Record) => { + if ('chapters' in obj && 'sections' in obj) { + throw new Error(`both 'chapters' and 'sections' in same table: ${obj}`); + } + const caption = obj['part'] ?? obj['chapter']; + delete obj['part']; + delete obj['chapter']; + + if (caption !== undefined) { + obj['caption'] = caption; + } + for (const key of ['parts', 'chapters', 'sections', 'entries']) { + if (key in obj) { + const children = obj[key] as Record[]; + children.forEach(adjustConfig); + } + } + }; + + adjustConfig(toc); + return toc; +} + +/** + * Parse a sphinx-external-toc table of contents + * + * @param contents: raw TOC yaml + */ +export function parseTOC(contents: string): { toc: TOC; didUpgrade: boolean } { + let toc: any; + try { + toc = yaml.load(contents) as any; + } catch (err) { + throw new Error(`Unable to parse TOC yaml`); + } + let didUpgrade = false; + + if (Array.isArray(toc)) { + toc = upgradeOldJupyterBookTOC(toc); + console.log('upgraded', JSON.stringify(toc)); + didUpgrade = true; + } + + const ajv = new Ajv.default(); + const validate = ajv.compile(schema); + if (!validate(toc)) { + throw new Error( + `The given contents do not form a valid TOC. Please see: https://sphinx-external-toc.readthedocs.io/en/latest/user_guide/sphinx.html#basic-structure for information about valid ToC contents`, + ); + } + + return { toc: toc as TOC, didUpgrade }; +} + +export function isBasicTOC(toc: TOC): toc is BasicTOC { + return !('format' in (toc as any)); +} + +export function isBookTOC(toc: TOC): toc is BookTOC { + return (toc as any).format === 'jb-book'; +} + +export function isArticleTOC(toc: TOC): toc is ArticleTOC { + return (toc as any).format === 'jb-article'; +} diff --git a/packages/sphinx-external-toc/src/types.ts b/packages/sphinx-external-toc/src/types.ts new file mode 100644 index 000000000..b9352f5e8 --- /dev/null +++ b/packages/sphinx-external-toc/src/types.ts @@ -0,0 +1,177 @@ +///////// Common types //////// + +/** + * Sphinx toctree options + **/ +export type ToctreeOptions = { + caption?: string; + hidden?: boolean; + maxdepth?: number; + numbered?: boolean; + reversed?: boolean; + titlesonly?: boolean; +}; + +/** + * Entry with a path to a single document with or without the file extension + */ +export type FileEntry = { + file: string; + title?: string; +}; + +/** + * Entry with a URL to an external URL + */ +export type URLEntry = { + url: string; + title?: string; +}; + +/** + * Entry with a glob for one or more document files via Unix shell-styloe wildcards + * Similar to fnmatch, but single stars do not match slashes. + */ +export type GlobEntry = { + glob: string; +}; + +///// Basic format ///// + +/** + * Single TOC entry + */ +export type BasicEntry = (BasicHasSubtrees | BasicShorthandSubtree | Record) & + (FileEntry | URLEntry | GlobEntry); + +/** + * Object containing explicit toctrees + */ +export type BasicHasSubtrees = { + subtrees: BasicSubtree[]; +}; + +/** + * Explicit toctree + */ +export type BasicSubtree = ToctreeOptions & { + entries: BasicEntry[]; +}; + +/** + * Shorthand for a single (inline) subtree + */ +export type BasicShorthandSubtree = { + entries: BasicEntry[]; + options?: ToctreeOptions; +}; + +/** + * Basic (no format) table of contents + */ +export type BasicTOC = { + root: string; + defaults?: ToctreeOptions; +} & (BasicHasSubtrees | BasicShorthandSubtree | Record); + +/////// Article format /////// + +/** + * Object which has child subtrees + */ +export type ArticleHasSubtrees = { + subtrees: ArticleSubtree[]; +}; + +/** + * Single TOC entry + */ +export type ArticleEntry = (ArticleHasSubtrees | ArticleShorthandSubtree | Record) & + (FileEntry | URLEntry | GlobEntry); + +/** + * Single toctree + */ +export type ArticleSubtree = ToctreeOptions & { + sections: ArticleEntry[]; +}; + +/** + * Shorthand for a single (inline) subtree + */ +export type ArticleShorthandSubtree = { + sections: ArticleEntry[]; + options?: ToctreeOptions; +}; + +/** + * Article (jb-article) table of contents + */ +export type ArticleTOC = { + format: 'jb-article'; + root: string; + defaults?: ToctreeOptions; +} & (ArticleHasSubtrees | ArticleShorthandSubtree | Record); + +////// Book format ////// + +/** + * Object which has child (outer) subtrees + */ +export type BookOuterHasSubtrees = { + parts: BookOuterSubtree[]; +}; + +/** + * Object which has child (inner) subtrees + */ +export type BookHasSubtrees = { + subtrees: BookSubtree[]; +}; + +/** + * Single TOC entry + */ +export type BookEntry = (BookHasSubtrees | BookShorthandSubtree | Record) & + (FileEntry | URLEntry | GlobEntry); + +/** + * Single top-level toctree + */ +export type BookOuterSubtree = ToctreeOptions & { + chapters: BookEntry[]; +}; + +/** + * Shorthand for a single outer (inline) subtree + */ +export type BookOuterShorthandSubtree = { + chapters: BookEntry[]; + options?: ToctreeOptions; +}; + +/** + * Single toctree + */ +export type BookSubtree = ToctreeOptions & { + sections: BookEntry[]; +}; + +/** + * Shorthand for a single inner (inline) subtree + */ +export type BookShorthandSubtree = { + sections: BookEntry[]; + options?: ToctreeOptions; +}; + +/** + * Book (jb-book) table of contents + */ +export type BookTOC = { + format: 'jb-book'; + root: string; + defaults?: ToctreeOptions; +} & (BookOuterHasSubtrees | BookOuterShorthandSubtree | Record); + +export type TOC = BasicTOC | ArticleTOC | BookTOC; diff --git a/packages/sphinx-external-toc/tests/examples.spec.ts b/packages/sphinx-external-toc/tests/examples.spec.ts new file mode 100644 index 000000000..77d264381 --- /dev/null +++ b/packages/sphinx-external-toc/tests/examples.spec.ts @@ -0,0 +1,50 @@ +import { describe, test, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import yaml from 'js-yaml'; +import { parseTOC } from '../src'; + +type TestCase = { + title: string; + content: string; + 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; + }); + +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, didUpgrade }) => { + if (output) { + const { toc, didUpgrade } = parseTOC(content); + expect(toc).toEqual(output); + expect(didUpgrade).toEqual(didUpgrade); + } else if (throws) { + const pattern = new RegExp(throws); + expect(() => parseTOC(content)).toThrowError(pattern); + } else { + parseTOC(content); + } + }, + ); + }); +}); diff --git a/packages/sphinx-external-toc/tests/test.yml b/packages/sphinx-external-toc/tests/test.yml new file mode 100644 index 000000000..0417b9d39 --- /dev/null +++ b/packages/sphinx-external-toc/tests/test.yml @@ -0,0 +1,132 @@ +title: Table of Contents +cases: + - title: Legacy TOC upgrades + content: | + - file: intro + title: Drop this title + - part: Get started + chapters: + - file: start/overview + - file: start/build + - file: start/publish + sections: + - file: publish/gh-pages + - file: publish/netlify + - file: customize/config + - file: customize/toc + - file: file-types/index + sections: + - file: file-types/markdown + - file: file-types/notebooks + - file: file-types/myst-notebooks + - file: file-types/jupytext + - file: file-types/restructuredtext + - part: Write book content + chapters: + - file: content/myst + - file: content/content-blocks + - file: content/citations + - file: content/math + - file: content/figures + - file: content/layout + - file: content/execute + - file: content/code-outputs + - part: Make your book interactive + chapters: + - file: interactive/launchbuttons + - file: interactive/hiding + - file: interactive/interactive + - file: interactive/comments + sections: + - file: interactive/comments/hypothesis + - file: interactive/comments/utterances + - part: Advanced and miscellaneous + chapters: + - file: advanced/pdf + - file: advanced/sphinx + - file: advanced/advanced + - file: contribute/intro + + - part: Reference + chapters: + - url: https://executablebooks.org/en/latest/gallery.html + title: Gallery of Jupyter Books + - file: reference/cheatsheet + - file: reference/cli + - file: reference/glossary + - file: reference/_changelog + title: Change log + didUpgrade: true + - title: JB book ToC Parses + content: | + root: intro + format: jb-book + parts: + - caption: User Guide + chapters: + - file: user_guide/sphinx + - file: user_guide/cli + - file: user_guide/api + - caption: Other + chapters: + - file: foo.md + - glob: bar/baz*.md + sections: + - file: other.md + - title: JB article ToC Parses + content: | + root: intro + format: jb-article + sections: + - file: user_guide/sphinx + - file: user_guide/cli + - file: user_guide/api + - file: foo.md + - glob: bar/baz*.md + - title: Unknown format fails + content: | + root: intro + format: jb-article-new + sections: + - file: user_guide/sphinx + - file: user_guide/cli + - file: user_guide/api + - file: foo.md + - glob: bar/baz*.md + throws: 'The given contents do not form a valid TOC' + - title: Parts and chapters fails + content: | + root: intro + format: jb-book + parts: + - file: user_guide/sphinx + - file: user_guide/cli + - file: user_guide/api + chapters: + - file: foo.md + - glob: bar/baz*.md + throws: 'The given contents do not form a valid TOC' + - title: Sections and chapters fails + content: | + root: intro + format: jb-book + sections: + - file: user_guide/sphinx + - file: user_guide/cli + - file: user_guide/api + chapters: + - file: foo.md + - glob: bar/baz*.md + throws: 'The given contents do not form a valid TOC' + - title: Sections and parts fails + content: | + root: intro + format: jb-book + sections: + - file: user_guide/sphinx + - file: user_guide/cli + - file: user_guide/api + parts: + - file: foo.md + - glob: bar/baz*.md + throws: 'The given contents do not form a valid TOC' diff --git a/packages/sphinx-external-toc/tsconfig.json b/packages/sphinx-external-toc/tsconfig.json new file mode 100644 index 000000000..9ee547579 --- /dev/null +++ b/packages/sphinx-external-toc/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "resolveJsonModule": true, + }, + "include": ["."], + "exclude": ["dist", "build", "node_modules", "src/**/*.spec.ts", "tests"], +} From f09577dc92770f5a77cff2f9147d477566897d3b Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Thu, 7 Mar 2024 09:52:15 +0000 Subject: [PATCH 2/5] Update packages/sphinx-external-toc/src/toc.ts Co-authored-by: Franklin Koch --- packages/sphinx-external-toc/src/toc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sphinx-external-toc/src/toc.ts b/packages/sphinx-external-toc/src/toc.ts index aa7d8042c..faef2e193 100644 --- a/packages/sphinx-external-toc/src/toc.ts +++ b/packages/sphinx-external-toc/src/toc.ts @@ -24,7 +24,7 @@ function upgradeOldJupyterBookTOC(oldTOC: any[]) { delete tocUpdated[key]; if (!Array.isArray(items)) { - throw new Error(); + throw new Error(`'${key}' in toc must be an array`); } firstItems = [...firstItems, ...items]; From 911836ac3fc5c91922931856d99a46114336a58d Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Thu, 7 Mar 2024 09:55:05 +0000 Subject: [PATCH 3/5] fix: revert changes to package.json --- package.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/package.json b/package.json index e67f936ee..1d33f7154 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,5 @@ "npm": ">=7.0.0", "node": ">=14.0.0" }, - "packageManager": "npm@8.10.0", - "dependencies": { - "json2ts": "^0.0.7" - } + "packageManager": "npm@8.10.0" } From 1ec13bb03aa9c95345eab218fe35e347844eb0c9 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Thu, 21 Mar 2024 11:20:03 +0000 Subject: [PATCH 4/5] fix: correct types --- packages/sphinx-external-toc/src/toc.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-external-toc/src/toc.ts b/packages/sphinx-external-toc/src/toc.ts index faef2e193..bf69e436b 100644 --- a/packages/sphinx-external-toc/src/toc.ts +++ b/packages/sphinx-external-toc/src/toc.ts @@ -1,7 +1,10 @@ import yaml from 'js-yaml'; import type { TOC, ArticleTOC, BookTOC, BasicTOC } from './types.js'; import schema from './schema.json'; -import Ajv from 'ajv'; +import _Ajv from 'ajv'; + +// Adjust types for ES module +const Ajv = _Ajv as unknown as typeof _Ajv.default; // See https://executablebooks.org/en/latest/blog/2021-06-18-update-toc/ // Implementation transpiled from https://github.com/executablebooks/sphinx-external-toc/blob/v1.0.1/sphinx_external_toc/tools.py#L277 From b4d5c3713bb50da8d5c6b312293cb47d51bd37bd Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Thu, 21 Mar 2024 17:26:19 +0000 Subject: [PATCH 5/5] fix: use ignore for now --- packages/sphinx-external-toc/src/toc.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sphinx-external-toc/src/toc.ts b/packages/sphinx-external-toc/src/toc.ts index bf69e436b..460aeb9e6 100644 --- a/packages/sphinx-external-toc/src/toc.ts +++ b/packages/sphinx-external-toc/src/toc.ts @@ -4,6 +4,7 @@ import schema from './schema.json'; import _Ajv from 'ajv'; // Adjust types for ES module +// @ts-ignore const Ajv = _Ajv as unknown as typeof _Ajv.default; // See https://executablebooks.org/en/latest/blog/2021-06-18-update-toc/