UNPKG

myst-cli

Version:
320 lines (319 loc) 9.82 kB
import { z } from 'zod'; import { resolveExtension } from '../../utils/resolveExtension.js'; import { join, relative } from 'node:path'; import { cwd } from 'node:process'; const TOCTreeOptions = z .object({ caption: z.string(), hidden: z.boolean(), maxdepth: z.number(), numbered: z.boolean(), reversed: z.boolean(), titlesonly: z.boolean(), }) .partial(); const FileEntry = z.object({ file: z.string(), title: z.string().optional(), }); const URLEntry = z.object({ url: z.string(), title: z.string().optional(), }); const GlobEntry = z.object({ glob: z.string(), }); const NoFormatSubtree = TOCTreeOptions.extend({ entries: z.lazy(() => NoFormatEntry.array()), }); const NoFormatShorthandSubtree = z.object({ entries: z.lazy(() => NoFormatEntry.array()), options: TOCTreeOptions.optional(), }); const NoFormatHasSubtrees = z.object({ subtrees: NoFormatSubtree.array(), }); const NoFormatEntry = z.union([ FileEntry.and(NoFormatShorthandSubtree), FileEntry.merge(NoFormatHasSubtrees), FileEntry, URLEntry, GlobEntry, ]); const NoFormatTOCBase = z.object({ root: z.string(), defaults: TOCTreeOptions.optional(), }); const NoFormatTOC = z.union([ NoFormatTOCBase.and(NoFormatShorthandSubtree), NoFormatTOCBase.merge(NoFormatHasSubtrees).strict(), NoFormatTOCBase.strict(), ]); const ArticleSubtree = TOCTreeOptions.extend({ sections: z.lazy(() => ArticleEntry.array()), }); const ArticleShorthandSubtree = z.object({ sections: z.lazy(() => ArticleEntry.array()), options: TOCTreeOptions.optional(), }); const ArticleHasSubtrees = z.object({ subtrees: ArticleSubtree.array(), }); const ArticleEntry = z.union([ FileEntry.and(ArticleShorthandSubtree), FileEntry.merge(ArticleHasSubtrees), FileEntry, URLEntry, GlobEntry, ]); const ArticleTOCBase = z.object({ root: z.string(), format: z.literal('jb-article'), defaults: TOCTreeOptions.optional(), }); const ArticleTOC = z.union([ ArticleTOCBase.and(ArticleShorthandSubtree), ArticleTOCBase.merge(ArticleHasSubtrees).strict(), ArticleTOCBase.strict(), ]); const BookOuterSubtree = TOCTreeOptions.extend({ chapters: z.lazy(() => BookEntry.array()), }); const BookInnerSubtree = TOCTreeOptions.extend({ sections: z.lazy(() => BookEntry.array()), }); const BookShorthandOuterSubtree = z.object({ chapters: z.lazy(() => BookEntry.array()), options: TOCTreeOptions.optional(), }); const BookShorthandInnerSubtree = z.object({ sections: z.lazy(() => BookEntry.array()), options: TOCTreeOptions.optional(), }); const BookHasOuterSubtrees = z.object({ parts: BookOuterSubtree.array(), }); const BookHasInnerSubtrees = z.object({ subtrees: BookInnerSubtree.array(), }); const BookEntry = z.union([ FileEntry.and(BookShorthandInnerSubtree), FileEntry.merge(BookHasInnerSubtrees), FileEntry, URLEntry, GlobEntry, ]); const BookTOCBase = z.object({ root: z.string(), format: z.literal('jb-book'), defaults: TOCTreeOptions.optional(), }); const BookTOC = z.union([ BookTOCBase.and(BookShorthandOuterSubtree), BookTOCBase.merge(BookHasOuterSubtrees).strict(), BookTOCBase.strict(), ]); /** TOC **/ const SphinxExternalTOC = z.union([ArticleTOC, BookTOC, NoFormatTOC]); export function validateSphinxExternalTOC(toc) { const result = SphinxExternalTOC.safeParse(toc); if (!result.success) { const errors = result.error.errors.map((issue) => `${issue.path.join('.')}: ${issue.message} (${issue.code})`); throw new Error(`Error(s) in parsing Jupyter Book TOC:\n${errors}`); } else { return result.data; } } /** * Helper function throwing a compile error if the branch is reachable */ function assertNever() { throw new Error('unreachable code'); } function maybeResolveDocument(dir, name, session) { const resolved = resolveExtension(join(dir, name)); if (resolved) { return relative(dir, resolved); } session.log.error(`Could not find a file named ${name} (declared in table of contents)`); return name; } /** * Convert a no-format TOC to a MyST TOC * * @param session - session with logging * @param dir - directory in which the _toc.yml lives * @param data - validated TOC */ function convertNoFormat(session, dir, data) { const rootEntry = { file: maybeResolveDocument(dir, data.root, session) }; const convertEntry = (item) => { let entry; if ('file' in item) { entry = { file: maybeResolveDocument(dir, item.file, session), title: item.title, }; } else if ('url' in item) { entry = { url: item.url, title: item.title, }; } else if ('glob' in item) { entry = { pattern: item.glob, }; } else { assertNever(); } if ('subtrees' in item || 'entries' in item) { const children = convertHasOrIsSubtree(item); entry = { ...entry, children: children }; } return entry; }; const convertSubtree = (item, options, index) => { var _a; return { title: (_a = options === null || options === void 0 ? void 0 : options.caption) !== null && _a !== void 0 ? _a : `Subtree ${index}`, children: item.entries.map(convertEntry), }; }; const convertHasOrIsSubtree = (item) => { if ('subtrees' in item) { return item.subtrees.map((subtree, i) => convertSubtree(subtree, subtree, i)); } else { // Convert the subtree const subtree = convertSubtree(item, item.options, 0); // Lift the children (erasing the shorthand subtree) return subtree.children; } }; const entries = [rootEntry]; if ('subtrees' in data || 'entries' in data) { entries.push(...convertHasOrIsSubtree(data)); } return entries; } /** * Convert a Book TOC into a no-format TOC * * @param data - validated TOC */ function convertBookToNoFormat(data) { const convertEntry = (item) => { // Drop subtrees and sections // eslint-disable-next-line prefer-const, @typescript-eslint/no-unused-vars let { sections, subtrees, ...result } = item; if ('sections' in item || 'subtrees' in item) { result = { ...result, ...convertHasOrIsInnerSubtree(item) }; } return result; }; const convertInnerSubtree = (item) => { const { sections, ...result } = item; return { ...result, entries: sections.map(convertEntry) }; }; const convertHasOrIsInnerSubtree = (item) => { if ('subtrees' in item) { const { subtrees, ...rest } = item; return { ...rest, subtrees: subtrees.map(convertInnerSubtree) }; } else { const { options, ...rest } = item; return { options, ...convertInnerSubtree(rest) }; } }; const convertOuterSubtree = (item) => { const { chapters, ...rest } = item; return { ...rest, entries: chapters.map(convertEntry) }; }; const convertHasOrIsOuterSubtree = (item) => { if ('parts' in item) { const { parts, ...rest } = item; return { ...rest, subtrees: parts.map(convertOuterSubtree) }; } else { const { options, ...rest } = item; return { options, ...convertOuterSubtree(rest) }; } }; const { root, defaults, format: _, ...rest } = data; let result = { root, defaults, }; if ('chapters' in rest || 'parts' in rest) { result = { ...result, ...convertHasOrIsOuterSubtree(rest) }; } return result; } /** * Convert a Article TOC into a no-format TOC * * @param data - validated TOC */ function convertArticleToNoFormat(data) { const convertEntry = (item) => { // Drop subtrees and sections // eslint-disable-next-line prefer-const, @typescript-eslint/no-unused-vars let { sections, subtrees, ...result } = item; if ('sections' in item || 'subtrees' in item) { result = { ...result, ...convertHasOrIsSubtree(item) }; } return result; }; const convertSubtree = (item) => { const { sections, ...result } = item; return { ...result, entries: sections.map(convertEntry) }; }; const convertHasOrIsSubtree = (item) => { if ('subtrees' in item) { const { subtrees, ...rest } = item; return { ...rest, subtrees: subtrees.map(convertSubtree) }; } else { const { options, ...rest } = item; return { options, ...convertSubtree(rest) }; } }; const { root, defaults, format: _, ...rest } = data; let result = { root, defaults, }; if ('sections' in rest || 'subtrees' in rest) { result = { ...result, ...convertHasOrIsSubtree(rest) }; } return result; } /** * Upgrade a sphinx-external-toc TOC into a MyST TOC */ export function upgradeTOC(session, data) { const dir = cwd(); let dataNoFormat; if ('format' in data) { switch (data.format) { case 'jb-book': { dataNoFormat = convertBookToNoFormat(data); } break; case 'jb-article': { dataNoFormat = convertArticleToNoFormat(data); } break; } } else { dataNoFormat = data; } return convertNoFormat(session, dir, dataNoFormat); }