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/bin/cli.mjs ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // branding-engine CLI.
3
+ // branding-engine init scaffold a site brand.config + npm script
4
+ // branding-engine generate [--public <dir>] [--config <file>] favicons + manifest + tokens -> public/
5
+ // branding-engine build [--config <dir|brand.json>] [--out <dir>] [--only a,b]
6
+ // branding-engine kit <slug> <hex> <initials> ["Wordmark"] [--font f] [--out d] [--only a,b]
7
+ // Stages for --only: mark, wordmark, sheet, web, cards (mark includes favicons).
8
+ import { buildBrand, buildKit } from '../src/build.mjs';
9
+ import { generateSite, initSite } from '../src/site.mjs';
10
+
11
+ function parse(argv) {
12
+ const pos = [];
13
+ const opt = {};
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const a = argv[i];
16
+ if (a.startsWith('--')) {
17
+ const key = a.slice(2);
18
+ const next = argv[i + 1];
19
+ opt[key] = next && !next.startsWith('--') ? argv[++i] : true;
20
+ } else {
21
+ pos.push(a);
22
+ }
23
+ }
24
+ return { pos, opt };
25
+ }
26
+
27
+ const USAGE =
28
+ 'Usage:\n' +
29
+ ' branding-engine init scaffold brand.config.json + a `brand` npm script\n' +
30
+ ' branding-engine generate [--public <dir>] [--config <file>] favicons + manifest + tokens -> public/\n' +
31
+ ' branding-engine build [--config <dir|brand.json>] [--out <dir>] [--only mark,wordmark,sheet,web,cards]\n' +
32
+ ' branding-engine kit <slug> <hex> <initials> ["Wordmark"] [--font <file>] [--out <dir>] [--only ...]';
33
+
34
+ const [cmd, ...rest] = process.argv.slice(2);
35
+ const { pos, opt } = parse(rest);
36
+
37
+ try {
38
+ if (cmd === 'init') {
39
+ const { created, headSnippet } = initSite({});
40
+ if (created.length) console.log('Created:\n' + created.map((c) => ' ' + c).join('\n'));
41
+ console.log('\nNext: edit brand.config.json (accent, glyph, name), then run `npm run brand`.');
42
+ console.log('\nAdd this to your site <head> (or import public/brand-tokens.css for the CSS vars):\n');
43
+ console.log(headSnippet + '\n');
44
+ } else if (cmd === 'generate') {
45
+ const { written, publicDir, headSnippet } = await generateSite({ config: opt.config, publicDir: opt.public });
46
+ console.log(`Wrote ${written.length} files to ${publicDir}:`);
47
+ console.log(written.map((w) => ' ' + w).join('\n'));
48
+ console.log('\n<head> snippet (theme-color reflects your accent):\n');
49
+ console.log(headSnippet);
50
+ } else if (cmd === 'build') {
51
+ await buildBrand({ config: opt.config, outDir: opt.out, only: opt.only });
52
+ } else if (cmd === 'kit') {
53
+ const [slug, hex, glyph, wordmark] = pos;
54
+ if (!slug || !hex || !glyph) {
55
+ console.error(USAGE);
56
+ process.exit(1);
57
+ }
58
+ await buildKit({ slug, hex, glyph, wordmark, font: opt.font, outDir: opt.out, only: opt.only });
59
+ } else {
60
+ console.error(USAGE);
61
+ process.exit(cmd ? 1 : 0);
62
+ }
63
+ } catch (err) {
64
+ console.error(`\n${err.message}`);
65
+ process.exit(1);
66
+ }
package/index.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // Programmatic API. The CLI (bin/cli.mjs) is a thin wrapper over buildBrand /
2
+ // buildKit; import these directly to embed the engine in a build pipeline.
3
+ export { buildBrand, buildKit } from './src/build.mjs';
4
+ export { initSite, generateSite } from './src/site.mjs';
5
+ export { makeMark } from './src/make-mark.mjs';
6
+ export { makeWordmark } from './src/make-wordmark.mjs';
7
+ export { makeSheet } from './src/make-sheet.mjs';
8
+ export { makeWeb } from './src/make-web.mjs';
9
+ export { makeCards } from './src/make-cards.mjs';
10
+ export { markSvg } from './src/lib/mark.mjs';
11
+ export { wordmarkSvg } from './src/lib/wordmark.mjs';
12
+ // Lower-level primitives for embedding the renderers in a custom pipeline (e.g.
13
+ // a site that writes brand assets to its own paths).
14
+ export { renderCard } from './src/lib/card.mjs';
15
+ export { launchBrowser } from './src/lib/render.mjs';
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "branding-engine",
3
+ "version": "0.1.0",
4
+ "description": "Generate a complete brand kit — favicon set, vector mark, wordmark lockups, social cards, brand sheet, and web tokens — from one accent color and a monogram.",
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "bin": {
10
+ "branding-engine": "bin/cli.mjs"
11
+ },
12
+ "exports": {
13
+ ".": "./index.mjs"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "assets",
19
+ "index.mjs",
20
+ "requirements.txt"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/joeseverino/branding-engine.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/joeseverino/branding-engine/issues"
31
+ },
32
+ "homepage": "https://github.com/joeseverino/branding-engine#readme",
33
+ "keywords": [
34
+ "branding",
35
+ "favicon",
36
+ "logo",
37
+ "svg",
38
+ "social-cards"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "sharp": "0.34.5"
45
+ },
46
+ "optionalDependencies": {
47
+ "@playwright/test": "^1.60.0"
48
+ },
49
+ "license": "MIT"
50
+ }
@@ -0,0 +1,3 @@
1
+ # For ./glyphs.sh (glyph extraction). Install with: pip install -r requirements.txt
2
+ fonttools>=4.60
3
+ brotli>=1.1
package/src/build.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // Orchestrates the engine. buildBrand() renders every kit defined by a brand
2
+ // config; buildKit() renders a single one-off. Neither hardcodes anything
3
+ // brand-specific: the config (and the output directory) are the inputs.
4
+ //
5
+ // A brand config is a brand.json (or a directory holding one) describing the
6
+ // primary identity, optional surfaces, and optional social cards. Paths inside it
7
+ // (`font`, `portrait`) are resolved relative to the config's own directory.
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { DEFAULT_FONT } from './lib/font.mjs';
11
+ import { WORDMARK_CHARS, ensureGlyphs } from './lib/glyphs.mjs';
12
+ import { darken } from './lib/color.mjs';
13
+ import { launchBrowser } from './lib/render.mjs';
14
+ import { makeMark } from './make-mark.mjs';
15
+ import { makeWordmark } from './make-wordmark.mjs';
16
+ import { makeSheet } from './make-sheet.mjs';
17
+ import { makeWeb } from './make-web.mjs';
18
+ import { makeCards } from './make-cards.mjs';
19
+
20
+ const ALL_STAGES = ['mark', 'wordmark', 'sheet', 'web', 'cards'];
21
+ const BASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
22
+
23
+ // Normalize an `only` selector (array, comma string, or null=all) to a Set.
24
+ function stageSet(only) {
25
+ if (!only) return new Set(ALL_STAGES);
26
+ const list = Array.isArray(only) ? only : String(only).split(',');
27
+ return new Set(list.map((s) => s.trim()).filter(Boolean));
28
+ }
29
+
30
+ // Resolve a config: an object, a brand.json path, or a directory holding one.
31
+ function loadConfig(config) {
32
+ if (config && typeof config === 'object') {
33
+ return { brand: config, surfaces: config.surfaces || {}, brandDir: process.cwd() };
34
+ }
35
+ let file = config || process.cwd();
36
+ let dir;
37
+ if (existsSync(file) && !file.endsWith('.json')) {
38
+ dir = path.resolve(file);
39
+ file = path.join(dir, 'brand.json');
40
+ } else {
41
+ file = path.resolve(file);
42
+ dir = path.dirname(file);
43
+ }
44
+ const brand = JSON.parse(readFileSync(file, 'utf8'));
45
+ const sFile = path.join(dir, 'surfaces.json');
46
+ const surfaces = existsSync(sFile)
47
+ ? JSON.parse(readFileSync(sFile, 'utf8'))
48
+ : brand.surfaces || {};
49
+ return { brand, surfaces, brandDir: dir };
50
+ }
51
+
52
+ /** Build every kit defined by a brand config into outDir. */
53
+ export async function buildBrand({ config, outDir, only } = {}) {
54
+ const { brand, surfaces, brandDir } = loadConfig(config);
55
+ outDir = outDir ? path.resolve(outDir) : path.join(process.cwd(), 'kits');
56
+ const stages = stageSet(only);
57
+
58
+ const font = brand.font ? path.resolve(brandDir, brand.font) : DEFAULT_FONT;
59
+ const weight = brand.weight || 800;
60
+ const wordmarkWeight = brand.wordmarkWeight || 700;
61
+ const id = brand.identity;
62
+
63
+ const kits = [
64
+ { slug: id.slug, color: id.color, glyph: id.glyph, wordmark: id.wordmark, deep: id.deep, onColor: id.onColor },
65
+ ...Object.entries(surfaces).map(([slug, s]) => ({
66
+ slug, color: s.color, glyph: s.glyph || id.glyph, wordmark: s.wordmark, deep: s.deep, onColor: s.onColor,
67
+ })),
68
+ ];
69
+ const markChars = [...new Set([...BASE_CHARS, ...kits.flatMap((k) => [...k.glyph])])].join('');
70
+
71
+ process.env.BRAND_FONT = font;
72
+ process.env.BRAND_GLYPHS = 'brand-glyphs.json';
73
+ process.env.BRAND_WORDMARK_GLYPHS = 'wordmark-glyphs.json';
74
+
75
+ if (stages.has('mark') || stages.has('sheet')) {
76
+ ensureGlyphs({ file: 'brand-glyphs.json', font, weight, chars: markChars, label: 'mark glyphs' });
77
+ }
78
+ if (stages.has('wordmark')) {
79
+ ensureGlyphs({ file: 'wordmark-glyphs.json', font, weight: wordmarkWeight, chars: WORDMARK_CHARS, label: 'wordmark glyphs' });
80
+ }
81
+
82
+ const browser = stages.has('sheet') || (stages.has('cards') && brand.cards) ? await launchBrowser() : null;
83
+ try {
84
+ console.log(`Building ${brand.name || 'brand'} -> ${outDir}`);
85
+ for (const k of kits) {
86
+ if (stages.has('mark')) await makeMark({ slug: k.slug, hex: k.color, glyph: k.glyph, outDir });
87
+ if (stages.has('wordmark') && k.wordmark) await makeWordmark({ slug: k.slug, hex: k.color, text: k.wordmark, glyph: k.glyph, weight: wordmarkWeight, outDir });
88
+ if (stages.has('sheet')) await makeSheet({ slug: k.slug, hex: k.color, glyph: k.glyph, wordmark: k.wordmark, deep: k.deep, browser, outDir });
89
+ if (stages.has('web')) makeWeb({ slug: k.slug, hex: k.color, glyph: k.glyph, name: k.wordmark || brand.name, deep: k.deep, onColor: k.onColor, outDir });
90
+ }
91
+ if (stages.has('cards') && brand.cards) {
92
+ const photoPath = brand.portrait ? path.resolve(brandDir, brand.portrait) : path.join(brandDir, 'portrait.jpg');
93
+ const colors = {
94
+ panel: id.color,
95
+ panelDeep: id.deep || darken(id.color),
96
+ onPanel: id.onColor || '#ffffff',
97
+ accent: brand.cardPalette.accent,
98
+ textSoft: brand.cardPalette.textSoft,
99
+ textMuted: brand.cardPalette.textMuted,
100
+ };
101
+ await makeCards({ cards: brand.cards, colors, photoPath, outDir, browser });
102
+ }
103
+ } finally {
104
+ if (browser) await browser.close();
105
+ }
106
+ console.log('Done.');
107
+ }
108
+
109
+ /** Build a single one-off kit (no config file needed). */
110
+ export async function buildKit({
111
+ slug, hex, glyph = 'JS', wordmark, font, outDir, only,
112
+ weight = 800, wordmarkWeight = 700, browser,
113
+ } = {}) {
114
+ outDir = outDir ? path.resolve(outDir) : path.join(process.cwd(), 'kits');
115
+ const stages = stageSet(only);
116
+
117
+ // A custom font points the caches at font-specific files and pre-extracts the
118
+ // mark glyphs for these initials; the default font uses the bundled caches and
119
+ // needs no python. (makeWordmark extracts its own wordmark cache.)
120
+ if (font) {
121
+ const abs = path.isAbsolute(font) ? font : path.resolve(process.cwd(), font);
122
+ const stem = path.basename(abs).replace(/\.[^.]+$/, '');
123
+ process.env.BRAND_FONT = abs;
124
+ process.env.BRAND_GLYPHS = `${stem}-glyphs.json`;
125
+ process.env.BRAND_WORDMARK_GLYPHS = `${stem}-wordmark-glyphs.json`;
126
+ if (stages.has('mark') || stages.has('sheet')) {
127
+ ensureGlyphs({ file: process.env.BRAND_GLYPHS, font: abs, weight, chars: glyph, label: 'mark glyphs' });
128
+ }
129
+ } else {
130
+ delete process.env.BRAND_FONT;
131
+ process.env.BRAND_GLYPHS = 'brand-glyphs.json';
132
+ process.env.BRAND_WORDMARK_GLYPHS = 'wordmark-glyphs.json';
133
+ }
134
+
135
+ const ownsBrowser = !browser && stages.has('sheet');
136
+ const b = browser || (ownsBrowser ? await launchBrowser() : null);
137
+ try {
138
+ if (stages.has('mark')) await makeMark({ slug, hex, glyph, outDir });
139
+ if (stages.has('wordmark') && wordmark) await makeWordmark({ slug, hex, text: wordmark, glyph, weight: wordmarkWeight, outDir });
140
+ if (stages.has('sheet')) await makeSheet({ slug, hex, glyph, wordmark, browser: b, outDir });
141
+ if (stages.has('web')) makeWeb({ slug, hex, glyph, name: wordmark, outDir });
142
+ } finally {
143
+ if (ownsBrowser && b) await b.close();
144
+ }
145
+ console.log(`Kit ${slug} -> ${outDir}/${slug}`);
146
+ }
@@ -0,0 +1,54 @@
1
+ // Shared social-card renderer: a "text panel + photo" card rendered in real Inter
2
+ // via headless Chromium (full-text Inter is beyond the glyph-outline mark pipeline).
3
+ // Self-contained: the Inter woff2 is bundled in the kit and embedded as a data URI,
4
+ // and the palette is passed in, so no dependency on any other repo.
5
+ import { readFileSync } from 'node:fs';
6
+ import sharp from 'sharp';
7
+ import { esc } from './html.mjs';
8
+ import { fontFaceCss, withPage } from './render.mjs';
9
+
10
+ /**
11
+ * Render a card to a PNG on the given (caller-owned) browser.
12
+ * @param {import('@playwright/test').Browser} browser
13
+ * @param {object} o width, height, photoWidth, eyebrow, name, tagline, meta, url,
14
+ * photoPath, outPath, and colors { panel, panelDeep, onPanel,
15
+ * accent, textSoft, textMuted }.
16
+ */
17
+ export async function renderCard(browser, o) {
18
+ const c = o.colors;
19
+ const photoB64 = readFileSync(o.photoPath).toString('base64');
20
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>
21
+ ${fontFaceCss('Inter')}
22
+ *{margin:0;padding:0;box-sizing:border-box}
23
+ html,body{width:${o.width}px;height:${o.height}px}
24
+ body{display:flex;font-family:Inter,sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}
25
+ .panel{width:${o.width - o.photoWidth}px;height:${o.height}px;
26
+ background:linear-gradient(135deg,${c.panel},${c.panelDeep});
27
+ padding:72px;display:flex;flex-direction:column;justify-content:center;color:${c.onPanel}}
28
+ .eyebrow{font-size:24px;font-weight:600;letter-spacing:3.5px;color:${c.textMuted};text-transform:uppercase}
29
+ .name{font-size:78px;font-weight:800;letter-spacing:-2px;line-height:1.05;margin-top:18px}
30
+ .rule{width:64px;height:5px;border-radius:2.5px;background:${c.accent};margin-top:26px}
31
+ .tagline{font-size:30px;font-weight:400;color:${c.textSoft};margin-top:30px}
32
+ .meta{font-size:24px;font-weight:600;letter-spacing:.5px;color:${c.textMuted};margin-top:14px}
33
+ .url{font-size:25px;font-weight:600;letter-spacing:.5px;color:${c.textMuted};margin-top:34px}
34
+ .photo{width:${o.photoWidth}px;height:${o.height}px;object-fit:cover;object-position:top}
35
+ </style></head><body>
36
+ <div class="panel">
37
+ <div class="eyebrow">${esc(o.eyebrow)}</div>
38
+ <div class="name">${esc(o.name)}</div>
39
+ <div class="rule"></div>
40
+ <div class="tagline">${esc(o.tagline)}</div>
41
+ <div class="meta">${esc(o.meta)}</div>
42
+ <div class="url">${esc(o.url)}</div>
43
+ </div>
44
+ <img class="photo" src="data:image/jpeg;base64,${photoB64}">
45
+ </body></html>`;
46
+
47
+ const shot = await withPage(
48
+ browser,
49
+ { html, viewport: { width: o.width, height: o.height } },
50
+ (page) => page.screenshot({ type: 'png' }),
51
+ );
52
+ // Rendered at 2x for crisp text; downscale to the exact card size.
53
+ await sharp(shot).resize(o.width, o.height).png().toFile(o.outPath);
54
+ }
@@ -0,0 +1,21 @@
1
+ // Color helpers shared across the generators, so every kit normalizes and
2
+ // validates color the same way, and derives the same "deep" shade.
3
+
4
+ // Return a validated "#rrggbb" from "rrggbb" or "#rrggbb"; throw otherwise.
5
+ export function normalizeHex(hex) {
6
+ const v = '#' + String(hex).replace(/^#/, '');
7
+ if (!/^#[0-9a-fA-F]{6}$/.test(v)) {
8
+ throw new Error(`Invalid hex color: "${hex}". Expected 6 hex digits, e.g. 1f4d57.`);
9
+ }
10
+ return v;
11
+ }
12
+
13
+ // Multiply each channel toward black. The default 0.62 is the kits' fallback
14
+ // "deep" shade when brand.json doesn't supply a curated one.
15
+ export function darken(hex, f = 0.62) {
16
+ const n = parseInt(normalizeHex(hex).slice(1), 16);
17
+ const r = Math.round(((n >> 16) & 255) * f);
18
+ const g = Math.round(((n >> 8) & 255) * f);
19
+ const b = Math.round((n & 255) * f);
20
+ return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
21
+ }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """Extract glyph outlines as SVG path data for the brand mark.
3
+
4
+ Defaults to the bundled Inter variable woff2, but any font works: pass a path to
5
+ a .ttf/.otf/.woff2. Variable fonts are instantiated at the given weight; static
6
+ fonts use their single master. Output is JSON the node generators consume, so
7
+ they stay pure-node.
8
+
9
+ Usage:
10
+ python3 bin/lib/extract-glyphs.py "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 800
11
+ python3 bin/lib/extract-glyphs.py "AB" 700 ~/fonts/SomeFont.ttf
12
+ python3 bin/lib/extract-glyphs.py "AB" 700 ~/fonts/SomeFont.ttf myfont-glyphs.json
13
+
14
+ The default run writes brand-glyphs.json (the kit's glyph cache). A custom font
15
+ writes <font-stem>-glyphs.json unless you name the output, so the brand cache is
16
+ never clobbered by accident. Point a generator at a non-default file with
17
+ BRAND_GLYPHS=<file> (e.g. BRAND_GLYPHS=somefont-glyphs.json ./kit.sh ...).
18
+ """
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from fontTools.ttLib import TTFont
24
+ from fontTools.varLib.instancer import instantiateVariableFont
25
+ from fontTools.pens.svgPathPen import SVGPathPen
26
+ from fontTools.pens.boundsPen import BoundsPen
27
+
28
+ here = Path(__file__).resolve().parent
29
+ root = here.parents[1]
30
+ default_font = root / "assets/fonts/inter/inter-variable-latin.woff2"
31
+
32
+ chars = sys.argv[1] if len(sys.argv) > 1 else "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
33
+ weight = float(sys.argv[2]) if len(sys.argv) > 2 else 800.0
34
+ font_path = Path(sys.argv[3]).expanduser() if len(sys.argv) > 3 else default_font
35
+
36
+ if len(sys.argv) > 4:
37
+ out_path = Path(sys.argv[4])
38
+ if not out_path.is_absolute():
39
+ out_path = here / out_path
40
+ elif len(sys.argv) > 3:
41
+ # Custom font, no explicit output: derive a name so Inter is never clobbered.
42
+ out_path = here / f"{font_path.stem}-glyphs.json"
43
+ else:
44
+ out_path = here / "brand-glyphs.json"
45
+
46
+ font = TTFont(str(font_path))
47
+ if "fvar" in font:
48
+ instantiateVariableFont(font, {"wght": weight}, inplace=True)
49
+
50
+ glyph_set = font.getGlyphSet()
51
+ cmap = font.getBestCmap()
52
+ upem = font["head"].unitsPerEm
53
+
54
+ glyphs = {}
55
+ for ch in dict.fromkeys(chars):
56
+ name = cmap[ord(ch)]
57
+ g = glyph_set[name]
58
+ pen = SVGPathPen(glyph_set)
59
+ g.draw(pen)
60
+ bp = BoundsPen(glyph_set)
61
+ g.draw(bp)
62
+ # Blank glyphs (e.g. the space) have no contours, so BoundsPen leaves
63
+ # bounds None; they still carry an advance width the layout needs.
64
+ xmin, ymin, xmax, ymax = bp.bounds or (0, 0, 0, 0)
65
+ glyphs[ch] = {
66
+ "path": pen.getCommands(),
67
+ "advance": g.width,
68
+ "bounds": {"xMin": xmin, "yMin": ymin, "xMax": xmax, "yMax": ymax},
69
+ }
70
+
71
+ out_path.write_text(json.dumps(
72
+ {"font": font_path.name, "unitsPerEm": upem, "weight": weight, "glyphs": glyphs},
73
+ indent=2,
74
+ ))
75
+ print(f"wrote {out_path.name} from {font_path.name} — upem={upem} weight={int(weight)} chars={''.join(glyphs)}")
@@ -0,0 +1,17 @@
1
+ // Resolves the live-text font used by the wordmark/card renderers and the glyph
2
+ // extractor. The default is the Inter bundled with this package; BRAND_FONT (or a
3
+ // brand config's `font`) overrides it. A relative override is resolved from the
4
+ // caller's working directory, since the consumer: not this package: owns it.
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
9
+
10
+ // Absolute path to the bundled default font.
11
+ export const DEFAULT_FONT = path.join(pkgRoot, 'assets/fonts/inter/inter-variable-latin.woff2');
12
+
13
+ export function fontPath() {
14
+ const f = process.env.BRAND_FONT;
15
+ if (!f) return DEFAULT_FONT;
16
+ return path.isAbsolute(f) ? f : path.resolve(process.cwd(), f);
17
+ }
@@ -0,0 +1,93 @@
1
+ // Shared glyph-outline cache management. A cache is the JSON that
2
+ // extract-glyphs.py writes: { font, unitsPerEm, weight, glyphs }. Both the mark
3
+ // (uppercase monogram at the brand weight) and the wordmark (mixed case at the
4
+ // lighter wordmark weight) sit on this, so extraction logic lives in one place.
5
+ //
6
+ // Two cache locations: the read-only set BUNDLED with this package (the default
7
+ // Inter, full alphabet: so the common case needs no python and never writes),
8
+ // and a writable directory (BRAND_CACHE_DIR, else <cwd>/.brand-cache) used when a
9
+ // custom font or a missing glyph forces a fresh extraction. The package install
10
+ // itself is never written to.
11
+ import { execFileSync } from 'node:child_process';
12
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { DEFAULT_FONT, fontPath } from './font.mjs';
16
+
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ const BUNDLED_DIR = path.resolve(here, '..', '..', 'assets', 'glyphs');
19
+
20
+ function cacheDir() {
21
+ return process.env.BRAND_CACHE_DIR || path.join(process.cwd(), '.brand-cache');
22
+ }
23
+
24
+ // Where loadGlyphs would read `file` from: the writable cache if present, else the
25
+ // bundled set. Custom fonts only ever land in the writable cache.
26
+ function resolveRead(file) {
27
+ const writable = path.join(cacheDir(), file);
28
+ return existsSync(writable) ? writable : path.join(BUNDLED_DIR, file);
29
+ }
30
+
31
+ // The wordmark cache bundles the whole alphabet (both cases), digits, and space,
32
+ // so any name renders from the default-font cache without re-extracting: the
33
+ // same reason the mark cache bundles A-Z/0-9. Text-driven extraction would let a
34
+ // one-off ("kit chris-blake …") overwrite the shared set with just its own chars.
35
+ export const WORDMARK_CHARS =
36
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 ';
37
+
38
+ // True when a readable cache covers this font, weight, and every character (a
39
+ // missing glyph or a font/weight change forces a re-extract).
40
+ export function cacheCovers({ file, font, weight, chars }) {
41
+ const p = resolveRead(file);
42
+ if (!existsSync(p)) return false;
43
+ try {
44
+ const g = JSON.parse(readFileSync(p, 'utf8'));
45
+ if (font && g.font !== path.basename(font)) return false;
46
+ if (weight != null && Number(g.weight) !== Number(weight)) return false;
47
+ return [...chars].every((ch) => g.glyphs && g.glyphs[ch]);
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ // Extract `chars` from `font` at `weight` into the writable cache unless a
54
+ // readable cache already covers them. Throws (with an actionable message) only if
55
+ // extraction is actually needed and python3/fonttools is missing.
56
+ export function ensureGlyphs({ file, font = fontPath(), weight, chars, label = file }) {
57
+ if (cacheCovers({ file, font, weight, chars })) return;
58
+ const dir = cacheDir();
59
+ const out = path.join(dir, file);
60
+ const charset = [...new Set([...chars])].join('');
61
+ mkdirSync(dir, { recursive: true });
62
+ console.log(`Extracting ${label} "${charset}" @ ${weight} from ${path.basename(font)}`);
63
+ try {
64
+ execFileSync(
65
+ 'python3',
66
+ [path.resolve(here, 'extract-glyphs.py'), charset, String(weight), font, out],
67
+ { stdio: 'inherit' },
68
+ );
69
+ } catch {
70
+ throw new Error(
71
+ `Could not extract glyphs into ${out}. A custom font or new glyph needs ` +
72
+ `python3 + fonttools (pip install -r requirements.txt). The bundled Inter ` +
73
+ `needs none of this.`,
74
+ );
75
+ }
76
+ }
77
+
78
+ export function loadGlyphs(file) {
79
+ return JSON.parse(readFileSync(resolveRead(file), 'utf8'));
80
+ }
81
+
82
+ // The wordmark's outline cache filename. Defaults to wordmark-glyphs.json (the
83
+ // bundled Inter set); a one-off in a custom font gets its own file so Inter's
84
+ // cache is never clobbered. Both the extractor and the renderer call this so they
85
+ // always agree on the filename.
86
+ export function wordmarkGlyphFile() {
87
+ if (process.env.BRAND_WORDMARK_GLYPHS) return process.env.BRAND_WORDMARK_GLYPHS;
88
+ const f = process.env.BRAND_FONT;
89
+ if (f && path.basename(f) !== path.basename(DEFAULT_FONT)) {
90
+ return `${path.basename(f).replace(/\.[^.]+$/, '')}-wordmark-glyphs.json`;
91
+ }
92
+ return 'wordmark-glyphs.json';
93
+ }
@@ -0,0 +1,3 @@
1
+ // Escape text for safe interpolation into the HTML the Chromium renderers build.
2
+ export const esc = (s) =>
3
+ String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
@@ -0,0 +1,23 @@
1
+ // Minimal, dependency-free PNG-based .ico encoder. Takes [{ size, buffer }] of
2
+ // PNG buffers and packs them into a single .ico (used for favicon.ico).
3
+ import { Buffer } from 'node:buffer';
4
+
5
+ export function pngsToIco(images) {
6
+ const header = Buffer.alloc(6);
7
+ header.writeUInt16LE(1, 2);
8
+ header.writeUInt16LE(images.length, 4);
9
+ const entries = [];
10
+ let offset = 6 + images.length * 16;
11
+ for (const { size, buffer } of images) {
12
+ const e = Buffer.alloc(16);
13
+ e.writeUInt8(size >= 256 ? 0 : size, 0);
14
+ e.writeUInt8(size >= 256 ? 0 : size, 1);
15
+ e.writeUInt16LE(1, 4);
16
+ e.writeUInt16LE(32, 6);
17
+ e.writeUInt32LE(buffer.length, 8);
18
+ e.writeUInt32LE(offset, 12);
19
+ offset += buffer.length;
20
+ entries.push(e);
21
+ }
22
+ return Buffer.concat([header, ...entries, ...images.map((i) => i.buffer)]);
23
+ }
@@ -0,0 +1,73 @@
1
+ // Builds a brand mark SVG from real Inter (weight 800) glyph outlines.
2
+ // Parameterized: the color and glyph are arguments, so one monogram renders in
3
+ // any surface's accent. Self-contained, with no dependency on any other repo.
4
+ //
5
+ // Outlines come from brand-glyphs.json (A-Z and 0-9 are bundled by default).
6
+ import { loadGlyphs } from './glyphs.mjs';
7
+
8
+ // Read glyphs lazily so a BRAND_GLYPHS set in-process (by the build) is honored.
9
+ // Defaults to brand-glyphs.json; point at another extracted set with BRAND_GLYPHS.
10
+ let _glyphData;
11
+ function glyphData() {
12
+ if (!_glyphData) _glyphData = loadGlyphs(process.env.BRAND_GLYPHS || 'brand-glyphs.json');
13
+ return _glyphData;
14
+ }
15
+
16
+ const RENDER = {
17
+ letterSpacing: -0.045, // em
18
+ widthRatio: 0.63, // glyph ink width relative to the box
19
+ radiusRatio: 0.22, // rounded-square corner radius (0 = square)
20
+ };
21
+
22
+ // Lay the glyph string out in font units and measure the combined ink box.
23
+ function layout(glyph) {
24
+ const { unitsPerEm, glyphs } = glyphData();
25
+ const ls = RENDER.letterSpacing * unitsPerEm;
26
+ const chars = [...glyph];
27
+ let penX = 0;
28
+ const placed = [];
29
+ chars.forEach((ch, i) => {
30
+ const g = glyphs[ch];
31
+ if (!g) {
32
+ throw new Error(
33
+ `No outline for glyph "${ch}". Available: ${Object.keys(glyphs).join(', ')}. ` +
34
+ `It will be extracted automatically for the bundled font; for a custom ` +
35
+ `font, ensure python3 + fonttools are installed.`,
36
+ );
37
+ }
38
+ placed.push({ x: penX, g });
39
+ penX += g.advance + (i < chars.length - 1 ? ls : 0);
40
+ });
41
+ let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
42
+ for (const { x, g } of placed) {
43
+ xMin = Math.min(xMin, x + g.bounds.xMin);
44
+ xMax = Math.max(xMax, x + g.bounds.xMax);
45
+ yMin = Math.min(yMin, g.bounds.yMin);
46
+ yMax = Math.max(yMax, g.bounds.yMax);
47
+ }
48
+ return { placed, cx: (xMin + xMax) / 2, cy: (yMin + yMax) / 2, gw: xMax - xMin };
49
+ }
50
+
51
+ /**
52
+ * Build the mark as a self-contained SVG string.
53
+ * @param {object} opts
54
+ * @param {number} [opts.size=512] square canvas size
55
+ * @param {boolean} [opts.rounded=true] rounded-square (false = full square)
56
+ * @param {string|null} [opts.bg] tile fill, or null for transparent
57
+ * @param {string} [opts.fg='#ffffff'] glyph fill
58
+ * @param {string} [opts.glyph='JS'] monogram (J and S are the extracted outlines)
59
+ */
60
+ export function markSvg({ size = 512, rounded = true, bg, fg = '#ffffff', glyph = 'JS' } = {}) {
61
+ const { placed, cx, cy, gw } = layout(glyph);
62
+ const s = (RENDER.widthRatio * size) / gw;
63
+ const rx = rounded ? +(size * RENDER.radiusRatio).toFixed(2) : 0;
64
+ const bgRect = bg ? `<rect width="${size}" height="${size}" rx="${rx}" fill="${bg}"/>` : '';
65
+ const paths = placed
66
+ .map(({ x, g }) => `<path transform="translate(${x.toFixed(2)} 0)" d="${g.path}"/>`)
67
+ .join('');
68
+ // Glyph outlines are y-up (font space); flip and centre them in the canvas.
69
+ return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">`
70
+ + bgRect
71
+ + `<g fill="${fg}" transform="translate(${size / 2} ${size / 2}) scale(${s.toFixed(5)} ${(-s).toFixed(5)}) translate(${(-cx).toFixed(2)} ${(-cy).toFixed(2)})">${paths}</g>`
72
+ + `</svg>`;
73
+ }