diff --git a/.changeset/green-brooms-applaud.md b/.changeset/green-brooms-applaud.md new file mode 100644 index 000000000..18f4a4ae2 --- /dev/null +++ b/.changeset/green-brooms-applaud.md @@ -0,0 +1,5 @@ +--- +'myst-cli': patch +--- + +Support circular deps and live reloading for extending config diff --git a/.changeset/large-peas-melt.md b/.changeset/large-peas-melt.md new file mode 100644 index 000000000..dbd160a02 --- /dev/null +++ b/.changeset/large-peas-melt.md @@ -0,0 +1,6 @@ +--- +'myst-frontmatter': patch +'myst-cli': patch +--- + +Load and fill frontmatter from extend config key diff --git a/.changeset/many-worms-know.md b/.changeset/many-worms-know.md new file mode 100644 index 000000000..3ce655570 --- /dev/null +++ b/.changeset/many-worms-know.md @@ -0,0 +1,6 @@ +--- +'myst-config': patch +'myst-cli': patch +--- + +Add extend key to top-level config diff --git a/.changeset/stupid-experts-pull.md b/.changeset/stupid-experts-pull.md new file mode 100644 index 000000000..ab9b6ec94 --- /dev/null +++ b/.changeset/stupid-experts-pull.md @@ -0,0 +1,5 @@ +--- +'myst-cli': patch +--- + +Refactor config loading to separate validation from saving diff --git a/docs/frontmatter.md b/docs/frontmatter.md index e0cd9ac19..c3626c30a 100644 --- a/docs/frontmatter.md +++ b/docs/frontmatter.md @@ -54,6 +54,24 @@ project: open_access: true ``` +(composing-myst-yml)= +#### Composing multiple `.yml` files + +You may separate your frontmatter into multiple, composable files. To reference other files from your main `myst.yml` file, use the `extends` key with relative path(s) to the other configuration files: + +```yaml +version: 1 +site: ... +project: ... +extends: + - ../macros.yml + - ../funding.yml +``` + +Each of these files listed under `extends` must contain valid `myst.yml` structure with `version: 1` and `site` or `project` keys. They may also have additional files listed under `extends`. + +Composing files together this way allows you to have a single source of truth for project frontmatter that may be reused across multiple projects, for example math macros or funding information. + +++ ## Available frontmatter fields diff --git a/docs/quickstart-myst-websites.md b/docs/quickstart-myst-websites.md index a459471c5..dd5b99642 100644 --- a/docs/quickstart-myst-websites.md +++ b/docs/quickstart-myst-websites.md @@ -246,7 +246,7 @@ site: ... ``` -Doing this will keep the `_build` directory at the root level, but everything else outside of the `content` folder will be ignored. If you have a project in the same configuration file it can be accessed with `path: .`. Projects are "mounted" at the `slug:` (i.e. `/my-content/`) above. +Doing this will keep the `_build` directory at the root level, but everything else outside of the `content` folder will be ignored. If you have a project in the same configuration file it can be accessed with `path: .`. Projects are "mounted" at the `slug:` (e.g. `/my-content/` above). ::: ## Additional options diff --git a/packages/myst-cli/src/build/site/watch.ts b/packages/myst-cli/src/build/site/watch.ts index 777b17b69..8aea18f1f 100644 --- a/packages/myst-cli/src/build/site/watch.ts +++ b/packages/myst-cli/src/build/site/watch.ts @@ -16,8 +16,10 @@ function watchConfigAndPublic( opts: ProcessSiteOptions, ) { const watchFiles = ['public']; - const siteConfigFile = selectors.selectCurrentSiteFile(session.store.getState()); + const state = session.store.getState(); + const siteConfigFile = selectors.selectCurrentSiteFile(state); if (siteConfigFile) watchFiles.push(siteConfigFile); + watchFiles.push(...selectors.selectConfigExtensions(state)); return chokidar .watch(watchFiles, { ignoreInitial: true, @@ -37,6 +39,7 @@ function triggerProjectReload( const projectConfigFile = projectPath ? selectors.selectLocalConfigFile(state, projectPath) : selectors.selectCurrentProjectFile(state); + if (selectors.selectConfigExtensions(state).includes(file)) return true; if (file === projectConfigFile || basename(file) === '_toc.yml') return true; // Reload project if file is added or remvoed if (['add', 'unlink'].includes(eventType)) return true; @@ -64,8 +67,11 @@ async function processorFn( !KNOWN_FAST_BUILDS.has(extname(file)) || ['add', 'unlink'].includes(eventType) ) { - let reloadProject = false; - if (file && triggerProjectReload(session, file, eventType, siteProject?.path)) { + let reloadProject = opts?.reloadProject ?? false; + if ( + reloadProject || + (file && triggerProjectReload(session, file, eventType, siteProject?.path)) + ) { session.log.info('💥 Triggered full project load and site rebuild'); reloadProject = true; } else { diff --git a/packages/myst-cli/src/config.ts b/packages/myst-cli/src/config.ts index 9a48b55eb..0614b1002 100644 --- a/packages/myst-cli/src/config.ts +++ b/packages/myst-cli/src/config.ts @@ -5,8 +5,16 @@ import { writeFileToFolder } from 'myst-cli-utils'; import { fileError, fileWarn, RuleId } from 'myst-common'; import type { Config, ProjectConfig, SiteConfig, SiteProject } from 'myst-config'; import { validateProjectConfig, validateSiteConfig } from 'myst-config'; +import { fillProjectFrontmatter } from 'myst-frontmatter'; import type { ValidationOptions } from 'simple-validators'; -import { incrementOptions, validateKeys, validateObject, validationError } from 'simple-validators'; +import { + incrementOptions, + validateObjectKeys, + validationError, + validateList, + validateString, + fillMissingKeys, +} from 'simple-validators'; import { VFile } from 'vfile'; import { prepareToWrite } from './frontmatter.js'; import type { ISession } from './session/types.js'; @@ -41,18 +49,9 @@ export function configFromPath(session: ISession, path: string) { } /** - * Load site/project config from local path to redux store - * - * Errors if config file does not exist or if config file exists but is invalid. + * Load config yaml file and throw error if it fails */ -export function loadConfig(session: ISession, path: string) { - const file = configFromPath(session, path); - if (!file) { - session.log.debug(`No config loaded from path: ${path}`); - return; - } - const vfile = new VFile(); - vfile.path = file; +function loadConfigYaml(file: string) { if (!fs.existsSync(file)) throw Error(`Cannot find config file: ${file}`); let rawConf: Record; try { @@ -61,34 +60,65 @@ export function loadConfig(session: ISession, path: string) { const suffix = (err as Error).message ? `\n\n${(err as Error).message}` : ''; throw Error(`Unable to read config file ${file} as YAML${suffix}`); } - const existingConf = selectors.selectLocalRawConfig(session.store.getState(), path); - if (existingConf && JSON.stringify(rawConf) === JSON.stringify(existingConf.raw)) { - return existingConf.validated; - } - const opts: ValidationOptions = { - file, - property: 'config', + return rawConf; +} + +/** + * Helper function to generate basic validation options + */ +function configValidationOpts(vfile: VFile, property: string, ruleId: RuleId): ValidationOptions { + return { + file: vfile.path, + property, messages: {}, errorLogFn: (message: string) => { - fileError(vfile, message, { ruleId: RuleId.validConfigStructure }); + fileError(vfile, message, { ruleId }); }, warningLogFn: (message: string) => { - fileWarn(vfile, message, { ruleId: RuleId.validConfigStructure }); + fileWarn(vfile, message, { ruleId }); }, }; - const conf = validateObject(yaml.load(fs.readFileSync(file, 'utf-8')), opts); - if (conf) { - const filteredConf = validateKeys( - conf, - { required: ['version'], optional: ['site', 'project'] }, - opts, +} + +/** + * Function to add filler keys to base if the keys are not defined in base + */ +function fillSiteConfig(base: SiteConfig, filler: SiteConfig) { + return fillMissingKeys(base, filler, Object.keys(filler)); +} + +/** + * Load and validate a file as yaml config file + * + * Returns validated site and project configs. + * + * Throws errors config file is malformed or invalid. + */ +function getValidatedConfigsFromFile( + session: ISession, + file: string, + vfile?: VFile, + stack?: string[], +) { + if (!vfile) { + vfile = new VFile(); + vfile.path = file; + } + const opts = configValidationOpts(vfile, 'config', RuleId.validConfigStructure); + const conf = validateObjectKeys( + loadConfigYaml(file), + { + required: ['version'], + optional: ['site', 'project', 'extend'], + alias: { extends: 'extend' }, + }, + opts, + ); + if (conf && conf.version !== VERSION) { + validationError( + `"${conf.version}" does not match ${VERSION}`, + incrementOptions('version', opts), ); - if (filteredConf && filteredConf.version !== VERSION) { - validationError( - `"${filteredConf.version}" does not match ${VERSION}`, - incrementOptions('version', opts), - ); - } } logMessagesFromVFile(session, vfile); if (!conf || opts.messages.errors) { @@ -120,24 +150,101 @@ export function loadConfig(session: ISession, path: string) { const { logoText, ...rest } = conf.site; conf.site = { logo_text: logoText, ...rest }; } - session.store.dispatch( - config.actions.receiveRawConfig({ path, file, raw: rawConf, validated: conf }), - ); - const { site, project } = conf ?? {}; + let site: SiteConfig | undefined; + let project: ProjectConfig | undefined; + const projectOpts = configValidationOpts(vfile, 'config.project', RuleId.validProjectConfig); + let extend: string[] | undefined; + if (conf.extend) { + extend = validateList( + conf.extend, + { coerce: true, ...incrementOptions('extend', opts) }, + (item, index) => { + const relativeFile = validateString(item, incrementOptions(`extend.${index}`, opts)); + if (!relativeFile) return relativeFile; + return resolveToAbsolute(session, dirname(file), relativeFile); + }, + ); + stack = [...(stack ?? []), file]; + extend?.forEach((extFile) => { + if (stack?.includes(extFile)) { + fileError(vfile, 'Circular dependency encountered during "config.extend" resolution', { + ruleId: RuleId.validConfigStructure, + note: [...stack, extFile].map((f) => resolveToRelative(session, '.', f)).join(' > '), + }); + return; + } + const { site: extSite, project: extProject } = getValidatedConfigsFromFile( + session, + extFile, + vfile, + stack, + ); + session.store.dispatch(config.actions.receiveConfigExtension({ file: extFile })); + if (extSite) { + site = site ? fillSiteConfig(extSite, site) : extSite; + } + if (extProject) { + project = project ? fillProjectFrontmatter(extProject, project, projectOpts) : extProject; + } + }); + } + const { site: rawSite, project: rawProject } = conf ?? {}; + const path = dirname(file); + if (rawSite) { + site = fillSiteConfig(validateSiteConfigAndThrow(session, path, vfile, rawSite), site ?? {}); + } if (site) { - validateSiteConfigAndSave(session, path, vfile, site); session.log.debug(`Loaded site config from ${file}`); } else { session.log.debug(`No site config in ${file}`); } + if (rawProject) { + project = fillProjectFrontmatter( + validateProjectConfigAndThrow(session, path, vfile, rawProject), + project ?? {}, + projectOpts, + ); + } if (project) { - validateProjectConfigAndSave(session, path, vfile, project); session.log.debug(`Loaded project config from ${file}`); } else { session.log.debug(`No project config defined in ${file}`); } logMessagesFromVFile(session, vfile); - return conf; + return { site, project, extend }; +} + +/** + * Load site/project config from local path to redux store + * + * Errors if config file does not exist or if config file exists but is invalid. + */ +export function loadConfig(session: ISession, path: string, opts?: { reloadProject?: boolean }) { + const file = configFromPath(session, path); + if (!file) { + session.log.debug(`No config loaded from path: ${path}`); + return; + } + const rawConf = loadConfigYaml(file); + if (!opts?.reloadProject) { + const existingConf = selectors.selectLocalRawConfig(session.store.getState(), path); + if (existingConf && JSON.stringify(rawConf) === JSON.stringify(existingConf.raw)) { + return existingConf.validated; + } + } + const { site, project, extend } = getValidatedConfigsFromFile(session, file); + const validated = { ...rawConf, site, project, extend }; + session.store.dispatch( + config.actions.receiveRawConfig({ + path, + file, + raw: rawConf, + validated, + }), + ); + if (site) saveSiteConfig(session, path, site); + if (project) saveProjectConfig(session, path, project); + return validated; } export function resolveToAbsolute( @@ -241,56 +348,48 @@ function resolveProjectConfigPaths( return { ...projectConfig, ...resolvedFields }; } -function validateSiteConfigAndSave( +function validateSiteConfigAndThrow( session: ISession, path: string, vfile: VFile, - rawSiteConfig: Record, -) { - let siteConfig = validateSiteConfig(rawSiteConfig, { - file: vfile.path, - property: 'site', - messages: {}, - errorLogFn: (message: string) => { - fileError(vfile, message, { ruleId: RuleId.validSiteConfig }); - }, - warningLogFn: (message: string) => { - fileWarn(vfile, message, { ruleId: RuleId.validSiteConfig }); - }, - }); + rawSite: Record, +): SiteConfig { + const site = validateSiteConfig( + rawSite, + configValidationOpts(vfile, 'config.site', RuleId.validSiteConfig), + ); logMessagesFromVFile(session, vfile); - if (!siteConfig) { + if (!site) { const errorSuffix = vfile.path ? ` in ${vfile.path}` : ''; throw Error(`Please address invalid site config${errorSuffix}`); } - siteConfig = resolveSiteConfigPaths(session, path, siteConfig, resolveToAbsolute); - session.store.dispatch(config.actions.receiveSiteConfig({ path, ...siteConfig })); + return resolveSiteConfigPaths(session, path, site, resolveToAbsolute); +} + +function saveSiteConfig(session: ISession, path: string, site: SiteConfig) { + session.store.dispatch(config.actions.receiveSiteConfig({ path, ...site })); } -function validateProjectConfigAndSave( +function validateProjectConfigAndThrow( session: ISession, path: string, vfile: VFile, - rawProjectConfig: Record, -) { - let projectConfig = validateProjectConfig(rawProjectConfig, { - file: vfile.path, - property: 'project', - messages: {}, - errorLogFn: (message: string) => { - fileError(vfile, message, { ruleId: RuleId.validProjectConfig }); - }, - warningLogFn: (message: string) => { - fileWarn(vfile, message, { ruleId: RuleId.validProjectConfig }); - }, - }); + rawProject: Record, +): ProjectConfig { + const project = validateProjectConfig( + rawProject, + configValidationOpts(vfile, 'config.project', RuleId.validProjectConfig), + ); logMessagesFromVFile(session, vfile); - if (!projectConfig) { + if (!project) { const errorSuffix = vfile.path ? ` in ${vfile.path}` : ''; throw Error(`Please address invalid project config${errorSuffix}`); } - projectConfig = resolveProjectConfigPaths(session, path, projectConfig, resolveToAbsolute); - session.store.dispatch(config.actions.receiveProjectConfig({ path, ...projectConfig })); + return resolveProjectConfigPaths(session, path, project, resolveToAbsolute); +} + +function saveProjectConfig(session: ISession, path: string, project: ProjectConfig) { + session.store.dispatch(config.actions.receiveProjectConfig({ path, ...project })); } /** @@ -317,13 +416,21 @@ export function writeConfigs( // Get site config to save const vfile = new VFile(); vfile.path = file; - if (siteConfig) validateSiteConfigAndSave(session, path, vfile, siteConfig); + if (siteConfig) { + saveSiteConfig(session, path, validateSiteConfigAndThrow(session, path, vfile, siteConfig)); + } siteConfig = selectors.selectLocalSiteConfig(session.store.getState(), path); if (siteConfig) { siteConfig = resolveSiteConfigPaths(session, path, siteConfig, resolveToRelative); } // Get project config to save - if (projectConfig) validateProjectConfigAndSave(session, path, vfile, projectConfig); + if (projectConfig) { + saveProjectConfig( + session, + path, + validateProjectConfigAndThrow(session, path, vfile, projectConfig), + ); + } projectConfig = selectors.selectLocalProjectConfig(session.store.getState(), path); if (projectConfig) { projectConfig = prepareToWrite(projectConfig); @@ -335,8 +442,7 @@ export function writeConfigs( return; } // Get raw config to override - const rawConfig = loadConfig(session, path); - const validatedRawConfig = rawConfig?.validated ?? emptyConfig(); + const validatedRawConfig = loadConfig(session, path) ?? emptyConfig(); let logContent: string; if (siteConfig && projectConfig) { logContent = 'site and project configs'; diff --git a/packages/myst-cli/src/project/load.ts b/packages/myst-cli/src/project/load.ts index 01b6f843c..f04313375 100644 --- a/packages/myst-cli/src/project/load.ts +++ b/packages/myst-cli/src/project/load.ts @@ -38,6 +38,7 @@ export async function loadProjectFromDisk( const cachedProject = selectors.selectLocalProject(session.store.getState(), path); if (cachedProject) return cachedProject; } + loadConfig(session, path, opts); const projectConfig = selectors.selectLocalProjectConfig(session.store.getState(), path); const file = join(path, session.configFiles[0]); if (!projectConfig && opts?.warnOnNoConfig) { diff --git a/packages/myst-cli/src/store/reducers.ts b/packages/myst-cli/src/store/reducers.ts index 9a6936fa9..a5ae886c6 100644 --- a/packages/myst-cli/src/store/reducers.ts +++ b/packages/myst-cli/src/store/reducers.ts @@ -3,7 +3,7 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import type { ProjectConfig, SiteConfig } from 'myst-config'; import { combineReducers } from 'redux'; -import type { BuildWarning, ExternalLinkResult } from './types.js'; +import type { BuildWarning, ExternalLinkResult, ValidatedRawConfig } from './types.js'; import type { LocalProject } from '../project/types.js'; export const projects = createSlice({ @@ -38,10 +38,11 @@ export const config = createSlice({ } as { currentProjectPath: string | undefined; currentSitePath: string | undefined; - rawConfigs: Record; validated: Record }>; + rawConfigs: Record; validated: ValidatedRawConfig }>; projects: Record; sites: Record>; filenames: Record; + configExtensions?: string[]; }, reducers: { receiveCurrentProjectPath(state, action: PayloadAction<{ path: string }>) { @@ -54,7 +55,7 @@ export const config = createSlice({ state, action: PayloadAction<{ raw: Record; - validated: Record; + validated: ValidatedRawConfig; path: string; file: string; }>, @@ -71,6 +72,10 @@ export const config = createSlice({ const { path, ...payload } = action.payload; state.projects[resolve(path)] = payload; }, + receiveConfigExtension(state, action: PayloadAction<{ file: string }>) { + state.configExtensions ??= []; + state.configExtensions.push(action.payload.file); + }, }, }); diff --git a/packages/myst-cli/src/store/selectors.ts b/packages/myst-cli/src/store/selectors.ts index 45b400aca..34f2ba053 100644 --- a/packages/myst-cli/src/store/selectors.ts +++ b/packages/myst-cli/src/store/selectors.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path'; import type { ProjectConfig, SiteConfig } from 'myst-config'; import type { LocalProject, LocalProjectPage } from '../project/types.js'; import type { RootState } from './reducers.js'; -import type { BuildWarning, ExternalLinkResult } from './types.js'; +import type { BuildWarning, ExternalLinkResult, ValidatedRawConfig } from './types.js'; function mutableCopy(obj?: Record) { if (!obj) return; @@ -65,10 +65,14 @@ export function selectLocalConfigFile(state: RootState, path: string): string | export function selectLocalRawConfig( state: RootState, path: string, -): { raw: Record; validated: Record } | undefined { +): { raw: Record; validated: ValidatedRawConfig } | undefined { return mutableCopy(state.local.config.rawConfigs[resolve(path)]); } +export function selectConfigExtensions(state: RootState): string[] { + return [...(state.local.config.configExtensions ?? [])]; +} + export function selectReloadingState(state: RootState) { const { reloading, reloadRequested } = state.local.watch; return { reloading, reloadRequested }; diff --git a/packages/myst-cli/src/store/types.ts b/packages/myst-cli/src/store/types.ts index 5077ec1cf..a772eaa8b 100644 --- a/packages/myst-cli/src/store/types.ts +++ b/packages/myst-cli/src/store/types.ts @@ -18,3 +18,9 @@ export type BuildWarning = { position?: VFileMessage['position']; ruleId?: string | null; }; + +export type ValidatedRawConfig = { + site?: Record; + project?: Record; + extend?: string[]; +}; diff --git a/packages/myst-config/src/index.ts b/packages/myst-config/src/index.ts index 2f783637c..6c3e5f84a 100644 --- a/packages/myst-config/src/index.ts +++ b/packages/myst-config/src/index.ts @@ -6,6 +6,7 @@ export * from './site/index.js'; export type Config = { version: 1; + extend?: string[]; project?: ProjectConfig; site?: SiteConfig; }; diff --git a/packages/myst-frontmatter/src/utils/fillPageFrontmatter.spec.ts b/packages/myst-frontmatter/src/utils/fillPageFrontmatter.spec.ts index f126d5cad..b3caa5cf2 100644 --- a/packages/myst-frontmatter/src/utils/fillPageFrontmatter.spec.ts +++ b/packages/myst-frontmatter/src/utils/fillPageFrontmatter.spec.ts @@ -17,7 +17,6 @@ const TEST_PAGE_FRONTMATTER: PageFrontmatter = { }, ], affiliations: [{ id: 'univb', name: 'University B' }], - name: 'example.md', doi: '10.1000/abcd/efg012', arxiv: 'https://arxiv.org/example', open_access: true, @@ -52,7 +51,6 @@ const TEST_PROJECT_FRONTMATTER: ProjectFrontmatter = { ], affiliations: [{ id: 'univa', name: 'University A' }], date: '14 Dec 2021', - name: 'example.md', doi: '10.1000/abcd/efg012', arxiv: 'https://arxiv.org/example', open_access: true, @@ -100,7 +98,6 @@ describe('fillPageFrontmatter', () => { const result = { ...TEST_PROJECT_FRONTMATTER }; delete result.title; delete result.description; - delete result.name; delete result.oxa; delete result.exports; delete result.requirements; diff --git a/packages/myst-frontmatter/src/utils/fillPageFrontmatter.ts b/packages/myst-frontmatter/src/utils/fillPageFrontmatter.ts index af3550c09..3bd6f224a 100644 --- a/packages/myst-frontmatter/src/utils/fillPageFrontmatter.ts +++ b/packages/myst-frontmatter/src/utils/fillPageFrontmatter.ts @@ -19,39 +19,48 @@ export function fillPageFrontmatter( pageFrontmatter: PageFrontmatter, projectFrontmatter: ProjectFrontmatter, opts: ValidationOptions, +): PageFrontmatter { + return fillProjectFrontmatter(pageFrontmatter, projectFrontmatter, opts, USE_PROJECT_FALLBACK); +} + +export function fillProjectFrontmatter( + base: ProjectFrontmatter, + filler: ProjectFrontmatter, + opts: ValidationOptions, + keys?: string[], ) { - const frontmatter = fillMissingKeys(pageFrontmatter, projectFrontmatter, USE_PROJECT_FALLBACK); + const frontmatter = fillMissingKeys(base, filler, keys ?? Object.keys(filler)); - if (pageFrontmatter.numbering || projectFrontmatter.numbering) { - frontmatter.numbering = fillNumbering(pageFrontmatter.numbering, projectFrontmatter.numbering); + if (filler.numbering || base.numbering) { + frontmatter.numbering = fillNumbering(base.numbering, filler.numbering); } // Combine all math macros defined on page and project - if (projectFrontmatter.math || pageFrontmatter.math) { - frontmatter.math = { ...(projectFrontmatter.math ?? {}), ...(pageFrontmatter.math ?? {}) }; + if (filler.math || base.math) { + frontmatter.math = { ...(filler.math ?? {}), ...(base.math ?? {}) }; } // Combine all abbreviation defined on page and project - if (projectFrontmatter.abbreviations || pageFrontmatter.abbreviations) { + if (filler.abbreviations || base.abbreviations) { frontmatter.abbreviations = { - ...(projectFrontmatter.abbreviations ?? {}), - ...(pageFrontmatter.abbreviations ?? {}), + ...(filler.abbreviations ?? {}), + ...(base.abbreviations ?? {}), }; } // Combine all options defined on page and project - if (projectFrontmatter.options || pageFrontmatter.options) { + if (filler.options || base.options) { frontmatter.options = { - ...(projectFrontmatter.options ?? {}), - ...(pageFrontmatter.options ?? {}), + ...(filler.options ?? {}), + ...(base.options ?? {}), }; } // Combine all settings defined on page and project - if (projectFrontmatter.settings || pageFrontmatter.settings) { + if (filler.settings || base.settings) { frontmatter.settings = { - ...(projectFrontmatter.settings ?? {}), - ...(pageFrontmatter.settings ?? {}), + ...(filler.settings ?? {}), + ...(base.settings ?? {}), }; } @@ -83,10 +92,10 @@ export function fillPageFrontmatter( if (frontmatter.authors?.length || contributorIds.size) { // Gather all people from page/project authors/contributors const people = [ - ...(pageFrontmatter.authors ?? []), - ...(projectFrontmatter.authors ?? []), - ...(pageFrontmatter.contributors ?? []), - ...(projectFrontmatter.contributors ?? []), + ...(base.authors ?? []), + ...(filler.authors ?? []), + ...(base.contributors ?? []), + ...(filler.contributors ?? []), ]; const peopleLookup: Record = {}; people.forEach((auth) => { @@ -126,10 +135,7 @@ export function fillPageFrontmatter( }); if (affiliationIds.size) { - const affiliations = [ - ...(pageFrontmatter.affiliations ?? []), - ...(projectFrontmatter.affiliations ?? []), - ]; + const affiliations = [...(base.affiliations ?? []), ...(filler.affiliations ?? [])]; const affiliationLookup: Record = {}; affiliations.forEach((aff) => { if (!aff.id || isStashPlaceholder(aff)) return; diff --git a/packages/mystmd/tests/exports.yml b/packages/mystmd/tests/exports.yml index ab71a1a88..89c11945f 100644 --- a/packages/mystmd/tests/exports.yml +++ b/packages/mystmd/tests/exports.yml @@ -144,3 +144,11 @@ cases: outputs: - path: notebook-fig-embed/_build/site/content/index.json content: notebook-fig-embed/outputs/index.json + - title: Extend config test + cwd: extend-config/proj-a + command: myst build + outputs: + - path: extend-config/proj-a/_build/site/content/index.json + content: outputs/extend-config-index.json + - path: extend-config/proj-a/_build/site/config.json + content: outputs/extend-config-config.json diff --git a/packages/mystmd/tests/extend-config/proj-a/index.md b/packages/mystmd/tests/extend-config/proj-a/index.md new file mode 100644 index 000000000..8efc74893 --- /dev/null +++ b/packages/mystmd/tests/extend-config/proj-a/index.md @@ -0,0 +1,8 @@ +--- +math: + b: defined-on-page +--- + +# Testing Config + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. \ No newline at end of file diff --git a/packages/mystmd/tests/extend-config/proj-a/myst.yml b/packages/mystmd/tests/extend-config/proj-a/myst.yml new file mode 100644 index 000000000..dee5d27c7 --- /dev/null +++ b/packages/mystmd/tests/extend-config/proj-a/myst.yml @@ -0,0 +1,15 @@ +version: 1 +project: + id: 7c806d98-0093-41ae-9889-dea2b9019064 + title: MyST Config Extend Test + math: + a: defined-in-project-a + b: defined-in-project-a +site: + template: ../../templates/site/myst/book-theme + nav: [] + actions: + - title: Learn More + url: https://mystmd.org/guide + domains: [] +extends: ../proj-b/myst.yml diff --git a/packages/mystmd/tests/extend-config/proj-b/myst.yml b/packages/mystmd/tests/extend-config/proj-b/myst.yml new file mode 100644 index 000000000..b18234577 --- /dev/null +++ b/packages/mystmd/tests/extend-config/proj-b/myst.yml @@ -0,0 +1,20 @@ +version: 1 +project: + id: 36470d68-33d2-4b16-b0cf-5c955396fe57 + keywords: + - my-kw + authors: + - John Doe + license: CC-BY-4.0 + math: + a: defined-in-project-b + c: defined-in-project-b +site: + template: book-theme + nav: [] + actions: + - title: Learn More + url: https://mystmd.org/guide + domains: [] +extends: + - ../proj-c/macros.yml diff --git a/packages/mystmd/tests/extend-config/proj-c/macros.yml b/packages/mystmd/tests/extend-config/proj-c/macros.yml new file mode 100644 index 000000000..60700801f --- /dev/null +++ b/packages/mystmd/tests/extend-config/proj-c/macros.yml @@ -0,0 +1,6 @@ +version: 1 +project: + math: + a: defined-in-project-c + c: defined-in-project-c + d: defined-in-project-c diff --git a/packages/mystmd/tests/outputs/basic-site-config.json b/packages/mystmd/tests/outputs/basic-site-config.json index 146e36ae7..e306b906c 100644 --- a/packages/mystmd/tests/outputs/basic-site-config.json +++ b/packages/mystmd/tests/outputs/basic-site-config.json @@ -1 +1,19 @@ -{"options":{},"myst":"1.2.3","nav":[],"actions":[{"title":"Learn More","url":"https://mystmd.org/guide","internal":false,"static":false}],"projects":[{"github":"https://github.com/executablebooks/mystjs","keywords":[],"id":"22c218e1-66c6-428f-9df9-a7f2c6a3bd76","exports":[],"bibliography":[],"title":"Basic Test","index":"index","pages":[]}]} \ No newline at end of file +{ + "options": {}, + "nav": [], + "actions": [ + { "title": "Learn More", "url": "https://mystmd.org/guide", "internal": false, "static": false } + ], + "projects": [ + { + "github": "https://github.com/executablebooks/mystjs", + "keywords": [], + "id": "22c218e1-66c6-428f-9df9-a7f2c6a3bd76", + "exports": [], + "bibliography": [], + "title": "Basic Test", + "index": "index", + "pages": [] + } + ] +} diff --git a/packages/mystmd/tests/outputs/extend-config-config.json b/packages/mystmd/tests/outputs/extend-config-config.json new file mode 100644 index 000000000..cd4c597cb --- /dev/null +++ b/packages/mystmd/tests/outputs/extend-config-config.json @@ -0,0 +1,34 @@ +{ + "options": {}, + "nav": [], + "actions": [ + { "title": "Learn More", "url": "https://mystmd.org/guide", "internal": false, "static": false } + ], + "projects": [ + { + "license": { + "content": { + "id": "CC-BY-4.0", + "name": "Creative Commons Attribution 4.0 International", + "free": true, + "CC": true, + "url": "https://creativecommons.org/licenses/by/4.0/" + } + }, + "math": { + "a": "defined-in-project-a", + "c": "defined-in-project-b", + "d": "defined-in-project-c", + "b": "defined-in-project-a" + }, + "title": "MyST Config Extend Test", + "authors": [{ "id": "John Doe", "name": "John Doe" }], + "keywords": ["my-kw"], + "id": "7c806d98-0093-41ae-9889-dea2b9019064", + "exports": [], + "bibliography": [], + "index": "index", + "pages": [] + } + ] +} diff --git a/packages/mystmd/tests/outputs/extend-config-index.json b/packages/mystmd/tests/outputs/extend-config-index.json new file mode 100644 index 000000000..ada9751fc --- /dev/null +++ b/packages/mystmd/tests/outputs/extend-config-index.json @@ -0,0 +1,59 @@ +{ + "kind": "Article", + "sha256": "1303d3b24843b9c9a7a3df04f2b8625a9e690eae98dee1f87ee870d6db6986af", + "slug": "index", + "location": "/index.md", + "dependencies": [], + "frontmatter": { + "title": "Testing Config", + "content_includes_title": false, + "authors": [{ "id": "John Doe", "name": "John Doe" }], + "license": { + "content": { + "id": "CC-BY-4.0", + "name": "Creative Commons Attribution 4.0 International", + "free": true, + "CC": true, + "url": "https://creativecommons.org/licenses/by/4.0/" + } + }, + "keywords": ["my-kw"], + "math": { + "a": "defined-in-project-a", + "c": "defined-in-project-b", + "d": "defined-in-project-c", + "b": "defined-on-page" + }, + "exports": [ + { + "format": "md", + "filename": "index.md" + } + ] + }, + "mdast": { + "type": "root", + "children": [ + { + "type": "block", + "children": [ + { + "type": "paragraph", + "position": { "start": { "line": 8, "column": 1 }, "end": { "line": 8, "column": 1 } }, + "children": [ + { + "type": "text", + "value": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + "position": { + "start": { "line": 8, "column": 1 }, + "end": { "line": 8, "column": 1 } + } + } + ] + } + ] + } + ] + }, + "references": { "cite": { "order": [], "data": {} } } +} diff --git a/packages/mystmd/tests/outputs/site-xrefs-config.json b/packages/mystmd/tests/outputs/site-xrefs-config.json index 32b432806..5f05c6509 100644 --- a/packages/mystmd/tests/outputs/site-xrefs-config.json +++ b/packages/mystmd/tests/outputs/site-xrefs-config.json @@ -1,6 +1,5 @@ { "options": {}, - "myst": "1.2.3", "nav": [], "actions": [ { "title": "Learn More", "url": "https://mystmd.org/guide", "internal": false, "static": false }