Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions tests/sharp-image-fallback.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// tests/sharp-image-fallback.test.mjs — favicon & social-card without ImageMagick.
//
// On a box with no ImageMagick binary on PATH, make-favicon and make-social-card must still
// produce their full output set via sharp/libvips instead of dying with "ImageMagick not found":
// the favicon PNG set + a multi-resolution favicon.ico + apple-touch-icon, and a 1200×630 social
// card. The ImageMagick path is unchanged when a binary is present.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';

const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const FAVICON = path.join(REPO, 'tools', 'make-favicon.mjs');
const SOCIAL = path.join(REPO, 'tools', 'make-social-card.mjs');
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ICO_MAGIC = Buffer.from([0x00, 0x00, 0x01, 0x00]); // reserved=0, type=1 (icon)

async function makeBuildDir() {
const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sharpfb-'));
const assets = path.join(buildDir, 'assets');
fs.mkdirSync(assets, { recursive: true });
const heroPath = path.join(assets, 'hero.png');
await sharp({ create: { width: 1536, height: 1024, channels: 3, background: '#7c3aed' } })
.png().toFile(heroPath);
const ctx = {
repo: { name: 'demo-repo' },
understanding: { repoName: 'demo-repo' },
concept: { palette: { primary: '#7c3aed', accent: '#38bdf8', text: '#ffffff' }, tagline: 'A short, legible tagline for the social card' },
visuals: { hero: { file: heroPath } },
};
fs.writeFileSync(path.join(buildDir, 'build.json'), JSON.stringify(ctx, null, 2));
return { buildDir, assets };
}

// Force ImageMagick "absent" by running with an empty PATH so findBin() finds no magick/convert.
const NO_MAGICK_ENV = { ...process.env, PATH: '' };

test('favicon — sharp fallback produces the full icon set + a multi-resolution .ico', async () => {
const { buildDir, assets } = await makeBuildDir();
const r = spawnSync(process.execPath, [FAVICON, buildDir], { encoding: 'utf8', env: NO_MAGICK_ENV });
assert.equal(r.status, 0, `favicon fallback must succeed: ${r.stdout}\n${r.stderr}`);
assert.match(r.stderr, /sharp fallback renderer/, 'it must announce the fallback');
for (const n of [16, 32, 48, 192, 512]) {
const p = path.join(assets, `favicon-${n}.png`);
assert.ok(fs.existsSync(p), `favicon-${n}.png must exist`);
assert.ok(fs.readFileSync(p).subarray(0, 8).equals(PNG_MAGIC), `favicon-${n}.png must be a real PNG`);
}
const apple = path.join(assets, 'apple-touch-icon.png');
assert.ok(fs.existsSync(apple) && fs.readFileSync(apple).subarray(0, 8).equals(PNG_MAGIC));
const ico = path.join(assets, 'favicon.ico');
assert.ok(fs.existsSync(ico), 'favicon.ico must exist');
const icoBuf = fs.readFileSync(ico);
assert.ok(icoBuf.subarray(0, 4).equals(ICO_MAGIC), 'favicon.ico must be a real ICO');
assert.equal(icoBuf.readUInt16LE(4), 3, 'the .ico must carry 3 resolutions (16/32/48)');
});

test('social-card — sharp fallback produces a 1200×630 PNG', async () => {
const { buildDir, assets } = await makeBuildDir();
const r = spawnSync(process.execPath, [SOCIAL, buildDir], { encoding: 'utf8', env: NO_MAGICK_ENV });
assert.equal(r.status, 0, `social-card fallback must succeed: ${r.stdout}\n${r.stderr}`);
assert.match(r.stderr, /sharp fallback renderer/, 'it must announce the fallback');
const card = path.join(assets, 'social-card.png');
assert.ok(fs.existsSync(card), 'social-card.png must exist');
const buf = fs.readFileSync(card);
assert.ok(buf.subarray(0, 8).equals(PNG_MAGIC), 'social-card.png must be a real PNG');
const meta = await sharp(card).metadata();
assert.equal(meta.width, 1200);
assert.equal(meta.height, 630);
});
81 changes: 79 additions & 2 deletions tools/make-favicon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';

const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const RGB = /^rgba?\(/i;
Expand Down Expand Up @@ -77,6 +78,76 @@ function resolveHero(buildDir, ctx) {
return p;
}

// ── sharp fallback when ImageMagick is absent ─────────────────────────────────────────────────
// Same outputs as the magick path: center-square crops of the hero at 16/32/48/192/512, a
// 180px alpha-flattened apple-touch-icon, and a multi-resolution favicon.ico (16/32/48, 32-bit
// BMP frames — the ICO format hand-assembled here because sharp writes only single-size .ico).
async function centerCropPng(src, n, out, flattenBg) {
let p = sharp(src).resize(n, n, { fit: 'cover' });
if (flattenBg) p = p.flatten({ background: flattenBg });
return p.png().toFile(out);
}

async function rasterToBmpFrame(rawBuf, width, height) {
const rowBytes = width * 4;
const header = Buffer.alloc(40);
header.writeUInt32LE(40, 0); // BITMAPINFOHEADER size
header.writeInt32LE(width, 4);
header.writeInt32LE(height * 2, 8); // ICO doubles the height
header.writeUInt16LE(1, 12); // planes
header.writeUInt16LE(32, 14); // bpp
header.writeUInt32LE(0, 16); // BI_RGB
header.writeUInt32LE(rowBytes * height, 20);
const data = Buffer.alloc(rowBytes * height);
for (let y = 0; y < height; y++) {
const sRow = y * rowBytes, dRow = (height - 1 - y) * rowBytes; // bottom-up
for (let x = 0; x < width; x++) {
const s = sRow + x * 4, d = dRow + x * 4;
data[d] = rawBuf[s + 2]; // B
data[d + 1] = rawBuf[s + 1]; // G
data[d + 2] = rawBuf[s]; // R
data[d + 3] = rawBuf[s + 3]; // A
}
}
return Buffer.concat([header, data]);
}

function assembleIco(frames) {
const dirSize = 6 + 16 * frames.length;
const total = dirSize + frames.reduce((s, f) => s + f.bmp.length, 0);
const buf = Buffer.alloc(total);
buf.writeUInt16LE(0, 0); // reserved
buf.writeUInt16LE(1, 2); // type: icon
buf.writeUInt16LE(frames.length, 4); // count
let off = dirSize;
frames.forEach((f, i) => {
const e = 6 + 16 * i;
buf.writeUInt8(f.size >= 256 ? 0 : f.size, e);
buf.writeUInt8(f.size >= 256 ? 0 : f.size, e + 1);
buf.writeUInt8(0, e + 2); // colour count (0)
buf.writeUInt8(0, e + 3); // reserved
buf.writeUInt16LE(1, e + 4); // planes
buf.writeUInt16LE(32, e + 6); // bpp
buf.writeUInt32LE(f.bmp.length, e + 8);
buf.writeUInt32LE(off, e + 12);
off += f.bmp.length;
});
let p = dirSize;
for (const f of frames) { f.bmp.copy(buf, p); p += f.bmp.length; }
return buf;
}

async function makeFaviconsWithSharp(hero, assets, bg) {
for (const n of ICON_SIZES) await centerCropPng(hero, n, path.join(assets, `favicon-${n}.png`));
await centerCropPng(hero, 180, path.join(assets, 'apple-touch-icon.png'), bg);
const frames = [];
for (const n of [16, 32, 48]) {
const { data, info } = await sharp(hero).resize(n, n, { fit: 'cover' }).raw().toBuffer({ resolveWithObject: true });
frames.push({ size: n, bmp: await rasterToBmpFrame(data, info.width, info.height) });
}
fs.writeFileSync(path.join(assets, 'favicon.ico'), assembleIco(frames));
}

// Re-read fresh and merge only brand.favicon, to minimise the window vs the parallel make-social-card.
function mergeFavicon(buildDir, slot) {
const bj = path.join(buildDir, 'build.json');
Expand All @@ -86,7 +157,7 @@ function mergeFavicon(buildDir, slot) {
fs.writeFileSync(bj, JSON.stringify(ctx, null, 2) + '\n');
}

function main() {
async function main() {
const buildDir = process.argv[2];
if (!buildDir) {
emit({ ok: false, outputs: {}, error: 'usage: node tools/make-favicon.mjs <build-dir>' });
Expand All @@ -95,7 +166,6 @@ function main() {
}
try {
const bin = findBin();
if (!bin) throw new Error('ImageMagick not found — need `magick` (v7) or `convert` (v6) on PATH');

const ctx = readCtx(buildDir);
const hero = resolveHero(buildDir, ctx);
Expand All @@ -104,6 +174,12 @@ function main() {
const assets = path.join(buildDir, 'assets');
fs.mkdirSync(assets, { recursive: true });

if (!bin) {
// FALLBACK: no ImageMagick binary on PATH (and possibly no rights to install it).
// sharp produces the identical output set, incl. a hand-assembled multi-resolution .ico.
log('ImageMagick not found — using the sharp fallback renderer.');
await makeFaviconsWithSharp(hero, assets, bg);
} else {
// Standard square favicons — center-square crop of the hero so the icon keeps the hero's identity.
for (const n of ICON_SIZES) {
const out = path.join(assets, `favicon-${n}.png`);
Expand All @@ -121,6 +197,7 @@ function main() {
'(', '-clone', '0', '-resize', '16x16', ')',
'(', '-clone', '0', '-resize', '32x32', ')',
ico]);
}

const set = [...ICON_SIZES.map((n) => `favicon-${n}.png`), 'favicon.ico'];
const allFiles = [...set, 'apple-touch-icon.png'];
Expand Down
115 changes: 83 additions & 32 deletions tools/make-social-card.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';

const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const RGB = /^rgba?\(/i;
Expand Down Expand Up @@ -106,7 +107,50 @@ function mergeSocialCard(buildDir, slot) {
fs.writeFileSync(bj, JSON.stringify(ctx, null, 2) + '\n');
}

function main() {
// ── sharp fallback when ImageMagick is absent ─────────────────────────────────────────────────
// Same composed result as the magick path: hero cover-cropped to 1200×630, a legibility scrim
// gradient from the brand colour, the repo-name kicker top-left, and the wrapped tagline
// bottom-left. The text is an SVG layer rendered by libvips/librsvg (DejaVu Sans is installed).
function escapeXml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function wrapText(text, maxChars) {
const words = String(text).split(/\s+/).filter(Boolean);
const lines = [];
let cur = '';
for (const w of words) {
if (cur && (cur + ' ' + w).length > maxChars) { lines.push(cur); cur = w; }
else cur = cur ? `${cur} ${w}` : w;
}
if (cur) lines.push(cur);
return lines.slice(0, 4);
}
async function makeCardWithSharp(hero, out, { tagline, repoName, base, accent, textColor }) {
const rgb = hexToRgb(base);
const [r, g, b] = rgb || [22, 24, 40];
const lines = wrapText(tagline, 34);
const lineEls = lines.map((ln, i) =>
`<text x="${PAD}" y="${CARD_H - 72 - (lines.length - 1 - i) * 68}" font-family="DejaVu Sans, Ubuntu, sans-serif" font-size="58" font-weight="bold" fill="${textColor}">${escapeXml(ln)}</text>`).join('');
const kicker = repoName
? `<text x="${PAD}" y="80" font-family="DejaVu Sans, Ubuntu, sans-serif" font-size="34" font-weight="bold" fill="${accent}">${escapeXml(repoName)}</text>`
: '';
const overlay = `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_W}" height="${CARD_H}">`
+ `<defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1">`
+ `<stop offset="0" stop-color="rgba(${r},${g},${b},0)"/>`
+ `<stop offset="0.4" stop-color="rgba(${r},${g},${b},0.35)"/>`
+ `<stop offset="1" stop-color="rgba(${r},${g},${b},0.95)"/>`
+ `</linearGradient></defs>`
+ `<rect width="${CARD_W}" height="${CARD_H}" fill="url(#s)"/>${kicker}${lineEls}</svg>`;
await sharp(hero).resize(CARD_W, CARD_H, { fit: 'cover' })
.composite([{ input: Buffer.from(overlay), top: 0, left: 0 }])
.png().toFile(out);
const meta = await sharp(out).metadata();
if (meta.width !== CARD_W || meta.height !== CARD_H) {
throw new Error(`social card is ${meta.width}x${meta.height}, expected ${CARD_W}x${CARD_H}`);
}
}

async function main() {
const buildDir = process.argv[2];
if (!buildDir) {
emit({ ok: false, outputs: {}, error: 'usage: node tools/make-social-card.mjs <build-dir>' });
Expand All @@ -116,7 +160,7 @@ function main() {
let stage = null;
try {
const bin = findBin();
if (!bin) throw new Error('ImageMagick not found — need `magick` (v7) or `convert` (v6) on PATH');
if (!bin) log('ImageMagick not found — using the sharp fallback renderer.');

const ctx = readCtx(buildDir);
const hero = resolveHero(buildDir, ctx);
Expand Down Expand Up @@ -145,40 +189,47 @@ function main() {
? ctx.understanding.repoName.trim()
: (typeof ctx?.repo?.name === 'string' && ctx.repo.name.trim() ? ctx.repo.name.trim() : null);

const font = pickFont();
const assets = path.join(buildDir, 'assets');
fs.mkdirSync(assets, { recursive: true });
stage = fs.mkdtempSync(path.join(os.tmpdir(), 'social-card-'));
const baseImg = path.join(stage, 'base.png');
const tagImg = path.join(stage, 'tag.png');
const out = path.join(assets, 'social-card.png');

// 1) Hero full-bleed cover + scrim, with the repo-name kicker baked into the top-left.
const baseArgs = [hero, '-resize', `${CARD_W}x${CARD_H}^`, '-gravity', 'center', '-extent', `${CARD_W}x${CARD_H}`,
'(', '-size', `${CARD_W}x${CARD_H}`, scrim, ')', '-composite'];
if (repoName) {
baseArgs.push('-gravity', 'NorthWest', '-fill', accent);
if (font) baseArgs.push('-font', font);
baseArgs.push('-pointsize', '34', '-annotate', `+${PAD}+54`, repoName);
let dims;

if (bin) {
stage = fs.mkdtempSync(path.join(os.tmpdir(), 'social-card-'));
const baseImg = path.join(stage, 'base.png');
const tagImg = path.join(stage, 'tag.png');

// 1) Hero full-bleed cover + scrim, with the repo-name kicker baked into the top-left.
const baseArgs = [hero, '-resize', `${CARD_W}x${CARD_H}^`, '-gravity', 'center', '-extent', `${CARD_W}x${CARD_H}`,
'(', '-size', `${CARD_W}x${CARD_H}`, scrim, ')', '-composite'];
const font = pickFont();
if (repoName) {
baseArgs.push('-gravity', 'NorthWest', '-fill', accent);
if (font) baseArgs.push('-font', font);
baseArgs.push('-pointsize', '34', '-annotate', `+${PAD}+54`, repoName);
}
baseArgs.push(baseImg);
magick(bin, baseArgs);

// 2) The tagline as a wrapped caption layer (auto-wrapped at the text column width).
const tagArgs = ['-background', 'none', '-fill', textColor];
if (font) tagArgs.push('-font', font);
tagArgs.push('-size', `${CARD_W - PAD * 2}x`, '-gravity', 'West', '-pointsize', '58', `caption:${tagline}`, tagImg);
magick(bin, tagArgs);

// 3) Composite the tagline into the lower-left and write the final card.
magick(bin, [baseImg, tagImg, '-gravity', 'SouthWest', '-geometry', `+${PAD}+72`, '-composite', out]);

if (!fs.existsSync(out) || fs.statSync(out).size === 0) throw new Error(`social card not written: ${out}`);
// `magick identify …` (v7) vs the standalone `identify` (v6).
dims = (bin === 'magick'
? execFileSync(bin, ['identify', '-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] })
: execFileSync('identify', ['-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] })).toString().trim();
if (dims !== `${CARD_W}x${CARD_H}`) throw new Error(`social card is ${dims}, expected ${CARD_W}x${CARD_H}`);
} else {
await makeCardWithSharp(hero, out, { tagline, repoName, base, accent, textColor });
dims = `${CARD_W}x${CARD_H}`;
}
baseArgs.push(baseImg);
magick(bin, baseArgs);

// 2) The tagline as a wrapped caption layer (auto-wrapped at the text column width).
const tagArgs = ['-background', 'none', '-fill', textColor];
if (font) tagArgs.push('-font', font);
tagArgs.push('-size', `${CARD_W - PAD * 2}x`, '-gravity', 'West', '-pointsize', '58', `caption:${tagline}`, tagImg);
magick(bin, tagArgs);

// 3) Composite the tagline into the lower-left and write the final card.
magick(bin, [baseImg, tagImg, '-gravity', 'SouthWest', '-geometry', `+${PAD}+72`, '-composite', out]);

if (!fs.existsSync(out) || fs.statSync(out).size === 0) throw new Error(`social card not written: ${out}`);
// `magick identify …` (v7) vs the standalone `identify` (v6).
const dims = (bin === 'magick'
? execFileSync(bin, ['identify', '-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] })
: execFileSync('identify', ['-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] })).toString().trim();
if (dims !== `${CARD_W}x${CARD_H}`) throw new Error(`social card is ${dims}, expected ${CARD_W}x${CARD_H}`);

const slot = { px: `${CARD_W}x${CARD_H}`, file: out, tagline };
mergeSocialCard(buildDir, slot);
Expand Down