scripts/brand/sync.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Writes every text copy of the brand geometry from packages/ui/lib/multica-brand.ts:
// favicons, the PWA icon source, the README wordmark SVGs, the mobile wordmark and
// the inline SVG on the `multica login` callback page.
//
// node scripts/brand/sync.mjs rewrite the copies
// node scripts/brand/sync.mjs --check exit 1 if any copy drifted (CI)
//
// Dependency-free on purpose: CI runs it without installing node_modules.
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export const INK = "#0b0b0c";
export const PAPER = "#ffffff";
const CLI_WORDMARK_HEIGHT = 19.5;
export async function loadBrand(root) {
return import(pathToFileURL(join(root, "packages/ui/lib/multica-brand.ts")).href);
}
export function svgs(brand) {
const { WORDMARK, WORDMARK_STROKE, ICON_TILE_RADIUS, starPath, iconStar, iconStarTransform } = brand;
const star = (px, attrs) => {
const { scale, stroke } = iconStar(px);
return `<path ${attrs} transform="${iconStarTransform(scale)}" d="${starPath(stroke)}"/>`;
};
const wordmark = (color, extra = "") =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${WORDMARK.viewBox}" role="img" aria-label="Multica"${extra}><g fill="none" stroke="${color}" stroke-width="${WORDMARK_STROKE}">${WORDMARK.letters.map((l) => `<path d="${l.d}"/>${l.star ? `<path d="${l.star}" fill="${color}" stroke="none"/>` : ""}`).join("")}</g></svg>`;
const tile = (px, { bleed = false, bg = INK, fg = PAPER } = {}) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${px}" height="${px}"><rect width="100" height="100"${bleed ? "" : ` rx="${ICON_TILE_RADIUS}"`} fill="${bg}"/>${star(px, `fill="${fg}"`)}</svg>`;
const [, , vbW, vbH] = WORDMARK.viewBox.split(" ").map(Number);
return {
tile,
wordmark,
favicon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Multica"><style>.t{fill:${INK}}.m{fill:${PAPER}}@media (prefers-color-scheme:dark){.t{fill:${PAPER}}.m{fill:${INK}}}</style><rect class="t" width="100" height="100" rx="${ICON_TILE_RADIUS}"/>${star(16, `class="m"`)}</svg>\n`,
pwaIcon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Multica">
<!--
Full-bleed source for the generated home-screen icons (apple-touch-icon.png,
icon-maskable-512.png). Full-bleed because both surfaces mask the artwork
themselves: iOS rounds the corners, Android crops to the launcher shape.
Geometry is packages/ui/lib/multica-brand.ts (starPath, iconStar).
--><rect width="100" height="100" fill="${INK}"/>${star(512, `fill="${PAPER}"`)}</svg>\n`,
cliWordmark: wordmark("currentColor", ` height="${CLI_WORDMARK_HEIGHT}" width="${Math.round((CLI_WORDMARK_HEIGHT * vbW) / vbH)}"`),
};
}
function mobileBlock({ WORDMARK, WORDMARK_STROKE }) {
const [x, y, w, h] = WORDMARK.viewBox.split(" ").map(Number);
const star = WORDMARK.letters.find((l) => l.star).star;
return [
`const VIEW_BOX = { x: ${x}, y: ${y}, w: ${w}, h: ${h} };`,
`const STROKE = ${WORDMARK_STROKE};`,
"const LETTERS = [",
...WORDMARK.letters.map((l) => ` "${l.d}",`),
"];",
"/** The star on the i, filled. */",
"const STAR =",
` "${star}";`,
].join("\n");
}
const MOBILE_BEGIN = "// BEGIN GENERATED by scripts/brand/sync.mjs from WORDMARK in packages/ui/lib/multica-brand.ts\n";
const MOBILE_END = "\n// END GENERATED";
const CLI_SVG = /(<div class="brand">)<svg[^]*?<\/svg>(<\/div>)/;
function splice(text, file, re, body) {
if (!re.test(text)) throw new Error(`${file}: cannot find the generated wordmark`);
return text.replace(re, (_, open, close) => open + body + close);
}
/** Every synced file as `[path relative to root, expected content]`. */
export async function targets(root) {
const brand = await loadBrand(root);
const s = svgs(brand);
const read = (rel) => readFileSync(join(root, rel), "utf8");
const mobile = "apps/mobile/components/brand/multica-logo.tsx";
const cli = "server/cmd/multica/cmd_auth.go";
const between = new RegExp(`(${escapeRe(MOBILE_BEGIN)})[^]*?(${escapeRe(MOBILE_END)})`);
return [
["apps/web/public/favicon.svg", s.favicon],
["apps/docs/app/icon.svg", s.favicon],
["apps/web/public/icons/icon.svg", s.pwaIcon],
["docs/assets/logo-light.svg", s.wordmark(INK) + "\n"],
["docs/assets/logo-dark.svg", s.wordmark(PAPER) + "\n"],
[mobile, splice(read(mobile), mobile, between, mobileBlock(brand))],
[cli, splice(read(cli), cli, CLI_SVG, s.cliWordmark)],
];
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
}
export async function sync(root, { check = false } = {}) {
const drifted = [];
for (const [rel, want] of await targets(root)) {
const file = join(root, rel);
let have = null;
try {
have = readFileSync(file, "utf8");
} catch {}
if (have === want) continue;
drifted.push(rel);
if (!check) writeFileSync(file, want);
}
return drifted;
}
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
const args = process.argv.slice(2);
const check = args.includes("--check");
const rootAt = args.indexOf("--root");
const root = rootAt >= 0 ? resolve(args[rootAt + 1]) : resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const drifted = await sync(root, { check });
if (check && drifted.length) {
console.error(`Out of sync with packages/ui/lib/multica-brand.ts:\n${drifted.map((f) => ` ${f}`).join("\n")}\nRun: node scripts/brand/sync.mjs`);
process.exit(1);
}
console.log(drifted.length ? `updated ${drifted.join(", ")}` : "brand copies in sync");
}