From 2d3dadd04d146968b51bdcff245b54a49a08a03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20M=C3=A9tral?= Date: Sun, 11 Aug 2024 20:13:03 +0800 Subject: [PATCH] Pass collection context to mustache (#34) * Build context object from all pages The context is not fed to mustache yet, this will be done in a follow-up commit. This commit also includes cleanup around how paths are determined. There is one minor breaking change: the third attribute that was passed to a custom buildScript, the current page's slug, used to omit the trailing slash. It now includes it, for consistency. * Pass context to mustache BREAKING CHANGE: the mustache view changed from frontmatter `{ [key]: value }` to `{ page: { frontmatter: { [key]: value } }, context: { pages: Page[] } }`. Where a template was referencing the frontmatter `title` via `{{title}}`, it now needs to be referenced as `{{page.frontmatter.title}}`. * Add a config mechanism to preprocess context before it's handed over to the templating engine * Replace custom build script by regular mustache in example site * Add collections Instead of containing all page metadata, context only contains pages that are part of collections. Collections are defined in config and must point to a directory that is a direct descendant of the pagesDir, e.g. `/pages/posts` :point_right: `posts`. Collections are based on the path (e.g. pages in `/pages/posts`). To make sure to only include collection pages and not collection indices (e.g. `/pages/posts/index.html`), we check if the page has a `published_date`. The hardcoding of the frontmatter date key is intentional: in a follow-up commit, we can automatically make the published_date a JavaScript Date, to avoid having to do this manually in a processContext script. Inspired by Cobalt. * Add third post to website This is useful for testing sorting by date * Sort collections by date This required another iteration through pages. Any better suggestion to solve this faster is welcome. * Bump version * Update tests to match www changes --- brut/package.json | 2 +- brut/src/buildPages.js | 189 ++++++++++++++++++++------- brut/src/index.js | 10 +- package-lock.json | 4 +- www/brut.config.js | 14 ++ www/package.json | 1 - www/pages/404.md | 2 +- www/pages/posts.html | 14 ++ www/pages/posts.md | 8 -- www/pages/posts/production-ready.md | 11 ++ www/pages/posts/second-post.md | 2 +- www/pages/posts/unpopular-opinion.md | 2 +- www/partials/nav.html | 2 +- www/scripts/buildPostsPage.js | 73 ----------- www/templates/default.html | 4 +- www/tests/e2e.spec.js | 4 +- 16 files changed, 199 insertions(+), 143 deletions(-) create mode 100644 www/pages/posts.html delete mode 100644 www/pages/posts.md create mode 100644 www/pages/posts/production-ready.md delete mode 100644 www/scripts/buildPostsPage.js diff --git a/brut/package.json b/brut/package.json index 239b0ae..556475d 100644 --- a/brut/package.json +++ b/brut/package.json @@ -1,6 +1,6 @@ { "name": "brut", - "version": "0.0.33", + "version": "0.0.34", "private": false, "license": "MIT", "repository": { diff --git a/brut/src/buildPages.js b/brut/src/buildPages.js index 8dd0828..4644cd1 100644 --- a/brut/src/buildPages.js +++ b/brut/src/buildPages.js @@ -12,7 +12,7 @@ import rehypeStringify from "rehype-stringify"; import mustache from "mustache"; import { minify as minifier } from "html-minifier-terser"; -const { writeFile, readFile, readdir, ensureDir } = fs; +const { writeFile, readFile, readdir, mkdir } = fs; /** @typedef {{[x: string]: string}} Frontmatter */ @@ -93,28 +93,33 @@ async function minify(html) { /** * Get the page's build script and run it on the html. - * @param {string} content html content - * @param {Frontmatter} frontmatter - * @param {string} path page slug (for `/posts/first.html`, this would be `/posts/first/`) - * @param {{[x: string]: string}} templates key-value representation of all templates - * @param {{[x: string]: string}} partials key-value representation of all partials + * @param {{ + * page: Page; + * context: {pages: Page[]} + * templates: {[x: string]: string} + * partials: {[x: string]: string} + * }} arguments * @returns {Promise} */ -async function buildPage(content, frontmatter, path, templates, partials) { +async function buildPage({ page, context, templates, partials }) { + let { frontmatter, content, slug } = page; + // TODO: make this easier to follow by avoiding mutating `content` // 1. inject into the template + // TODO: pass context to mustache const hasTemplate = !!frontmatter.template; if (hasTemplate) { content = mustache.render( templates[frontmatter.template], - frontmatter, // variables a.k.a. view + { page, context }, // variables a.k.a. view { content, ...partials } // partials ); } // 2. run build script + // TODO: move away from these? Or give them context to work with (e.g. to avoid having to parse frontmatter from the filesystem) const hasScript = !!frontmatter.buildScript; if (hasScript) { const script = await import(cwd() + frontmatter.buildScript); - content = await script.buildPage(content, frontmatter, path); + content = await script.buildPage(content, frontmatter, slug); } // 3. minify and return return minify(content); @@ -196,60 +201,148 @@ async function loadPartials(partialsDir) { return partials; } +/** + * Returns a slug from a given file path + * @param {string} path The full filesystem file path + * @param {string} pagesDir The config pages directory, to strip from slugs + * @returns {string} + */ +function getSlug(path, pagesDir) { + // 1. initial path + // index: /home/user/Developer/website/pages/index.html + // post: /home/user/Developer/website/pages/posts/my-post.md + let slug = path; + // 2. strip full path + // index: /index.html + // post: /posts/my-post.md + slug = path.replace(pagesDir, ""); + const isIndexFile = path.endsWith("/index.html") || path.endsWith("index.md"); + // 3. strip extension + if (isIndexFile) { + // index: / + // post: (n/a) + slug = slug.replace("/index.html", "/").replace("/index.md", "/"); + } else { + // index: (n/a) + // post: /posts/my-post/ + slug = slug.replace(".md", "/").replace(".html", "/"); + } + return slug; +} + +/** @typedef {{ path: string; slug: string; frontmatter: Frontmatter; content: string; }} Page */ +/** + * Loads pages from the filesystem and returns their content and metadata + * @param {string} pagesDir + * @returns {Promise} +} + */ +async function loadPages(pagesDir) { + const paths = await getFiles(pagesDir); + const pages = /** @type {Page[]} */ ([]); + await Promise.all( + paths.map(async (path) => { + // only process markdown or html pages + if (path.endsWith(".md") || path.endsWith(".html")) { + const file = await readFile(path, "utf-8"); + const { frontmatter, content } = extractFrontmatter(file); + pages.push({ + path, + slug: getSlug(path, pagesDir), + frontmatter, + content, + }); + } + }) + ); + console.log(`Building ${pages.length} pages.`); + return pages; +} + +/** + * Build context from a raw array of pages + * @param {Page[]} pages + * @param {string[]} collections + * @param {string} pagesDir + * @return {{[x: string]: Page[]}} context + */ +function buildContext(pages, collections, pagesDir) { + // 1. sort pages by descending published_date by default (newest posts first in the array) + //  if a page is missing a published_date, it is filtered out (pages in collections must have a published_date) + const sortedPages = pages + .filter((page) => !!page.frontmatter.published_date) + .sort( + (a, b) => + new Date(b.frontmatter.published_date).getTime() - + new Date(a.frontmatter.published_date).getTime() + ); + // 2. reduce pages into collection arrays under a context object + const context = sortedPages.reduce((acc, cur) => { + // attempt to get the collection from the page path + // note: a collection can't be a descendant of another collection, e.g. `posts` and `posts/recipes` (open an issue if you'd like this implemented!) + const collection = collections.find((collection) => + cur.path.includes(`${pagesDir}/${collection}`) ? collection : null + ); + if (collection) { + if (!acc[collection]) { + acc[collection] = []; + } + acc[collection].push(cur); + return acc; + } + // page is not in a collection, we omit it from the acc + return acc; + }, {}); + return context; +} + /** * @param {Config} config * @returns {Promise} */ export default async function buildPages({ - outDir: outDirRoot, + outDir, pagesDir, templatesDir, partialsDir, + collections, + processContext, }) { try { - const paths = await getFiles(pagesDir); - const [templates, partials] = await Promise.all([ + const [pages, templates, partials] = await Promise.all([ + loadPages(pagesDir), loadTemplates(templatesDir), loadPartials(partialsDir), ]); + const context = buildContext(pages, collections, pagesDir); + const processedContext = processContext(context); await Promise.all( - paths.map(async (path) => { - // only process markdown or html pages - if (path.endsWith(".md") || path.endsWith(".html")) { - // 1. extract frontmatter and content from file - const file = await readFile(path, "utf-8"); - const { frontmatter, content } = extractFrontmatter(file); - // 2. parse markdown into HTML - let html = content; - if (path.endsWith(".md")) { - html = await processMarkdown(content); - } - // 3. pretty urls - const isIndexFile = - path.endsWith("/index.html") || path.endsWith("index.md"); - let outDir = `${outDirRoot}${path.replace(pagesDir, "")}`; - if (isIndexFile) { - outDir = outDir.replace("/index.html", "").replace("/index.md", ""); - } else { - outDir = outDir.replace(".md", "").replace(".html", ""); - } - // 4. build page and write to fs - const relPath = outDir.replace(outDirRoot, ""); - const result = await buildPage( - html, - frontmatter, - relPath, - templates, - partials - ); - // TEMP: handle 404 pages for Cloudflare Pages - if (outDir === `${outDirRoot}/404`) { - await writeFile(`${outDir}.html`, result); - } else { - await ensureDir(outDir); - await writeFile(`${outDir}/index.html`, result); - } + pages.map(async (page) => { + const { path, content } = page; + // 1. parse markdown into HTML + let html = content; + if (path.endsWith(".md")) { + html = await processMarkdown(content); + } + // 2. feed page, context, templates and partials to mustache + const result = await buildPage({ + page: { + ...page, + content: html, // overwrite previous markdown content with built html + }, + context: processedContext, + templates, + partials, + }); + // 3. write to fs + // TEMP: fix 404 pages for Cloudflare Pages, see https://github.com/robinmetral/brut/issues/20 + if (page.slug === `/404/`) { + await writeFile(`${outDir}/404.html`, result); + } else { + const parentDir = `${outDir}${page.slug}`; + await mkdir(parentDir, { recursive: true }); + await writeFile(`${parentDir}/index.html`, result); } }) ); diff --git a/brut/src/index.js b/brut/src/index.js index fd7a04f..a2c1427 100644 --- a/brut/src/index.js +++ b/brut/src/index.js @@ -12,6 +12,8 @@ const { emptyDir } = fs; * @property {string} [partialsDir] The top-level directory containing partials. Defaults to `/partials`. * @property {string} [publicDir] The top-level directory containing static assets to copy to the `outDir`. Defaults to `/public`. * @property {string} [outDir] The top-level directory for the build output. Defaults to `/dist`. + * @property {string[]} [collections] Array of collections. Collection directories must be direct children of the `pagesDir`. Example: `["posts"]`. + * @property {function} [processContext] A function that receives a context object for processing before it's handed over to mustache */ /** @typedef {Required} Config */ @@ -20,7 +22,7 @@ const { emptyDir } = fs; */ async function getConfig() { const { default: configObject } = await import(`${cwd()}/brut.config.js`); - const config = { + const config = /** @type {Config} */ ({ pagesDir: configObject.pagesDir ? `${cwd()}${configObject.pagesDir}` : `${cwd()}/pages`, @@ -36,7 +38,11 @@ async function getConfig() { outDir: configObject.outDir ? `${cwd()}${configObject.outDir}` : `${cwd()}/dist`, - }; + processContext: configObject.processContext + ? configObject.processContext + : (context) => context, + collections: configObject.collections || [], + }); return config; } diff --git a/package-lock.json b/package-lock.json index 6a3d9f3..8b5f55e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ } }, "brut": { - "version": "0.0.31", + "version": "0.0.33", "license": "MIT", "dependencies": { "fs-extra": "^10.0.0", @@ -2970,7 +2970,6 @@ "devDependencies": { "@playwright/test": "^1.46.0", "brut": "*", - "js-yaml": "^4.1.0", "serve": "^14.2.3", "start-server-and-test": "^2.0.5" } @@ -3286,7 +3285,6 @@ "requires": { "@playwright/test": "^1.46.0", "brut": "*", - "js-yaml": "^4.1.0", "serve": "^14.2.3", "start-server-and-test": "^2.0.5" } diff --git a/www/brut.config.js b/www/brut.config.js index e7f2131..eeb31ae 100644 --- a/www/brut.config.js +++ b/www/brut.config.js @@ -1,3 +1,17 @@ export default { pagesDir: "/pages", + collections: ["posts"], + processContext: (context) => { + // make dates human-readable + // this could be any other kind of data preprocessing + // it can also be done in a separate file + const posts = context.posts.map((post) => { + post.frontmatter.published_date = new Date( + post.frontmatter.published_date + ).toLocaleDateString(); + return post; + }); + context.posts = posts; + return context; + }, }; diff --git a/www/package.json b/www/package.json index 96f25b6..0d17d7b 100644 --- a/www/package.json +++ b/www/package.json @@ -10,7 +10,6 @@ "devDependencies": { "@playwright/test": "^1.46.0", "brut": "*", - "js-yaml": "^4.1.0", "serve": "^14.2.3", "start-server-and-test": "^2.0.5" } diff --git a/www/pages/404.md b/www/pages/404.md index 0734c4b..24f0d57 100644 --- a/www/pages/404.md +++ b/www/pages/404.md @@ -2,4 +2,4 @@ template: default --- -## Not found +# Not found diff --git a/www/pages/posts.html b/www/pages/posts.html new file mode 100644 index 0000000..30d9926 --- /dev/null +++ b/www/pages/posts.html @@ -0,0 +1,14 @@ + + +

Posts

+{{#context.posts.length}} + +{{/context.posts.length}} diff --git a/www/pages/posts.md b/www/pages/posts.md deleted file mode 100644 index 928eda6..0000000 --- a/www/pages/posts.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -template: default -buildScript: /scripts/buildPostsPage.js ---- - -# Blog - -- --POSTS-- diff --git a/www/pages/posts/production-ready.md b/www/pages/posts/production-ready.md new file mode 100644 index 0000000..78b8b5f --- /dev/null +++ b/www/pages/posts/production-ready.md @@ -0,0 +1,11 @@ +--- +template: default +title: "Production-ready" +published_date: "2024-08-11" +--- + +Looking for a production-ready static site generator? + +Use Eleventy. + +(And watch brut for the stable release.) diff --git a/www/pages/posts/second-post.md b/www/pages/posts/second-post.md index 0b63798..fff35e3 100644 --- a/www/pages/posts/second-post.md +++ b/www/pages/posts/second-post.md @@ -1,7 +1,7 @@ --- template: default title: "Second Post" -date: "2022-02-12" +published_date: "2022-02-12" --- # Second Post diff --git a/www/pages/posts/unpopular-opinion.md b/www/pages/posts/unpopular-opinion.md index a4ac91e..044cbd2 100644 --- a/www/pages/posts/unpopular-opinion.md +++ b/www/pages/posts/unpopular-opinion.md @@ -1,7 +1,7 @@ --- template: default title: "Unpopular Opinion" -date: "2021-11-12" +published_date: "2021-11-12" --- # Unpopular Opinion diff --git a/www/partials/nav.html b/www/partials/nav.html index 3e756c5..0aa5085 100644 --- a/www/partials/nav.html +++ b/www/partials/nav.html @@ -2,7 +2,7 @@ diff --git a/www/scripts/buildPostsPage.js b/www/scripts/buildPostsPage.js deleted file mode 100644 index 55340a1..0000000 --- a/www/scripts/buildPostsPage.js +++ /dev/null @@ -1,73 +0,0 @@ -import { cwd } from "process"; -import { readdir, readFile } from "fs/promises"; -import { load } from "js-yaml"; - -const POSTS_DIR = `${cwd()}/pages/posts`; - -/** - * @typedef {Object.} Frontmatter - */ - -/** - * Extracts frontmatter from string using js-yaml - * - * @param {string} file - * @returns {Frontmatter} - */ -function getFrontmatter(file) { - const match = - /^---(?:\r?\n|\r)(?:([\s\S]*?)(?:\r?\n|\r))?---(?:\r?\n|\r|$)/.exec(file); - - if (match) { - const frontmatter = /** @type {Frontmatter} */ (load(match[1])); - return frontmatter; - } else { - return {}; - } -} - -/** - * @typedef {Object} Post - * @property {string} title - * @property {string} date - * @property {string} slug - */ - -/** - * @returns {Promise} - */ -async function getPosts() { - const files = await readdir(POSTS_DIR); - const posts /** @type {Post[]} */ = await Promise.all( - files.map(async (file) => { - const content = await readFile(`${POSTS_DIR}/${file}`, "utf-8"); - const frontmatter = getFrontmatter(content); - return { - title: frontmatter.title, - date: new Date(frontmatter.date).toLocaleDateString(), - slug: `/posts/${file.replace(".md", "")}`, - }; - }) - ); - return posts; -} - -/** - * - * @param {string} html - * @returns {Promise} - */ -export async function buildPage(html) { - try { - const posts = await getPosts(); - const postsHtml = posts - .map( - (post) => - `
  • ${post.date}: ${post.title}
  • ` - ) - .join(""); - return html.replace("
  • --POSTS--
  • ", postsHtml); - } catch (error) { - throw new Error(`Failed to build page: ${error}`); - } -} diff --git a/www/templates/default.html b/www/templates/default.html index 7e7d2d9..fa00c0a 100644 --- a/www/templates/default.html +++ b/www/templates/default.html @@ -1,7 +1,9 @@ - {{#title}}{{title}} | {{/title}}Brut + + {{#page.frontmatter.title}}{{.}} | {{/page.frontmatter.title}}Brut + diff --git a/www/tests/e2e.spec.js b/www/tests/e2e.spec.js index dbfc8e6..81cee08 100644 --- a/www/tests/e2e.spec.js +++ b/www/tests/e2e.spec.js @@ -10,8 +10,8 @@ test("should render", async ({ page }) => { }); test("should navigate to other pages", async ({ page }) => { - await page.click("text=Blog"); - await expect(page.locator("h1").first()).toHaveText("Blog"); + await page.click("text=Posts"); + await expect(page.locator("h1").first()).toHaveText("Posts"); await page.click("text=Unpopular Opinion"); await expect(page).toHaveTitle(/Unpopular Opinion/); });