diff --git a/src/build.ts b/src/build.ts index 85eb588..830d86f 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,4 +1,4 @@ -import fs from 'node:fs'; +import { promises as fs } from 'node:fs'; import { createAtomFeed } from './build/feeds/create-atom-feed'; import { createRssFeed } from './build/feeds/create-rss-feed'; import { createSitemap } from './build/feeds/create-sitemap'; @@ -10,14 +10,14 @@ import { DIR_DIST, DIR_SRC_STATIC } from './constants'; async function build() { // Prepare the DIST directory. - fs.rmSync(DIR_DIST, { recursive: true, force: true }); - fs.mkdirSync(DIR_DIST); + await fs.rm(DIR_DIST, { recursive: true, force: true }); + await fs.mkdir(DIR_DIST); // Copy the content to DIST. - copySourceToTarget(DIR_SRC_STATIC, DIR_DIST); + await copySourceToTarget(DIR_SRC_STATIC, DIR_DIST); // Collect all blog posts and tags. - const posts = getPosts(); + const posts = await getPosts(); const tags = [...new Set(posts.map((post) => post.meta.tags || []).flat())]; // Create the HTML pages. @@ -27,9 +27,9 @@ async function build() { await optimize(); // Create feeds. - createAtomFeed(posts); - createRssFeed(posts); - createSitemap(pages); + await createAtomFeed(posts); + await createRssFeed(posts); + await createSitemap(pages); } await build(); diff --git a/src/build/feeds/create-atom-feed.ts b/src/build/feeds/create-atom-feed.ts index 6522e8c..de2f563 100644 --- a/src/build/feeds/create-atom-feed.ts +++ b/src/build/feeds/create-atom-feed.ts @@ -7,7 +7,7 @@ import { DIR_DIST } from '../../constants'; const FILE_NAME = 'atom.xml'; -export function createAtomFeed(posts: Post[]): void { +export async function createAtomFeed(posts: Post[]): Promise { const fileHref = new URL(FILE_NAME, BUILD.baseUrl).href; // It is important there is no whitespace before the XML tag. @@ -39,5 +39,5 @@ export function createAtomFeed(posts: Post[]): void { })} `; - createFile(path.join(DIR_DIST, FILE_NAME), contents); + await createFile(path.join(DIR_DIST, FILE_NAME), contents); } diff --git a/src/build/feeds/create-rss-feed.ts b/src/build/feeds/create-rss-feed.ts index 65901e1..bf16973 100644 --- a/src/build/feeds/create-rss-feed.ts +++ b/src/build/feeds/create-rss-feed.ts @@ -7,7 +7,7 @@ import { DIR_DIST } from '../../constants'; const FILE_NAME = 'rss.xml'; -export function createRssFeed(posts: Post[]): void { +export async function createRssFeed(posts: Post[]): Promise { const fileHref = new URL(FILE_NAME, BUILD.baseUrl).href; const buildDate = BUILD.date.toUTCString(); @@ -48,5 +48,5 @@ export function createRssFeed(posts: Post[]): void { `; - createFile(path.join(DIR_DIST, FILE_NAME), contents); + await createFile(path.join(DIR_DIST, FILE_NAME), contents); } diff --git a/src/build/feeds/create-sitemap.ts b/src/build/feeds/create-sitemap.ts index 906661e..2acf65c 100644 --- a/src/build/feeds/create-sitemap.ts +++ b/src/build/feeds/create-sitemap.ts @@ -18,7 +18,7 @@ function pagesToSitemapEntries(pages: PageData[]): string { ); } -export function createSitemap(pages: RenderedPages): void { +export async function createSitemap(pages: RenderedPages): Promise { const { content, posts, tags } = pages; // It is important there is no whitespace before the XML tag. @@ -31,5 +31,5 @@ export function createSitemap(pages: RenderedPages): void { ])} `.trim(); - createFile(path.join(DIR_DIST, 'sitemap.xml'), contents); + await createFile(path.join(DIR_DIST, 'sitemap.xml'), contents); } diff --git a/src/build/optimize.ts b/src/build/optimize.ts index 36015b1..8479379 100644 --- a/src/build/optimize.ts +++ b/src/build/optimize.ts @@ -1,14 +1,15 @@ import { transform } from 'lightningcss'; import crypto from 'node:crypto'; -import fs from 'node:fs'; +import { promises as fs } from 'node:fs'; import path from 'node:path'; import { minify as minifyHtml } from 'html-minifier-terser'; import { minify as minifyJs } from 'terser'; import { findFilesByExtension } from '../utils/node'; import { DIR_DIST } from '../constants'; -// A map of asset file paths to their hashed file names. -const fileHashMap: Record = {}; +// A map of asset file paths to their hashed file names. This prevents issues +// when multiple async operations try to change the same file at the same time. +const fileHashCache = new Map>(); const minifyHtmlOptions = { collapseBooleanAttributes: true, @@ -20,7 +21,7 @@ const minifyHtmlOptions = { }; export async function optimize(): Promise { - optimizeCssFiles(); + await optimizeCssFiles(); await optimizeJsFiles(); // Do not run in parallel with the above because this function renames assets. @@ -30,24 +31,28 @@ export async function optimize(): Promise { /** * Optimize CSS assets by minifying them. */ -function optimizeCssFiles(): void { - for (const file of findFilesByExtension('css', DIR_DIST)) { - const input = fs.readFileSync(file); - // `filename` is required but unused. - const { code } = transform({ filename: 'foo', code: input, minify: true }); - fs.writeFileSync(file, code); - } +async function optimizeCssFiles(): Promise { + const cssFiles = await findFilesByExtension('css', DIR_DIST); + await Promise.all( + cssFiles.map(async (file) => { + const input = await fs.readFile(file); + // `filename` is required but unused. + const { code } = transform({ filename: 'x', code: input, minify: true }); + await fs.writeFile(file, code); + }), + ); } /** * Optimize JS assets by minifying them. */ async function optimizeJsFiles(): Promise { + const jsFiles = await findFilesByExtension('js', DIR_DIST); await Promise.all( - findFilesByExtension('js', DIR_DIST).map(async (file) => { - const contents = fs.readFileSync(file, 'utf8'); + jsFiles.map(async (file) => { + const contents = await fs.readFile(file, 'utf8'); const minified = (await minifyJs(contents)).code || ''; - fs.writeFileSync(file, minified); + await fs.writeFile(file, minified); }), ); } @@ -56,12 +61,13 @@ async function optimizeJsFiles(): Promise { * Optimize HTML files by minifying them and hashing assets. */ async function optimizeHtmlFiles(): Promise { + const htmlFiles = await findFilesByExtension('html', DIR_DIST); await Promise.all( - findFilesByExtension('html', DIR_DIST).map(async (file) => { - const contents = fs.readFileSync(file, 'utf8'); + htmlFiles.map(async (file) => { + const contents = await fs.readFile(file, 'utf8'); const minified = await minifyHtml(contents, minifyHtmlOptions); const output = await hashRelativeAssetUrlsInHtml(minified); - fs.writeFileSync(file, output); + await fs.writeFile(file, output); }), ); } @@ -76,34 +82,61 @@ async function hashRelativeAssetUrlsInHtml(contents: string): Promise { /(window\._paths_\..*?=")([\s\S]*?)(")/g, ]; - return regexes.reduce((html, regex) => { - return html.replace(regex, (match, prefix, value, suffix) => { - return value.startsWith('.') || value.startsWith('/') - ? `${prefix}${renameFileToHash(value)}${suffix}` - : match; - }); - }, contents); + let html = contents; + + for (const regex of regexes) { + const matches = [...html.matchAll(regex)]; + + // Find all replacements. + const replacements = await Promise.all( + matches.map(async (match) => { + const [fullMatch, prefix, value, suffix] = match; + return { + start: match.index, + end: match.index + fullMatch.length, + replacement: + value.startsWith('.') || value.startsWith('/') + ? `${prefix}${await hashFile(value)}${suffix}` + : fullMatch, + }; + }), + ); + + // Apply replacements. + for (const { start, end, replacement } of replacements.reverse()) { + html = html.slice(0, start) + replacement + html.slice(end); + } + } + + return html; } -function renameFileToHash(file: string): string { +function hashFile(file: string): Promise { const absolutePath = path.join(DIR_DIST, file); - if (!fileHashMap[absolutePath]) { - const hash = createFileHash(absolutePath).slice(0, 6); + if (!fileHashCache.has(absolutePath)) { + fileHashCache.set(absolutePath, renameFileWithHash(file, absolutePath)); + } + return fileHashCache.get(absolutePath)!; +} + +async function renameFileWithHash( + file: string, + absoluteFilePath: string, +): Promise { + const hash = (await createFileHash(absoluteFilePath)).slice(0, 6); - // ! "-cache" in the name is important for the caching headers. It's how to - // know for sure which assets are safe to cache long term. - const newFileName = `${hash}-cache${path.extname(file)}`; + // ! "-cache" in the name is important for the caching headers. It's how to + // know for sure which assets are safe to cache long term. + const newFileName = `${hash}-cache${path.extname(file)}`; - const newFileRelativePath = path.join(path.dirname(file), newFileName); - fs.renameSync(absolutePath, path.join(DIR_DIST, newFileRelativePath)); - fileHashMap[absolutePath] = newFileRelativePath; - } + const newFileRelativePath = path.join(path.dirname(file), newFileName); + await fs.rename(absoluteFilePath, path.join(DIR_DIST, newFileRelativePath)); - return fileHashMap[absolutePath]; + return newFileRelativePath; } -function createFileHash(file: string): string { - const contents = fs.readFileSync(file, 'utf8'); +async function createFileHash(file: string): Promise { + const contents = await fs.readFile(file, 'utf8'); return crypto.createHash('md5').update(contents, 'utf8').digest('hex'); } diff --git a/src/build/posts/get-posts.ts b/src/build/posts/get-posts.ts index fcbc601..bf40850 100644 --- a/src/build/posts/get-posts.ts +++ b/src/build/posts/get-posts.ts @@ -1,5 +1,5 @@ import type { ParsedFrontMatter, Post } from '@types'; -import fs from 'node:fs'; +import { promises as fs } from 'node:fs'; import yamlToJs from 'js-yaml'; import { convert } from 'quote-quote'; import { findFilesByExtension } from '../../utils/node'; @@ -14,16 +14,20 @@ const frontMatterRegex = /^---([\s\S]*?)---/; /** * Return all Markdown files transformed to posts. */ -export function getPosts(): Post[] { - return findFilesByExtension('md', DIR_SRC_STATIC) - .map((file) => transformMarkdownFileToPost(file)) - .sort((a, b) => b.meta.date.getTime() - a.meta.date.getTime()); +export async function getPosts(): Promise { + const mdFiles = await findFilesByExtension('md', DIR_SRC_STATIC); + const result = await Promise.all( + mdFiles.map((file) => transformMarkdownFileToPost(file)), + ); + return result.sort((a, b) => b.meta.date.getTime() - a.meta.date.getTime()); } -function transformMarkdownFileToPost(file: string): Post { +async function transformMarkdownFileToPost(file: string): Promise { + const mdFileContent = await fs.readFile(file, 'utf-8'); + // Important to format contents before extracting front matter. The front // matter may need to be formatted as well (like the description). - const contents = convert(fs.readFileSync(file, 'utf-8'), { ellipsis: true }); + const contents = convert(mdFileContent, { ellipsis: true }); const frontMatter = parseFrontMatter(contents.match(frontMatterRegex)?.[1]); const contentWithoutFrontMatter = contents.replace(frontMatterRegex, ''); diff --git a/src/build/render-pages.ts b/src/build/render-pages.ts index 90dac27..75c61bb 100644 --- a/src/build/render-pages.ts +++ b/src/build/render-pages.ts @@ -11,7 +11,7 @@ export async function renderPages( posts: Post[], tags: string[], ): Promise { - const tsFiles = findFilesByExtension('ts', DIR_SRC_STATIC); + const tsFiles = await findFilesByExtension('ts', DIR_SRC_STATIC); const [content, _posts, _tags] = await Promise.all([ Promise.all(tsFiles.map((file) => renderContentPage(file, posts, tags))), Promise.all(posts.map((post) => renderPostPage(post, posts, tags))), @@ -53,7 +53,7 @@ async function renderPostPage( return page; } -async function renderTagPage( +function renderTagPage( tag: string, posts: Post[], tags: string[], @@ -63,16 +63,16 @@ async function renderTagPage( return renderPage(target, tagLayout, data); } -function renderPage( +async function renderPage( target: string, pageOrLayout: Page | Layout, data: PageData, -): PageData { +): Promise { const result = pageOrLayout(data); if (!result.content) { throw new Error('No content'); } - createFile(target, result.content); + await createFile(target, result.content); return result; } diff --git a/src/build/social-image/create-social-image.ts b/src/build/social-image/create-social-image.ts index 6281e7c..72a7df9 100644 --- a/src/build/social-image/create-social-image.ts +++ b/src/build/social-image/create-social-image.ts @@ -5,7 +5,7 @@ import { loadImage, registerFont, } from 'canvas'; -import fs from 'node:fs'; +import { promises as fs } from 'node:fs'; import path from 'node:path'; import url from 'node:url'; import { SOCIAL_FILE_NAME, SOCIAL_MIME_TYPE } from '../../constants'; @@ -57,7 +57,7 @@ export async function createSocialImage(post: Post, postFile: string) { // Save the social image. const buffer = canvas.toBuffer(SOCIAL_MIME_TYPE); const filePath = path.join(path.dirname(postFile), SOCIAL_FILE_NAME); - fs.writeFileSync(filePath, buffer); + await fs.writeFile(filePath, buffer); } /** diff --git a/src/utils/node.ts b/src/utils/node.ts index e2818d0..aee491a 100644 --- a/src/utils/node.ts +++ b/src/utils/node.ts @@ -1,68 +1,87 @@ -import fs from 'node:fs'; +import { promises as fs } from 'node:fs'; import path from 'node:path'; +/** + * Unfortunately, Node.js does not provide a `existsSync()`-like function in the + * `fs.promises` API, so we have to implement our own. + * https://github.com/nodejs/node/issues/39960 + */ +async function exists(file: string): Promise { + return fs.access(file).then( + () => true, + () => false, + ); +} + /** * Ensure a directory exists (by creating it if necessary). */ -export function ensureDir(dir: string): void { +async function ensureDir(dir: string): Promise { const dirName = path.dirname(dir); - fs.mkdirSync(dirName, { recursive: true }); + await fs.mkdir(dirName, { recursive: true }); } /** * Create a new file. */ -export function createFile(file: string, content: string): void { - ensureDir(file); - fs.writeFileSync(file, content); +export async function createFile(file: string, content: string): Promise { + await ensureDir(file); + await fs.writeFile(file, content); } /** * Copy the source directory to the target directory. */ -export function copySourceToTarget(source: string, target: string): void { - if (!fs.existsSync(target)) { - fs.mkdirSync(target); +export async function copySourceToTarget( + source: string, + target: string, +): Promise { + if (!(await exists(target))) { + await fs.mkdir(target); } - const entries = fs.readdirSync(source, { withFileTypes: true }); + const entries = await fs.readdir(source, { withFileTypes: true }); - for (const entry of entries) { - const sourcePath = path.join(source, entry.name); - const targetPath = path.join(target, entry.name); + await Promise.all( + entries.map(async (entry) => { + const sourcePath = path.join(source, entry.name); + const targetPath = path.join(target, entry.name); - if (entry.isDirectory()) { - copySourceToTarget(sourcePath, targetPath); - } else if (!['.md', '.ts'].includes(path.extname(sourcePath))) { - fs.copyFileSync(sourcePath, targetPath); - } - } + if (entry.isDirectory()) { + await copySourceToTarget(sourcePath, targetPath); + } else if (!['.md', '.ts'].includes(path.extname(sourcePath))) { + await fs.copyFile(sourcePath, targetPath); + } + }), + ); } /** * Deep find all files in a directory by file extension. */ -export function findFilesByExtension( +export async function findFilesByExtension( extension: string, directory: string, -): string[] { +): Promise { const filesFound: string[] = []; - function findFilesRecursively(dir: string): void { - const entries = fs.readdirSync(dir, { withFileTypes: true }); + async function findFilesRecursively(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); + await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - findFilesRecursively(entryPath); - } else if (path.extname(entry.name) === `.${extension}`) { - filesFound.push(entryPath); - } - } + if (entry.isDirectory()) { + await findFilesRecursively(entryPath); + } else if (path.extname(entry.name) === `.${extension}`) { + filesFound.push(entryPath); + } + }), + ); } - findFilesRecursively(directory); + await findFilesRecursively(directory); return filesFound; }