Files
core-js/website/build.mjs
T
2026-04-26 17:27:12 +07:00

353 lines
14 KiB
JavaScript

import { expandVersionsConfig, getDefaultVersion } from './scripts/helpers.mjs';
import fm from 'front-matter';
import { JSDOM } from 'jsdom';
import { Marked } from 'marked';
import { gfmHeadingId, getHeadingList } from 'marked-gfm-heading-id';
import markedAlert from 'marked-alert';
import config from './config/config.mjs';
import { argv, fs } from 'zx';
const { copy, mkdir, readFile, readJson, readdir, writeFile } = fs;
const branchArg = argv._.find(item => item.startsWith('branch='));
const BRANCH = branchArg ? branchArg.slice('branch='.length) : undefined;
const LOCAL = argv._.includes('local');
const BASE = LOCAL && BRANCH ? '/core-js/website/dist/' : BRANCH ? `/branches/${ BRANCH }/` : '/';
const DEFAULT_VERSION = await getDefaultVersion(config.versionsFile, BRANCH);
let htmlFileName = '';
let docsMenu = '';
let isBlog = false;
let isDocs = false;
async function getAllMdFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const subFiles = await getAllMdFiles(fullPath);
files.push(...subFiles);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath);
}
}
return files;
}
async function buildDocsMenu(item) {
if (Object.hasOwn(item, 'children')) {
let result = `<li class="collapsible"><a href="#">${ item.title }</a><ul>`;
for (const child of item.children) {
result += await buildDocsMenu(child);
}
result += '</ul></li>';
return result;
}
return `<li><a href="${ item.url }" class="with-docs-version" data-default-version="${ DEFAULT_VERSION }">${ item.title }</a></li>`;
}
const docsMenus = [];
const docsMenuItems = [];
async function getDocsMenuItems(version) {
if (docsMenuItems[version]) return docsMenuItems[version];
echo(chalk.green(`Getting menu items from file for version: ${ version }`));
const jsonPath = BRANCH ? `${ config.docsDir }docs/menu.json` : `${ config.docsDir }${ version }/docs/menu.json`;
try {
const docsMenuJson = await readJson(jsonPath);
docsMenuItems[version] = docsMenuJson === '' ? [] : docsMenuJson;
return docsMenuItems[version];
} catch {
echo(chalk.yellow(`Menu JSON file not found: ${ jsonPath }`));
return [];
}
}
async function buildDocsMenuForVersion(version) {
if (docsMenus[version]) return docsMenus[version];
echo(chalk.green(`Building docs menu for version: ${ version }`));
const menuItems = await getDocsMenuItems(version);
if (!menuItems.length) return '';
let menu = '<ul><li>{versions-menu}</li>';
for (const item of menuItems) {
menu += await buildDocsMenu(item);
}
menu += '</ul>';
docsMenus[version] = menu;
return menu;
}
function getBundlesPath(version) {
return path.join(config.bundlesPath, version.branch ?? version.tag);
}
async function buildVersionsMenuList(versions, currentVersion, section) {
let versionsMenuHtml = '<div class="dropdown-block">';
for (const v of versions) {
const activityClass = v.label === currentVersion && !v.default ? ' class="active"' : '';
const defaultBadge = v.default ? ' (default)' : '';
const versionPath = v.default ? '' : `${ v.path }/`;
versionsMenuHtml += `<a href="./${ versionPath }${ section }"${ activityClass }>${ v.label }${ defaultBadge }</a>`;
}
versionsMenuHtml += '</div>';
return versionsMenuHtml;
}
async function buildVersionsMenu(versions, currentVersion, section) {
const innerMenu = await buildVersionsMenuList(versions, currentVersion, section);
return `<div class="dropdown versions-menu"><div class="dropdown-wrapper"><a href="#" class="current">${
currentVersion }</a>${ innerMenu }</div><div class="backdrop"></div></div>`;
}
let fileMetadata = {};
function metadata(markdown) {
const { attributes, body } = fm(markdown);
fileMetadata = { ...attributes };
return body;
}
const markedInline = new Marked();
const linkRenderer = {
link({ href, text }) {
const htmlContent = markedInline.parseInline(text);
const isExternal = /^https?:\/\//.test(href);
const isAnchor = href.startsWith('#');
if (isAnchor) href = htmlFileName.replace('.html', '') + href.toLowerCase();
let html = `<a href="${ href }"`;
if (isExternal) html += ' target="_blank"';
html += `>${ htmlContent }</a>`;
return html;
},
};
const marked = new Marked();
marked.use(markedAlert(), gfmHeadingId({ prefix: 'h-' }));
marked.use({ hooks: { preprocess: metadata } });
marked.use({ renderer: linkRenderer });
const markedWithContents = new Marked();
markedWithContents.use(markedAlert(), gfmHeadingId({ prefix: 'h-' }));
markedWithContents.use({
hooks: {
preprocess: metadata,
postprocess: buildMenus,
},
renderer: linkRenderer,
});
function buildMenus(html) {
const headings = getHeadingList().filter(({ level }) => level > 1);
let result = '<div class="wrapper">';
if (isBlog || isDocs) {
result += `<div class="docs-menu sticky"><div class="container"><div class="docs-links">${
isBlog ? blogMenuCache : docsMenu }</div><div class="mobile-trigger"></div></div></div>`;
}
result += `<div class="content">${ html }</div>`;
if (headings.length && !Object.hasOwn(fileMetadata, 'disableContentMenu')) {
result += `<div class="table-of-contents sticky"><div class="container"><div class="mobile-trigger"></div><div class="toc-links">
${ headings.map(({ id, raw, level }) => `<div class="toc-link"><a href="${
htmlFileName.replace('.html', '') }#${ id }" class="h${
level } with-docs-version scroll-to" data-hash="#${ id }" data-default-version="${ DEFAULT_VERSION }">${
raw }</a></div>`).join('\n') }
</div></div></div>`;
}
return result;
}
let blogMenuCache = '';
async function buildBlogMenu() {
if (blogMenuCache !== '') return blogMenuCache;
const mdFiles = await getAllMdFiles(config.blogDir);
mdFiles.reverse();
let index = '---\ndisableContentMenu: true\n---\n';
let menu = '<ul>';
for (const mdPath of mdFiles) {
if (mdPath.endsWith('index.md')) continue;
const content = await readFile(mdPath, 'utf8');
const tokens = marked.lexer(content);
const firstH1 = tokens.find(token => token.type === 'heading' && token.depth === 1);
if (!firstH1) {
echo(chalk.yellow(`H1 not found in ${ mdPath }`));
continue;
}
let htmlContent = await marked.parse(content);
// eslint-disable-next-line redos/no-vulnerable -- safe
htmlContent = htmlContent.replace(/<h1[^>]*>.*?<\/h1>/i, '');
const hrIndex = htmlContent.search(/<hr>/i);
const preview = hrIndex !== -1 ? htmlContent.slice(0, hrIndex) : htmlContent;
const match = mdPath.match(/(?<date>\d{4}-\d{2}-\d{2})-/);
const date = match?.groups?.date ?? '';
const fileName = mdPath.replace(config.blogDir, '').replace(/\.md$/i, '');
menu += `<li><a href="./blog/${ fileName }">${ date }: ${ firstH1.text }</a></li>`;
index += `## [${ firstH1.text }](./blog/${ fileName })\n\n`;
if (date) index += `*${ date }*\n\n`;
index += `${ preview }\n\n`;
}
menu += '</ul>';
blogMenuCache = menu;
const blogIndexPath = path.join(config.blogDir, 'index.md');
await writeFile(blogIndexPath, index, 'utf8');
echo(chalk.green(`File created: ${ blogIndexPath }`));
return menu;
}
async function getVersionFromMdFile(mdPath) {
const match = mdPath.match(/\/web\/(?<version>[^/]+)\/docs\//);
return match?.groups?.version ?? DEFAULT_VERSION;
}
async function buildPlaygrounds(template, versions) {
for (const version of versions) {
await buildPlayground(template, version, versions);
}
}
async function buildPlayground(template, version, versions) {
const bundlesPath = getBundlesPath(version);
const bundleScript = `<script nomodule src="${ bundlesPath }/${ config.bundleName }"></script>`;
const bundleESModulesScript = `<script type="module" src="${ bundlesPath }/${ config.bundleNameESModules }"></script>`;
const babelScript = '<script src="./babel.min.js"></script>';
const playgroundContent = await readFile(`${ config.srcDir }playground.html`, 'utf8');
const versionsMenu = await buildVersionsMenu(versions, version.label, 'playground');
let playground = template.replace('{content}', playgroundContent);
playground = playground.replace('{base}', BASE);
playground = playground.replace('{title}', 'Playground - ');
playground = playground.replace('{core-js-bundle}', bundleScript);
playground = playground.replace('{core-js-bundle-esmodules}', bundleESModulesScript);
playground = playground.replace('{babel-script}', babelScript);
const playgroundWithVersion = playground.replace('{versions-menu}', versionsMenu);
const playgroundFilePath = path.join(config.resultDir, version.path, 'playground.html');
if (version.default) {
const defaultVersionsMenu = await buildVersionsMenu(versions, version.label, 'playground');
const defaultVersionPlayground = playground.replace('{versions-menu}', defaultVersionsMenu);
const defaultPlaygroundPath = path.join(config.resultDir, 'playground.html');
await writeFile(defaultPlaygroundPath, defaultVersionPlayground, 'utf8');
echo(chalk.green(`File created: ${ defaultPlaygroundPath }`));
} else {
await mkdir(path.dirname(playgroundFilePath), { recursive: true });
await writeFile(playgroundFilePath, playgroundWithVersion, 'utf8');
echo(chalk.green(`File created: ${ playgroundFilePath }`));
}
}
async function createDocsIndexes(versions) {
if (BRANCH) versions = [{ path: '' }];
for (const version of versions) {
if (version.default && versions.length > 1) continue;
const versionPath = version.path;
const menuItems = await getDocsMenuItems(versionPath);
if (!menuItems.length) continue;
const firstDocPath = path.join(config.resultDir,
`${ menuItems[0].url }.html`.replace(`{docs-version}${ BRANCH ? '/' : '' }`, versionPath));
const indexFilePath = path.join(config.resultDir, `${ versionPath }/docs/`, 'index.html');
await copy(firstDocPath, indexFilePath);
echo(chalk.green(`File created: ${ indexFilePath }`));
}
}
async function getVersions() {
if (BRANCH) {
return [{
branch: BRANCH,
default: true,
label: BRANCH,
path: BRANCH,
}];
}
const versions = await readJson(config.versionsFile);
echo(chalk.green('Got versions from file'));
return expandVersionsConfig(versions);
}
function getTitle(content) {
const match = /^# (?<title>.+)$/m.exec(content);
return match?.groups?.title ? `${ match.groups.title } - ` : '';
}
async function build() {
const template = await readFile(config.templatePath, 'utf8');
await buildBlogMenu();
const mdFiles = await getAllMdFiles(config.docsDir);
const versions = await getVersions();
const [defaultVersion] = versions;
const bundlesPath = getBundlesPath(defaultVersion);
const bundleScript = `<script nomodule src="${ bundlesPath }/${ config.bundleName }"></script>`;
const bundleESModulesScript = `<script type="module" src="${ bundlesPath }/${ config.bundleNameESModules }"></script>`;
let currentVersion = '';
let versionsMenu = '';
let isChangelog;
for (const mdPath of mdFiles) {
const content = await readFile(mdPath, 'utf8');
isDocs = mdPath.includes('/docs');
isChangelog = mdPath.includes('/changelog');
isBlog = mdPath.includes('/blog');
const title = getTitle(content);
const versionFromMdFile = await getVersionFromMdFile(mdPath);
if (currentVersion !== versionFromMdFile) {
currentVersion = versionFromMdFile;
docsMenu = await buildDocsMenuForVersion(currentVersion);
versionsMenu = await buildVersionsMenu(versions, currentVersion, 'docs/');
}
htmlFileName = mdPath.replace(config.docsDir, '').replace(/\.md$/i, '.html');
const htmlFilePath = path.join(config.resultDir, htmlFileName);
const htmlContent = isDocs || isBlog || isChangelog ? markedWithContents.parse(content) : marked.parse(content);
let resultHtml = template.replace('{content}', htmlContent.replaceAll('$', '&#36;'));
resultHtml = resultHtml.replace('{title}', title.replaceAll('$', '&#36;'));
resultHtml = resultHtml.replace('{base}', BASE);
resultHtml = resultHtml.replace('{core-js-bundle}', bundleScript);
resultHtml = resultHtml.replace('{core-js-bundle-esmodules}', bundleESModulesScript);
resultHtml = resultHtml.replaceAll('{versions-menu}', versionsMenu);
resultHtml = resultHtml.replaceAll('{current-version}', currentVersion);
if (isDocs || isBlog || isChangelog) {
const resultDOM = new JSDOM(resultHtml);
const { document } = resultDOM.window;
document.querySelectorAll('h2[id], h3[id], h4[id], h5[id], h6[id]').forEach(heading => {
const newHeading = heading.cloneNode(true);
const anchor = document.createElement('a');
anchor.className = 'anchor';
anchor.href = `${ htmlFileName.replace('.html', '') }#${ newHeading.id }`;
anchor.innerHTML = '<svg viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>';
newHeading.append(anchor);
newHeading.classList.add('with-anchor');
resultHtml = resultHtml.replaceAll(heading.outerHTML, newHeading.outerHTML);
});
}
resultHtml = resultHtml.replaceAll('{docs-version}', currentVersion);
await mkdir(path.dirname(htmlFilePath), { recursive: true });
await writeFile(htmlFilePath, resultHtml, 'utf8');
echo(chalk.green(`File created: ${ htmlFilePath }`));
}
await buildPlaygrounds(template, versions);
await createDocsIndexes(versions);
}
await build().catch(console.error);