1ed624eaf1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The landing now always opens in Russian (saved 🌐 choice still wins) —
browser-language detection made the indexed content nondeterministic
(Googlebot renders with en-US). landing.html gains the static Russian
SEO head: title/description, canonical pinned to the production origin,
Open Graph card (Telegram/VK link previews), twitter:card, JSON-LD,
theme-color and the favicon set; the SPA shell turns noindex and its
tab title becomes «Эрудит (Скрэббл)». New assets/icons generator
(same tile design as the VK loader) produces favicon.svg/ico,
apple-touch-icon.png and og-image.png into ui/public/, plus robots.txt.
129 lines
6.7 KiB
JavaScript
129 lines
6.7 KiB
JavaScript
'use strict';
|
|
// Site icons + the Open Graph card for the public landing, drawn from the same
|
|
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
|
|
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
|
|
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
|
|
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
|
|
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
|
|
// ui/public/:
|
|
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
|
|
// favicon.ico 32x32 PNG-in-ICO render of the same
|
|
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
|
|
// og-image.png 1200x630 card: tile + wordmark on the board green
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
|
|
const UI = path.resolve(__dirname, '../../../ui');
|
|
const OUT = path.join(UI, 'public');
|
|
|
|
// ---- palette (vk loader tile + app.css board tokens) ------------------------
|
|
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
|
|
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
|
|
|
|
// ---- glyph outlines -> SVG path data ----------------------------------------
|
|
const r2 = n => Math.round(n * 100) / 100;
|
|
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
|
|
function pathD(g, sc, tx, ty) {
|
|
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
|
|
const Z = [0, 0];
|
|
return g.contours.map(ct => {
|
|
const n = ct.v.length;
|
|
let d = `M${pt(ct.v[0], Z)}`;
|
|
for (let k = 1; k <= n; k++) {
|
|
const a = k - 1, b = k % n;
|
|
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
|
|
}
|
|
return d + 'Z';
|
|
}).join('');
|
|
}
|
|
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
|
|
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
|
|
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
|
|
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
|
|
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
|
|
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
|
|
}
|
|
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
|
|
// the capital height (measured on «Э»). Returns the combined path + the width.
|
|
function textLine(str, capPx, x, y, colour) {
|
|
const sc = capPx / G.glyphs['Э'].bbox.h;
|
|
let d = '', w = 0;
|
|
for (const ch of str) {
|
|
const g = G.glyphs[ch];
|
|
if (g.contours.length) d += pathD(g, sc, x + w, y);
|
|
w += g.adv * sc;
|
|
}
|
|
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
|
|
}
|
|
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
|
|
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
|
|
function tile(cx, cy, size, withScore) {
|
|
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
|
|
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
|
|
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
|
|
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
|
|
return s;
|
|
}
|
|
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
|
|
|
// ---- favicon.svg (the committed vector master) -------------------------------
|
|
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
|
|
|
|
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
|
|
const appleTouch = svg(180, 180,
|
|
`<rect width="180" height="180" fill="${FACE}"/>` +
|
|
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
|
|
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
|
|
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
|
|
|
|
// ---- og-image: tile + wordmark, centred as one group on the board green ------
|
|
function ogImage() {
|
|
const W = 1200, H = 630, tileSize = 340, gap = 84;
|
|
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
|
|
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
|
|
const textW = Math.max(l1.width, l2.width);
|
|
const left = (W - (tileSize + gap + textW)) / 2;
|
|
const tx = left + tileSize + gap;
|
|
// Two baselines around the vertical centre; the tile centre sits between them.
|
|
const b1 = 295, b2 = 408;
|
|
const body =
|
|
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
|
|
tile(left + tileSize / 2, H / 2, tileSize, true) +
|
|
textLine('Эрудит', 112, tx, b1, TEXT).svg +
|
|
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
|
|
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
|
|
return svg(W, H, body);
|
|
}
|
|
|
|
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
|
|
async function shoot(page, markup, w, h, transparent) {
|
|
await page.setViewportSize({ width: w, height: h });
|
|
await page.setContent(`<body style="margin:0">${markup}</body>`);
|
|
return page.screenshot({ omitBackground: transparent });
|
|
}
|
|
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
|
|
function icoFromPNG(png, sizePx) {
|
|
const h = Buffer.alloc(22);
|
|
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
|
|
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
|
|
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
|
|
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
|
|
return Buffer.concat([h, png]);
|
|
}
|
|
|
|
(async () => {
|
|
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
|
|
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
|
|
const browser = await chromium.launch();
|
|
const page = await browser.newPage();
|
|
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
|
|
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
|
|
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
|
|
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
|
|
await browser.close();
|
|
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
|
|
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
|
|
}
|
|
})();
|