Skip to content

Commit 29ea031

Browse files
r8a5claude
andcommitted
Cover system: live SVG rendering + 25+ template variables + 20 filters
Two big shifts in how covers work: 1. Live rendering, no per-post storage. New endpoint /cover/<slug>.svg looks up the post + the default cover_template + settings, builds a rich template context, and renders the SVG on the fly. Cached at the edge for 1h. Backgrounds + logos live ONCE in R2; per- post PNG copies are gone. page_render.js routes <img>, og:image, and structured-data image to /cover/<slug>.svg when hero_image_mode=cover AND a default template exists. Falls back to /image/<key> for legacy AI covers, or /og/<slug>.svg as the last resort. 2. Template variables expanded ~6 → 25+: {pub_date}, {pub_date_long}, {pub_date_short}, {pub_year}, {pub_month}, {pub_day}, {pub_dow}, {update_date}, {today_long}, {year}, {excerpt}, {keywords}, {reading_time}, {word_count}, {provider}, {brand.tagline}, {brand.domain}, {brand.logo_url}, {brand.primary_color}, {brand.accent_color}, {site.host}, {site.url}, {site.canonical}, {has_image}, {has_logo}. New filters: capitalize, kebab, snake, trim, first_word, domain, ordinal, pad, number_format, pluralize, replace, prepend, append, read_time, word_count. date filter gains long/short/us/iso/year/ month/dow/relative formats. New settings keys: site_tagline, brand_logo_url, brand_primary_color, brand_accent_color. 3. install-official rebuilds the premium template using the new variables. Eyebrow with {brand.name|upper} + {brand.tagline} guard, excerpt subtitle, pub_date + reading_time kicker, footer with brand.domain. Version 2. 4. Editor mirrors all new filters + ctx fields. New "Vars" rail panel lists every variable with a one-line description; clicking copies the token to the clipboard and appends it to a selected text layer. /og/<slug>.svg uses cover_svg too so social cards and in-article heroes are pixel-identical. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 18d3781 commit 29ea031

11 files changed

Lines changed: 990 additions & 168 deletions

File tree

functions/_lib/cover_svg.js

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Layer-spec → SVG renderer.
2+
//
3+
// Same layer schema the browser editor produces (text / box / logo).
4+
// Rendered server-side as SVG (the Workers runtime has no canvas);
5+
// the SVG is sent to the client where the browser rasterizes it,
6+
// the OG card scraper rasterizes it, or Twitter/Facebook caches a
7+
// rasterized version.
8+
//
9+
// Key differences vs canvas rendering:
10+
//
11+
// - Web fonts have to be requested by the BROWSER (or the OG
12+
// scraper) — we just reference them by family. The SVG <style>
13+
// block declares an @import from fonts.googleapis.com so a
14+
// fresh paint loads the right font. Google's OG previewer does
15+
// execute the @import and pick up the right typography; some
16+
// older scrapers don't and fall back to the family stack.
17+
//
18+
// - Text wrapping isn't automatic. We measure char widths heuristic-
19+
// ally and break at word boundaries to fit the layer width. The
20+
// measurements aren't pixel-perfect (the browser does the real
21+
// layout) but they're close enough that titles don't overflow.
22+
//
23+
// - Rotation is applied via a transform on the group, not a per-
24+
// layer matrix.
25+
26+
import { renderTemplate } from './template.js';
27+
28+
// SVG-attribute XML escape. NOT the same as HTML escape — we need to
29+
// quote the five XML entities. Caller is responsible for ensuring
30+
// strings going into class/font-family/href are safe.
31+
function xml(s) {
32+
return String(s ?? '').replace(/[&<>"']/g, (c) =>
33+
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }[c]));
34+
}
35+
36+
// Greedy word-wrap given an approximate "characters per line" budget.
37+
// We compute that from the layer's pixel width and the font size,
38+
// using a rough 0.55 em-width-per-character heuristic (works well for
39+
// most proportional fonts at heading sizes).
40+
function wrapText(text, fontSize, layerWidthPx, maxLines = 6) {
41+
const charBudget = Math.max(8, Math.floor(layerWidthPx / (fontSize * 0.55)));
42+
const lines = [];
43+
for (const para of String(text).split('\n')) {
44+
const words = para.split(/\s+/).filter(Boolean);
45+
if (!words.length) { lines.push(''); continue; }
46+
let line = '';
47+
for (const w of words) {
48+
const trial = line ? line + ' ' + w : w;
49+
if (trial.length <= charBudget) line = trial;
50+
else {
51+
if (line) lines.push(line);
52+
line = w;
53+
if (lines.length >= maxLines) break;
54+
}
55+
}
56+
if (line && lines.length < maxLines) lines.push(line);
57+
}
58+
// Add ellipsis if we ran out of room.
59+
if (lines.length >= maxLines) {
60+
lines[maxLines - 1] = lines[maxLines - 1].replace(/\s*\S{1,8}$/, '') + '…';
61+
}
62+
return lines.slice(0, maxLines);
63+
}
64+
65+
// Extract the first quoted family from a CSS family stack so we can
66+
// declare an @import for it. Returns '' for system stacks where no
67+
// @import is needed.
68+
function googleFontFamily(stack) {
69+
const m = String(stack || '').match(/"([^"]+)"/);
70+
if (!m) return '';
71+
const fam = m[1].trim();
72+
// Skip families that are guaranteed to be in the system or aren't
73+
// on Google Fonts.
74+
if (/^(Times New Roman|Helvetica Neue|Courier New|Trebuchet MS|Arial|Impact)$/i.test(fam)) return '';
75+
return fam;
76+
}
77+
78+
// Convert a hex/rgba colour into a string SVG accepts. SVG accepts
79+
// CSS-style rgba() and hex directly, so we mostly pass through.
80+
function colour(v) {
81+
if (!v) return '#000';
82+
return String(v);
83+
}
84+
85+
// Render one layer as an SVG fragment.
86+
function renderLayer(layer, ctx) {
87+
const opacity = layer.opacity != null ? layer.opacity : 1;
88+
const rot = layer.rotation || 0;
89+
const cx = layer.x + layer.w / 2;
90+
const cy = layer.y + layer.h / 2;
91+
const transform = rot ? ` transform="rotate(${rot} ${cx} ${cy})"` : '';
92+
const opAttr = opacity < 1 ? ` opacity="${opacity}"` : '';
93+
94+
if (layer.kind === 'box') {
95+
const fill = colour(layer.fill || 'rgba(0,0,0,0.55)');
96+
const r = layer.radius || 0;
97+
return `<rect x="${layer.x}" y="${layer.y}" width="${layer.w}" height="${layer.h}" rx="${r}" ry="${r}" fill="${xml(fill)}"${opAttr}${transform}/>`;
98+
}
99+
100+
if (layer.kind === 'text') {
101+
const fontSize = layer.size || 60;
102+
const family = layer.family || 'system-ui, sans-serif';
103+
const weight = layer.weight || '600';
104+
const italic = layer.italic ? 'italic' : 'normal';
105+
const align = layer.align || 'left';
106+
const color = colour(layer.color || '#ffffff');
107+
const lineH = fontSize * (layer.lineHeight || 1.15);
108+
109+
// Expand tokens in the text string against the context.
110+
const display = renderTemplate(layer.text || '', ctx);
111+
const lines = wrapText(display, fontSize, layer.w);
112+
113+
// text-anchor maps from CSS text-align.
114+
const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
115+
const anchorX = align === 'center' ? (layer.x + layer.w / 2)
116+
: align === 'right' ? (layer.x + layer.w)
117+
: layer.x;
118+
119+
// Drop shadow: SVG supports the same shadow-blur via <filter>.
120+
// We define an inline filter per text layer that wants shadow.
121+
const shadowId = layer.shadow ? `shadow-${Math.random().toString(36).slice(2, 8)}` : '';
122+
const shadowDef = layer.shadow ? `
123+
<defs>
124+
<filter id="${shadowId}" x="-10%" y="-10%" width="120%" height="120%">
125+
<feDropShadow dx="0" dy="2" stdDeviation="${(layer.shadowBlur != null ? layer.shadowBlur : 8) / 2}" flood-color="${xml(layer.shadowColor || 'rgba(0,0,0,0.6)')}"/>
126+
</filter>
127+
</defs>` : '';
128+
const filterAttr = shadowId ? ` filter="url(#${shadowId})"` : '';
129+
130+
const tspans = lines.map((line, i) =>
131+
`<tspan x="${anchorX}" dy="${i === 0 ? 0 : lineH}">${xml(line)}</tspan>`
132+
).join('');
133+
134+
return `${shadowDef}<text x="${anchorX}" y="${layer.y + fontSize * 0.85}"
135+
font-family="${xml(family)}"
136+
font-size="${fontSize}"
137+
font-weight="${xml(weight)}"
138+
font-style="${italic}"
139+
fill="${xml(color)}"
140+
text-anchor="${anchor}"${opAttr}${filterAttr}${transform}>${tspans}</text>`;
141+
}
142+
143+
if (layer.kind === 'logo' && layer.url) {
144+
// SVG <image> takes href + preserveAspectRatio. We use xMidYMid
145+
// meet so the logo scales to fit without distortion (same as the
146+
// canvas renderer's Math.min(w/iw, h/ih) cover-fit).
147+
const href = layer.url;
148+
return `<image href="${xml(href)}" x="${layer.x}" y="${layer.y}" width="${layer.w}" height="${layer.h}" preserveAspectRatio="xMidYMid meet"${opAttr}${transform}/>`;
149+
}
150+
151+
return '';
152+
}
153+
154+
// Public entry. Renders the entire template against ctx and returns
155+
// a self-contained SVG document string.
156+
//
157+
// spec — the cover_template spec_json (parsed): { width, height,
158+
// background, layers: [...], __official?, __version? }
159+
// ctx — template context (see buildBrandContext in template.js)
160+
//
161+
// Returns the SVG string, ready to send with content-type:
162+
// image/svg+xml.
163+
export function renderCoverSvg(spec, ctx) {
164+
const W = spec?.width || 1200;
165+
const H = spec?.height || 630;
166+
const layers = Array.isArray(spec?.layers) ? spec.layers : [];
167+
168+
// Collect unique Google Fonts referenced by text layers.
169+
const families = new Set();
170+
for (const l of layers) {
171+
if (l.kind === 'text') {
172+
const fam = googleFontFamily(l.family);
173+
if (fam) families.add(fam);
174+
}
175+
}
176+
// Compose the @import URL. fonts.googleapis.com supports loading
177+
// multiple families in one stylesheet — concatenate with &.
178+
const fontImport = families.size
179+
? '@import url("https://fonts.googleapis.com/css2?' +
180+
[...families].map((f) =>
181+
`family=${encodeURIComponent(f).replace(/%20/g, '+')}:wght@300;400;500;600;700;800`
182+
).join('&') + '&display=swap");'
183+
: '';
184+
185+
// Background. Either an asset URL (covers the whole viewport) or a
186+
// solid colour from the first 'backdrop' box layer if present.
187+
let backgroundEl = '';
188+
if (spec?.background?.url) {
189+
backgroundEl = `<image href="${xml(spec.background.url)}" x="0" y="0" width="${W}" height="${H}" preserveAspectRatio="xMidYMid slice"/>`;
190+
} else {
191+
// Fall back to a near-black backdrop so transparent text doesn't
192+
// render on a transparent canvas.
193+
backgroundEl = `<rect x="0" y="0" width="${W}" height="${H}" fill="#0a0c10"/>`;
194+
}
195+
196+
const layerEls = layers.map((l) => renderLayer(l, ctx)).filter(Boolean).join('\n ');
197+
198+
return `<?xml version="1.0" encoding="UTF-8"?>
199+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
200+
<style>${fontImport}</style>
201+
${backgroundEl}
202+
${layerEls}
203+
</svg>`;
204+
}

functions/_lib/page_render.js

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ function jsonLD({ site, post, host, kind, settings }) {
3737
const webId = `${baseUrl}/#website`;
3838
const pageId = `${baseUrl}${post.urlPath}#main`;
3939

40-
const heroAbs = post.hero_image_key
41-
? `${baseUrl}/image/${post.hero_image_key}`
42-
: `${baseUrl}/og/${encodeURIComponent(post.slug || 'home')}.png`; // dynamic fallback
40+
// Same three-way precedence as the on-page <img> hero so the
41+
// structured-data image, the og:image, and the visible hero are
42+
// all the same URL.
43+
const useCover = (settings?.hero_image_mode === 'cover') && settings?._has_default_template;
44+
const heroAbs = useCover
45+
? `${baseUrl}/cover/${encodeURIComponent(post.slug || 'home')}.svg`
46+
: post.hero_image_key
47+
? `${baseUrl}/image/${post.hero_image_key}`
48+
: `${baseUrl}/og/${encodeURIComponent(post.slug || 'home')}.svg`;
4349

4450
const graph = [
4551
{
@@ -108,14 +114,33 @@ export function renderContentPage({ env, request, post, kind, related = [], sett
108114
year: 'numeric', month: 'long', day: 'numeric',
109115
});
110116

111-
// Hero image: explicit width/height + fetchpriority=high so this
112-
// image becomes the LCP element with no CLS. decoding=async lets
113-
// it not block the main thread. When there's no hero key, we point
114-
// at /og/<slug>.png which a future endpoint will render on demand
115-
// — and meanwhile, the placeholder div keeps the layout stable.
116-
const heroSrc = post.hero_image_key
117-
? `/image/${esc(post.hero_image_key)}`
118-
: `/og/${esc(post.slug || 'home')}.svg`;
117+
// Hero image source — three-way fall-through, in priority order:
118+
//
119+
// 1. hero_image_mode === 'cover' AND a default cover_template
120+
// exists → /cover/<slug>.svg. The SVG renders live from the
121+
// template + post variables, so flipping the template
122+
// retroactively updates every post's cover with no per-post
123+
// storage. This is the path that makes "Apply to all" + the
124+
// template editor a single source of truth.
125+
//
126+
// 2. Else if post.hero_image_key is set → /image/<key>. Legacy
127+
// per-post PNGs (AI-generated or manually applied) keep
128+
// working.
129+
//
130+
// 3. Else → /og/<slug>.svg. A built-in generic card so we
131+
// ALWAYS emit a valid og:image (never null), even for posts
132+
// that haven't been through the image pipeline.
133+
//
134+
// The hero is the LCP element so we set width/height,
135+
// decoding=async, fetchpriority=high, and a preload link in
136+
// <head>. With those four together CLS is zero and LCP-time
137+
// drops measurably.
138+
const useCoverEndpoint = (settings?.hero_image_mode === 'cover') && settings?._has_default_template;
139+
const heroSrc = useCoverEndpoint
140+
? `/cover/${esc(post.slug || 'home')}.svg`
141+
: post.hero_image_key
142+
? `/image/${esc(post.hero_image_key)}`
143+
: `/og/${esc(post.slug || 'home')}.svg`;
119144
const heroAlt = esc(post.hero_image_alt || post.title);
120145
const heroImg = `<img class="hero" src="${heroSrc}" alt="${heroAlt}" width="${HERO_W}" height="${HERO_H}" decoding="async" fetchpriority="high" />`;
121146

functions/_lib/settings.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,26 @@ const FALLBACK = {
9090
// bing_site_verification: same for Bing's msvalidate.01 meta.
9191
google_site_verification: () => '',
9292
bing_site_verification: () => '',
93+
94+
// Brand identity for cover templates + JSON-LD. The cover renderer
95+
// exposes these as {brand.tagline}, {brand.logo_url}, {brand.
96+
// primary_color}, {brand.accent_color} so the same template can
97+
// produce different visual identities on different installs without
98+
// editing the spec.
99+
//
100+
// site_tagline — short subtitle shown under the brand name
101+
// ("daily SEO articles", "shop fashion online")
102+
// brand_logo_url — absolute URL to a small logo image (PNG/SVG)
103+
// used in the corner of cover templates.
104+
// Leave empty if you don't want one.
105+
// brand_primary_color — hex string, used as default fill on box
106+
// layers ({brand.primary_color}).
107+
// brand_accent_color — hex string, used as accent/highlight on
108+
// cover templates ({brand.accent_color}).
109+
site_tagline: () => '',
110+
brand_logo_url: () => '',
111+
brand_primary_color: () => '#0a0c10',
112+
brand_accent_color: () => '#d4af62',
93113
};
94114

95115
const KEYS = Object.keys(FALLBACK);

0 commit comments

Comments
 (0)