From b05ef7eacdfcb203a1641837a08db84812adb6a8 Mon Sep 17 00:00:00 2001 From: Ole Eskild Steensen <6201338+oleeskild@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:14:00 +0200 Subject: [PATCH] Fix three build crashes: non-string titles, footnotes in headings, raster favicons - index.11tydata.js: coerce note titles to strings before sorting/comparing, so a numeric or date title no longer crashes the front page. - utils.js: render heading inline tokens with state.env so markdown-it-footnote finds env.docId when a heading contains a footnote. - normalize-favicon.js: detect PNG/JPEG/GIF/WebP saved as favicon.svg and wrap them in an SVG with a data URI instead of failing the build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017iXNomAvG3sXxCWaNSgNqZ --- src/helpers/__tests__/buildRobustness.test.js | 73 +++++++++++++++++++ src/helpers/utils.js | 4 +- src/site/index.11tydata.js | 4 +- src/site/normalize-favicon.js | 38 +++++++++- 4 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 src/helpers/__tests__/buildRobustness.test.js diff --git a/src/helpers/__tests__/buildRobustness.test.js b/src/helpers/__tests__/buildRobustness.test.js new file mode 100644 index 0000000000..14391722cb --- /dev/null +++ b/src/helpers/__tests__/buildRobustness.test.js @@ -0,0 +1,73 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { createRequire } from "module"; +import { describe, it, expect, afterEach } from "vitest"; + +const require = createRequire(import.meta.url); +const markdownIt = require("markdown-it"); +const markdownItFootnote = require("markdown-it-footnote"); +const sharp = require("sharp"); +const { namedHeadingsFilter } = require("../utils"); +const normalizeFavicon = require("../../site/normalize-favicon"); +const frontPage = require("../../site/index.11tydata"); + +describe("fallback front page", () => { + it("sorts notes whose frontmatter title isn't a string", () => { + const note = (title, fileSlug) => ({ + url: `/${fileSlug}/`, + fileSlug, + data: { "dg-publish": true, title }, + }); + const notes = frontPage.eleventyComputed.publishedNotes({ + collections: { note: [note("Zebra", "z"), note(2024, "y"), note(undefined, "apple")] }, + }); + expect(notes.map((n) => n.title)).toEqual(["2024", "apple", "Zebra"]); + }); +}); + +describe("named headings", () => { + it("renders a heading that contains a footnote reference", () => { + const md = markdownIt().use(markdownItFootnote).use(namedHeadingsFilter); + const html = md.render("# Character Sheet[^1]\n\nText\n\n[^1]: A footnote\n"); + expect(html).toMatch(/

Character Sheet/); + }); +}); + +describe("favicon normalization", () => { + let dir; + afterEach(() => dir && fs.rmSync(dir, { recursive: true, force: true })); + + const tmp = () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "favicon-")); + return dir; + }; + + it("wraps a PNG saved as favicon.svg so the favicon generator can read it", async () => { + const d = tmp(); + const png = await sharp({ + create: { width: 64, height: 64, channels: 4, background: "#c33" }, + }) + .png() + .toBuffer(); + fs.writeFileSync(path.join(d, "favicon.svg"), png); + + normalizeFavicon(path.join(d, "favicon.svg"), path.join(d, "out.svg")); + + const out = fs.readFileSync(path.join(d, "out.svg"), "utf8"); + expect(out).toMatch(/^]*>]*href="data:image\/png;base64,/); + const rendered = await sharp(Buffer.from(out)).resize(16, 16).raw().toBuffer({ resolveWithObject: true }); + expect(rendered.info.width).toBe(16); + expect(rendered.data[0]).toBeGreaterThan(150); // the red came through + }); + + it("leaves a small SVG favicon unchanged", () => { + const d = tmp(); + const svg = ''; + fs.writeFileSync(path.join(d, "favicon.svg"), svg); + + normalizeFavicon(path.join(d, "favicon.svg"), path.join(d, "out.svg")); + + expect(fs.readFileSync(path.join(d, "out.svg"), "utf8")).toBe(svg); + }); +}); diff --git a/src/helpers/utils.js b/src/helpers/utils.js index 8120f5b205..353fc152b6 100644 --- a/src/helpers/utils.js +++ b/src/helpers/utils.js @@ -32,7 +32,9 @@ function namedHeadings(md, state) { state.tokens.forEach(function(token, i) { if (token.type === 'heading_open') { - var text = md.renderer.render(state.tokens[i + 1].children, md.options) + // Pass env through: renderers like markdown-it-footnote read + // env.docId and crash on a footnote inside a heading without it + var text = md.renderer.render(state.tokens[i + 1].children, md.options, state.env) var id = headerToId(text); var uniqId = uncollide(ids, id) ids[uniqId] = true diff --git a/src/site/index.11tydata.js b/src/site/index.11tydata.js index d1c2583d79..954791a45a 100644 --- a/src/site/index.11tydata.js +++ b/src/site/index.11tydata.js @@ -21,8 +21,10 @@ const allSettings = [ ...pluginLoader.getNoteSettingKeys(), ]; +// Frontmatter titles aren't always strings (`title: 2024` parses as a +// number), and the listing sorts them with localeCompare function noteTitle(note) { - return (note.data && note.data.title) || note.fileSlug || note.url; + return String((note.data && note.data.title) || note.fileSlug || note.url); } module.exports = { diff --git a/src/site/normalize-favicon.js b/src/site/normalize-favicon.js index b6688c3291..5d2aff10c6 100644 --- a/src/site/normalize-favicon.js +++ b/src/site/normalize-favicon.js @@ -3,8 +3,44 @@ const path = require("path"); const MAX_DIM = 512; +const RASTER_SIGNATURES = [ + { mime: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47] }, + { mime: "image/jpeg", bytes: [0xff, 0xd8, 0xff] }, + { mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] }, + { mime: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46], extra: [8, [0x57, 0x45, 0x42, 0x50]] }, +]; + +function rasterMimeType(buffer) { + const startsWith = (offset, bytes) => bytes.every((b, i) => buffer[offset + i] === b); + const match = RASTER_SIGNATURES.find( + (sig) => startsWith(0, sig.bytes) && (!sig.extra || startsWith(sig.extra[0], sig.extra[1])) + ); + return match ? match.mime : null; +} + +// A PNG/JPEG/GIF/WebP uploaded as favicon.svg can't be read by the favicon +// generator, which failed the whole build. Embed it in an SVG instead. +function wrapRaster(buffer, mime) { + const href = `data:${mime};base64,${buffer.toString("base64")}`; + return ( + `` + + `` + ); +} + function normalizeFavicon(inputPath, outputPath) { - const content = fs.readFileSync(inputPath, "utf8"); + const buffer = fs.readFileSync(inputPath); + + const mime = rasterMimeType(buffer); + if (mime) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, wrapRaster(buffer, mime)); + return; + } + + const content = buffer.toString("utf8"); const svgTagMatch = content.match(/]*>/i); if (!svgTagMatch) {