On brand check
@brandkit/on-brand-check
Review a page, stylesheet, email, deck outline, or post against your brand guide and get a short fix list: off-palette colors (with the nearest brand color), wrong fonts, sizes and spacing off the scale, banned words, logo misuse, and voice. Reports only; changes nothing.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1// Reads the tokens block from a brand guide (brand.md). Node built-ins only.
- 2//
- 3// The tokens block is a fenced code block whose info string is yaml, yml, or json.
- 4// A block marked "tokens" (```yaml tokens) wins; otherwise the first yaml or json block.
- 5// The YAML reader understands the small subset brand guides need: nested maps, "- item"
- 6// lists, [inline, lists], { inline: maps }, quoted or plain scalars, and "# comments".
- 8const GROUP_ALIASES = {
- 9 colors: "color",
- 10 colour: "color",
- 11 colours: "color",
- 12 fonts: "font",
- 13 typeface: "font",
- 14 typefaces: "font",
- 15 size: "text",
- 16 sizes: "text",
- 17 type: "text",
- 18 "font-size": "text",
- 19 fontsize: "text",
- 20 spacing: "space",
- 21 radii: "radius",
- 22 shadows: "shadow",
- 23};
- 25export function extractTokenBlock(markdown) {
- 26 const blocks = [];
- 27 const re = /^(```|~~~)[ \t]*([^\n]*)\n([\s\S]*?)^\1[ \t]*$/gm;
- 28 for (const m of markdown.matchAll(re)) {
- 29 const info = m[2].trim().toLowerCase().split(/\s+/);
- 30 const lang = info[0] ?? "";
- 31 if (!["yaml", "yml", "json"].includes(lang)) continue;
- 32 blocks.push({ lang, tagged: info.includes("tokens"), body: m[3] });
- 33 }
- 34 return blocks.find((b) => b.tagged) ?? blocks[0] ?? null;
- 35}
- 37function stripComment(line) {
- 38 let quote = null;
- 39 for (let i = 0; i < line.length; i++) {
- 40 const ch = line[i];
- 41 if (quote) {
- 42 if (ch === quote) quote = null;
- 43 } else if (ch === '"' || ch === "'") quote = ch;
- 44 else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1])) && /\s|$/.test(line[i + 1] ?? "")) {
- 45 return line.slice(0, i);
- 46 }
- 47 }
- 48 return line;
- 49}
- 51function scalar(raw) {
- 52 const v = raw.trim();
- 53 if (v === "") return "";
- 54 if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
- 55 return v.slice(1, -1);
- 56 }
- 57 if (v.startsWith("[") && v.endsWith("]")) {
- 58 return splitInline(v.slice(1, -1)).map(scalar);
- 59 }
- 60 if (v.startsWith("{") && v.endsWith("}")) {
- 61 const map = {};
- 62 for (const pair of splitInline(v.slice(1, -1))) {
- 63 const colon = pair.search(/:(\s|$)/);
- 64 if (colon === -1) throw new Error(`expected "key: value" inside { } in "${v}"`);
- 65 map[scalar(pair.slice(0, colon))] = scalar(pair.slice(colon + 1));
- 66 }
- 67 return map;
- 68 }
- 69 return v;
- 70}
- 72function splitInline(s) {
- 73 const out = [];
- 74 let cur = "";
- 75 let quote = null;
- 76 let depth = 0;
- 77 for (const ch of s) {
- 78 if (quote) {
- 79 cur += ch;
- 80 if (ch === quote) quote = null;
- 81 } else if (ch === '"' || ch === "'") {
- 82 quote = ch;
- 83 cur += ch;
- 84 } else if (ch === "[" || ch === "{") {
- 85 depth++;
- 86 cur += ch;
- 87 } else if (ch === "]" || ch === "}") {
- 88 depth--;
- 89 cur += ch;
- 90 } else if (ch === "," && depth === 0) {
- 91 out.push(cur);
- 92 cur = "";
- 93 } else cur += ch;
- 94 }
- 95 if (cur.trim() !== "") out.push(cur);
- 96 return out;
- 97}
- 99export function parseYamlSubset(text) {
- 100 const lines = text
- 101 .split(/\r?\n/)
- 102 .map((l, i) => ({ n: i + 1, raw: stripComment(l).replace(/\s+$/, "") }))
- 103 .filter((l) => l.raw.trim() !== "");
- 104 const root = {};
- 105 // Stack of { indent, container } where container is an object or array.
- 106 const stack = [{ indent: -1, container: root }];
- 107 for (let i = 0; i < lines.length; i++) {
- 108 const { n, raw } = lines[i];
- 109 if (raw.includes("\t")) throw new Error(`tokens line ${n}: use spaces, not tabs`);
- 110 const indent = raw.length - raw.trimStart().length;
- 111 const body = raw.trim();
- 112 while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
- 113 const parent = stack[stack.length - 1].container;
- 114 if (body.startsWith("- ") || body === "-") {
- 115 if (!Array.isArray(parent)) throw new Error(`tokens line ${n}: list item outside a list`);
- 116 parent.push(scalar(body.slice(1)));
- 117 continue;
- 118 }
- 119 const colon = body.search(/:(\s|$)/);
- 120 if (colon === -1) throw new Error(`tokens line ${n}: expected "key: value"`);
- 121 if (Array.isArray(parent)) throw new Error(`tokens line ${n}: key inside a list`);
- 122 const key = scalar(body.slice(0, colon));
- 123 const rest = body.slice(colon + 1).trim();
- 124 if (rest !== "") {
- 125 parent[key] = scalar(rest);
- 126 continue;
- 127 }
- 128 const next = lines[i + 1];
- 129 const nextIndent = next ? next.raw.length - next.raw.trimStart().length : -1;
- 130 const isList = next && nextIndent > indent && next.raw.trim().startsWith("-");
- 131 const child = isList ? [] : {};
- 132 parent[key] = child;
- 133 stack.push({ indent, container: child });
- 134 }
- 135 return root;
- 136}
- 138/** Parses brand.md text and returns normalized tokens. Throws with a helpful message. */
- 139export function readTokens(markdown) {
- 140 const block = extractTokenBlock(markdown);
- 141 if (!block) {
- 142 throw new Error(
- 143 'no tokens block found. Add a fenced block like ```yaml tokens ... ``` to brand.md (see examples/brand.md).',
- 144 );
- 145 }
- 146 let raw;
- 147 try {
- 148 raw = block.lang === "json" ? JSON.parse(block.body) : parseYamlSubset(block.body);
- 149 } catch (err) {
- 150 throw new Error(`could not read the tokens block: ${err.message}`);
- 151 }
- 152 const tokens = {};
- 153 for (const [key, value] of Object.entries(raw)) {
- 154 const k = String(key).toLowerCase();
- 155 tokens[GROUP_ALIASES[k] ?? k] = value;
- 156 }
- 157 return tokens;
- 158}
- 160/** Flattens a nested group into [["primary", "#123"], ["gray-100", "#eee"]]. */
- 161export function flatten(group, prefix = "") {
- 162 if (group === null || typeof group !== "object" || Array.isArray(group)) return [];
- 163 const out = [];
- 164 for (const [k, v] of Object.entries(group)) {
- 165 const name = prefix ? `${prefix}-${k}` : String(k);
- 166 if (v !== null && typeof v === "object" && !Array.isArray(v)) out.push(...flatten(v, name));
- 167 else out.push([name, Array.isArray(v) ? v.join(", ") : String(v)]);
- 168 }
- 169 return out;
- 170}
- 172export function slug(s) {
- 173 return String(s)
- 174 .toLowerCase()
- 175 .replace(/[^a-z0-9]+/g, "-")
- 176 .replace(/^-|-$/g, "");
- 177}
