branding-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ // Shared headless-Chromium plumbing for the renderers that need live text (the
2
+ // social cards and the brand sheet; the mark and wordmark are pure SVG and need
3
+ // none of this). Centralizes the @font-face embed and the launch / fonts-ready /
4
+ // close dance, and lets a whole build reuse one browser instead of launching one
5
+ // per kit.
6
+ import { readFileSync } from 'node:fs';
7
+ import { fontPath } from './font.mjs';
8
+
9
+ // The bundled font embedded as a data URI, as an @font-face for `family`.
10
+ export function fontFaceCss(family) {
11
+ const b64 = readFileSync(fontPath()).toString('base64');
12
+ return `@font-face{font-family:${family};font-weight:200 900;font-display:block;` +
13
+ `src:url(data:font/woff2;base64,${b64}) format('woff2')}`;
14
+ }
15
+
16
+ // Playwright is an optional dependency: only the sheet and social-card renderers
17
+ // need a browser, so it's imported lazily. The mark/wordmark/icons path never
18
+ // touches this and installs lean.
19
+ export async function launchBrowser() {
20
+ let chromium;
21
+ try {
22
+ ({ chromium } = await import('@playwright/test'));
23
+ } catch {
24
+ throw new Error(
25
+ 'Brand sheets and social cards need Playwright. Install it with:\n' +
26
+ ' npm i -D @playwright/test && npx playwright install chromium\n' +
27
+ 'The mark, wordmark, and icons need none of this.',
28
+ );
29
+ }
30
+ return chromium.launch();
31
+ }
32
+
33
+ // Open a page on `browser`, load `html`, wait for webfonts, hand the page to
34
+ // `fn` (which takes the screenshots), and always close the page. The browser is
35
+ // the caller's to own and reuse.
36
+ export async function withPage(browser, { html, viewport, deviceScaleFactor = 2 }, fn) {
37
+ const page = await browser.newPage({ viewport, deviceScaleFactor });
38
+ try {
39
+ await page.setContent(html, { waitUntil: 'load' });
40
+ await page.evaluate(async () => { await document.fonts.ready; });
41
+ return await fn(page);
42
+ } finally {
43
+ await page.close();
44
+ }
45
+ }
46
+
47
+ // For one-off CLIs: launch a browser just for `fn`, then close it. The build
48
+ // passes its own shared browser to withPage directly instead.
49
+ export async function withBrowser(fn) {
50
+ const browser = await launchBrowser();
51
+ try {
52
+ return await fn(browser);
53
+ } finally {
54
+ await browser.close();
55
+ }
56
+ }
@@ -0,0 +1,100 @@
1
+ // Build a horizontal lockup as a single self-contained SVG: the mark tile, then
2
+ // the name set in real Inter outlines, on one baseline. No raster, no headless
3
+ // browser. The tile keeps its surface accent (white glyph); the text ink is
4
+ // `currentColor` by default, so one SVG serves both light and dark backgrounds.
5
+ //
6
+ // Outlines come from the wordmark glyph cache (extracted at the wordmark weight,
7
+ // which is lighter than the mark's), so this stays pure node like mark.mjs.
8
+ import { markSvg } from './mark.mjs';
9
+ import { normalizeHex } from './color.mjs';
10
+ import { loadGlyphs, wordmarkGlyphFile } from './glyphs.mjs';
11
+
12
+ // Proportions are expressed against the tile so the lockup scales as one unit.
13
+ // The 128/36/96 ratios match the original lockup; letter-spacing matches the
14
+ // site's .brand (caps) and the title-case lockup respectively.
15
+ const TILE = 256;
16
+ const PAD = (TILE * 24) / 128; // breathing room around the lockup
17
+ const GAP = (TILE * 36) / 128; // tile-to-text gap
18
+ const FONT_PX = (TILE * 96) / 128; // text size relative to the tile
19
+ const LETTER_SPACING = { title: -0.03125, caps: -0.02 }; // em
20
+
21
+ // Read glyphs lazily so a cache (re)written earlier in the same process, or a
22
+ // BRAND_WORDMARK_GLYPHS set by build.mjs, is honored on first render.
23
+ let _glyphData;
24
+ function glyphData() {
25
+ if (!_glyphData) _glyphData = loadGlyphs(wordmarkGlyphFile());
26
+ return _glyphData;
27
+ }
28
+
29
+ // Lay the text out in font units and measure its ink box. Blank glyphs (space)
30
+ // carry advance but no path, so they push the pen without affecting the bounds.
31
+ function layoutText(text, letterSpacingEm) {
32
+ const { unitsPerEm, glyphs } = glyphData();
33
+ const ls = letterSpacingEm * unitsPerEm;
34
+ const chars = [...text];
35
+ let penX = 0;
36
+ const placed = [];
37
+ chars.forEach((ch, i) => {
38
+ const g = glyphs[ch];
39
+ if (!g) {
40
+ throw new Error(
41
+ `No wordmark outline for "${ch}". Available: ${Object.keys(glyphs).join('')}. ` +
42
+ `Re-run with this text so build extracts it, or extend ${wordmarkGlyphFile()}.`,
43
+ );
44
+ }
45
+ placed.push({ x: penX, g });
46
+ penX += g.advance + (i < chars.length - 1 ? ls : 0);
47
+ });
48
+ let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
49
+ for (const { x, g } of placed) {
50
+ if (!g.path) continue;
51
+ xMin = Math.min(xMin, x + g.bounds.xMin);
52
+ xMax = Math.max(xMax, x + g.bounds.xMax);
53
+ yMin = Math.min(yMin, g.bounds.yMin);
54
+ yMax = Math.max(yMax, g.bounds.yMax);
55
+ }
56
+ return { placed, xMin, xMax, cy: (yMin + yMax) / 2 };
57
+ }
58
+
59
+ /**
60
+ * Build the wordmark lockup as a self-contained SVG string.
61
+ * @param {object} opts
62
+ * @param {string} opts.tileHex tile accent fill
63
+ * @param {string} opts.text the name (case as stored)
64
+ * @param {string} [opts.glyph='JS'] tile monogram
65
+ * @param {string} [opts.ink='currentColor'] text fill (explicit color for PNGs)
66
+ * @param {boolean} [opts.caps=false] uppercase the text (matches the site)
67
+ */
68
+ export function wordmarkSvg({ tileHex, text, glyph = 'JS', ink = 'currentColor', caps = false }) {
69
+ const fill = normalizeHex(tileHex);
70
+ const rendered = caps ? text.toUpperCase() : text;
71
+ const { unitsPerEm } = glyphData();
72
+ const s = FONT_PX / unitsPerEm;
73
+ const { placed, xMin, xMax, cy } = layoutText(rendered, caps ? LETTER_SPACING.caps : LETTER_SPACING.title);
74
+
75
+ const H = TILE + 2 * PAD;
76
+ const x0 = PAD + TILE + GAP; // text left edge
77
+ const W = +(x0 + s * (xMax - xMin) + PAD).toFixed(2);
78
+
79
+ // The tile reuses markSvg; strip its outer <svg> and place it as a group so we
80
+ // avoid nesting a full svg inside another.
81
+ const tileInner = markSvg({ size: TILE, rounded: true, bg: fill, glyph })
82
+ .replace(/^<svg[^>]*>/, '')
83
+ .replace(/<\/svg>$/, '');
84
+ const tile = `<g transform="translate(${PAD} ${PAD})">${tileInner}</g>`;
85
+
86
+ // Glyph outlines are y-up (font space); flip them, left-align at x0, centre the
87
+ // ink box on the tile's vertical centre.
88
+ const paths = placed
89
+ .filter((p) => p.g.path)
90
+ .map(({ x, g }) => `<path transform="translate(${x.toFixed(2)} 0)" d="${g.path}"/>`)
91
+ .join('');
92
+ const textTransform =
93
+ `translate(${x0} ${H / 2}) scale(${s.toFixed(5)} ${(-s).toFixed(5)}) ` +
94
+ `translate(${(-xMin).toFixed(2)} ${(-cy).toFixed(2)})`;
95
+
96
+ return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">`
97
+ + tile
98
+ + `<g fill="${ink}" transform="${textTransform}">${paths}</g>`
99
+ + `</svg>`;
100
+ }
@@ -0,0 +1,28 @@
1
+ // Render a set of social cards into <outDir>/cards/. The card copy, palette, and
2
+ // photo are passed in by the caller (the build reads them from a brand config),
3
+ // so nothing brand-specific is hardcoded here.
4
+ import { mkdirSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { renderCard } from './lib/card.mjs';
7
+ import { withBrowser } from './lib/render.mjs';
8
+
9
+ export async function makeCards({ cards, colors, photoPath, outDir, browser }) {
10
+ if (!cards || !cards.length) return;
11
+ const dir = path.join(outDir, 'cards');
12
+ mkdirSync(dir, { recursive: true });
13
+
14
+ const render = async (b) => {
15
+ for (const c of cards) {
16
+ await renderCard(b, {
17
+ width: c.width, height: c.height, photoWidth: c.photoWidth,
18
+ eyebrow: c.eyebrow, name: c.name, tagline: c.tagline, meta: c.meta, url: c.url,
19
+ colors, photoPath,
20
+ outPath: path.join(dir, c.file),
21
+ });
22
+ }
23
+ };
24
+ // Reuse the build's browser when given one; otherwise launch our own.
25
+ if (browser) await render(browser);
26
+ else await withBrowser(render);
27
+ console.log(` cards ${cards.map((c) => c.file).join(', ')}`);
28
+ }
@@ -0,0 +1,45 @@
1
+ // Generate a full icon set for one surface: the shared monogram in a given accent
2
+ // color, into <outDir>/<slug>/icons/ (favicon svg/ico, favicon-32/192,
3
+ // apple-touch-icon at 180 full-bleed) and <outDir>/<slug>/mark/ (mark svg +
4
+ // 512/1024 + transparent light/dark).
5
+ import { Buffer } from 'node:buffer';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import sharp from 'sharp';
9
+ import { markSvg } from './lib/mark.mjs';
10
+ import { normalizeHex } from './lib/color.mjs';
11
+ import { pngsToIco } from './lib/ico.mjs';
12
+
13
+ export async function makeMark({ slug, hex, glyph = 'JS', outDir }) {
14
+ const fill = normalizeHex(hex);
15
+ const iconsDir = path.join(outDir, slug, 'icons');
16
+ const markDir = path.join(outDir, slug, 'mark');
17
+ fs.mkdirSync(iconsDir, { recursive: true });
18
+ fs.mkdirSync(markDir, { recursive: true });
19
+
20
+ const rounded = markSvg({ size: 512, rounded: true, bg: fill, glyph });
21
+ const square = markSvg({ size: 512, rounded: false, bg: fill, glyph });
22
+ const transLight = markSvg({ size: 1024, rounded: true, bg: null, fg: fill, glyph });
23
+ const transDark = markSvg({ size: 1024, rounded: true, bg: null, fg: '#ffffff', glyph });
24
+
25
+ const png = (svg, size) => sharp(Buffer.from(svg)).resize(size, size).png().toBuffer();
26
+ const icon = (name, buf) => fs.writeFileSync(path.join(iconsDir, name), buf);
27
+ const mk = (name, buf) => fs.writeFileSync(path.join(markDir, name), buf);
28
+
29
+ icon('favicon.svg', markSvg({ size: 64, rounded: true, bg: fill, glyph }));
30
+ icon('favicon-32.png', await png(rounded, 32));
31
+ icon('favicon-192.png', await png(rounded, 192));
32
+ icon('apple-touch-icon.png', await png(square, 180));
33
+ icon('favicon.ico', pngsToIco([
34
+ { size: 16, buffer: await png(rounded, 16) },
35
+ { size: 32, buffer: await png(rounded, 32) },
36
+ ]));
37
+
38
+ mk('mark.svg', rounded);
39
+ mk('mark-512.png', await png(rounded, 512));
40
+ mk('mark-1024.png', await png(rounded, 1024));
41
+ mk('mark-transparent-light.png', await png(transLight, 1024));
42
+ mk('mark-transparent-dark.png', await png(transDark, 1024));
43
+
44
+ console.log(` mark ${slug.padEnd(12)} ${glyph} on ${fill}`);
45
+ }
@@ -0,0 +1,170 @@
1
+ // Generate a brand sheet for one kit into <outDir>/<slug>/: a rendered one-pager
2
+ // (overview.png, in the kit's own font) plus a README.md that inlines the poster
3
+ // and the kit's assets from the same folder. Reads BRAND_FONT (live text) and
4
+ // BRAND_GLYPHS (mark outlines) like the others.
5
+ import { Buffer } from 'node:buffer';
6
+ import { mkdirSync, writeFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import sharp from 'sharp';
9
+ import { markSvg } from './lib/mark.mjs';
10
+ import { fontPath } from './lib/font.mjs';
11
+ import { darken, normalizeHex } from './lib/color.mjs';
12
+ import { esc } from './lib/html.mjs';
13
+ import { fontFaceCss, withBrowser, withPage } from './lib/render.mjs';
14
+
15
+ const b64 = (s) => Buffer.from(s).toString('base64');
16
+
17
+ // "inter-variable-latin.woff2" -> "Inter"; "Arial Unicode.ttf" -> "Arial".
18
+ function prettyFont(file) {
19
+ const stem = file.replace(/\.[^.]+$/, '').split(/[-_ ]/)[0];
20
+ return stem.charAt(0).toUpperCase() + stem.slice(1);
21
+ }
22
+
23
+ export async function makeSheet({ slug, hex, glyph = 'JS', wordmark, deep, browser, outDir }) {
24
+ const fill = normalizeHex(hex);
25
+ const deepShade = deep ? normalizeHex(deep) : darken(fill);
26
+ const title = wordmark || glyph;
27
+
28
+ const font = fontPath();
29
+ const fontFile = path.basename(font);
30
+ const fontLabel = prettyFont(fontFile);
31
+
32
+ const rounded = b64(markSvg({ size: 512, rounded: true, bg: fill, glyph }));
33
+ const tLight = b64(markSvg({ size: 512, rounded: true, bg: null, fg: fill, glyph }));
34
+ const tDark = b64(markSvg({ size: 512, rounded: true, bg: null, fg: '#ffffff', glyph }));
35
+
36
+ const kitDir = path.join(outDir, slug);
37
+ const sheetDir = path.join(kitDir, 'sheet');
38
+ mkdirSync(sheetDir, { recursive: true });
39
+
40
+ const wordmarkBlock = wordmark
41
+ ? '\n## Wordmark\n\n![wordmark](wordmark/wordmark-light.png)\n\n' +
42
+ '![wordmark caps](wordmark/wordmark-caps-light.png)\n\n' +
43
+ 'Vector-first: `wordmark.svg` (text in `currentColor`) is the source; the ' +
44
+ '`-light/-dark` PNGs rasterize from it. Title case and all-caps both ship.\n'
45
+ : '';
46
+
47
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>
48
+ ${fontFaceCss('Brand')}
49
+ *{margin:0;padding:0;box-sizing:border-box}
50
+ :root{--accent:${fill};--deep:${deepShade};--ink:#0b0620;--muted:#6b6b73;--line:#ececf0}
51
+ html,body{width:1200px}
52
+ body{font-family:Brand,system-ui,sans-serif;background:#fff;color:var(--ink);-webkit-font-smoothing:antialiased;padding:84px 88px}
53
+ .eyebrow{font-size:18px;font-weight:700;letter-spacing:4px;text-transform:uppercase;color:var(--accent)}
54
+ header{display:flex;align-items:center;gap:34px;padding-bottom:40px;border-bottom:3px solid var(--accent)}
55
+ header>img{width:120px;height:120px}
56
+ .name{font-size:84px;font-weight:800;letter-spacing:-3px;line-height:1}
57
+ .sub{font-size:21px;font-weight:500;color:var(--muted);margin-top:8px}
58
+ section{padding:46px 0;border-bottom:1px solid var(--line)}
59
+ h2{font-size:15px;font-weight:700;letter-spacing:3px;text-transform:uppercase;color:var(--muted);margin-bottom:28px}
60
+ .marks{display:flex;align-items:center;gap:28px}
61
+ .marks>img,.chip{width:132px;height:132px;border-radius:26px}
62
+ .chip{display:flex;align-items:center;justify-content:center}
63
+ .chip img{width:100%;height:100%;display:block}
64
+ .chip.paper{border:1px solid var(--line)}
65
+ .chip.dark{background:#0b1220}
66
+ .swatches{display:flex;gap:22px}
67
+ .sw{flex:1}
68
+ .box{height:118px;border-radius:16px;border:1px solid var(--line)}
69
+ .lab{margin-top:12px;font-size:18px;font-weight:700}
70
+ .hex{font-size:15px;color:var(--muted);font-variant-numeric:tabular-nums}
71
+ .big{font-size:128px;font-weight:800;letter-spacing:-5px;line-height:1}
72
+ .alpha{font-size:33px;font-weight:600;letter-spacing:1px;margin-top:22px;line-height:1.5}
73
+ .row{font-size:28px;margin-top:14px}
74
+ .w4{font-weight:400}.w6{font-weight:600}.w8{font-weight:800}
75
+ footer{padding-top:38px;font-size:16px;color:var(--muted);display:flex;justify-content:space-between}
76
+ </style></head><body>
77
+ <header>
78
+ <img src="data:image/svg+xml;base64,${rounded}">
79
+ <div><div class="eyebrow">Brand Kit</div><div class="name">${esc(title)}</div><div class="sub">${esc(slug)} &middot; ${esc(fill)} &middot; ${esc(fontLabel)}</div></div>
80
+ </header>
81
+ <section class="sec-mark">
82
+ <h2>Mark</h2>
83
+ <div class="marks">
84
+ <img src="data:image/svg+xml;base64,${rounded}">
85
+ <div class="chip paper"><img src="data:image/svg+xml;base64,${tLight}"></div>
86
+ <div class="chip dark"><img src="data:image/svg+xml;base64,${tDark}"></div>
87
+ </div>
88
+ </section>
89
+ <section class="sec-color">
90
+ <h2>Color</h2>
91
+ <div class="swatches">
92
+ <div class="sw"><div class="box" style="background:${fill}"></div><div class="lab">Accent</div><div class="hex">${fill}</div></div>
93
+ <div class="sw"><div class="box" style="background:${deepShade}"></div><div class="lab">Deep</div><div class="hex">${deepShade}</div></div>
94
+ <div class="sw"><div class="box" style="background:#0b0620"></div><div class="lab">Ink</div><div class="hex">#0b0620</div></div>
95
+ <div class="sw"><div class="box" style="background:#fff"></div><div class="lab">Paper</div><div class="hex">#ffffff</div></div>
96
+ </div>
97
+ </section>
98
+ <section class="sec-type">
99
+ <h2>Type &middot; ${esc(fontLabel)}</h2>
100
+ <div class="big">Aa</div>
101
+ <div class="alpha">ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>abcdefghijklmnopqrstuvwxyz 0123456789</div>
102
+ <div class="row w4">Regular: hands-on security and infrastructure.</div>
103
+ <div class="row w6">Semibold: hands-on security and infrastructure.</div>
104
+ <div class="row w8">Black: hands-on security and infrastructure.</div>
105
+ </section>
106
+ <footer><span>${esc(title)} &middot; Brand Kit</span><span>${esc(fontLabel)}</span></footer>
107
+ </body></html>`;
108
+
109
+ // The poster, plus each section as its own library image the README inlines.
110
+ const shoot = (b) =>
111
+ withPage(b, { html, viewport: { width: 1200, height: 800 } }, async (page) => ({
112
+ overview: await page.screenshot({ type: 'png', fullPage: true }),
113
+ mark: await page.locator('.sec-mark').screenshot(),
114
+ palette: await page.locator('.sec-color').screenshot(),
115
+ type: await page.locator('.sec-type').screenshot(),
116
+ }));
117
+ // Reuse the build's browser when given one; otherwise launch our own.
118
+ const shots = browser ? await shoot(browser) : await withBrowser(shoot);
119
+
120
+ const save = (buf, name, w) => sharp(buf).resize({ width: w }).png().toFile(path.join(sheetDir, name));
121
+ await save(shots.overview, 'overview.png', 1200);
122
+ await save(shots.mark, 'sheet-mark.png', 1024);
123
+ await save(shots.palette, 'palette.png', 1024);
124
+ await save(shots.type, 'type-specimen.png', 1024);
125
+
126
+ const md = `# ${title} Brand Kit
127
+
128
+ The full kit: the poster up top, each section's own image below, then the files,
129
+ all generated from the brand's mark, color, and font.
130
+
131
+ ![${title} brand overview](sheet/overview.png)
132
+
133
+ ## Mark
134
+
135
+ ![mark](sheet/sheet-mark.png)
136
+
137
+ Rounded accent tile with a white glyph (default); accent glyph and white glyph on
138
+ transparent for light and dark surfaces. In \`mark/\`: \`mark.svg\`,
139
+ \`mark-512/1024.png\`, \`mark-transparent-light/dark.png\`.
140
+
141
+ ## Color
142
+
143
+ ![palette](sheet/palette.png)
144
+
145
+ | Role | Hex |
146
+ |---|---|
147
+ | Accent | \`${fill}\` |
148
+ | Deep | \`${deepShade}\` |
149
+ | Ink | \`#0b0620\` |
150
+ | Paper | \`#ffffff\` |
151
+
152
+ CSS custom properties in \`web/tokens.css\`.
153
+
154
+ ## Type
155
+
156
+ ![type specimen](sheet/type-specimen.png)
157
+
158
+ ${fontLabel} (\`${fontFile}\`).
159
+ ${wordmarkBlock}
160
+ ## Folders
161
+
162
+ - \`icons/\` favicons + apple-touch
163
+ - \`mark/\` the mark: vector, raster, transparent light/dark
164
+ - \`wordmark/\` horizontal lockups: \`wordmark.svg\` + light/dark PNGs, title + caps
165
+ - \`sheet/\` this overview and its section images
166
+ - \`web/\` \`tokens.css\`, \`site.webmanifest\`, \`head.html\` (drop-in wiring)
167
+ `;
168
+ writeFileSync(path.join(kitDir, 'README.md'), md);
169
+ console.log(` sheet ${slug.padEnd(12)} sheet/ + README.md`);
170
+ }
@@ -0,0 +1,52 @@
1
+ // Generate the drop-in web wiring for a kit into <outDir>/<slug>/web/:
2
+ // tokens.css the palette as CSS custom properties
3
+ // site.webmanifest PWA/icon manifest
4
+ // head.html the favicon + theme-color <link> block, copy-paste ready
5
+ import { mkdirSync, writeFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { darken, normalizeHex } from './lib/color.mjs';
8
+
9
+ export function makeWeb({ slug, hex, glyph = 'JS', name, deep, onColor, outDir }) {
10
+ const accent = normalizeHex(hex);
11
+ const deepShade = deep ? normalizeHex(deep) : darken(accent);
12
+ const onAccent = onColor ? normalizeHex(onColor) : '#ffffff';
13
+ const label = name || slug;
14
+ const webDir = path.join(outDir, slug, 'web');
15
+ mkdirSync(webDir, { recursive: true });
16
+
17
+ writeFileSync(
18
+ path.join(webDir, 'tokens.css'),
19
+ `:root {\n` +
20
+ ` --brand-accent: ${accent};\n` +
21
+ ` --brand-deep: ${deepShade};\n` +
22
+ ` --brand-on-accent: ${onAccent};\n` +
23
+ ` --brand-ink: #0b0620;\n` +
24
+ ` --brand-paper: #ffffff;\n` +
25
+ `}\n`,
26
+ );
27
+
28
+ const manifest = {
29
+ name: label,
30
+ short_name: glyph,
31
+ icons: [
32
+ { src: '/icons/favicon-192.png', sizes: '192x192', type: 'image/png' },
33
+ { src: '/icons/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
34
+ ],
35
+ theme_color: accent,
36
+ background_color: '#ffffff',
37
+ display: 'standalone',
38
+ };
39
+ writeFileSync(path.join(webDir, 'site.webmanifest'), JSON.stringify(manifest, null, 2) + '\n');
40
+
41
+ writeFileSync(
42
+ path.join(webDir, 'head.html'),
43
+ `<!-- Adjust the paths to wherever you deploy the icons. -->\n` +
44
+ `<link rel="icon" href="/favicon.ico" sizes="any">\n` +
45
+ `<link rel="icon" type="image/svg+xml" href="/icons/favicon.svg">\n` +
46
+ `<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">\n` +
47
+ `<link rel="manifest" href="/site.webmanifest">\n` +
48
+ `<meta name="theme-color" content="${accent}">\n`,
49
+ );
50
+
51
+ console.log(` web ${slug.padEnd(12)} tokens.css, site.webmanifest, head.html`);
52
+ }
@@ -0,0 +1,42 @@
1
+ // Render the horizontal lockup (mark tile + name) for one surface into
2
+ // <outDir>/<slug>/wordmark/, SVG-first: a vector wordmark.svg is the source, and
3
+ // the light/dark PNGs are rasterized from it. Two cases ship: title ("Joe
4
+ // Severino") and all-caps ("JOE SEVERINO").
5
+ import { Buffer } from 'node:buffer';
6
+ import { mkdirSync, writeFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import sharp from 'sharp';
9
+ import { wordmarkSvg } from './lib/wordmark.mjs';
10
+ import { WORDMARK_CHARS, ensureGlyphs, wordmarkGlyphFile } from './lib/glyphs.mjs';
11
+
12
+ const INK = { light: '#0b0620', dark: '#ffffff' }; // ink for light / dark backgrounds
13
+ const PNG_HEIGHT = 512; // raster export height; SVG stays the source of truth
14
+ const VARIANTS = [
15
+ { caps: false, base: 'wordmark' },
16
+ { caps: true, base: 'wordmark-caps' },
17
+ ];
18
+
19
+ export async function makeWordmark({ slug, hex, text, glyph = 'JS', weight = 700, outDir }) {
20
+ const dir = path.join(outDir, slug, 'wordmark');
21
+ mkdirSync(dir, { recursive: true });
22
+
23
+ // The build pre-extracts, but a one-off kit lands straight here, so make sure
24
+ // the cache exists. It bundles the whole alphabet (WORDMARK_CHARS) so a one-off
25
+ // never overwrites the shared set with just its own name's letters.
26
+ ensureGlyphs({ file: wordmarkGlyphFile(), weight, chars: WORDMARK_CHARS, label: 'wordmark glyphs' });
27
+
28
+ const toPng = (svg) => sharp(Buffer.from(svg)).resize({ height: PNG_HEIGHT }).png().toBuffer();
29
+ for (const { caps, base } of VARIANTS) {
30
+ writeFileSync(
31
+ path.join(dir, `${base}.svg`),
32
+ wordmarkSvg({ tileHex: hex, text, glyph, caps, ink: 'currentColor' }),
33
+ );
34
+ for (const [name, ink] of Object.entries(INK)) {
35
+ writeFileSync(
36
+ path.join(dir, `${base}-${name}.png`),
37
+ await toPng(wordmarkSvg({ tileHex: hex, text, glyph, caps, ink })),
38
+ );
39
+ }
40
+ }
41
+ console.log(` wordmark ${slug.padEnd(12)} "${text}" + caps`);
42
+ }
package/src/site.mjs ADDED
@@ -0,0 +1,116 @@
1
+ // Site plug-and-play. `initSite` scaffolds a flat brand config (and an npm
2
+ // script) into a project; `generateSite` reads it and writes a favicon set,
3
+ // web manifest, and CSS tokens into the project's public/ directory, at the
4
+ // root paths a static site (Astro, Eleventy, plain HTML) expects.
5
+ //
6
+ // This path is pure vector + sharp, so it needs no headless browser. The richer
7
+ // kit (wordmark lockups, social cards, brand sheet) comes from `build` / `kit`.
8
+ import { Buffer } from 'node:buffer';
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import sharp from 'sharp';
12
+ import { markSvg } from './lib/mark.mjs';
13
+ import { darken, normalizeHex } from './lib/color.mjs';
14
+ import { pngsToIco } from './lib/ico.mjs';
15
+
16
+ const DEFAULT_CONFIG = {
17
+ name: 'My Site',
18
+ accent: '#2563EB',
19
+ glyph: 'MS',
20
+ wordmark: 'My Site',
21
+ };
22
+
23
+ // The <head> block that wires the generated files. theme-color reflects accent.
24
+ function headSnippet(accent) {
25
+ return [
26
+ '<link rel="icon" href="/favicon.ico" sizes="any" />',
27
+ '<link rel="icon" type="image/svg+xml" href="/favicon.svg" />',
28
+ '<link rel="apple-touch-icon" href="/apple-touch-icon.png" />',
29
+ '<link rel="manifest" href="/site.webmanifest" />',
30
+ `<meta name="theme-color" content="${accent}" />`,
31
+ ].join('\n');
32
+ }
33
+
34
+ function loadConfig(configPath, cwd) {
35
+ const file = configPath ? path.resolve(cwd, configPath) : path.join(cwd, 'brand.config.json');
36
+ if (!existsSync(file)) {
37
+ throw new Error(`No brand config at ${file}. Run \`branding-engine init\` first.`);
38
+ }
39
+ return JSON.parse(readFileSync(file, 'utf8'));
40
+ }
41
+
42
+ /** Scaffold brand.config.json (and a `brand` npm script) into a project. */
43
+ export function initSite({ cwd = process.cwd() } = {}) {
44
+ const created = [];
45
+ const cfgPath = path.join(cwd, 'brand.config.json');
46
+ if (existsSync(cfgPath)) {
47
+ console.log('brand.config.json already exists, leaving it untouched.');
48
+ } else {
49
+ writeFileSync(cfgPath, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
50
+ created.push('brand.config.json');
51
+ }
52
+
53
+ const pkgPath = path.join(cwd, 'package.json');
54
+ if (existsSync(pkgPath)) {
55
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
56
+ pkg.scripts = pkg.scripts || {};
57
+ if (!pkg.scripts.brand) {
58
+ pkg.scripts.brand = 'branding-engine generate';
59
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
60
+ created.push('package.json ("brand" script)');
61
+ }
62
+ }
63
+ return { created, headSnippet: headSnippet(DEFAULT_CONFIG.accent) };
64
+ }
65
+
66
+ /** Generate the favicon set, manifest, and CSS tokens into <publicDir>/. */
67
+ export async function generateSite({ config, publicDir = 'public', cwd = process.cwd() } = {}) {
68
+ const cfg = loadConfig(config, cwd);
69
+ const accent = normalizeHex(cfg.accent);
70
+ const onAccent = cfg.onColor ? normalizeHex(cfg.onColor) : '#ffffff';
71
+ const deep = cfg.deep ? normalizeHex(cfg.deep) : darken(accent);
72
+ const glyph = cfg.glyph || 'JS';
73
+ const name = cfg.name || 'Site';
74
+
75
+ const pub = path.resolve(cwd, publicDir);
76
+ mkdirSync(pub, { recursive: true });
77
+
78
+ const rounded = markSvg({ size: 512, rounded: true, bg: accent, fg: onAccent, glyph });
79
+ const square = markSvg({ size: 512, rounded: false, bg: accent, fg: onAccent, glyph });
80
+ const png = (svg, size) => sharp(Buffer.from(svg)).resize(size, size).png().toBuffer();
81
+ const write = (name_, buf) => writeFileSync(path.join(pub, name_), buf);
82
+
83
+ write('favicon.svg', markSvg({ size: 64, rounded: true, bg: accent, fg: onAccent, glyph }));
84
+ write('favicon-32.png', await png(rounded, 32));
85
+ write('favicon-192.png', await png(rounded, 192));
86
+ write('apple-touch-icon.png', await png(square, 180));
87
+ write('favicon.ico', pngsToIco([
88
+ { size: 16, buffer: await png(rounded, 16) },
89
+ { size: 32, buffer: await png(rounded, 32) },
90
+ ]));
91
+ write('site.webmanifest', JSON.stringify({
92
+ name,
93
+ short_name: glyph,
94
+ icons: [
95
+ { src: '/favicon-192.png', sizes: '192x192', type: 'image/png' },
96
+ { src: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
97
+ ],
98
+ theme_color: accent,
99
+ background_color: '#ffffff',
100
+ display: 'standalone',
101
+ }, null, 2) + '\n');
102
+ write('brand-tokens.css',
103
+ `:root {\n` +
104
+ ` --brand-accent: ${accent};\n` +
105
+ ` --brand-deep: ${deep};\n` +
106
+ ` --brand-on-accent: ${onAccent};\n` +
107
+ ` --brand-ink: #0b0620;\n` +
108
+ ` --brand-paper: #ffffff;\n` +
109
+ `}\n`);
110
+
111
+ const written = [
112
+ 'favicon.svg', 'favicon-32.png', 'favicon-192.png', 'apple-touch-icon.png',
113
+ 'favicon.ico', 'site.webmanifest', 'brand-tokens.css',
114
+ ];
115
+ return { publicDir: pub, written, headSnippet: headSnippet(accent) };
116
+ }