myst-cli
Version:
Command line tools for MyST
277 lines (276 loc) • 13 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 '../../config.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) {
var _a;
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 = (_a = pageFrontmatter === null || pageFrontmatter === void 0 ? void 0 : pageFrontmatter.downloads) === null || _a === void 0 ? void 0 : _a.map((download) => {
var _a;
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 = (_a = download.id) !== null && _a !== void 0 ? _a : 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, path.join(projectPath, 'myst.yml'), projFrontmatter, session.publicPath(), { altOutputFolder: '/', webp: true });
const thumbnail = await transformThumbnail(session, null, 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 === null || thumbnail === void 0 ? void 0 : thumbnail.url) || projectFileInfo.thumbnail,
thumbnailOptimized: (thumbnail === null || thumbnail === void 0 ? void 0 : thumbnail.urlOptimized) ||
// Do not fall back to optimized page thumbnail if unoptimized project thumbnail exists
((thumbnail === null || thumbnail === void 0 ? void 0 : thumbnail.url) ? undefined : projectFileInfo.thumbnailOptimized) ||
undefined,
banner: (banner === null || banner === void 0 ? void 0 : banner.url) || projectFileInfo.banner,
bannerOptimized: (banner === null || banner === void 0 ? void 0 : banner.urlOptimized) ||
((banner === null || banner === void 0 ? void 0 : banner.url) ? undefined : projectFileInfo.bannerOptimized) ||
undefined,
exports,
downloads,
parts,
bibliography: projFrontmatter.bibliography || [],
title: manifestTitleFromProject(session, projectPath),
slug: projectSlug,
index,
enumerator: frontmatter === null || frontmatter === void 0 ? void 0 : frontmatter.enumerator,
pages,
};
}
async function resolveTemplateFileOptions(session, mystTemplate, options) {
var _a;
const resolvedOptions = { ...options };
await Promise.all(((_a = mystTemplate.getValidatedTemplateYml().options) !== null && _a !== void 0 ? _a : []).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) {
var _a, _b, _c;
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 = (_a = action.filename) !== null && _a !== void 0 ? _a : path.basename(resolvedFile);
return {
title: (_b = action.title) !== null && _b !== void 0 ? _b : filename,
url: `/${fileHash}`,
filename,
format: (_c = action.format) !== null && _c !== void 0 ? _c : 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) {
var _a, _b, _c, _d;
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((_b = (_a = siteConfig.projects) === null || _a === void 0 ? void 0 : _a.map(async (p) => localToManifestProject(session, p.path, p.slug))) !== null && _b !== void 0 ? _b : [])).filter((p) => !!p);
const { nav } = siteConfig;
const actions = (_c = siteConfig.actions) === null || _c === void 0 ? void 0 : _c.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((_d = siteFrontmatter.options) !== null && _d !== void 0 ? _d : {}, 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;
}