Skip to content
Closed
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
425 changes: 425 additions & 0 deletions url-to-frame-md/POLISH-LOG.md

Large diffs are not rendered by default.

Binary file added url-to-frame-md/assets/demo-avatar.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added url-to-frame-md/assets/demo-hero.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
897 changes: 897 additions & 0 deletions url-to-frame-md/build-frame-from-capture.mjs

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions url-to-frame-md/lib/color.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// color.mjs — CSS color parsing + comparison, shared by the generator and verifier.
// Parses hex / #rgb / rgb()/rgba() / hsl() / oklch() → "#RRGGBB" (modern sites emit oklch/hsl).

export const HEX6 = /^#[0-9a-fA-F]{6}$/;
export const isHex = (v) => HEX6.test(String(v).trim());
export const upper = (h) => String(h).trim().toUpperCase();
export const hexToRgb = (hex) => {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
// perceptual-ish weighted RGB distance (cheap, good enough for nearest-key); non-hex → Infinity.
export const colorDist = (a, b) => {
if (!isHex(a) || !isHex(b)) return Infinity;
const [r1, g1, b1] = hexToRgb(a);
const [r2, g2, b2] = hexToRgb(b);
const rm = (r1 + r2) / 2;
return Math.sqrt(
(2 + rm / 256) * (r1 - r2) ** 2 + 4 * (g1 - g2) ** 2 + (2 + (255 - rm) / 256) * (b1 - b2) ** 2,
);
};

const clamp01 = (x) => Math.min(1, Math.max(0, x));
const to255 = (x) =>
Math.max(0, Math.min(255, Math.round(x)))
.toString(16)
.padStart(2, "0")
.toUpperCase();
const rgbHex = (r, g, b) => `#${to255(r)}${to255(g)}${to255(b)}`;
const hslToRgb = (h, s, l) => {
s /= 100;
l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [f(0) * 255, f(8) * 255, f(4) * 255];
};
const oklchToRgb = (Lraw, C, H) => {
const L = String(Lraw).endsWith("%") ? parseFloat(Lraw) / 100 : parseFloat(Lraw);
const hr = (H * Math.PI) / 180;
const a = C * Math.cos(hr);
const b = C * Math.sin(hr);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
const l = l_ ** 3,
m = m_ ** 3,
s = s_ ** 3;
const lin2srgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
const R = lin2srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
const G = lin2srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
const B = lin2srgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
return [clamp01(R) * 255, clamp01(G) * 255, clamp01(B) * 255];
};
export const toHex = (raw) => {
const s = String(raw ?? "").trim();
if (!s) return null;
if (HEX6.test(s)) return s.toUpperCase();
if (/^#[0-9a-fA-F]{3}$/.test(s))
return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`.toUpperCase();
let m;
if ((m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(s)))
return rgbHex(+m[1], +m[2], +m[3]);
if ((m = /^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/i.exec(s)))
return rgbHex(...hslToRgb(+m[1], +m[2], +m[3]));
if ((m = /^oklch\(\s*([\d.]+%?)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:deg)?/i.exec(s)))
return rgbHex(...oklchToRgb(m[1], +m[2], +m[3]));
return null;
};
// luminance 0..255 of a #RRGGBB (Rec.601), for "near-white?" checks; null for non-hex.
export const lumOf = (hex) => {
if (!isHex(hex)) return null;
const [r, g, b] = hexToRgb(hex);
return 0.299 * r + 0.587 * g + 0.114 * b;
};
118 changes: 118 additions & 0 deletions url-to-frame-md/lib/fonts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// fonts.mjs — brand-font classification + @font-face staging, shared by the generator.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

// icon / emoji / symbol faces are never brand TEXT — an emoji font (NotoEmoji, Apple Color Emoji)
// leaking into a button/heading typography is a capture artifact, so classify it as an icon font.
export const isIconFont = (n) =>
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather|emoji|symbols?/i.test(
String(n),
);
// serif/editorial display faces (the !sans guard drops ui-sans-serif / PT Sans).
export const isSerifFont = (n) =>
!/sans/i.test(String(n)) &&
/serif|times|georgia|playfair|tiempos|lora|garamond|caslon|didot|freight|canela|fraunces|spectral|merriweather|domaine|editorial|instrument/i.test(
String(n),
);
// match "mono" anywhere + named mono faces (monaco/consolas/…).
export const isMonoFont = (n) =>
/mono|consol|courier|menlo|monaco|jetbrains|berkeley\s*mono|source\s*code|fira\s*code|geist\s*mono|dm\s*mono|ibm\s*plex\s*mono|sf\s*mono|roboto\s*mono/i.test(
String(n),
);

// bind brand font names to files via fonts-manifest.json; returns a "## Font loading" markdown block, or "".
export function stageFonts(captureDir, outDir, brandFonts, sansFont) {
if (!brandFonts.length) return { block: "", families: [] };
const extOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase();
const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" };
const norm = (s) =>
String(s)
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const weightFromName = (name) => {
const s = name.toLowerCase();
if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" };
if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" };
if (/bold/.test(s)) return { n: 700, w: "Bold" };
if (/medium/.test(s)) return { n: 500, w: "Medium" };
if (/light|thin/.test(s)) return { n: 300, w: "Light" };
return { n: 400, w: "Regular" };
};
const manifestPath = join(captureDir, "extracted/fonts-manifest.json");
let manifest = {};
if (existsSync(manifestPath)) {
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch {
manifest = {}; // unreadable manifest → fall back to name-only binding
}
}
const metaByFile = new Map((manifest.files ?? []).map((f) => [f.file, f]));
const variableFams = new Set(
(manifest.families ?? []).filter((f) => f.variable).map((f) => norm(f.family)),
);
const srcDir = join(captureDir, "assets/fonts");
// staged output lands in this same dir, so on a re-run skip our own clean-named files.
const stagedPrefixes = brandFonts.map((b) => b.replace(/[^A-Za-z0-9]/g, ""));
const isStaged = (f) =>
stagedPrefixes.some((p) =>
new RegExp(
`^${p}-(Regular|Light|Medium|SemiBold|Bold|ExtraBold)\\.(woff2?|ttf|otf)$`,
"i",
).test(f),
);
// never stage an ICON font — the orphan-assignment would bind glyph files to a text family.
const isIconFile = (f) =>
metaByFile.get(f)?.isIcon ||
/icon|glyph|fontawesome/i.test(f) ||
isIconFont(metaByFile.get(f)?.family);
// skip ITALIC/oblique files — the clean-name dedup ("Family-Regular") sorts "Italic" before
// "Regular", so an italic subset would win the normal slot and render the whole family slanted.
const isItalicFile = (f) => metaByFile.get(f)?.style === "italic" || /italic|oblique/i.test(f);
const files = existsSync(srcDir)
? readdirSync(srcDir)
.filter((f) => extOf(f) && !isStaged(f) && !isIconFile(f) && !isItalicFile(f))
.sort()
: [];

const nonMono = brandFonts.filter((f) => !isMonoFont(f));
const monoBrand = brandFonts.find(isMonoFont) ?? null;
const monoish = (s) =>
isMonoFont(s) || /commit|courier|consol|menlo|monaco|mono/i.test(String(s));

const assign = files.map((file) => {
const fam = metaByFile.get(file)?.family ?? "";
if (monoBrand && (monoish(file) || monoish(fam))) return { file, name: monoBrand };
const nf = norm(fam);
const named =
nf && fam !== "." ? nonMono.find((b) => nf.includes(norm(b)) || norm(b).includes(nf)) : null;
return { file, name: named ?? null };
});
const have = new Set(assign.filter((a) => a.name).map((a) => a.name));
const orphan = nonMono.find((b) => !have.has(b)) ?? nonMono[nonMono.length - 1] ?? sansFont;
for (const a of assign) if (!a.name) a.name = orphan;

const faces = [];
const staged = new Set();
const families = new Set(); // @font-face family names actually emitted — the ONLY names that render
for (const { file, name } of assign) {
if (!name) continue;
const meta = metaByFile.get(file) ?? {};
const { n, w } = weightFromName(file);
const clean = `${name.replace(/[^A-Za-z0-9]/g, "")}-${w}.${extOf(file)}`;
if (staged.has(clean)) continue;
mkdirSync(outDir, { recursive: true });
// copy unconditionally (idempotent) so a stale clean-named file from an earlier run can't survive.
if (join(srcDir, file) !== join(outDir, clean))
copyFileSync(join(srcDir, file), join(outDir, clean));
staged.add(clean);
families.add(name);
const isVar = meta.variable || variableFams.has(norm(meta.family ?? ""));
faces.push(
`@font-face{font-family:"${name}";font-weight:${isVar ? "100 900" : n};font-style:normal;font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(file)]}");}`,
);
}
if (!faces.length) return { block: "", families: [] };
const block = `\n## Font loading (auto-generated)\n\nThe brand fonts ship as local files in \`assets/fonts/\`. Paste this into every frame's \`<head>\`:\n\n\`\`\`html\n<style>\n${faces.join("\n")}\n</style>\n\`\`\`\n`;
return { block, families: [...families] };
}
Loading
Loading