Skip to content
Merged
Show file tree
Hide file tree
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
16 changes: 8 additions & 8 deletions src/build.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand All @@ -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();
4 changes: 2 additions & 2 deletions src/build/feeds/create-atom-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const fileHref = new URL(FILE_NAME, BUILD.baseUrl).href;

// It is important there is no whitespace before the XML tag.
Expand Down Expand Up @@ -39,5 +39,5 @@ export function createAtomFeed(posts: Post[]): void {
})}
</feed>`;

createFile(path.join(DIR_DIST, FILE_NAME), contents);
await createFile(path.join(DIR_DIST, FILE_NAME), contents);
}
4 changes: 2 additions & 2 deletions src/build/feeds/create-rss-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const fileHref = new URL(FILE_NAME, BUILD.baseUrl).href;
const buildDate = BUILD.date.toUTCString();

Expand Down Expand Up @@ -48,5 +48,5 @@ export function createRssFeed(posts: Post[]): void {
</rss>
`;

createFile(path.join(DIR_DIST, FILE_NAME), contents);
await createFile(path.join(DIR_DIST, FILE_NAME), contents);
}
4 changes: 2 additions & 2 deletions src/build/feeds/create-sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function pagesToSitemapEntries(pages: PageData[]): string {
);
}

export function createSitemap(pages: RenderedPages): void {
export async function createSitemap(pages: RenderedPages): Promise<void> {
const { content, posts, tags } = pages;

// It is important there is no whitespace before the XML tag.
Expand All @@ -31,5 +31,5 @@ export function createSitemap(pages: RenderedPages): void {
])}
</urlset>`.trim();

createFile(path.join(DIR_DIST, 'sitemap.xml'), contents);
await createFile(path.join(DIR_DIST, 'sitemap.xml'), contents);
}
107 changes: 70 additions & 37 deletions src/build/optimize.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
// 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<string, Promise<string>>();

const minifyHtmlOptions = {
collapseBooleanAttributes: true,
Expand All @@ -20,7 +21,7 @@ const minifyHtmlOptions = {
};

export async function optimize(): Promise<void> {
optimizeCssFiles();
await optimizeCssFiles();
await optimizeJsFiles();

// Do not run in parallel with the above because this function renames assets.
Expand All @@ -30,24 +31,28 @@ export async function optimize(): Promise<void> {
/**
* 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<void> {
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<void> {
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);
}),
);
}
Expand All @@ -56,12 +61,13 @@ async function optimizeJsFiles(): Promise<void> {
* Optimize HTML files by minifying them and hashing assets.
*/
async function optimizeHtmlFiles(): Promise<void> {
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);
}),
);
}
Expand All @@ -76,34 +82,61 @@ async function hashRelativeAssetUrlsInHtml(contents: string): Promise<string> {
/(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<string> {
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<string> {
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<string> {
const contents = await fs.readFile(file, 'utf8');
return crypto.createHash('md5').update(contents, 'utf8').digest('hex');
}
18 changes: 11 additions & 7 deletions src/build/posts/get-posts.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Post[]> {
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<Post> {
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, '');
Expand Down
10 changes: 5 additions & 5 deletions src/build/render-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function renderPages(
posts: Post[],
tags: string[],
): Promise<RenderedPages> {
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))),
Expand Down Expand Up @@ -53,7 +53,7 @@ async function renderPostPage(
return page;
}

async function renderTagPage(
function renderTagPage(
tag: string,
posts: Post[],
tags: string[],
Expand All @@ -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<PageData> {
const result = pageOrLayout(data);
if (!result.content) {
throw new Error('No content');
}
createFile(target, result.content);
await createFile(target, result.content);
return result;
}

Expand Down
4 changes: 2 additions & 2 deletions src/build/social-image/create-social-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading