From 58894c2f69f62ba24d0cecde757ff16cd71ecd98 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Wed, 19 Jun 2024 13:40:38 +0100 Subject: [PATCH] feat: add upgrade process --- packages/myst-cli/src/index.ts | 1 + .../myst-cli/src/init/gh-actions/index.ts | 21 +- packages/myst-cli/src/init/index.ts | 1 + packages/myst-cli/src/init/init.ts | 48 ++-- .../myst-cli/src/init/jupyter-book/config.ts | 180 ++++++++++++++ .../myst-cli/src/init/jupyter-book/index.ts | 1 + .../myst-cli/src/init/jupyter-book/syntax.ts | 145 +++++++++++ .../myst-cli/src/init/jupyter-book/toc.ts | 235 ++++++++++++++++++ .../myst-cli/src/init/jupyter-book/upgrade.ts | 50 ++++ packages/myst-cli/src/utils/defined.ts | 3 + packages/myst-cli/src/utils/fsExists.ts | 16 ++ packages/myst-cli/src/utils/git.ts | 16 ++ packages/myst-cli/src/utils/index.ts | 1 + 13 files changed, 682 insertions(+), 36 deletions(-) create mode 100644 packages/myst-cli/src/init/index.ts create mode 100644 packages/myst-cli/src/init/jupyter-book/config.ts create mode 100644 packages/myst-cli/src/init/jupyter-book/index.ts create mode 100644 packages/myst-cli/src/init/jupyter-book/syntax.ts create mode 100644 packages/myst-cli/src/init/jupyter-book/toc.ts create mode 100644 packages/myst-cli/src/init/jupyter-book/upgrade.ts create mode 100644 packages/myst-cli/src/utils/defined.ts create mode 100644 packages/myst-cli/src/utils/fsExists.ts create mode 100644 packages/myst-cli/src/utils/git.ts diff --git a/packages/myst-cli/src/index.ts b/packages/myst-cli/src/index.ts index abdccf4b2..051dabfbb 100644 --- a/packages/myst-cli/src/index.ts +++ b/packages/myst-cli/src/index.ts @@ -1,6 +1,7 @@ export * from './build/index.js'; export * from './cli/index.js'; export * from './config.js'; +export * from './init/index.js'; export * from './frontmatter.js'; export * from './plugins.js'; export * from './process/index.js'; diff --git a/packages/myst-cli/src/init/gh-actions/index.ts b/packages/myst-cli/src/init/gh-actions/index.ts index 62211c588..7030be633 100644 --- a/packages/myst-cli/src/init/gh-actions/index.ts +++ b/packages/myst-cli/src/init/gh-actions/index.ts @@ -3,8 +3,9 @@ import path from 'node:path'; import inquirer from 'inquirer'; import chalk from 'chalk'; import type { ISession } from 'myst-cli-utils'; -import { makeExecutable, writeFileToFolder } from 'myst-cli-utils'; +import { writeFileToFolder } from 'myst-cli-utils'; import { getGithubUrl } from '../../utils/github.js'; +import { checkFolderIsGit, checkAtGitRoot } from '../../utils/git.js'; function createGithubPagesAction({ defaultBranch = 'main', @@ -97,24 +98,6 @@ jobs: `; } -async function checkFolderIsGit(): Promise { - try { - await makeExecutable('git status', null)(); - return true; - } catch (error) { - return false; - } -} - -async function checkAtGitRoot(): Promise { - try { - fs.readdirSync('.git'); - return true; - } catch (error) { - return false; - } -} - async function prelimGitChecks(session: ISession): Promise { const inGitRepo = await checkFolderIsGit(); if (!inGitRepo) { diff --git a/packages/myst-cli/src/init/index.ts b/packages/myst-cli/src/init/index.ts new file mode 100644 index 000000000..deda958f8 --- /dev/null +++ b/packages/myst-cli/src/init/index.ts @@ -0,0 +1 @@ +export * from './init.js'; diff --git a/packages/myst-cli/src/init/init.ts b/packages/myst-cli/src/init/init.ts index 239a37583..200de97f4 100644 --- a/packages/myst-cli/src/init/init.ts +++ b/packages/myst-cli/src/init/init.ts @@ -11,6 +11,8 @@ import type { ISession } from '../session/types.js'; import { startServer } from '../build/site/start.js'; import { githubCurvenoteAction, githubPagesAction } from './gh-actions/index.js'; import { getGithubUrl } from '../utils/github.js'; +import { upgradeJupyterBook } from './jupyter-book/upgrade.js'; +import { fsExists } from '../utils/fsExists.js'; const VERSION_CONFIG = '# See docs at: https://mystmd.org/guide/frontmatter\nversion: 1\n'; @@ -54,6 +56,7 @@ Learn more about this CLI and MyST Markdown at: ${chalk.bold('https://mystmd.org `; + export async function init(session: ISession, opts: InitOptions) { const { project, site, writeTOC, ghPages, ghCurvenote } = opts; @@ -93,24 +96,35 @@ export async function init(session: ISession, opts: InitOptions) { await writeConfigs(session, '.', { siteConfig, projectConfig }); } } else { - // If no config is present, write it explicitly to include comments. - const configFile = defaultConfigFile(session, '.'); - let configData: string; - let configDoc: string; - if (site && !project) { - configData = `${VERSION_CONFIG}${SITE_CONFIG}`; - configDoc = 'site'; - } else if (project && !site) { - configData = `${VERSION_CONFIG}${createProjectConfig({ github })}`; - configDoc = 'project'; - } else { - configData = `${VERSION_CONFIG}${createProjectConfig({ github })}${SITE_CONFIG}`; - configDoc = 'project and site'; + // Is this a Jupyter Book? + if (await fsExists('_config.yml')) { + const configFile = defaultConfigFile(session, '.'); + session.log.info( + `📘 Found a legacy Jupyter Book, writing new config file: ${chalk.blue(path.resolve(configFile))}`, + ); + await upgradeJupyterBook(session, configFile); + } + // Otherwise, write some default configs + else { + // If no config is present, write it explicitly to include comments. + const configFile = defaultConfigFile(session, '.'); + let configData: string; + let configDoc: string; + if (site && !project) { + configData = `${VERSION_CONFIG}${SITE_CONFIG}`; + configDoc = 'site'; + } else if (project && !site) { + configData = `${VERSION_CONFIG}${createProjectConfig({ github })}`; + configDoc = 'project'; + } else { + configData = `${VERSION_CONFIG}${createProjectConfig({ github })}${SITE_CONFIG}`; + configDoc = 'project and site'; + } + session.log.info( + `💾 Writing new ${configDoc} config file: ${chalk.blue(path.resolve(configFile))}`, + ); + fs.writeFileSync(configFile, configData); } - session.log.info( - `💾 Writing new ${configDoc} config file: ${chalk.blue(path.resolve(configFile))}`, - ); - fs.writeFileSync(configFile, configData); } if (writeTOC) { await loadConfig(session, '.'); diff --git a/packages/myst-cli/src/init/jupyter-book/config.ts b/packages/myst-cli/src/init/jupyter-book/config.ts new file mode 100644 index 000000000..3a2c95bb2 --- /dev/null +++ b/packages/myst-cli/src/init/jupyter-book/config.ts @@ -0,0 +1,180 @@ +import { z } from 'zod'; +import { defined } from '../../utils/defined.js'; +import type { Config, ProjectConfig, SiteConfig } from 'myst-config'; + +const JupyterBookConfig = z.object({ + title: z.string().optional(), + author: z.string().optional(), + copyright: z.string().optional(), + logo: z.string().optional(), + exclude_patterns: z.array(z.string()).optional(), + parse: z + .object({ + myst_enable_extensions: z.union([z.null(), z.array(z.string())]).optional(), + myst_url_schemes: z.union([z.null(), z.array(z.string())]).optional(), + myst_dmath_double_inline: z.boolean().default(true), + }) + .optional(), + execute: z + .object({ + eval_regex: z.string().default('^.*$'), + raise_on_error: z.boolean().default(false), + show_tb: z.boolean().default(false), + execute_notebooks: z + .union([ + z.literal('auto'), + z.literal('cache'), + z.literal('force'), + z.literal('inline'), + z.literal('off'), + z.literal(false), + ]) + .default('auto'), + cache: z.string().optional(), + timeout: z.number().gte(-1).default(30), + allow_errors: z.boolean().default(false), + stderr_output: z + .enum(['show', 'remove', 'remove-warn', 'warn', 'error', 'severe']) + .default('show'), + run_in_temp: z.boolean().default(false), + exclude_patterns: z.array(z.string()).optional(), + }) + .optional(), + html: z + .object({ + favicon: z.string().optional(), + use_edit_page_button: z.boolean().optional(), + use_repository_button: z.boolean().optional(), + use_issues_button: z.boolean().optional(), + extra_footer: z.string().optional(), + analytics: z + .object({ + plausible_analytics_domain: z.string().optional(), + google_analytics_id: z.string().optional(), + }) + .optional(), + home_page_in_navbar: z.boolean().optional(), + baseurl: z.string().optional(), + comments: z + .object({ + hypothesis: z.union([z.boolean(), z.record(z.any())]).optional(), + utterances: z.union([z.boolean(), z.record(z.any())]).optional(), + }) + .optional(), + announcement: z.string().optional(), + }) + .optional(), + latex: z.object({ latex_engine: z.string().default('pdflatex') }).optional(), + launch_buttons: z + .object({ + notebook_interface: z.string().optional(), + binderhub_url: z.string().optional(), + jupyterhub_url: z.string().optional(), + thebe: z.boolean().optional(), + colab_url: z.string().optional(), + }) + .optional(), + repository: z + .object({ + url: z.string().optional(), + path_to_book: z.string().optional(), + branch: z.string().optional(), + }) + .optional(), + sphinx: z + .object({ + extra_extensions: z.union([z.null(), z.array(z.string())]).optional(), + local_extensions: z.union([z.null(), z.record(z.any())]).optional(), + recursive_update: z.boolean().optional(), + config: z.union([z.null(), z.record(z.any())]).optional(), + }) + .optional(), +}); + +export type JupyterBookConfig = z.infer; +export function validateJupyterBookConfig(config: unknown): JupyterBookConfig | undefined { + const result = JupyterBookConfig.safeParse(config); + if (!result.success) { + console.error(result.error); + return undefined; + } else { + return result.data; + } +} + +function parseGitHubRepoURL(url: string): string | undefined { + //eslint-disable-next-line + const match = url.match(/(?:git@|https:\/\/)github.com[:\/](.*)(?:.git)?/); + if (!match) { + return undefined; + } + return match[1]; +} + +export function upgradeConfig(data: JupyterBookConfig): Pick { + const project: ProjectConfig = {}; + const siteOptions: SiteConfig['options'] = {}; + const site: SiteConfig = { + options: siteOptions, + template: "book-theme" + }; + + if (defined(data.title)) { + project.title = data.title; + } + + if (defined(data.author)) { + const authors = data.author.split(/,\s*(?:and\s)?\s*|\s+and\s+/); + if (authors.length === 1) { + project.authors = [{ name: data.author }]; // TODO prompt user for alias? + } else { + project.authors = authors.map((name) => ({ name })); + } + } + + if (defined(data.copyright)) { + project.copyright = data.copyright; + } + + if (defined(data.logo)) { + siteOptions.logo = data.logo; + } + + if (defined(data.exclude_patterns)) { + project.exclude = data.exclude_patterns; + } + + if (defined(data.html?.favicon)) { + siteOptions.favicon = data.html.favicon; + } + + if (defined(data.html?.analytics?.google_analytics_id)) { + siteOptions.analytics_google = data.html.analytics.google_analytics_id; + } + + if (defined(data.html?.analytics?.plausible_analytics_domain)) { + siteOptions.analytics_plausible = data.html.analytics.plausible_analytics_domain; + } + + const repo = defined(data.repository?.url) ? parseGitHubRepoURL(data.repository?.url) : undefined; + if (defined(repo)) { + project.github = repo; + } + + // Do we want to enable thebe and mybinder? + if ( + defined(repo) && + (defined(data.launch_buttons?.binderhub_url) || !!data.launch_buttons?.thebe) + ) { + project.thebe = { + binder: { + repo: repo, + provider: 'github', + url: data.launch_buttons?.binderhub_url, + ref: data.repository?.branch, + }, + }; + } + + return { project, site }; +} diff --git a/packages/myst-cli/src/init/jupyter-book/index.ts b/packages/myst-cli/src/init/jupyter-book/index.ts new file mode 100644 index 000000000..8f0b8cb36 --- /dev/null +++ b/packages/myst-cli/src/init/jupyter-book/index.ts @@ -0,0 +1 @@ +export * from './upgrade.js'; diff --git a/packages/myst-cli/src/init/jupyter-book/syntax.ts b/packages/myst-cli/src/init/jupyter-book/syntax.ts new file mode 100644 index 000000000..20aee3375 --- /dev/null +++ b/packages/myst-cli/src/init/jupyter-book/syntax.ts @@ -0,0 +1,145 @@ +import { mystParse } from 'myst-parser'; +import fs from 'node:fs/promises'; +import { glob } from 'glob'; +import { selectAll } from 'unist-util-select'; +import { toText } from 'myst-common'; +import { fsExists } from '../../utils/fsExists.js'; +import chalk from 'chalk'; +import type { ISession } from '../../session/types.js'; +import { makeExecutable } from 'myst-cli-utils'; +import { parse } from 'node:path'; +type Line = { + content: string; + offset: number; +}; + +type LegacyGlossaryItem = { + termLines: Line[]; + definitionLines: Line[]; +}; + +export async function upgradeGlossaries(session: ISession) { + let markdownPaths: string[]; + try { + const allFiles = (await makeExecutable('git ls-files', null)()).split(/\r\n|\r|\n/); + markdownPaths = allFiles.filter((path) => { + const { ext } = parse(path); + return ext == '.md'; + }); + console.log(allFiles, markdownPaths); + } catch (error) { + markdownPaths = await glob('**/*.md'); + } + await Promise.all(markdownPaths.map((path) => upgradeGlossary(session, path))); +} + +const SPLIT_PATTERN = /\r\n|\r|\n/; + +async function upgradeGlossary(session: ISession, path: string) { + const backupFilePath = `.${path}.myst.bak`; + + // Ensure that we havent' already done this once + if (await fsExists(backupFilePath)) { + return; + } + const data = (await fs.readFile(path)).toString(); + const documentLines = data.split(SPLIT_PATTERN); + + const mdast = mystParse(data); + const glossaryNodes = selectAll('mystDirective[name=glossary]', mdast); + + // Track the edit point + let editOffset = 0; + for (const node of glossaryNodes) { + const nodeLines = ((node as any).value as string).split(SPLIT_PATTERN); + + // TODO: assert span items + + // Flag tracking whether the line-processor expects definition lines + let inDefinition = false; + let indentSize = 0; + + const entries: LegacyGlossaryItem[] = []; + + // Parse lines into separate entries + for (let i = 0; i < nodeLines.length; i++) { + const line = nodeLines[i]; + // Is the line a comment? + if (/^\.\.\s/.test(line) || !line.length) { + continue; + } + // Is the line a non-whitespace-leading line (term declaration)? + else if (/^[^\s]/.test(line[0])) { + // Comment + if (line.startsWith('.. ')) { + continue; + } + + // Do we need to create a new entry? + if (inDefinition || !entries.length) { + // Close the current definition, open a new term + entries.push({ + definitionLines: [], + termLines: [{ content: line, offset: i }], + }); + inDefinition = false; + } + // Can we extend existing entry with an additional term? + else if (entries.length) { + entries[entries.length - 1].termLines.push({ content: line, offset: i }); + } + } + // Open a definition + else if (!inDefinition) { + inDefinition = true; + indentSize = line.length - line.replace(/^\s+/, '').length; + + if (entries.length) { + entries[entries.length - 1].definitionLines.push({ + content: line.slice(indentSize), + offset: i, + }); + } + } + } + + // Build glossary + const newLines: string[] = []; + + for (const entry of entries) { + const { termLines, definitionLines } = entry; + + const definitionBody = definitionLines.map((line) => line.content).join('\n'); + const [firstTerm, ...restTerms] = termLines; + + // Initial definition + const firstTermValue = firstTerm.content.split(/\s+:\s+/, 1)[0]; + newLines.push(firstTermValue, `: ${definitionBody}\n`); + + if (restTerms) { + // Terms can contain markup, but we need the text-form to create a term reference + // TODO: what if something magical like an xref is used here? Assume not. + const parsedTerm = mystParse(firstTermValue); + const termName = toText(parsedTerm); + for (const { content } of restTerms) { + const term = content.split(/\s+:\s+/, 1)[0]; + newLines.push(term, `: {term}\`${termName}\`\n`); + } + } + } + const nodeSpan = { start: node.position?.start?.line, stop: node.position?.end?.line }; + const spanLength = nodeSpan.stop! - nodeSpan.start! - 1; + documentLines.splice(nodeSpan.start! + editOffset, spanLength, ...newLines); + + // Offset our insert cursor + editOffset += newLines.length - spanLength; + } + + // Update the file + if (glossaryNodes.length) { + await fs.rename(path, backupFilePath); + + session.log.info(chalk.dim(`Backed up original version of ${path} to ${backupFilePath}`)); + await fs.writeFile(path, documentLines.join('\n')); + } +} diff --git a/packages/myst-cli/src/init/jupyter-book/toc.ts b/packages/myst-cli/src/init/jupyter-book/toc.ts new file mode 100644 index 000000000..1bb966348 --- /dev/null +++ b/packages/myst-cli/src/init/jupyter-book/toc.ts @@ -0,0 +1,235 @@ +import { z } from 'zod'; +import { resolveExtension } from '../../utils/resolveExtension.js'; +import { join, relative } from 'node:path'; +import { cwd } from 'node:process'; +import type { Entry as MySTEntry } from 'myst-toc'; +const TOCTreeOptions = z + .object({ + caption: z.string(), + hidden: z.boolean(), + maxdepth: z.number(), + numberted: z.boolean(), + reversed: z.boolean(), + titlesonly: z.boolean(), + }) + .partial(); + +type FileEntry = z.infer; +const FileEntry = z.object({ + file: z.string(), + title: z.string().optional(), +}); + +type URLEntry = z.infer; +const URLEntry = z.object({ + url: z.string(), + title: z.string().optional(), +}); + +type GlobEntry = z.infer; +const GlobEntry = z.object({ + glob: z.string(), +}); + +/** Basic TOC Trees **/ +type NoFormatSubtreeType = z.infer & { + entries: z.infer[]; +}; +const NoFormatSubtree: z.ZodType = TOCTreeOptions.extend({ + entries: z.lazy(() => NoFormatEntry.array()), +}); + +type NoFormatShorthandSubtreeType = { + entries: z.infer[]; + options?: z.infer; +}; +const NoFormatShorthandSubtree: z.ZodType = 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(), +]); + +/** Article format **/ +type ArticleSubtreeType = z.infer & { + sections: z.infer[]; +}; +const ArticleSubtree: z.ZodType = TOCTreeOptions.extend({ + sections: z.lazy(() => ArticleEntry.array()), +}); + +type ArticleShorthandSubtreeType = { + sections: z.infer[]; + options?: z.infer; +}; +const ArticleShorthandSubtree: z.ZodType = 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(), +]); + +/** Book format **/ +type BookOuterSubtreeType = z.infer & { + chapters: z.infer[]; +}; +const BookOuterSubtree: z.ZodType = TOCTreeOptions.extend({ + chapters: z.lazy(() => BookEntry.array()), +}); + +type BookInnerSubtreeType = z.infer & { + sections: z.infer[]; +}; +const BookInnerSubtree: z.ZodType = TOCTreeOptions.extend({ + sections: z.lazy(() => BookEntry.array()), +}); + +type BookShorthandOuterSubtreeType = { + chapters: z.infer[]; + options?: z.infer; +}; +const BookShorthandOuterSubtree: z.ZodType = z.object({ + chapters: z.lazy(() => BookEntry.array()), + options: TOCTreeOptions.optional(), +}); + +type BookShorthandInnerSubtreeType = { + sections: z.infer[]; + options?: z.infer; +}; +const BookShorthandInnerSubtree: z.ZodType = 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 type SphinxExternalTOC = z.infer; +export function validateSphinxExternalTOC(toc: unknown): SphinxExternalTOC | undefined { + const result = SphinxExternalTOC.safeParse(toc); + if (!result.success) { + console.error(result.error); + return undefined; + } else { + return result.data; + } +} + +type PrimitiveEntry = FileEntry | URLEntry | GlobEntry; + +function convertPrimitive(dir: string, data: PrimitiveEntry): MySTEntry { + if ('file' in data) { + const resolved = resolveExtension(join(dir, data.file as string)); + // TODO: check this is valid! + return { + file: relative(dir, resolved as string), + title: data.title, + }; + } else if ('url' in data) { + return { + url: data.url, + title: data.title, + }; + } else if ('glob' in data) { + return { + pattern: data.glob, + }; + } else { + throw new Error('This should not happen!'); + } +} + +function convertGeneric(dir: string, data: Record): any { + // The JB schema is quite complex, so rather than being type-safe here + // we'll drop type-information in order to write something readable + + // TODO: handle numbering + if ('parts' in data || 'subtrees' in data) { + const parts = (data.parts ?? data.subtrees) as Record[]; + return parts.map((part, index) => { + return { title: part.caption ?? `Part ${index}`, children: convertGeneric(dir, part) }; + }); + } else if ('chapters' in data || 'sections' in data) { + const chapters = (data.chapters ?? data.sections) as Record[]; + return chapters.map((chapter) => convertGeneric(dir, chapter)); + } else { + return convertPrimitive(dir, data as any); + } +} +export function upgradeTOC(data: SphinxExternalTOC) { + const dir = cwd(); + const entries = convertGeneric(dir, data) as any[]; + return [{ file: relative(dir, resolveExtension(join(dir, data.root))!) }, ...entries]; +} diff --git a/packages/myst-cli/src/init/jupyter-book/upgrade.ts b/packages/myst-cli/src/init/jupyter-book/upgrade.ts new file mode 100644 index 000000000..bd8010145 --- /dev/null +++ b/packages/myst-cli/src/init/jupyter-book/upgrade.ts @@ -0,0 +1,50 @@ +import fs from 'node:fs/promises'; + +import { defined } from '../../utils/defined.js'; +import yaml from 'js-yaml'; +import type { Config } from 'myst-config'; +import { upgradeConfig, validateJupyterBookConfig } from './config.js'; +import { upgradeTOC, validateSphinxExternalTOC } from './toc.js'; +import { upgradeGlossaries } from './syntax.js'; +import { fsExists } from '../../utils/fsExists.js'; +import chalk from 'chalk'; +import type { ISession } from '../../session/types.js'; + +export async function upgradeJupyterBook(session: ISession, configFile: string) { + const config: Config = { + version: 1, + project: {}, + }; + + // Does config file exist? + if (!(await fsExists('_config.yml'))) { + throw new Error('_config.yml is a required Jupyter Book configuration file'); + } + const content = await fs.readFile('_config.yml', { encoding: 'utf-8' }); + const data = validateJupyterBookConfig(yaml.load(content)); + if (defined(data)) { + // Update MyST configuration + ({ site: config.site, project: config.project } = upgradeConfig(data)); + } + + // Does TOC exist? + if (await fsExists('_toc.yml')) { + const content = await fs.readFile('_toc.yml', { encoding: 'utf-8' }); + const data = validateSphinxExternalTOC(yaml.load(content)); + if (defined(data)) { + (config as any).project.toc = upgradeTOC(data); + } + } + + // Upgrade legacy syntax + await upgradeGlossaries(session); + + // Write new myst.yml + await fs.writeFile(configFile, yaml.dump(config)); + + await fs.rename('_config.yml', '._config.yml.myst.bak'); + session.log.info(chalk.dim('Renamed _config.yml to ._config.yml.myst.bak')); + + await fs.rename('_toc.yml', '._toc.yml.myst.bak'); + session.log.info(chalk.dim('Renamed _toc.yml to ._toc.yml.myst.bak')); +} diff --git a/packages/myst-cli/src/utils/defined.ts b/packages/myst-cli/src/utils/defined.ts new file mode 100644 index 000000000..92bc6d4be --- /dev/null +++ b/packages/myst-cli/src/utils/defined.ts @@ -0,0 +1,3 @@ +export function defined(value: T | undefined): value is T { + return value !== undefined; +} diff --git a/packages/myst-cli/src/utils/fsExists.ts b/packages/myst-cli/src/utils/fsExists.ts new file mode 100644 index 000000000..440f83347 --- /dev/null +++ b/packages/myst-cli/src/utils/fsExists.ts @@ -0,0 +1,16 @@ +import fs from 'node:fs/promises'; + + +/** + * Asynchronous version of fs.existsSync + * + * @param path - path to test for existence + */ +export async function fsExists(path: string): Promise { + try { + await fs.access(path, fs.constants.F_OK); + return true; + } catch (e) { + return false; + } +} diff --git a/packages/myst-cli/src/utils/git.ts b/packages/myst-cli/src/utils/git.ts new file mode 100644 index 000000000..67bf520d6 --- /dev/null +++ b/packages/myst-cli/src/utils/git.ts @@ -0,0 +1,16 @@ + +import { makeExecutable } from 'myst-cli-utils'; +import { fsExists } from './fsExists.js'; + +export async function checkFolderIsGit(): Promise { + try { + await makeExecutable('git status', null)(); + return true; + } catch (error) { + return false; + } +} + +export async function checkAtGitRoot(): Promise { + return await fsExists(".git"); +} diff --git a/packages/myst-cli/src/utils/index.ts b/packages/myst-cli/src/utils/index.ts index a7457f460..8bfe4deee 100644 --- a/packages/myst-cli/src/utils/index.ts +++ b/packages/myst-cli/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './addWarningForFile.js'; export * from './check.js'; export * from './createTempFolder.js'; +export * from './defined.js'; export * from './fileInfo.js'; export * from './filterFilenamesByExtension.js'; export * from './getAllBibtexFiles.js';