diff --git a/.changeset/fluffy-clocks-complain.md b/.changeset/fluffy-clocks-complain.md new file mode 100644 index 000000000..9226d3640 --- /dev/null +++ b/.changeset/fluffy-clocks-complain.md @@ -0,0 +1,5 @@ +--- +'myst-cli': patch +--- + +Do not duplicate site template options in redux store diff --git a/.changeset/gentle-moons-film.md b/.changeset/gentle-moons-film.md new file mode 100644 index 000000000..41157391c --- /dev/null +++ b/.changeset/gentle-moons-film.md @@ -0,0 +1,6 @@ +--- +'myst-config': patch +'myst-cli': patch +--- + +Write myst-cli version in site config diff --git a/.changeset/late-doors-dress.md b/.changeset/late-doors-dress.md new file mode 100644 index 000000000..2054353db --- /dev/null +++ b/.changeset/late-doors-dress.md @@ -0,0 +1,8 @@ +--- +'myst-frontmatter': patch +'myst-to-jats': patch +'myst-common': patch +'myst-cli': patch +--- + +Consume frontmatter parts alongside tagged parts diff --git a/.changeset/long-penguins-remember.md b/.changeset/long-penguins-remember.md new file mode 100644 index 000000000..8296bdcbd --- /dev/null +++ b/.changeset/long-penguins-remember.md @@ -0,0 +1,8 @@ +--- +'myst-frontmatter': patch +'myst-to-jats': patch +'myst-common': patch +'myst-cli': patch +--- + +Transform frontmatter parts into blocks in the mdast diff --git a/.changeset/long-zoos-shout.md b/.changeset/long-zoos-shout.md new file mode 100644 index 000000000..0402be003 --- /dev/null +++ b/.changeset/long-zoos-shout.md @@ -0,0 +1,5 @@ +--- +'myst-cli': patch +--- + +Resolve site logo path with other template options of type file diff --git a/.changeset/many-ravens-develop.md b/.changeset/many-ravens-develop.md new file mode 100644 index 000000000..afe1c48bf --- /dev/null +++ b/.changeset/many-ravens-develop.md @@ -0,0 +1,7 @@ +--- +'myst-templates': patch +'myst-cli': patch +'jtex': patch +--- + +Template parts may now specify as_list diff --git a/.changeset/pretty-zebras-warn.md b/.changeset/pretty-zebras-warn.md new file mode 100644 index 000000000..291dae276 --- /dev/null +++ b/.changeset/pretty-zebras-warn.md @@ -0,0 +1,7 @@ +--- +'myst-frontmatter': patch +'myst-config': patch +'myst-cli': patch +--- + +Consume frontmatter options for template/site options diff --git a/.changeset/sixty-sloths-impress.md b/.changeset/sixty-sloths-impress.md new file mode 100644 index 000000000..0e0320271 --- /dev/null +++ b/.changeset/sixty-sloths-impress.md @@ -0,0 +1,5 @@ +--- +'myst-frontmatter': patch +--- + +Add options to site/project/page frontmatter diff --git a/.changeset/small-pens-arrive.md b/.changeset/small-pens-arrive.md new file mode 100644 index 000000000..fbf34127c --- /dev/null +++ b/.changeset/small-pens-arrive.md @@ -0,0 +1,5 @@ +--- +'myst-common': patch +--- + +Ensure the block is visible by default in extractParts diff --git a/.changeset/stupid-knives-eat.md b/.changeset/stupid-knives-eat.md new file mode 100644 index 000000000..0f55b2ee4 --- /dev/null +++ b/.changeset/stupid-knives-eat.md @@ -0,0 +1,5 @@ +--- +'myst-frontmatter': patch +--- + +Add parts to page frontmatter diff --git a/.changeset/swift-eagles-call.md b/.changeset/swift-eagles-call.md new file mode 100644 index 000000000..e96399b6a --- /dev/null +++ b/.changeset/swift-eagles-call.md @@ -0,0 +1,6 @@ +--- +'myst-frontmatter': patch +'myst-cli': patch +--- + +Frontmatter parts each coerce to list diff --git a/docs/frontmatter.md b/docs/frontmatter.md index e727891a3..255b1dd73 100644 --- a/docs/frontmatter.md +++ b/docs/frontmatter.md @@ -133,6 +133,12 @@ The following table lists the available frontmatter fields, a brief description * - `abbreviations` - a dictionary of abbreviations in the project (see [](#abbreviations)) - page can override project +* - `parts` + - a dictionary of arbitrary content parts, not part of the main article, for example `abstract`, `data_availability` + - page only +* - `options` + - a dictionary of arbitrary options validated and consumed by templates, for example, during site or PDF build + - page can override project ``` +++ diff --git a/docs/quickstart-myst-websites.md b/docs/quickstart-myst-websites.md index 62c351f17..ab4221a82 100644 --- a/docs/quickstart-myst-websites.md +++ b/docs/quickstart-myst-websites.md @@ -183,7 +183,8 @@ project: site: template: book-theme # title: - # logo: + # options: + # logo: my_logo.png nav: [] actions: - title: Learn More diff --git a/package-lock.json b/package-lock.json index 11b27c81e..4b0c06e81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13691,6 +13691,7 @@ "license": "MIT", "dependencies": { "mdast": "^3.0.0", + "myst-frontmatter": "^1.1.11", "myst-spec": "^0.0.4", "nanoid": "^4.0.0", "unified": "^10.1.2", diff --git a/packages/jtex/src/types.ts b/packages/jtex/src/types.ts index e9c2fbc65..96dbb8218 100644 --- a/packages/jtex/src/types.ts +++ b/packages/jtex/src/types.ts @@ -9,6 +9,6 @@ export type TexRenderer = { CONTENT: string; doc: RendererDoc; options: Record; - parts: Record; + parts: Record; IMPORTS?: string; }; diff --git a/packages/myst-cli/package.json b/packages/myst-cli/package.json index c9d2f5d43..09f0dabaa 100644 --- a/packages/myst-cli/package.json +++ b/packages/myst-cli/package.json @@ -111,6 +111,7 @@ "@types/mime-types": "^2.1.1", "@types/which": "^3.0.0", "@types/ws": "^8.5.5", - "concurrently": "^8.2.0" + "concurrently": "^8.2.0", + "unist-util-visit": "^5.0.0" } } diff --git a/packages/myst-cli/src/build/docx/single.ts b/packages/myst-cli/src/build/docx/single.ts index 93885b1a4..e0ba71ac3 100644 --- a/packages/myst-cli/src/build/docx/single.ts +++ b/packages/myst-cli/src/build/docx/single.ts @@ -110,7 +110,7 @@ export async function runWordExport( const { options, doc } = mystTemplate.prepare({ frontmatter: data.frontmatter, parts: [], - options: exportOptions, + options: { ...data.frontmatter.options, ...exportOptions }, sourceFile: file, }); const renderer = exportOptions.renderer ?? defaultWordRenderer; diff --git a/packages/myst-cli/src/build/init.ts b/packages/myst-cli/src/build/init.ts index 39e0d676f..8a32827ae 100644 --- a/packages/myst-cli/src/build/init.ts +++ b/packages/myst-cli/src/build/init.ts @@ -25,7 +25,8 @@ function createProjectConfig({ github }: { github?: string } = {}) { const SITE_CONFIG = `site: template: book-theme # title: - # logo: + # options: + # logo: site_logo.png nav: [] actions: - title: Learn More diff --git a/packages/myst-cli/src/build/jats/single.ts b/packages/myst-cli/src/build/jats/single.ts index 931fd2632..054ccfccf 100644 --- a/packages/myst-cli/src/build/jats/single.ts +++ b/packages/myst-cli/src/build/jats/single.ts @@ -73,6 +73,7 @@ export async function runJatsExport( title: 'Data Availability', }, ], + // if we want to add templating here, we have access to { ...processedArticle.frontmatter.options, ...exportOptions } }); logMessagesFromVFile(session, jats); session.log.info(toc(`📑 Exported JATS in %s, copying to ${output}`)); diff --git a/packages/myst-cli/src/build/site/manifest.ts b/packages/myst-cli/src/build/site/manifest.ts index 667e6b08e..0e21bc474 100644 --- a/packages/myst-cli/src/build/site/manifest.ts +++ b/packages/myst-cli/src/build/site/manifest.ts @@ -2,11 +2,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { hashAndCopyStaticFile } from 'myst-cli-utils'; import { RuleId, TemplateOptionType } from 'myst-common'; -import type { SiteAction, SiteManifest, SiteTemplateOptions } from 'myst-config'; +import type { SiteAction, SiteManifest } from 'myst-config'; import { PROJECT_FRONTMATTER_KEYS, SITE_FRONTMATTER_KEYS } from 'myst-frontmatter'; import type MystTemplate from 'myst-templates'; import { filterKeys } from 'simple-validators'; -import { addWarningForFile } from '../../index.js'; +import { addWarningForFile, resolveToAbsolute, version } from '../../index.js'; import { resolvePageExports } from '../../process/site.js'; import type { ISession } from '../../session/types.js'; import type { RootState } from '../../store/index.js'; @@ -113,14 +113,18 @@ export async function localToManifestProject( async function resolveTemplateFileOptions( session: ISession, mystTemplate: MystTemplate, - options: SiteTemplateOptions, + options: Record, ) { const resolvedOptions = { ...options }; mystTemplate.getValidatedTemplateYml().options?.forEach((option) => { if (option.type === TemplateOptionType.file && options[option.id]) { + const configPath = selectors.selectCurrentSitePath(session.store.getState()); + const absPath = configPath + ? resolveToAbsolute(session, configPath, options[option.id]) + : options[option.id]; const fileHash = hashAndCopyStaticFile( session, - options[option.id], + absPath, session.publicPath(), (m: string) => { addWarningForFile(session, options[option.id], m, 'error', { @@ -172,10 +176,12 @@ export async function getSiteManifest( const { nav } = siteConfig; const actions = siteConfig.actions?.map((action) => resolveSiteManifestAction(session, action)); const siteFrontmatter = filterKeys(siteConfig as Record, SITE_FRONTMATTER_KEYS); - const siteTemplateOptions = selectors.selectCurrentSiteTemplateOptions(state) || {}; const mystTemplate = await getMystTemplate(session, opts); const siteConfigFile = selectors.selectCurrentSiteFile(state); - const validatedOptions = mystTemplate.validateOptions(siteTemplateOptions, siteConfigFile); + const validatedOptions = mystTemplate.validateOptions( + siteFrontmatter.options ?? {}, + siteConfigFile, + ); const validatedFrontmatter = mystTemplate.validateDoc( siteFrontmatter, validatedOptions, @@ -183,10 +189,10 @@ export async function getSiteManifest( siteConfigFile, ); const resolvedOptions = await resolveTemplateFileOptions(session, mystTemplate, validatedOptions); + validatedFrontmatter.options = resolvedOptions; const manifest: SiteManifest = { ...validatedFrontmatter, - ...resolvedOptions, - myst: 'v1', + myst: version, nav: nav || [], actions: actions || [], projects: siteProjects, diff --git a/packages/myst-cli/src/build/tex/single.spec.ts b/packages/myst-cli/src/build/tex/single.spec.ts index c9c67c4de..66da1cec7 100644 --- a/packages/myst-cli/src/build/tex/single.spec.ts +++ b/packages/myst-cli/src/build/tex/single.spec.ts @@ -3,7 +3,7 @@ import type { GenericParent } from 'myst-common'; import { Session } from '../../session'; import { extractTexPart } from './single'; -describe('extractPart', () => { +describe('extractTexPart', () => { it('no tagged part returns undefined', async () => { expect( extractTexPart( @@ -123,4 +123,208 @@ describe('extractPart', () => { preamble: '', }); }); + it('part as_list returns list', async () => { + const tree: GenericParent = { + type: 'root', + children: [ + { + type: 'block' as any, + data: { part: 'test_tag' }, + children: [{ type: 'text', value: 'tagged content' }], + }, + { + type: 'block' as any, + data: { part: 'test_tag' }, + children: [{ type: 'text', value: 'also tagged content' }], + }, + ], + }; + expect( + extractTexPart( + new Session(), + tree, + {}, + { id: 'test_tag', as_list: true }, + {}, + { myst: 'v1' }, + ), + ).toEqual([ + { + value: 'tagged content', + imports: [], + commands: {}, + preamble: '', + }, + { + value: 'also tagged content', + imports: [], + commands: {}, + preamble: '', + }, + ]); + }); + it('part as_list returns list for markdown list', async () => { + const tree: GenericParent = { + type: 'root', + children: [ + { + type: 'block' as any, + data: { part: 'test_tag' }, + children: [ + { + type: 'list', + ordered: false, + spread: false, + children: [ + { + type: 'listItem', + spread: true, + children: [{ type: 'text', value: 'tagged content' }], + }, + { + type: 'listItem', + spread: true, + children: [{ type: 'text', value: 'also tagged content' }], + }, + ], + }, + ], + }, + ], + }; + expect( + extractTexPart( + new Session(), + tree, + {}, + { id: 'test_tag', as_list: true }, + {}, + { myst: 'v1' }, + ), + ).toEqual([ + { + value: 'tagged content', + imports: [], + commands: {}, + preamble: '', + }, + { + value: 'also tagged content', + imports: [], + commands: {}, + preamble: '', + }, + ]); + }); + it('part as_list returns single item for block with markdown list and other stuff', async () => { + const tree: GenericParent = { + type: 'root', + children: [ + { + type: 'block' as any, + data: { part: 'test_tag' }, + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'some other stuff' }], + }, + { + type: 'list', + ordered: false, + spread: false, + children: [ + { + type: 'listItem', + spread: true, + children: [{ type: 'text', value: 'tagged content' }], + }, + { + type: 'listItem', + spread: true, + children: [{ type: 'text', value: 'also tagged content' }], + }, + ], + }, + ], + }, + ], + }; + expect( + extractTexPart( + new Session(), + tree, + {}, + { id: 'test_tag', as_list: true }, + {}, + { myst: 'v1' }, + ), + ).toEqual([ + { + value: + 'some other stuff\n\n\\begin{itemize}\n\\item tagged content\n\\item also tagged content\n\\end{itemize}', + imports: [], + commands: {}, + preamble: '', + }, + ]); + }); + it('part as_list ignores markdown list if there are multiple blocks', async () => { + const tree: GenericParent = { + type: 'root', + children: [ + { + type: 'block' as any, + data: { part: 'test_tag' }, + children: [ + { + type: 'list', + ordered: false, + spread: false, + children: [ + { + type: 'listItem', + spread: true, + children: [{ type: 'text', value: 'tagged content' }], + }, + { + type: 'listItem', + spread: true, + children: [{ type: 'text', value: 'also tagged content' }], + }, + ], + }, + ], + }, + { + type: 'block' as any, + data: { part: 'test_tag' }, + children: [{ type: 'text', value: 'more tagged content...' }], + }, + ], + }; + expect( + extractTexPart( + new Session(), + tree, + {}, + { id: 'test_tag', as_list: true }, + {}, + { myst: 'v1' }, + ), + ).toEqual([ + { + value: + '\\begin{itemize}\n\\item tagged content\n\\item also tagged content\n\\end{itemize}', + imports: [], + commands: {}, + preamble: '', + }, + { + value: 'more tagged content...', + imports: [], + commands: {}, + preamble: '', + }, + ]); + }); }); diff --git a/packages/myst-cli/src/build/tex/single.ts b/packages/myst-cli/src/build/tex/single.ts index 154874ba4..6300a49e3 100644 --- a/packages/myst-cli/src/build/tex/single.ts +++ b/packages/myst-cli/src/build/tex/single.ts @@ -14,6 +14,7 @@ import mystToTex from 'myst-to-tex'; import type { LatexResult } from 'myst-to-tex'; import type { LinkTransformer } from 'myst-transforms'; import { unified } from 'unified'; +import { select, selectAll } from 'unist-util-select'; import { findCurrentProjectAndLoad } from '../../config.js'; import { finalizeMdast } from '../../process/index.js'; import { loadProjectFromDisk } from '../../project/index.js'; @@ -32,7 +33,6 @@ import { getFileContent, resolveAndLogErrors, } from '../utils/index.js'; -import { select } from 'unist-util-select'; export const DEFAULT_BIB_FILENAME = 'main.bib'; const TEX_IMAGE_EXTENSIONS = [ @@ -70,12 +70,40 @@ export function extractTexPart( partDefinition: TemplatePartDefinition, frontmatter: PageFrontmatter, templateYml: TemplateYml, -): LatexResult | undefined { +): LatexResult | LatexResult[] | undefined { const part = extractPart(mdast, partDefinition.id); if (!part) return undefined; - // Do not build glossaries when extracting parts: references cannot be mapped to definitions - const partContent = mdastToTex(session, part, references, frontmatter, templateYml, false); - return partContent; + if (!partDefinition.as_list) { + // Do not build glossaries when extracting parts: references cannot be mapped to definitions + return mdastToTex(session, part, references, frontmatter, templateYml, false); + } + if ( + part.children.length === 1 && + part.children[0]?.children?.length === 1 && + part.children[0].children[0].type === 'list' + ) { + const items = selectAll('listItem', part) as GenericParent[]; + return items.map((item: GenericParent) => { + return mdastToTex( + session, + { type: 'root', children: item.children }, + references, + frontmatter, + templateYml, + false, + ); + }); + } + return part.children.map((block) => { + return mdastToTex( + session, + { type: 'root', children: [block] }, + references, + frontmatter, + templateYml, + false, + ); + }); } export async function localArticleToTexRaw( @@ -167,11 +195,17 @@ export async function localArticleToTexTemplated( }); const partDefinitions = templateYml?.parts || []; - const parts: Record = {}; + const parts: Record = {}; let collectedImports: TemplateImports = { imports: [], commands: {} }; partDefinitions.forEach((def) => { const result = extractTexPart(session, mdast, references, def, frontmatter, templateYml); - if (result != null) { + if (Array.isArray(result)) { + // This is the case if def.as_list is true + result.forEach((item) => { + collectedImports = mergeTemplateImports(collectedImports, item); + }); + parts[def.id] = result.map(({ value }) => value); + } else if (result != null) { collectedImports = mergeTemplateImports(collectedImports, result); parts[def.id] = result?.value ?? ''; } @@ -189,7 +223,7 @@ export async function localArticleToTexTemplated( outputPath: output, frontmatter, parts, - options: templateOptions, + options: { ...frontmatter.options, ...templateOptions }, bibliography: [DEFAULT_BIB_FILENAME], sourceFile: file, imports: mergeTemplateImports(collectedImports, result), diff --git a/packages/myst-cli/src/config.ts b/packages/myst-cli/src/config.ts index 0cfee0451..c6cbe04f8 100644 --- a/packages/myst-cli/src/config.ts +++ b/packages/myst-cli/src/config.ts @@ -4,7 +4,7 @@ import yaml from 'js-yaml'; import { writeFileToFolder } from 'myst-cli-utils'; import { fileError, fileWarn, RuleId } from 'myst-common'; import type { Config, ProjectConfig, SiteConfig, SiteProject } from 'myst-config'; -import { getSiteTemplateOptions, validateProjectConfig, validateSiteConfig } from 'myst-config'; +import { validateProjectConfig, validateSiteConfig } from 'myst-config'; import type { ValidationOptions } from 'simple-validators'; import { incrementOptions, validateKeys, validateObject, validationError } from 'simple-validators'; import { VFile } from 'vfile'; @@ -139,7 +139,7 @@ export function loadConfig(session: ISession, path: string) { return conf; } -function resolveToAbsolute(session: ISession, basePath: string, relativePath: string) { +export function resolveToAbsolute(session: ISession, basePath: string, relativePath: string) { let message: string; try { const absPath = resolve(join(basePath, relativePath)); @@ -244,17 +244,6 @@ function validateSiteConfigAndSave( } siteConfig = resolveSiteConfigPaths(session, path, siteConfig, resolveToAbsolute); session.store.dispatch(config.actions.receiveSiteConfig({ path, ...siteConfig })); - - let siteTemplateOptions = getSiteTemplateOptions(rawSiteConfig); - if (siteTemplateOptions.logo) { - siteTemplateOptions = { - ...siteTemplateOptions, - logo: resolveToAbsolute(session, path, siteTemplateOptions.logo), - }; - } - session.store.dispatch( - config.actions.receiveSiteTemplateOptions({ path, ...siteTemplateOptions }), - ); } function validateProjectConfigAndSave( diff --git a/packages/myst-cli/src/frontmatter.ts b/packages/myst-cli/src/frontmatter.ts index 8cddc7285..f7690e36c 100644 --- a/packages/myst-cli/src/frontmatter.ts +++ b/packages/myst-cli/src/frontmatter.ts @@ -70,7 +70,7 @@ export function processPageFrontmatter( const frontmatter = fillPageFrontmatter(pageFrontmatter, projectFrontmatter, validationOpts); - if (siteFrontmatter?.design?.hide_authors) { + if (siteFrontmatter?.options?.hide_authors || siteFrontmatter?.options?.design?.hide_authors) { delete frontmatter.authors; } return frontmatter; diff --git a/packages/myst-cli/src/process/mdast.ts b/packages/myst-cli/src/process/mdast.ts index 8267391da..8cee454ac 100644 --- a/packages/myst-cli/src/process/mdast.ts +++ b/packages/myst-cli/src/process/mdast.ts @@ -69,6 +69,7 @@ import { combineCitationRenderers } from './citations.js'; import { bibFilesInDir, selectFile } from './file.js'; import { loadIntersphinx } from './intersphinx.js'; import { select, selectAll } from 'unist-util-select'; +import { frontmatterPartsTransform } from '../transforms/parts.js'; const LINKS_SELECTOR = 'link,card,linkBlock'; @@ -161,6 +162,7 @@ export async function transformMdast( const state = new ReferenceState({ numbering: frontmatter.numbering, file: vfile }); cache.$internalReferences[file] = state; // Import additional content from mdast or other files + frontmatterPartsTransform(session, file, mdast, frontmatter); importMdastFromJson(session, file, mdast); await includeFilesTransform(session, file, mdast, vfile); // This needs to come before basic transformations since it may add labels to blocks diff --git a/packages/myst-cli/src/store/reducers.ts b/packages/myst-cli/src/store/reducers.ts index fd7d0a57f..726a7e4fb 100644 --- a/packages/myst-cli/src/store/reducers.ts +++ b/packages/myst-cli/src/store/reducers.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path'; import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import type { ProjectConfig, SiteConfig, SiteTemplateOptions } from 'myst-config'; +import type { ProjectConfig, SiteConfig } from 'myst-config'; import { combineReducers } from 'redux'; import type { BuildWarning, ExternalLinkResult } from './types.js'; import type { LocalProject } from '../project/types.js'; @@ -34,7 +34,6 @@ export const config = createSlice({ rawConfigs: {}, projects: {}, sites: {}, - siteTemplateOptions: {}, filenames: {}, } as { currentProjectPath: string | undefined; @@ -42,7 +41,6 @@ export const config = createSlice({ rawConfigs: Record; validated: Record }>; projects: Record; sites: Record; - siteTemplateOptions: Record; filenames: Record; }, reducers: { @@ -69,13 +67,6 @@ export const config = createSlice({ const { path, ...payload } = action.payload; state.sites[resolve(path)] = payload; }, - receiveSiteTemplateOptions( - state, - action: PayloadAction, - ) { - const { path, ...payload } = action.payload; - state.siteTemplateOptions[resolve(path)] = payload; - }, receiveProjectConfig(state, action: PayloadAction) { const { path, ...payload } = action.payload; state.projects[resolve(path)] = payload; diff --git a/packages/myst-cli/src/store/selectors.ts b/packages/myst-cli/src/store/selectors.ts index 2e0bbb20d..b4160b6f4 100644 --- a/packages/myst-cli/src/store/selectors.ts +++ b/packages/myst-cli/src/store/selectors.ts @@ -24,11 +24,6 @@ export function selectCurrentSiteConfig(state: RootState): SiteConfig | undefine return { ...config, projects: [{ path }] }; } -export function selectCurrentSiteTemplateOptions(state: RootState) { - if (!state.local.config.currentSitePath) return undefined; - return state.local.config.siteTemplateOptions[resolve(state.local.config.currentSitePath)]; -} - export function selectCurrentSitePath(state: RootState) { return state.local.config.currentSitePath; } diff --git a/packages/myst-cli/src/transforms/parts.spec.ts b/packages/myst-cli/src/transforms/parts.spec.ts new file mode 100644 index 000000000..1d8184113 --- /dev/null +++ b/packages/myst-cli/src/transforms/parts.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { visit } from 'unist-util-visit'; +import type { GenericParent } from 'myst-common'; +import { frontmatterPartsTransform } from './parts'; +import { Session } from '../session/session'; + +function stripPositions(tree: GenericParent) { + visit(tree, (node) => { + delete node.position; + }); + return tree; +} + +function testMdast() { + return { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'hi', + }, + ], + }, + ], + }; +} + +describe('frontmatterPartsTransform', () => { + it('frontmatter with no parts passes', async () => { + const mdast = testMdast(); + const frontmatter = { title: 'No parts' }; + frontmatterPartsTransform(new Session(), 'test.md', mdast, frontmatter); + expect(mdast).toEqual(testMdast()); + expect(frontmatter).toEqual({ title: 'No parts' }); + }); + it('frontmatter part is moved to mdast', async () => { + const mdast = testMdast(); + const frontmatter = { + title: 'Abstract and Statement part', + parts: { + abstract: ['This is my abstract'], + statement: ['and this is my statement', 'and a second statement'], + }, + }; + frontmatterPartsTransform(new Session(), 'test.md', mdast, frontmatter); + expect(stripPositions(mdast)).toEqual({ + type: 'root', + children: [ + { + type: 'block', + data: { part: 'abstract' }, + visibility: 'remove', + children: [ + { type: 'paragraph', children: [{ type: 'text', value: 'This is my abstract' }] }, + ], + }, + { + type: 'block', + data: { part: 'statement' }, + visibility: 'remove', + children: [ + { type: 'paragraph', children: [{ type: 'text', value: 'and this is my statement' }] }, + ], + }, + { + type: 'block', + data: { part: 'statement' }, + visibility: 'remove', + children: [ + { type: 'paragraph', children: [{ type: 'text', value: 'and a second statement' }] }, + ], + }, + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'hi', + }, + ], + }, + ], + }); + expect(frontmatter).toEqual({ + title: 'Abstract and Statement part', + }); + }); +}); diff --git a/packages/myst-cli/src/transforms/parts.ts b/packages/myst-cli/src/transforms/parts.ts new file mode 100644 index 000000000..e29bfd8cd --- /dev/null +++ b/packages/myst-cli/src/transforms/parts.ts @@ -0,0 +1,30 @@ +import type { Block } from 'myst-spec-ext'; +import type { ISession } from '../index.js'; +import { parseMyst } from '../index.js'; +import type { GenericParent } from 'myst-common'; +import type { PageFrontmatter } from 'myst-frontmatter'; + +export function frontmatterPartsTransform( + session: ISession, + file: string, + mdast: GenericParent, + frontmatter: PageFrontmatter, +) { + if (!frontmatter.parts) return; + const blocks = Object.entries(frontmatter.parts) + .map(([part, contents]) => { + const data = { part }; + return contents.map((content) => { + const root = parseMyst(session, content, file); + return { + type: 'block', + data, + visibility: 'remove', + children: root.children, + } as Block; + }); + }) + .flat(); + mdast.children = [...blocks, ...mdast.children]; + delete frontmatter.parts; +} diff --git a/packages/myst-common/package.json b/packages/myst-common/package.json index db946d2c8..e73f91b11 100644 --- a/packages/myst-common/package.json +++ b/packages/myst-common/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "mdast": "^3.0.0", + "myst-frontmatter": "^1.1.11", "myst-spec": "^0.0.4", "nanoid": "^4.0.0", "unified": "^10.1.2", diff --git a/packages/myst-common/src/extractParts.ts b/packages/myst-common/src/extractParts.ts index 2d3e9dc17..3922465df 100644 --- a/packages/myst-common/src/extractParts.ts +++ b/packages/myst-common/src/extractParts.ts @@ -1,4 +1,4 @@ -import type { Block } from 'myst-spec'; +import type { Block } from 'myst-spec-ext'; import type { GenericParent } from './types.js'; import { remove } from 'unist-util-remove'; import { selectAll } from 'unist-util-select'; @@ -8,13 +8,10 @@ import { copyNode } from './utils.js'; * Selects the block node(s) based on part (string) or tags (string[]). * If `part` is a string array, any of the parts will be treated equally. */ -export function selectBlockParts( - tree: GenericParent, - part: string | string[], -): Block[] | undefined { +export function selectBlockParts(tree: GenericParent, part: string | string[]): Block[] { if (!part) { // Prevent an undefined, null or empty part comparison - return; + return []; } const blockParts = selectAll('block', tree).filter((block) => { const parts = typeof part === 'string' ? [part] : part; @@ -27,7 +24,6 @@ export function selectBlockParts( }) .reduce((a, b) => a || b, false); }); - if (blockParts.length === 0) return; return blockParts as Block[]; } @@ -40,11 +36,13 @@ export function extractPart( opts?: { /** Helpful for when we are doing recursions, we don't want to extract the part again. */ removePartData?: boolean; + /** Ensure that blocks are by default turned to visible */ + keepVisibility?: boolean; }, ): GenericParent | undefined { const partStrings = typeof part === 'string' ? [part] : part; const blockParts = selectBlockParts(tree, part); - if (!blockParts) return undefined; + if (blockParts.length === 0) return undefined; const children = copyNode(blockParts).map((block) => { // Ensure the block always has the `part` defined, as it might be in the tags block.data ??= {}; @@ -60,6 +58,8 @@ export function extractPart( } } if (opts?.removePartData) delete block.data.part; + // The default is to remove the visibility on the parts + if (!opts?.keepVisibility) delete block.visibility; return block; }); const partsTree = { type: 'root', children } as GenericParent; diff --git a/packages/myst-config/src/site/types.ts b/packages/myst-config/src/site/types.ts index 4dcda3dda..f443e65be 100644 --- a/packages/myst-config/src/site/types.ts +++ b/packages/myst-config/src/site/types.ts @@ -22,8 +22,6 @@ export interface SiteAction { static?: boolean; } -export type SiteTemplateOptions = Record; - export type SiteConfig = SiteFrontmatter & { projects?: SiteProject[]; nav?: SiteNavItem[]; @@ -31,7 +29,7 @@ export type SiteConfig = SiteFrontmatter & { domains?: string[]; favicon?: string; template?: string; -} & SiteTemplateOptions; +}; type ManifestProjectItem = { title: string; @@ -61,7 +59,7 @@ type ManifestProject = { } & ProjectFrontmatter; export type SiteManifest = SiteFrontmatter & { - myst: 'v1'; + myst: string; id?: string; projects?: ManifestProject[]; nav?: SiteNavItem[]; @@ -69,4 +67,4 @@ export type SiteManifest = SiteFrontmatter & { domains?: string[]; favicon?: string; template?: string; -} & SiteTemplateOptions; +}; diff --git a/packages/myst-config/src/site/validators.ts b/packages/myst-config/src/site/validators.ts index c223276e6..33018e0e5 100644 --- a/packages/myst-config/src/site/validators.ts +++ b/packages/myst-config/src/site/validators.ts @@ -17,13 +17,7 @@ import { SITE_FRONTMATTER_KEYS, validateSiteFrontmatterKeys, } from 'myst-frontmatter'; -import type { - SiteAction, - SiteConfig, - SiteNavItem, - SiteProject, - SiteTemplateOptions, -} from './types.js'; +import type { SiteAction, SiteConfig, SiteNavItem, SiteProject } from './types.js'; export const SITE_CONFIG_KEYS = { optional: [ @@ -169,35 +163,24 @@ export function validateSiteConfigKeys( * Validate and return common, non-template attributes of SiteConfig */ export function validateSiteConfig(input: any, opts: ValidationOptions) { - const value = validateObjectKeys(input, SITE_CONFIG_KEYS, { + const valueAsObject = validateObject(input, opts); + if (valueAsObject === undefined) return undefined; + const value = validateKeys(valueAsObject, SITE_CONFIG_KEYS, { ...opts, returnInvalidPartial: true, // Template options will appear as extra keys here; no need for warnings. suppressWarnings: true, }); if (value === undefined) return undefined; - return validateSiteConfigKeys(value, opts); -} - -/** - * Return template options from SiteConfig - * - * These are all the non-predefined attributes. This object must be validated - * separately against the site template.yml - */ -export function getSiteTemplateOptions(input: any) { - const value = - validateObject(input, { - property: 'template_options', - messages: {}, - suppressWarnings: true, - suppressErrors: true, - }) || {}; - const output: SiteTemplateOptions = {}; - Object.keys(value) + Object.keys(valueAsObject) .filter((key) => !SITE_CONFIG_KEYS.optional.includes(key)) .forEach((key) => { - output[key] = value[key]; + if (value.options?.[key]) { + opts.errorLogFn?.(`duplicate value for site option ${key}`); + } else { + opts.warningLogFn?.(`extra site options should be nested under "options" key: ${key}`); + (value.options ??= {})[key] = valueAsObject[key]; + } }); - return output; + return validateSiteConfigKeys(value, opts); } diff --git a/packages/myst-frontmatter/src/page/page.yml b/packages/myst-frontmatter/src/page/page.yml index 47237dfee..1733bf13d 100644 --- a/packages/myst-frontmatter/src/page/page.yml +++ b/packages/myst-frontmatter/src/page/page.yml @@ -101,3 +101,79 @@ cases: date: https://example.com normalized: {} errors: 1 + - title: parts validates arbitrary keys + raw: + parts: + example_part: |- + Just an example part! + normalized: + parts: + example_part: + - Just an example part! + - title: parts validates list of strings + raw: + parts: + example_part: + - first example + - second example + normalized: + parts: + example_part: + - first example + - second example + - title: parts validates known keys + raw: + parts: + abstract: |- + Just an example part! + normalized: + parts: + abstract: + - Just an example part! + - title: known part moves to parts + raw: + abstract: |- + Just an example part! + normalized: + parts: + abstract: + - Just an example part! + - title: known part combines with parts + raw: + abstract: |- + Just an example part! + data_availability: Example + parts: + example_part: |- + Another example part! + normalized: + parts: + abstract: + - Just an example part! + data_availability: + - Example + example_part: + - Another example part! + - title: duplicate part errors + raw: + abstract: |- + Just an example part! + parts: + abstract: |- + Another example part! + normalized: + parts: + abstract: + - Another example part! + errors: 1 + - title: non-string abstract errors + raw: + abstract: true + normalized: {} + errors: 1 + - title: non-string part errors + raw: + parts: + abstract: true + normalized: {} + errors: 1 diff --git a/packages/myst-frontmatter/src/page/types.ts b/packages/myst-frontmatter/src/page/types.ts index 40aa99ef1..c7d05bb61 100644 --- a/packages/myst-frontmatter/src/page/types.ts +++ b/packages/myst-frontmatter/src/page/types.ts @@ -6,4 +6,5 @@ export type PageFrontmatter = ProjectAndPageFrontmatter & { kernelspec?: KernelSpec; jupytext?: Jupytext; tags?: string[]; + parts?: Record; }; diff --git a/packages/myst-frontmatter/src/page/validators.ts b/packages/myst-frontmatter/src/page/validators.ts index 2234eb31f..34b69ca3d 100644 --- a/packages/myst-frontmatter/src/page/validators.ts +++ b/packages/myst-frontmatter/src/page/validators.ts @@ -5,6 +5,8 @@ import { validateList, validateObjectKeys, validateString, + validateObject, + validationError, } from 'simple-validators'; import { PROJECT_AND_PAGE_FRONTMATTER_KEYS, @@ -15,12 +17,24 @@ import { FRONTMATTER_ALIASES } from '../index.js'; import { validateKernelSpec } from '../kernelspec/validators.js'; import { validateJupytext } from '../jupytext/validators.js'; +const KNOWN_PARTS = [ + 'abstract', + 'summary', + 'keypoints', + 'dedication', + 'epigraph', + 'data_availability', + 'acknowledgments', +]; + export const PAGE_FRONTMATTER_KEYS = [ ...PROJECT_AND_PAGE_FRONTMATTER_KEYS, // These keys only exist on the page 'kernelspec', 'jupytext', 'tags', + 'parts', + ...KNOWN_PARTS, ]; export const USE_PROJECT_FALLBACK = [ @@ -59,6 +73,36 @@ export function validatePageFrontmatterKeys(value: Record, opts: Va }, ); } + const partsOptions = incrementOptions('parts', opts); + let parts: Record | undefined; + if (defined(value.parts)) { + parts = validateObject(value.parts, partsOptions); + } + KNOWN_PARTS.forEach((partKey) => { + if (defined(value[partKey])) { + parts ??= {}; + if (parts[partKey]) { + validationError(`duplicate value for part ${partKey}`, partsOptions); + } else { + parts[partKey] = value[partKey]; + } + } + }); + if (parts) { + const partsEntries = Object.entries(parts) + .map(([k, v]) => { + return [ + k, + validateList(v, { coerce: true, ...incrementOptions(k, partsOptions) }, (item, index) => { + return validateString(item, incrementOptions(`${k}.${index}`, partsOptions)); + }), + ]; + }) + .filter((entry): entry is [string, string[]] => !!entry[1]?.length); + if (partsEntries.length > 0) { + output.parts = Object.fromEntries(partsEntries); + } + } return output; } diff --git a/packages/myst-frontmatter/src/site/options.yml b/packages/myst-frontmatter/src/site/options.yml new file mode 100644 index 000000000..91b4ffd34 --- /dev/null +++ b/packages/myst-frontmatter/src/site/options.yml @@ -0,0 +1,45 @@ +title: Options +cases: + - title: arbitrary options validate + raw: + options: + a: b + c: d + normalized: + options: + a: b + c: d + - title: non-string options validate + raw: + options: + a: 1 + b: true + c: + - x: y + normalized: + options: + a: 1 + b: true + c: + - x: y + - title: non-object options errors + raw: + options: '' + normalized: {} + errors: 1 + - title: reserved export keys error + raw: + options: + a: b + format: '' + template: '' + output: '' + id: '' + name: '' + renderer: '' + article: '' + sub_articles: '' + normalized: + options: + a: b + errors: 8 diff --git a/packages/myst-frontmatter/src/site/types.ts b/packages/myst-frontmatter/src/site/types.ts index a3327b395..ab4d249a8 100644 --- a/packages/myst-frontmatter/src/site/types.ts +++ b/packages/myst-frontmatter/src/site/types.ts @@ -19,4 +19,5 @@ export type SiteFrontmatter = { keywords?: string[]; funding?: Funding[]; contributors?: Contributor[]; + options?: Record; }; diff --git a/packages/myst-frontmatter/src/site/validators.ts b/packages/myst-frontmatter/src/site/validators.ts index f6571efbc..d18a1c2a5 100644 --- a/packages/myst-frontmatter/src/site/validators.ts +++ b/packages/myst-frontmatter/src/site/validators.ts @@ -1,5 +1,12 @@ import type { ValidationOptions } from 'simple-validators'; -import { defined, incrementOptions, validateList, validateString } from 'simple-validators'; +import { + defined, + incrementOptions, + validateList, + validateObject, + validateString, + validationError, +} from 'simple-validators'; import { validateAffiliation } from '../affiliations/validators.js'; import { validateContributor } from '../contributors/validators.js'; import { validateFunding } from '../funding/validators.js'; @@ -8,6 +15,7 @@ import { validateAndStashObject } from '../utils/referenceStash.js'; import { validateGithubUrl } from '../utils/validators.js'; import { validateVenue } from '../venues/validators.js'; import type { SiteFrontmatter } from './types.js'; +import { RESERVED_EXPORT_KEYS } from '../index.js'; export const SITE_FRONTMATTER_KEYS = [ 'title', @@ -25,6 +33,7 @@ export const SITE_FRONTMATTER_KEYS = [ 'keywords', 'affiliations', 'funding', + 'options', ]; export const FRONTMATTER_ALIASES = { @@ -33,6 +42,14 @@ export const FRONTMATTER_ALIASES = { affiliation: 'affiliations', export: 'exports', jupyter: 'thebe', + part: 'parts', + ack: 'acknowledgments', + acknowledgements: 'acknowledgments', + availability: 'data_availability', + plain_language_summary: 'summary', + quote: 'epigraph', + lay_summary: 'summary', + image: 'thumbnail', }; export function validateSiteFrontmatterKeys(value: Record, opts: ValidationOptions) { @@ -126,15 +143,29 @@ export function validateSiteFrontmatterKeys(value: Record, opts: Va }); } if (defined(value.funding)) { - const funding = Array.isArray(value.funding) ? value.funding : [value.funding]; output.funding = validateList( - funding, + value.funding, { coerce: true, ...incrementOptions('funding', opts) }, (fund, index) => { return validateFunding(fund, stash, incrementOptions(`funding.${index}`, opts)); }, ); } + if (defined(value.options)) { + const optionsOptions = incrementOptions('options', opts); + const options = validateObject(value.options, optionsOptions); + if (options) { + Object.entries(options).forEach(([key, val]) => { + if (RESERVED_EXPORT_KEYS.includes(key)) { + validationError(`options cannot include reserved key ${key}`, optionsOptions); + } else { + (output.options ??= {})[key] = val; + } + }); + } + } + + // Contributor resolution should happen last const stashContribAuthors = stash.contributors?.filter( (contrib) => stash.authorIds?.includes(contrib.id), ); diff --git a/packages/myst-frontmatter/src/utils/fillPageFrontmatter.spec.ts b/packages/myst-frontmatter/src/utils/fillPageFrontmatter.spec.ts index a463d85bb..78e032774 100644 --- a/packages/myst-frontmatter/src/utils/fillPageFrontmatter.spec.ts +++ b/packages/myst-frontmatter/src/utils/fillPageFrontmatter.spec.ts @@ -531,4 +531,20 @@ describe('fillPageFrontmatter', () => { }); expect(opts.messages.warnings?.length).toBeFalsy(); }); + it('project options fill page', async () => { + expect(fillPageFrontmatter({}, { options: { a: 'b' } }, opts)).toEqual({ options: { a: 'b' } }); + }); + it('page options persist', async () => { + expect(fillPageFrontmatter({ options: { a: 'b' } }, {}, opts)).toEqual({ options: { a: 'b' } }); + }); + it('project and page options combine', async () => { + expect(fillPageFrontmatter({ options: { a: 'b' } }, { options: { c: 'd' } }, opts)).toEqual({ + options: { a: 'b', c: 'd' }, + }); + }); + it('page options override project options', async () => { + expect(fillPageFrontmatter({ options: { a: 'b' } }, { options: { a: 'z' } }, opts)).toEqual({ + options: { a: 'b' }, + }); + }); }); diff --git a/packages/myst-frontmatter/src/utils/fillPageFrontmatter.ts b/packages/myst-frontmatter/src/utils/fillPageFrontmatter.ts index d368828d5..1bce01983 100644 --- a/packages/myst-frontmatter/src/utils/fillPageFrontmatter.ts +++ b/packages/myst-frontmatter/src/utils/fillPageFrontmatter.ts @@ -48,6 +48,14 @@ export function fillPageFrontmatter( }; } + // Combine all options defined on page and project + if (projectFrontmatter.options || pageFrontmatter.options) { + frontmatter.options = { + ...(projectFrontmatter.options ?? {}), + ...(pageFrontmatter.options ?? {}), + }; + } + // Gather all contributors and affiliations from funding sources const contributorIds: Set = new Set(); const affiliationIds: Set = new Set(); diff --git a/packages/myst-templates/src/template.ts b/packages/myst-templates/src/template.ts index 11ea678dd..ee4bdfba3 100644 --- a/packages/myst-templates/src/template.ts +++ b/packages/myst-templates/src/template.ts @@ -6,7 +6,7 @@ import type { ValidationOptions } from 'simple-validators'; import { downloadTemplate, resolveInputs, TEMPLATE_FILENAME, TEMPLATE_YML } from './download.js'; import { extendFrontmatter } from './frontmatter.js'; import type { TemplateYml, ISession } from './types.js'; -import { errorLogger, warningLogger } from './utils.js'; +import { debugLogger, errorLogger, warningLogger } from './utils.js'; import type { FileOptions, FileValidationOptions } from './validators.js'; import { validateTemplateDoc, @@ -22,6 +22,7 @@ class MystTemplate { validatedTemplateYml: TemplateYml | undefined; errorLogFn: (message: string) => void; warningLogFn: (message: string) => void; + debugLogFn: (message: string) => void; /** * MystTemplate class for template download / validation / render preparation @@ -39,6 +40,7 @@ class MystTemplate { buildDir?: string; errorLogFn?: (message: string) => void; warningLogFn?: (message: string) => void; + debugLogFn?: (message: string) => void; }, ) { this.session = session; @@ -47,6 +49,7 @@ class MystTemplate { this.templateUrl = templateUrl; this.errorLogFn = opts?.errorLogFn ?? errorLogger(this.session); this.warningLogFn = opts?.warningLogFn ?? warningLogger(this.session); + this.debugLogFn = opts?.debugLogFn ?? debugLogger(this.session); } getTemplateYmlPath() { @@ -91,7 +94,8 @@ class MystTemplate { property: 'options', messages: {}, errorLogFn: this.errorLogFn, - warningLogFn: this.warningLogFn, + // Warnings about extra options are just debug messages + warningLogFn: this.debugLogFn, ...fileOpts, }; const validatedOptions = validateTemplateOptions( diff --git a/packages/myst-templates/src/types.ts b/packages/myst-templates/src/types.ts index cb4e5dfcc..453b44cb5 100644 --- a/packages/myst-templates/src/types.ts +++ b/packages/myst-templates/src/types.ts @@ -49,6 +49,9 @@ export type TemplatePartDefinition = { description?: string; required?: boolean; plain?: boolean; + /** Expect parts as a list of entries instead of a single concatenated entry */ + as_list?: boolean; + /** If as_list is true, max_chars/max_words will apply to each entry of the list */ max_chars?: number; max_words?: number; condition?: { diff --git a/packages/myst-templates/src/utils.ts b/packages/myst-templates/src/utils.ts index 3f83dc97d..9a1a49e39 100644 --- a/packages/myst-templates/src/utils.ts +++ b/packages/myst-templates/src/utils.ts @@ -7,3 +7,7 @@ export function errorLogger(session: ISession) { export function warningLogger(session: ISession) { return (message: string) => session.log.warn(message); } + +export function debugLogger(session: ISession) { + return (message: string) => session.log.debug(message); +} diff --git a/packages/myst-templates/src/validators.spec.ts b/packages/myst-templates/src/validators.spec.ts index 53a577292..3d0c570e4 100644 --- a/packages/myst-templates/src/validators.spec.ts +++ b/packages/myst-templates/src/validators.spec.ts @@ -9,6 +9,7 @@ import { validateTemplatePartDefinition, validateTemplateYml, validateTemplateDoc, + validateTemplateParts, } from './validators'; let opts: ValidationOptions; @@ -646,7 +647,14 @@ describe('validateTemplatePartDefinition', () => { it('valid part definiton passes', async () => { expect( validateTemplatePartDefinition( - { id: 'key', description: 'desc', required: true, plain: false, extra: 'ignored' }, + { + id: 'key', + description: 'desc', + required: true, + plain: false, + as_list: true, + extra: 'ignored', + }, opts, ), ).toEqual({ @@ -654,10 +662,150 @@ describe('validateTemplatePartDefinition', () => { description: 'desc', required: true, plain: false, + as_list: true, }); }); }); +describe('validateTemplateParts', () => { + it('missing required part errors', async () => { + expect(validateTemplateParts({}, [{ id: 'key', required: true }], {}, opts)).toEqual({}); + expect(opts.messages.errors?.length).toEqual(1); + }); + it('missing optional part passes', async () => { + expect(validateTemplateParts({}, [{ id: 'key' }], {}, opts)).toEqual({}); + expect(opts.messages.errors?.length).toBeFalsy(); + }); + it('required part with invalid type errors', async () => { + expect(validateTemplateParts({ key: true }, [{ id: 'key', required: true }], {}, opts)).toEqual( + {}, + ); + expect(opts.messages.errors?.length).toEqual(1); + }); + it('required part as string passes', async () => { + expect( + validateTemplateParts({ key: 'part value' }, [{ id: 'key', required: true }], {}, opts), + ).toEqual({ key: 'part value' }); + expect(opts.messages.errors?.length).toBeFalsy(); + }); + it('required part as list passes with part as_list', async () => { + expect( + validateTemplateParts( + { key: ['part value'] }, + [{ id: 'key', required: true, as_list: true }], + {}, + opts, + ), + ).toEqual({ key: ['part value'] }); + expect(opts.messages.errors?.length).toBeFalsy(); + }); + it('required part as string errors with part as_list', async () => { + expect( + validateTemplateParts( + { key: 'part value' }, + [{ id: 'key', required: true, as_list: true }], + {}, + opts, + ), + ).toEqual({}); + expect(opts.messages.errors?.length).toEqual(1); + }); + it('required part as list errors without part as_list', async () => { + expect( + validateTemplateParts({ key: ['part value'] }, [{ id: 'key', required: true }], {}, opts), + ).toEqual({}); + expect(opts.messages.errors?.length).toEqual(1); + }); + it('max_chars applies to string', async () => { + expect( + validateTemplateParts( + { key: 'abc' }, + [{ id: 'key', required: true, max_chars: 3 }], + {}, + opts, + ), + ).toEqual({ key: 'abc' }); + expect(opts.messages.errors?.length).toBeFalsy(); + }); + it('max_chars errors on long string', async () => { + expect( + validateTemplateParts( + { key: 'abcdef' }, + [{ id: 'key', required: true, max_chars: 3 }], + {}, + opts, + ), + ).toEqual({ key: 'abcdef' }); + expect(opts.messages.errors?.length).toEqual(1); + }); + it('max_chars applies to each item in list', async () => { + expect( + validateTemplateParts( + { key: ['abc', 'def'] }, + [{ id: 'key', required: true, as_list: true, max_chars: 3 }], + {}, + opts, + ), + ).toEqual({ key: ['abc', 'def'] }); + expect(opts.messages.errors?.length).toBeFalsy(); + }); + it('max_chars errors if item is long', async () => { + expect( + validateTemplateParts( + { key: ['abcdef'] }, + [{ id: 'key', required: true, as_list: true, max_chars: 3 }], + {}, + opts, + ), + ).toEqual({ key: ['abcdef'] }); + expect(opts.messages.errors?.length).toEqual(1); + }); + it('max_words applies to string', async () => { + expect( + validateTemplateParts( + { key: 'abc' }, + [{ id: 'key', required: true, max_words: 1 }], + {}, + opts, + ), + ).toEqual({ key: 'abc' }); + expect(opts.messages.errors?.length).toBeFalsy(); + }); + it('max_words errors on long string', async () => { + expect( + validateTemplateParts( + { key: 'abc def' }, + [{ id: 'key', required: true, max_words: 1 }], + {}, + opts, + ), + ).toEqual({ key: 'abc def' }); + expect(opts.messages.errors?.length).toEqual(1); + }); + it('max_words applies to each item in list', async () => { + expect( + validateTemplateParts( + { key: ['abc', 'def'] }, + [{ id: 'key', required: true, as_list: true, max_words: 1 }], + {}, + opts, + ), + ).toEqual({ key: ['abc', 'def'] }); + expect(opts.messages.errors?.length).toBeFalsy(); + }); + it('max_words errors if item is long', async () => { + expect( + validateTemplateParts( + { key: ['abc def'] }, + [{ id: 'key', required: true, as_list: true, max_words: 1 }], + {}, + opts, + ), + ).toEqual({ key: ['abc def'] }); + expect(opts.messages.errors?.length).toEqual(1); + }); +}); + describe('validateTemplateYml', () => { it('invalid input errors', async () => { expect(validateTemplateYml(session, '', opts)).toEqual(undefined); diff --git a/packages/myst-templates/src/validators.ts b/packages/myst-templates/src/validators.ts index c94ac8e80..4893e875b 100644 --- a/packages/myst-templates/src/validators.ts +++ b/packages/myst-templates/src/validators.ts @@ -147,6 +147,25 @@ export function makeValidateOptionsFunction(reservedKeys: string[]) { export const validateTemplateOptions = makeValidateOptionsFunction(RESERVED_EXPORT_KEYS); +export function validateTemplatePart( + part: any, + partDefinition: TemplatePartDefinition, + opts: ValidationOptions, +) { + const { id, max_chars, max_words } = partDefinition; + const partValue = validateString(part, opts); + if (max_chars != null && partValue && partValue.length > max_chars) { + validationError( + `part block "${id}" must be less than or equal to ${max_chars} characters`, + opts, + ); + } + if (max_words != null && partValue && partValue.split(' ').length > max_words) { + validationError(`part block "${id}" must be less than or equal to ${max_words} words`, opts); + } + return partValue; +} + export function validateTemplateParts( parts: any, partsDefinitions: TemplatePartDefinition[], @@ -162,22 +181,17 @@ export function validateTemplateParts( { returnInvalidPartial: true, ...opts }, ); if (value === undefined) return undefined; - const output: Record = {}; + const output: Record = {}; filteredParts.forEach((def) => { - const { id, max_chars, max_words } = def; + const { id, as_list } = def; if (defined(value[id])) { - const partValue = validateString(value[id], incrementOptions(id, opts)); - if (max_chars != null && partValue && partValue.length > max_chars) { - validationError( - `part block "${id}" must be less than or equal to ${max_chars} characters`, - opts, - ); - } - if (max_words != null && partValue && partValue.split(' ').length > max_words) { - validationError( - `part block "${id}" must be less than or equal to ${max_words} words`, - opts, - ); + let partValue: string | string[] | undefined; + if (as_list) { + partValue = validateList(value[id], incrementOptions(id, opts), (item, index) => { + return validateTemplatePart(item, def, incrementOptions(`${id}.${index}`, opts)); + }); + } else { + partValue = validateTemplatePart(value[id], def, incrementOptions(id, opts)); } if (partValue !== undefined) output[def.id] = partValue; } @@ -368,6 +382,7 @@ export function validateTemplatePartDefinition(input: any, opts: ValidationOptio 'description', 'required', 'plain', + 'as_list', 'max_chars', 'max_words', 'condition', @@ -392,6 +407,9 @@ export function validateTemplatePartDefinition(input: any, opts: ValidationOptio if (defined(value.plain)) { output.plain = validateBoolean(value.plain, incrementOptions('plain', opts)); } + if (defined(value.as_list)) { + output.as_list = validateBoolean(value.as_list, incrementOptions('as_list', opts)); + } if (defined(value.max_chars)) { output.max_chars = validateNumber(value.max_chars, { min: 0, diff --git a/packages/myst-to-jats/src/index.ts b/packages/myst-to-jats/src/index.ts index 09cce2624..a5e147048 100644 --- a/packages/myst-to-jats/src/index.ts +++ b/packages/myst-to-jats/src/index.ts @@ -699,7 +699,9 @@ function createText(text: string): Element { } function renderPart(vfile: VFile, mdast: GenericParent, part: string | string[], opts?: Options) { - const partMdast = extractPart(mdast, part, { removePartData: true }); + const partMdast = extractPart(mdast, part, { + removePartData: true, + }); if (!partMdast) return undefined; const serializer = new JatsSerializer(vfile, partMdast as Root, opts); return serializer.render().elements(); @@ -1026,7 +1028,7 @@ export class JatsDocument { } export function writeJats(file: VFile, content: ArticleContent, opts?: DocumentOptions) { - const doc = new JatsDocument(file, content, opts ?? { handlers }); + const doc = new JatsDocument(file, content, opts); const element = opts?.writeFullArticle ? { type: 'element', diff --git a/packages/mystmd/tests/basic-md-and-config/myst.yml b/packages/mystmd/tests/basic-md-and-config/myst.yml index fa85501c7..b9aa7271e 100644 --- a/packages/mystmd/tests/basic-md-and-config/myst.yml +++ b/packages/mystmd/tests/basic-md-and-config/myst.yml @@ -1,16 +1,11 @@ # See docs at: https://mystmd.org/guide/frontmatter version: 1 project: - # title: - # description: keywords: [] authors: [] github: https://github.com/executablebooks/mystmd - # bibliography: [] site: template: book-theme - # title: - # logo: nav: [] actions: - title: Learn More