diff --git a/bin/build.ts b/bin/build.ts index 9675ff0..88f7a28 100644 --- a/bin/build.ts +++ b/bin/build.ts @@ -1,3 +1,3 @@ import { articles } from "../lib/tasks/articles.js"; -articles().then(() => console.log("Done.")); +(async () => await articles())(); diff --git a/lib/rx-utils.ts b/lib/rx-utils.ts index d079244..e588364 100644 --- a/lib/rx-utils.ts +++ b/lib/rx-utils.ts @@ -1,22 +1,10 @@ -import { Observable, from, map } from "rxjs"; +import { Observable, Subscriber } from "rxjs"; import File from "vinyl"; -import { Glob } from "glob"; import { readFile } from "node:fs/promises"; import { join, dirname } from "node:path"; import { existsSync } from "node:fs"; import { writeFile, mkdir } from "node:fs/promises"; -export function src(glob: string | string[]): Observable> { - return from(new Glob(glob, {})).pipe( - map(async (path) => new File({ path, contents: await readFile(path) })), - ); -} - -export function then(f: (v: T) => Promise) { - return (observable: Observable>): Observable> => - observable.pipe(map((v) => v.then(f))); -} - export function dest(prefix: string) { return async (file: File) => { const actualPath = join(prefix, file.path); @@ -30,30 +18,23 @@ export function dest(prefix: string) { }; } -export function synchronise() { - return (observable: Observable>): Observable => - new Observable((subscriber) => { - const runningPromises = new Set>(); - let done = false; +export const loadFile = async (path: string): Promise => + new File({ path, contents: await readFile(path) }); +export function onComplete(f: (sink: Subscriber) => Promise) { + return (observable: Observable) => + new Observable((subscriber) => observable.subscribe({ next(value) { - const promise = value.then((v) => { - runningPromises.delete(promise); - - subscriber.next(v); - if (runningPromises.size === 0 && done) { - subscriber.complete(); - } - }); - runningPromises.add(promise); + subscriber.next(value); + }, + error(err) { + subscriber.error(err); }, complete() { - done = true; - if (runningPromises.size === 0) { - subscriber.complete(); - } + f(subscriber); + subscriber.complete(); }, - }); - }); + }), + ); } diff --git a/lib/tasks/articles.ts b/lib/tasks/articles.ts index 429d0bb..57b456e 100644 --- a/lib/tasks/articles.ts +++ b/lib/tasks/articles.ts @@ -1,4 +1,4 @@ -import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join, basename, dirname } from "node:path"; import asciidoctor from "asciidoctor"; import File from "vinyl"; @@ -18,8 +18,9 @@ import { renderToStaticMarkup } from "preact-render-to-string"; import { SITE_TITLE, SITE_DESCRIPTION } from "../constants.js"; import { JSX } from "preact/jsx-runtime"; import { reloadAssets } from "../assets.js"; -import { lastValueFrom } from "rxjs"; -import { src, synchronise, dest, then } from "../rx-utils.js"; +import { from, mergeMap, Observable, Subscriber } from "rxjs"; +import { dest, loadFile, onComplete } from "../rx-utils.js"; +import { Glob } from "glob"; const Asciidoctor = asciidoctor(); const EXTENSION_REGISTRY = Asciidoctor.Extensions.create(); @@ -33,7 +34,7 @@ EXTENSION_REGISTRY.inlineMacro("abbr", function () { }); }); -function extractImages(array: File[], articlePath: string) { +function extractImages(sink: (image: File) => void, articlePath: string) { return function () { return function (tree) { visit(tree, "element", function (node: any) { @@ -47,7 +48,7 @@ function extractImages(array: File[], articlePath: string) { contents: readFileSync(imagePath), }); - array.push(image); + sink(image); }); }; }; @@ -57,75 +58,50 @@ function renderDocument(root: JSX.Element): Buffer { return Buffer.concat([Buffer.from(""), Buffer.from(renderToStaticMarkup(root))]); } -const output = (prefix: string) => (file: File) => { - const realPath = join(prefix, file.path); - if (!existsSync(dirname(realPath))) { - mkdirSync(dirname(realPath), { recursive: true }); - } +const transformArticle = + (sink: (file: File) => void, articles: Article[]) => async (file: File) => { + const slug = basename(file.path, ".asciidoc"); + const document = Asciidoctor.load(file.contents.toString(), { + extension_registry: EXTENSION_REGISTRY, + }); + const date = DateTime.fromISO(document.getAttribute("docdate", { zone: "UTC" })); + const article = { path: file.path, slug, date, document }; - console.log("[-] Outputting", join(prefix, file.path)); - writeFileSync(realPath, file.contents as Buffer); -}; + if (PRODUCTION && date.equals(DEFAULT_DATE)) { + return; + } -export async function articles(): Promise { - reloadAssets(); - const _output = output("dist"); - const articles: Article[] = []; - const images: File[] = []; + const vfile = await unified() + .use(rehypeParse) + .use(rehypeExternalLinks, { rel: "noopener", target: "_blank" }) + .use(extractImages(sink, file.path)) + .use(rehypePrism) + .use(rehypePresetMinify) + .use(rehypeStringify) + .process(document.convert()); + const content = renderLayout({ + title: document.getDoctitle({}) as string, + meta: { + description: document.getAttribute("description"), + keywords: document.getAttribute("keywords"), + "og:title": document.getDoctitle({}) as string, + "og:type": "article", + "og:article:published_time": document.getAttribute("docdate"), + "og:url": `https://adaedra.eu/${slug}/`, + "og:description": document.getAttribute("description"), + "og:site_name": SITE_TITLE, + }, + Content: () => renderArticleLayout({ article, body: vfile.toString() }), + }); - await lastValueFrom( - src("articles/**/*.asciidoc").pipe( - then(async (file) => { - const slug = basename(file.path, ".asciidoc"); - const document = Asciidoctor.load(file.contents.toString(), { - extension_registry: EXTENSION_REGISTRY, - }); - const date = DateTime.fromISO(document.getAttribute("docdate", { zone: "UTC" })); - const article = { path: file.path, slug, date, document }; + file.path = join(slug, "index.html"); + file.contents = renderDocument(content); - if (PRODUCTION && date.equals(DEFAULT_DATE)) { - return null; - } - - const vfile = await unified() - .use(rehypeParse) - .use(rehypeExternalLinks, { rel: "noopener", target: "_blank" }) - .use(extractImages(images, file.path)) - .use(rehypePrism) - .use(rehypePresetMinify) - .use(rehypeStringify) - .process(document.convert()); - const content = renderLayout({ - title: document.getDoctitle({}) as string, - meta: { - description: document.getAttribute("description"), - keywords: document.getAttribute("keywords"), - "og:title": document.getDoctitle({}) as string, - "og:type": "article", - "og:article:published_time": document.getAttribute("docdate"), - "og:url": `https://adaedra.eu/${slug}/`, - "og:description": document.getAttribute("description"), - "og:site_name": SITE_TITLE, - }, - Content: () => - renderArticleLayout({ - article, - body: vfile.toString(), - }), - }); - - file.path = join(slug, "index.html"); - file.contents = renderDocument(content); - - articles.push(article); - return file; - }), - then(dest("dist")), - synchronise(), - ), - ); - images.forEach(_output); + articles.push(article); + sink(file); + }; +const finalizeArticles = (articles: Article[]) => async (sink: Subscriber) => { articles.sort(({ date: a }, { date: b }) => b.diff(a).toMillis()); const contents = renderLayout({ meta: { @@ -137,10 +113,26 @@ export async function articles(): Promise { Content: () => renderIndex({ articles }), }); - _output( - new File({ - path: "index.html", - contents: renderDocument(contents), - }), - ); -} + sink.next(new File({ path: "index.html", contents: renderDocument(contents) })); +}; + +export const articles = () => { + reloadAssets(); + const articles = []; + + return from(new Glob("articles/**/*.asciidoc", {})) + .pipe( + mergeMap(loadFile), + mergeMap( + (file) => + new Observable((subscriber) => { + transformArticle( + (f) => subscriber.next(f), + articles, + )(file).then(() => subscriber.complete()); + }), + ), + onComplete(finalizeArticles(articles)), + ) + .forEach(dest("dist")); +}; diff --git a/lib/tasks/css.ts b/lib/tasks/css.ts index aea7383..92fcbdb 100644 --- a/lib/tasks/css.ts +++ b/lib/tasks/css.ts @@ -1,13 +1,15 @@ import postcss, { Result } from "postcss"; import config from "../../postcss.config.js"; -import { src, then, synchronise, dest } from "../rx-utils.js"; -import { map, lastValueFrom } from "rxjs"; +import { dest, loadFile } from "../rx-utils.js"; +import { from, lastValueFrom, mergeMap } from "rxjs"; import hashPaths from "../hash.js"; +import { Glob } from "glob"; export const css = () => lastValueFrom( - src("src/index.css").pipe( - then( + from(new Glob("src/index.css", {})).pipe( + mergeMap(loadFile), + mergeMap( (file) => new Promise((resolve) => postcss(config.plugins) @@ -20,8 +22,7 @@ export const css = () => }), ), ), - synchronise(), hashPaths("css.manifest"), - map(dest("dist/_assets")), + mergeMap(dest("dist/_assets")), ), ); diff --git a/lib/tasks/fonts.ts b/lib/tasks/fonts.ts index d303206..6627ce7 100644 --- a/lib/tasks/fonts.ts +++ b/lib/tasks/fonts.ts @@ -3,8 +3,9 @@ import tmp from "tmp"; import { execFile } from "node:child_process"; import { readFileSync, unlinkSync } from "node:fs"; import hashPaths from "../hash.js"; -import { src, then, synchronise, dest } from "../rx-utils.js"; -import { map } from "rxjs"; +import { dest, loadFile } from "../rx-utils.js"; +import { from, mergeMap } from "rxjs"; +import { Glob } from "glob"; const FONT_PRESETS = { mono: { ranges: ["20-7F", "2205", "2E22-2E25", "2713", "2717"] }, @@ -34,9 +35,6 @@ function compileFont(font: File): Promise { } export const fonts = () => - src("vendor/*.ttf").pipe( - then(compileFont), - synchronise(), - hashPaths("fonts.manifest"), - map(dest("dist/_assets")), - ); + from(new Glob("vendor/*.ttf", {})) + .pipe(mergeMap(loadFile), mergeMap(compileFont), hashPaths("fonts.manifest")) + .forEach(dest("dist/_assets")); diff --git a/lib/tasks/images.ts b/lib/tasks/images.ts index ad24aa4..69b5dc7 100644 --- a/lib/tasks/images.ts +++ b/lib/tasks/images.ts @@ -1,14 +1,13 @@ +import { Glob } from "glob"; import hashPaths from "../hash.js"; -import { src, then, synchronise, dest } from "../rx-utils.js"; -import { map } from "rxjs"; +import { dest, loadFile } from "../rx-utils.js"; +import { from, mergeMap, tap } from "rxjs"; export const images = () => - src("src/*.avif").pipe( - then(async (file) => { - file.path = file.path.substring(4); - return file; - }), - synchronise(), - hashPaths("images.manifest"), - map(dest("dist/_assets")), - ); + from(new Glob("src/*.avif", {})) + .pipe( + mergeMap(loadFile), + tap((f) => (f.path = f.path.substring(4))), + hashPaths("images.manifest"), + ) + .forEach(dest("dist/_assets")); diff --git a/lib/tasks/svg.ts b/lib/tasks/svg.ts index bacb021..6bc0e22 100644 --- a/lib/tasks/svg.ts +++ b/lib/tasks/svg.ts @@ -1,4 +1,12 @@ -import { src, dest } from "gulp"; +import { Glob } from "glob"; +import { from, mergeMap, tap } from "rxjs"; +import { dest, loadFile } from "../rx-utils.js"; // 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 svg = () => + from(new Glob("src/*.svg", {})) + .pipe( + mergeMap(loadFile), + tap((f) => (f.path = f.path.substring(4))), + ) + .forEach(dest("dist"));