blog/gulpfile.js

224 lines
8.1 KiB
JavaScript

import { src, dest, parallel, watch as _watch } from "gulp";
import postcss from "gulp-postcss";
import { Transform } from "node:stream";
import pug from "pug";
import asciidoctor from "asciidoctor";
import { basename, dirname, join } from "node:path";
import Vinyl from "vinyl";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
import rehypePresetMinify from "rehype-preset-minify";
import rehypeExternalLinks from "rehype-external-links";
import rehypePrism from "@mapbox/rehype-prism";
import { DateTime } from "luxon";
import visit from "unist-util-visit";
import { readFileSync, unlinkSync } from "node:fs";
import { env } from "node:process";
import tmp from "tmp";
import { execFile } from "node:child_process";
const PRODUCTION = env.NODE_ENV === "production";
const DEFAULT_DATE = DateTime.fromSeconds(Number(env.SOURCE_DATE_EPOCH)).toUTC();
const FONT_PRESETS = {
mono: { ranges: ["20-7F", "2E22-2E25", "2713", "2717"] },
text: { ranges: ["20-7F", "A0-FF", "2000-206F", "20AC"] },
};
const SITE_TITLE = "Ad\xE6dra's blog";
const SITE_DESCRIPTION = [
"Ad\xE6dra",
"Software Developper in Paris, France",
"Rust, Ruby, Typescript, Linux",
].join(" \u2022 ");
const Asciidoctor = asciidoctor();
const EXTENSION_REGISTRY = Asciidoctor.Extensions.create();
EXTENSION_REGISTRY.inlineMacro("abbr", function () {
this.process((parent, target, attributes) => {
return this.createInline(
parent,
"quoted",
`<abbr title="${attributes["$positional"]}">${target}</abbr>`,
);
});
});
const extractImages = (gulp, file) => () => (tree) => {
visit(tree, "element", (node) => {
if (node.tagName !== "img") {
return;
}
const path = join(dirname(file.path), node.properties.src);
const image = new Vinyl({
path: join(basename(file.path, ".asciidoc"), node.properties.src),
contents: readFileSync(path),
});
gulp.push(image);
});
};
const asset = env.ASSETS_HOSTS
? (path) => new URL(path, env.ASSETS_HOSTS).toString()
: (path) => join("/_assets/", path);
const renderArticle = () => {
const allArticles = [];
const renderLayout = pug.compileFile("src/layout.pug");
const renderArticleLayout = pug.compileFile("src/article.pug");
return new Transform({
readableObjectMode: true,
writableObjectMode: true,
async transform(file, _, callback) {
try {
const slug = basename(file.path, ".asciidoc");
const article = Asciidoctor.load(file.contents, {
extension_registry: EXTENSION_REGISTRY,
});
const date = DateTime.fromISO(article.getAttribute("docdate"), {
zone: "UTC",
});
if (PRODUCTION && date.equals(DEFAULT_DATE)) {
callback(null);
return;
}
allArticles.push([slug, date, article]);
const vfile = await unified()
.use(rehypeParse)
.use(rehypeExternalLinks, { rel: "noopener", target: "_blank" })
.use(extractImages(this, file))
.use(rehypePrism)
.use(rehypePresetMinify)
.use(rehypeStringify)
.process(article.convert());
const content = renderLayout({
SITE_TITLE,
asset,
title: article.getDoctitle(),
meta: {
description: article.getAttribute("description"),
keywords: article.getAttribute("keywords"),
"og:title": article.getDoctitle(),
"og:type": "article",
"og:article:published_time": article.getAttribute("docdate"),
"og:url": `https://adaedra.eu/${slug}/`,
"og:image":
"https://adaedra.blob.core.windows.net/blog-assets/cariboudev.avif",
"og:image:alt": "Ad\xE6dra's mascot",
"og:description": article.getAttribute("description"),
"og:locale": "en_GB",
"og:site_name": SITE_TITLE,
},
render() {
return renderArticleLayout({
asset,
article,
date,
DateTime,
body: vfile.toString(),
});
},
});
file.contents = Buffer.from(content);
file.path = join(slug, "index.html");
file.base = null;
callback(null, file);
} catch (e) {
console.error(e);
callback(e);
}
},
final(callback) {
try {
allArticles.sort(([, a], [, b]) => b.diff(a).toMillis());
const renderIndex = pug.compileFile("src/index.pug");
const contents = renderLayout({
SITE_TITLE,
asset,
meta: {
description: SITE_DESCRIPTION,
"og:title": SITE_TITLE,
"og:type": "website",
"og:url": `https://adaedra.eu/`,
"og:image":
"https://adaedra.blob.core.windows.net/blog-assets/cariboudev.avif",
"og:image:alt": "Ad\xE6dra's mascot",
"og:description": SITE_DESCRIPTION,
"og:locale": "en_GB",
},
render() {
return renderIndex({ articles: allArticles, asset });
},
});
this.push(
new Vinyl({
path: "index.html",
contents: Buffer.from(contents),
}),
);
callback(null);
} catch (e) {
console.error(e);
callback(e);
}
},
});
};
export const articles = () =>
src("articles/**/*.asciidoc").pipe(renderArticle()).pipe(dest("dist/"));
export const images = () => src("src/*.avif", { encoding: false }).pipe(dest("dist/_assets/"));
// SVG use has no way of allowing cross-origin, so we need to keep them with the HTML files.
export const svg = () => src("src/*.svg").pipe(dest("dist/"));
export const css = () => src("src/index.css").pipe(postcss()).pipe(dest("dist/_assets/"));
const compileFont = () =>
new Transform({
readableObjectMode: true,
writableObjectMode: true,
transform(chunk, _, callback) {
const [, variant, weight] = /([A-Z][a-z]+)-(\w+)\.ttf$/.exec(chunk.basename);
const tmpOutput = tmp.fileSync({ discardDescriptor: true });
const unicodes = FONT_PRESETS[variant.toLowerCase()].ranges;
execFile("pyftsubset", [
chunk.path,
`--unicodes=${unicodes.join(",")}`,
`--output-file=${tmpOutput.name}`,
"--layout-features=*",
"--flavor=woff2",
]).once("exit", () => {
const file = new Vinyl({
path: `iosevka-adaedra-${variant.toLowerCase()}-${weight.toLowerCase()}.woff2`,
contents: readFileSync(tmpOutput.name),
});
unlinkSync(tmpOutput.name);
callback(null, file);
});
},
});
export const fonts = () => src("vendor/*.ttf").pipe(compileFont()).pipe(dest("dist/_assets/"));
export default parallel(fonts, articles, images, svg, css);
export const watch = () => {
_watch(["src/*.pug", "articles/**/*.asciidoc"], articles);
_watch("src/*.css", css);
_watch("src/*.avif", images);
_watch("src/*.svg", svg);
};