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_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", `${target}`, ); }); }); 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({ 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": "Ad\xE6dra", }, 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({ asset, meta: { description: SITE_DESCRIPTION, "og:title": "Ad\xE6dra", "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); };