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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/assets/fonts/inter/NOTICE.md +11 -0
- package/assets/fonts/inter/inter-variable-latin.woff2 +0 -0
- package/assets/glyphs/brand-glyphs.json +367 -0
- package/assets/glyphs/wordmark-glyphs.json +637 -0
- package/bin/cli.mjs +66 -0
- package/index.mjs +15 -0
- package/package.json +50 -0
- package/requirements.txt +3 -0
- package/src/build.mjs +146 -0
- package/src/lib/card.mjs +54 -0
- package/src/lib/color.mjs +21 -0
- package/src/lib/extract-glyphs.py +75 -0
- package/src/lib/font.mjs +17 -0
- package/src/lib/glyphs.mjs +93 -0
- package/src/lib/html.mjs +3 -0
- package/src/lib/ico.mjs +23 -0
- package/src/lib/mark.mjs +73 -0
- package/src/lib/render.mjs +56 -0
- package/src/lib/wordmark.mjs +100 -0
- package/src/make-cards.mjs +28 -0
- package/src/make-mark.mjs +45 -0
- package/src/make-sheet.mjs +170 -0
- package/src/make-web.mjs +52 -0
- package/src/make-wordmark.mjs +42 -0
- package/src/site.mjs +116 -0
|
@@ -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\n\n' +
|
|
42
|
+
'\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)} · ${esc(fill)} · ${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 · ${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)} · 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
|
+

|
|
132
|
+
|
|
133
|
+
## Mark
|
|
134
|
+
|
|
135
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+
}
|
package/src/make-web.mjs
ADDED
|
@@ -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
|
+
}
|