Skip to content
SlashClone
Made by@brandkit

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.

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