myst-cli
Version:
Command line tools for MyST
276 lines (275 loc) • 12.2 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import { SPEC_VERSION } from '../../spec-version.js';
import { hashAndCopyStaticFile } from 'myst-cli-utils';
import { RuleId, TemplateOptionType } from 'myst-common';
import { EXT_TO_FORMAT, ExportFormats, PROJECT_FRONTMATTER_KEYS, SITE_FRONTMATTER_KEYS, } from 'myst-frontmatter';
import { filterKeys } from 'simple-validators';
import { resolveToAbsolute } from '../../utils/resolveToAbsolute.js';
import { selectors } from '../../store/index.js';
import { transformBanner, transformThumbnail } from '../../transforms/images.js';
import { addWarningForFile } from '../../utils/addWarningForFile.js';
import { resolveFrontmatterParts } from '../../utils/resolveFrontmatterParts.js';
import version from '../../version.js';
import { getSiteTemplate } from './template.js';
import { collectExportOptions } from '../utils/collectExportOptions.js';
import { filterPages } from '../../project/load.js';
import { getRawFrontmatterFromFile } from '../../process/file.js';
import { indexFrontmatterFromProject, manifestPagesFromProject, manifestTitleFromProject, } from '../utils/projectManifest.js';
export async function resolvePageExports(session, file) {
const exports = (await collectExportOptions(session, [file], [
ExportFormats.docx,
ExportFormats.pdf,
ExportFormats.tex,
ExportFormats.typst,
ExportFormats.xml,
ExportFormats.meca,
], {}))
.filter((exp) => {
return ['.docx', '.pdf', '.zip', '.xml'].includes(path.extname(exp.output));
})
.filter((exp) => {
return fs.existsSync(exp.output);
});
const exportsAsDownloads = exports.map((exp) => {
const { format, output } = exp;
const fileHash = hashAndCopyStaticFile(session, output, session.publicPath(), (m) => {
addWarningForFile(session, file, m, 'error', {
ruleId: RuleId.exportFileCopied,
});
});
return { format, filename: path.basename(output), url: `/${fileHash}` };
});
return exportsAsDownloads;
}
export async function resolvePageDownloads(session, file, projectPath) {
const state = session.store.getState();
if (!projectPath) {
projectPath = selectors.selectCurrentProjectPath(state);
}
const project = projectPath
? selectors.selectLocalProject(session.store.getState(), projectPath)
: undefined;
const files = project ? filterPages(project).map((page) => page.file) : [file];
const allExports = await collectExportOptions(session, files, [...Object.values(ExportFormats)], {
projectPath,
});
const expLookup = {};
allExports.forEach((exp) => {
if (exp.id)
expLookup[exp.id] = exp;
});
const pageFrontmatter = await getRawFrontmatterFromFile(session, file, projectPath);
const resolvedDownloads = pageFrontmatter?.downloads
?.map((download) => {
if (download.id && !expLookup[download.id]) {
addWarningForFile(session, file, `Unable to locate download file by export id "${download.id}"`, 'error', {
ruleId: RuleId.exportFileCopied,
});
return undefined;
}
const idOrUrl = download.id ?? download.url;
// Validation will catch this earlier
if (!idOrUrl)
return undefined;
const exp = expLookup[idOrUrl];
if (exp) {
download.format = exp.format;
download.url = exp.output;
download.static = true;
}
if (!download.url)
return undefined;
return resolveSiteAction(session, download, file, 'downloads');
})
.filter((download) => !!download);
return resolvedDownloads;
}
/**
* Convert local project representation to site manifest project
*
* This does a couple things:
* - Adds projectSlug (which locally comes from site config)
* - Removes any local file references
* - Adds validated frontmatter
* - Writes and transforms banner and thumbnail images
*/
export async function localToManifestProject(session, projectPath, projectSlug) {
if (!projectPath)
return null;
const state = session.store.getState();
const projConfig = selectors.selectLocalProjectConfig(state, projectPath);
const proj = selectors.selectLocalProject(state, projectPath);
if (!proj)
return null;
// Update all of the page title to the frontmatter title
const { index } = proj;
const projectFileInfo = selectors.selectFileInfo(state, proj.file);
const pages = await manifestPagesFromProject(session, projectPath);
const projFrontmatter = projConfig ? filterKeys(projConfig, PROJECT_FRONTMATTER_KEYS) : {};
const projConfigFile = selectors.selectLocalConfigFile(state, projectPath);
const exports = projConfigFile ? await resolvePageExports(session, projConfigFile) : [];
const downloads = projConfigFile
? await resolvePageDownloads(session, projConfigFile, projectPath)
: undefined;
const parts = resolveFrontmatterParts(session, projFrontmatter);
const banner = await transformBanner(session, projConfigFile ?? path.join(projectPath, 'myst.yml'), projFrontmatter, session.publicPath(), { altOutputFolder: '/', webp: true });
const thumbnail = await transformThumbnail(session, null, projConfigFile ?? path.join(projectPath, 'myst.yml'), projFrontmatter, session.publicPath(), { altOutputFolder: '/', webp: true });
const frontmatter = indexFrontmatterFromProject(session, projectPath);
return {
...projFrontmatter,
// TODO: a null in the project frontmatter should not fall back to index page
thumbnail: thumbnail?.url || projectFileInfo.thumbnail,
thumbnailOptimized: thumbnail?.urlOptimized ||
// Do not fall back to optimized page thumbnail if unoptimized project thumbnail exists
(thumbnail?.url ? undefined : projectFileInfo.thumbnailOptimized) ||
undefined,
banner: banner?.url || projectFileInfo.banner,
bannerOptimized: banner?.urlOptimized ||
(banner?.url ? undefined : projectFileInfo.bannerOptimized) ||
undefined,
exports,
downloads,
parts,
bibliography: projFrontmatter.bibliography || [],
title: manifestTitleFromProject(session, projectPath),
slug: projectSlug,
index,
enumerator: frontmatter?.enumerator,
pages,
};
}
async function resolveTemplateFileOptions(session, mystTemplate, options) {
const resolvedOptions = { ...options };
await Promise.all((mystTemplate.getValidatedTemplateYml().options ?? []).map(async (option) => {
if (option.type === TemplateOptionType.file && options[option.id]) {
const configPath = selectors.selectCurrentSitePath(session.store.getState());
const absPath = configPath
? await resolveToAbsolute(session, configPath, options[option.id], { allowRemote: true })
: options[option.id];
const fileHash = hashAndCopyStaticFile(session, absPath, session.publicPath(), (m) => {
addWarningForFile(session, options[option.id], m, 'error', {
ruleId: RuleId.templateFileCopied,
});
});
resolvedOptions[option.id] = `/${fileHash}`;
}
}));
return resolvedOptions;
}
function isInternalUrl(value) {
return !!value.match('^(/[a-zA-Z0-9._-]+)+$');
}
function isUrl(value) {
// Allow simple relative path in project
if (isInternalUrl(value))
return true;
try {
new URL(value);
}
catch {
return false;
}
return true;
}
/**
* Resolves Site Action, including hashing and copying static files
*
* Infers `static: true` if url is an existing local file
*/
function resolveSiteAction(session, action, file, property) {
const title = action.title;
if (action.static === false) {
if (!title) {
addWarningForFile(session, file, `"title" is required for resource "${action.url}" in ${property}`, 'error');
return undefined;
}
return {
title,
url: action.url,
filename: action.filename,
format: action.format,
internal: isInternalUrl(action.url),
static: false,
};
}
const resolvedFile = path.resolve(path.dirname(file), action.url);
// Cases where url does not exist as a local file
if (!fs.existsSync(resolvedFile)) {
if (action.static) {
addWarningForFile(session, file, `Could not find static resource at "${action.url}" in ${property}`, 'error', { ruleId: RuleId.staticActionFileCopied });
return undefined;
}
if (!isUrl(action.url)) {
addWarningForFile(session, file, `Resource "${action.url}" in ${property} should be a URL or path to static file`, 'error');
return undefined;
}
if (!title) {
addWarningForFile(session, file, `"title" is required for resource "${action.url}" in ${property}`, 'error');
return undefined;
}
return {
title,
url: action.url,
filename: action.filename,
format: action.format,
internal: isInternalUrl(action.url),
static: false,
};
}
if (!action.static && isUrl(action.url)) {
// Unlikely case where url is both an existing local file and a valid URL
addWarningForFile(session, file, `Linking resource "${action.url}" in ${property} to static file; to mark this as a URL instead, use 'static: false'`);
}
const fileHash = hashAndCopyStaticFile(session, resolvedFile, session.publicPath(), (m) => {
addWarningForFile(session, resolvedFile, m, 'error', {
ruleId: RuleId.staticActionFileCopied,
});
});
if (!title) {
addWarningForFile(session, file, `using filename for title of resource "${action.url}" in ${property}`);
}
const filename = action.filename ?? path.basename(resolvedFile);
return {
title: action.title ?? filename,
url: `/${fileHash}`,
filename,
format: action.format ?? EXT_TO_FORMAT[path.extname(resolvedFile)],
static: true,
};
}
/**
* Build site manifest from local redux state
*
* Site manifest acts as the configuration to build the website.
* It combines local site config and project configs into a single structure.
*/
export async function getSiteManifest(session, opts) {
const state = session.store.getState();
const siteConfig = selectors.selectCurrentSiteConfig(state);
const siteConfigFile = selectors.selectCurrentSiteFile(state);
if (!siteConfig || !siteConfigFile)
throw Error('no site config defined');
const siteProjects = (await Promise.all(siteConfig.projects?.map(async (p) => localToManifestProject(session, p.path, p.slug)) ?? [])).filter((p) => !!p);
const { nav } = siteConfig;
const actions = siteConfig.actions
?.map((action) => resolveSiteAction(session, action, siteConfigFile, 'actions'))
.filter((action) => !!action);
const siteFrontmatter = filterKeys(siteConfig, SITE_FRONTMATTER_KEYS);
const mystTemplate = await getSiteTemplate(session, opts);
const validatedOptions = mystTemplate.validateOptions(siteFrontmatter.options ?? {}, siteConfigFile, { allowRemote: true });
const validatedFrontmatter = mystTemplate.validateDoc(siteFrontmatter, validatedOptions, undefined, siteConfigFile);
const resolvedOptions = await resolveTemplateFileOptions(session, mystTemplate, validatedOptions);
validatedFrontmatter.options = resolvedOptions;
const parts = resolveFrontmatterParts(session, validatedFrontmatter);
const manifest = {
version: SPEC_VERSION,
myst: version,
...validatedFrontmatter,
parts,
nav: nav || [],
actions: actions || [],
projects: siteProjects,
};
return manifest;
}