diff --git a/bin/build.ts b/bin/build.ts new file mode 100644 index 0000000..9675ff0 --- /dev/null +++ b/bin/build.ts @@ -0,0 +1,3 @@ +import { articles } from "../lib/tasks/articles.js"; + +articles().then(() => console.log("Done.")); diff --git a/lib/rx-utils.ts b/lib/rx-utils.ts new file mode 100644 index 0000000..c75c634 --- /dev/null +++ b/lib/rx-utils.ts @@ -0,0 +1,42 @@ +import { Observable, from, map, mergeAll } from "rxjs"; +import File from "vinyl"; +import { Glob } from "glob"; +import { readFile } 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) })), + map(from), + mergeAll(), + ); +} + +export function synchronise() { + return (observable: Observable>): Observable => + new Observable((subscriber) => { + const promiseArray = []; + let done = false; + + observable.subscribe({ + next(value) { + const promise = value.then((v) => { + const i = promiseArray.findIndex((i) => i == promise); + promiseArray.splice(i, 1); + + subscriber.next(v); + if (promiseArray.length === 0 && done) { + subscriber.complete(); + } + }); + promiseArray.push(promise); + }, + complete() { + done = true; + if (promiseArray.length === 0) { + console.log("[synchronise] complete (from complete)"); + subscriber.complete(); + } + }, + }); + }); +} diff --git a/lib/tasks/articles.ts b/lib/tasks/articles.ts index dae602c..d224974 100644 --- a/lib/tasks/articles.ts +++ b/lib/tasks/articles.ts @@ -1,7 +1,6 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join, basename, dirname } from "node:path"; -import { Transform } from "node:stream"; -import asciidoctor, { Document } from "asciidoctor"; +import asciidoctor from "asciidoctor"; import File from "vinyl"; import { DateTime } from "luxon"; import { unified } from "unified"; @@ -12,7 +11,6 @@ import rehypePrism from "@mapbox/rehype-prism"; import { PRODUCTION, DEFAULT_DATE } from "../environment.js"; import rehypeExternalLinks from "rehype-external-links"; import visit from "unist-util-visit"; -import { src, dest } from "gulp"; import renderLayout from "../views/layout.js"; import renderArticleLayout from "../views/article.js"; import renderIndex from "../views/index.js"; @@ -20,6 +18,8 @@ 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 { map, toArray, lastValueFrom } from "rxjs"; +import { src, synchronise } from "../rx-utils.js"; const Asciidoctor = asciidoctor(); const EXTENSION_REGISTRY = Asciidoctor.Extensions.create(); @@ -33,7 +33,7 @@ EXTENSION_REGISTRY.inlineMacro("abbr", function () { }); }); -function extractImages(gulp: Transform, file: File) { +function extractImages(array: File[], articlePath: string) { return function () { return function (tree) { visit(tree, "element", function (node: any) { @@ -41,13 +41,13 @@ function extractImages(gulp: Transform, file: File) { return; } - const path = join(dirname(file.path)); + const imagePath = join(dirname(articlePath)); const image = new File({ - path: join(basename(file.path, ".asciidoc"), node.properties.src), - contents: readFileSync(path), + path: join(basename(articlePath, ".asciidoc"), node.properties.src), + contents: readFileSync(imagePath), }); - gulp.push(image); + array.push(image); }); }; }; @@ -57,91 +57,91 @@ function renderDocument(root: JSX.Element): Buffer { return Buffer.concat([Buffer.from(""), Buffer.from(renderToStaticMarkup(root))]); } -function renderArticle() { - const allArticles: [string, DateTime, Document][] = []; - reloadAssets(); +const output = (prefix: string) => (file: File) => { + const realPath = join(prefix, file.path); + if (!existsSync(dirname(realPath))) { + mkdirSync(dirname(realPath), { recursive: true }); + } - return new Transform({ - objectMode: true, - async transform(file: File, _, callback) { - try { + console.log("[-] Outputting", join(prefix, file.path)); + writeFileSync(realPath, file.contents as Buffer); +}; + +export async function articles(): Promise { + reloadAssets(); + const _output = output("dist"); + const images: File[] = []; + + const articles = await lastValueFrom( + src("articles/**/*.asciidoc").pipe( + map(async (file) => { const slug = basename(file.path, ".asciidoc"); - const article = Asciidoctor.load(file.contents.toString(), { + 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 }; - const date = DateTime.fromISO(article.getAttribute("docdate"), { zone: "UTC" }); if (PRODUCTION && date.equals(DEFAULT_DATE)) { - callback(null); - return; + return null; } - allArticles.push([slug, date, article]); const vfile = await unified() .use(rehypeParse) .use(rehypeExternalLinks, { rel: "noopener", target: "_blank" }) - .use(extractImages(this, file)) + .use(extractImages(images, file.path)) .use(rehypePrism) .use(rehypePresetMinify) .use(rehypeStringify) - .process(article.convert()); + .process(document.convert()); const content = renderLayout({ - title: article.getDoctitle({}) as string, + title: document.getDoctitle({}) as string, meta: { - description: article.getAttribute("description"), - keywords: article.getAttribute("keywords"), - "og:title": article.getDoctitle({}) as string, + description: document.getAttribute("description"), + keywords: document.getAttribute("keywords"), + "og:title": document.getDoctitle({}) as string, "og:type": "article", - "og:article:published_time": article.getAttribute("docdate"), + "og:article:published_time": document.getAttribute("docdate"), "og:url": `https://adaedra.eu/${slug}/`, - "og:description": article.getAttribute("description"), + "og:description": document.getAttribute("description"), "og:site_name": SITE_TITLE, }, Content: () => renderArticleLayout({ article, - date, body: vfile.toString(), }), }); - file.contents = renderDocument(content); file.path = join(slug, "index.html"); - file.base = null; + file.contents = renderDocument(content); - callback(null, file); - } catch (e) { - console.error(e); - callback(e); - } - }, - final(callback) { - try { - allArticles.sort(([, a], [, b]) => b.diff(a).toMillis()); - const contents = renderLayout({ - meta: { - description: SITE_DESCRIPTION, - "og:title": SITE_TITLE, - "og:type": "website", - "og:url": `https://adaedra.eu`, - }, - Content: () => renderIndex({ articles: allArticles }), - }); + _output(file); - this.push( - new File({ - path: "index.html", - contents: renderDocument(contents), - }), - ); - callback(null); - } catch (e) { - console.error(e); - callback(e); - } + return article; + }), + synchronise(), + toArray(), + ), + ); + + images.forEach(_output); + + articles.sort(({ date: a }, { date: b }) => b.diff(a).toMillis()); + const contents = renderLayout({ + meta: { + description: SITE_DESCRIPTION, + "og:title": SITE_TITLE, + "og:type": "website", + "og:url": `https://adaedra.eu`, }, + Content: () => renderIndex({ articles }), }); -} -export const articles = () => - src("articles/**/*.asciidoc").pipe(renderArticle()).pipe(dest("dist/")); + _output( + new File({ + path: "index.html", + contents: renderDocument(contents), + }), + ); +} diff --git a/lib/views/article.tsx b/lib/views/article.tsx index f2e76bf..844818b 100644 --- a/lib/views/article.tsx +++ b/lib/views/article.tsx @@ -3,12 +3,13 @@ import { DateTime } from "luxon"; import { asset } from "../assets.js"; type Props = { - article: Document; - date: DateTime; + article: Article; body: string; }; -export default ({ article, date, body }: Props) => ( +export type Article = { path: string; slug: string; date: DateTime; document: Document }; + +export default ({ article: { document, date }, body }: Props) => ( <>