Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 90 additions & 71 deletions packages/myst-cli/src/build/html/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import fs from 'fs-extra';
import path from 'node:path';
import { writeFileToFolder } from 'myst-cli-utils';
import { writeFileToFolder, createNpmLogger, makeExecutable } from 'myst-cli-utils';
import type { MystXRefs } from 'myst-transforms';
import type { ISession } from '../../session/types.js';
import type { StartOptions } from '../site/start.js';
Expand Down Expand Up @@ -70,10 +70,12 @@ export async function currentSiteRoutes(
};
}),
// Download other assets
...['robots.txt', 'myst-theme.css', 'sitemap.xml', 'sitemap_style.xsl'].map((asset) => ({
url: `${host}/${asset}`,
path: asset,
})),
...['robots.txt', 'myst-theme.css', 'sitemap.xml', 'sitemap_style.xsl', 'objects.inv'].map(
(asset) => ({
url: `${host}/${asset}`,
path: asset,
}),
),
{
url: `${host}/favicon.ico`,
path: 'favicon.ico',
Expand Down Expand Up @@ -167,64 +169,102 @@ function getBaseUrl(session: ISession): string | undefined {
*/
export async function buildHtml(session: ISession, opts: StartOptions) {
const template = await getSiteTemplate(session, opts);
// Ask template for command to render itself into HTML
const renderCommand = (template.getValidatedTemplateYml().build as any)?.render;
// The BASE_URL env variable allows for mounting the site in a folder, e.g., github pages
const baseurl = getBaseUrl(session);
// Note, this process is really only for Remix templates
// We could add a flag in the future for other templates
const htmlDir = path.join(session.buildPath(), 'html');
fs.rmSync(htmlDir, { recursive: true, force: true });
fs.mkdirSync(htmlDir, { recursive: true });
const appServer = await startServer(session, { ...opts, buildStatic: true, baseurl });
const appServer = await startServer(session, {
...opts,
buildStatic: true,
baseurl,
// We do not need a running site if the template knows how to render itself
headless: renderCommand !== undefined,
});
if (!appServer) return;

const host = `http://localhost:${appServer.port}`;
const routes = await currentSiteRoutes(session, host, baseurl);

// Fetch all HTML pages and assets by the template
await Promise.all(
routes.map(async (route) =>
limitConnections(async () => {
try {
const resp = await fetchWithRetry(session, route.url);
if (!resp.ok) {
session.log.error(`Error fetching ${route.url}`);
return;
}
if (route.binary && resp.body) {
await new Promise<void>((resolve, reject) => {
const filename = path.join(htmlDir, route.path);
if (!fs.existsSync(filename))
fs.mkdirSync(path.dirname(filename), { recursive: true });
const fileWriteStream = fs.createWriteStream(filename);
resp.body!.pipe(fileWriteStream);
resp.body!.on('error', reject);
fileWriteStream.on('error', reject);
fileWriteStream.on('finish', resolve);
});
} else {
const content = await resp.text();
writeFileToFolder(path.join(htmlDir, route.path), content);
// Use the template to render itself
if (renderCommand !== undefined) {
// Run pre-rendering
await makeExecutable(renderCommand, createNpmLogger(session), {
cwd: template.templatePath,
env: { ...process.env, BUILD_DIRECTORY: htmlDir, CONTENT_CDN: host },
})();
}
// Fallback on fetch-based rendering (deprecated)
else {
const routes = await currentSiteRoutes(session, host, baseurl);

// Fetch all HTML pages and assets by the template
await Promise.all(
routes.map(async (route) =>
limitConnections(async () => {
try {
const resp = await fetchWithRetry(session, route.url);
if (!resp.ok) {
session.log.error(`Error fetching ${route.url}`);
return;
}
if (route.binary && resp.body) {
await new Promise<void>((resolve, reject) => {
const filename = path.join(htmlDir, route.path);
if (!fs.existsSync(filename))
fs.mkdirSync(path.dirname(filename), { recursive: true });
const fileWriteStream = fs.createWriteStream(filename);
resp.body!.pipe(fileWriteStream);
resp.body!.on('error', reject);
fileWriteStream.on('error', reject);
fileWriteStream.on('finish', resolve);
});
} else {
const content = await resp.text();
writeFileToFolder(path.join(htmlDir, route.path), content);
}
} catch (error) {
if (!route.optional) throw error;
session.log.warn(
`Could not fetch optional asset ${route.url}: ${(error as Error).message}`,
);
}
} catch (error) {
if (!route.optional) throw error;
session.log.warn(
`Could not fetch optional asset ${route.url}: ${(error as Error).message}`,
);
}
}),
),
);
await appServer.stop();
}),
),
);

// Copy the files for the template used.
//
// This always includes the thebe JS chunks, even when no project enables
// `thebe`/`jupyter`. The myst-theme uses thebe-core to render Jupyter cell
// outputs, so these chunks are required for outputs to render at all.
const templateBuildDir = path.join(template.templatePath, 'public');
fs.copySync(templateBuildDir, htmlDir);
// Copy all of the static assets
fs.copySync(session.publicPath(), path.join(htmlDir, 'build'));
fs.copySync(
path.join(session.sitePath(), 'myst.search.json'),
path.join(htmlDir, 'myst.search.json'),
);

// Copy all of the static assets
fs.copySync(session.publicPath(), path.join(htmlDir, 'build'));
// NOTE: HTML static output needs to patch the contents, this is done on the fly by the server
const xrefs = JSON.parse(
fs.readFileSync(path.join(session.sitePath(), 'myst.xref.json')).toString(),
) as MystXRefs;
xrefs.references?.forEach((ref) => {
ref.data = ref.data?.replace(/^\/content/, '');
});
fs.writeFileSync(path.join(htmlDir, 'myst.xref.json'), JSON.stringify(xrefs));

// Copy the files for the template used.
//
// This always includes the thebe JS chunks, even when no project enables
// `thebe`/`jupyter`. The myst-theme uses thebe-core to render Jupyter cell
// outputs, so these chunks are required for outputs to render at all.
const templateBuildDir = path.join(template.templatePath, 'public');
fs.copySync(templateBuildDir, htmlDir);
Comment on lines +257 to +261

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be the responsibility of theme render function if provided of ensuring all assets are available. (Might need to pass template directory in as env variable?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code-path is only for the existing rendering (if a template doesn't support HTML rendering). My view is that we deprecate this, but remove it later.

fs.copySync(path.join(session.sitePath(), 'config.json'), path.join(htmlDir, 'config.json'));

// We need to go through and change all links to the right folder
rewriteAssetsFolder(htmlDir, baseurl);
}
await appServer.stop();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the template is in charge of rendering html, then we shouldn't have to start the server (some could probably do it without a server, but it should be a template choice).

@agoose77 agoose77 Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we only want the content server. To-do!


// Copy user static files to html build root
const siteConfig = selectors.selectCurrentSiteConfig(session.store.getState());
Expand All @@ -233,27 +273,6 @@ export async function buildHtml(session: ISession, opts: StartOptions) {
const projectConfig = selectors.selectLocalProjectConfig(session.store.getState(), proj.path);
copyStaticFiles(session, projectConfig?.static_files ?? [], htmlDir, proj.path);
}
fs.copySync(path.join(session.sitePath(), 'config.json'), path.join(htmlDir, 'config.json'));
fs.copySync(path.join(session.sitePath(), 'public.json'), path.join(htmlDir, 'public.json'));
fs.copySync(path.join(session.sitePath(), 'objects.inv'), path.join(htmlDir, 'objects.inv'));

// NOTE: HTML static output needs to patch the contents, this is done on the fly by the server
const xrefs = JSON.parse(
fs.readFileSync(path.join(session.sitePath(), 'myst.xref.json')).toString(),
) as MystXRefs;
xrefs.references?.forEach((ref) => {
ref.data = ref.data?.replace(/^\/content/, '');
});
fs.writeFileSync(path.join(htmlDir, 'myst.xref.json'), JSON.stringify(xrefs));

// Copy the search index
fs.copySync(
path.join(session.sitePath(), 'myst.search.json'),
path.join(htmlDir, 'myst.search.json'),
);

// We need to go through and change all links to the right folder
rewriteAssetsFolder(htmlDir, baseurl);

// Explicitly close the process as the web server doesn't always stop?
process.exit(0);
Expand Down
Loading