feat(landing): Russian default + SEO head, icons and OG card
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
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.
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# Erudit — site icons + Open Graph card
|
||||||
|
|
||||||
|
The favicon set and the `og:image` link-preview card for the public landing
|
||||||
|
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
|
||||||
|
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
|
||||||
|
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
|
||||||
|
|
||||||
|
| Output (committed to `ui/public/`) | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
|
||||||
|
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
|
||||||
|
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
|
||||||
|
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
|
||||||
|
character) from LiberationSans (Arial-metric, the game's font stack) with their
|
||||||
|
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
|
||||||
|
plain SVG from those outlines — no font is needed at generation time — writes
|
||||||
|
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
|
||||||
|
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
|
||||||
|
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
|
||||||
|
installed chromium version; the SVG sources are deterministic.
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
|
||||||
|
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
|
||||||
|
needs no extra packages**.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons
|
||||||
|
|
||||||
|
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
|
||||||
|
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
||||||
|
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
||||||
|
|
||||||
|
# 2. regenerate everything in ui/public/:
|
||||||
|
node build/generate.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
// Extract the glyph outlines the site icons and the og-image wordmark need from a
|
||||||
|
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
|
||||||
|
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
|
||||||
|
// plus the advance width so generate.js can lay out words without the font.
|
||||||
|
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
|
||||||
|
const opentype = require('opentype.js');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
|
||||||
|
const b = fs.readFileSync(FONT);
|
||||||
|
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
||||||
|
const FS = 1000; // em scale
|
||||||
|
|
||||||
|
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
|
||||||
|
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
|
||||||
|
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
|
||||||
|
|
||||||
|
function glyphData(ch) {
|
||||||
|
const g = font.charToGlyph(ch);
|
||||||
|
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
|
||||||
|
const contours = [];
|
||||||
|
let cur = null, prev = null;
|
||||||
|
for (const c of p.commands) {
|
||||||
|
if (c.type === 'M') {
|
||||||
|
if (cur) contours.push(cur);
|
||||||
|
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'L') {
|
||||||
|
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'C') {
|
||||||
|
cur[cur.length - 1].o = [c.x1, c.y1];
|
||||||
|
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'Q') {
|
||||||
|
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
|
||||||
|
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
|
||||||
|
cur[cur.length - 1].o = c1;
|
||||||
|
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
|
||||||
|
prev = { x: c.x, y: c.y };
|
||||||
|
} else if (c.type === 'Z') {
|
||||||
|
if (cur && cur.length > 1) {
|
||||||
|
const last = cur[cur.length - 1], first = cur[0];
|
||||||
|
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
|
||||||
|
first.i = last.i; // fold the duplicate closing point into the first
|
||||||
|
cur.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) { contours.push(cur); cur = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) contours.push(cur);
|
||||||
|
|
||||||
|
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
||||||
|
const out = contours.map(ct => {
|
||||||
|
const v = [], i = [], o = [];
|
||||||
|
ct.forEach(pt => {
|
||||||
|
v.push(pt.v);
|
||||||
|
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
|
||||||
|
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
|
||||||
|
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
|
||||||
|
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
|
||||||
|
});
|
||||||
|
return { i, o, v, c: true };
|
||||||
|
});
|
||||||
|
const bbox = out.length
|
||||||
|
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
|
||||||
|
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
|
||||||
|
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
const glyphs = {};
|
||||||
|
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
|
||||||
|
|
||||||
|
const out = __dirname + '/glyphs.json';
|
||||||
|
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
|
||||||
|
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'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');
|
||||||
|
}
|
||||||
|
})();
|
||||||
File diff suppressed because one or more lines are too long
@@ -1099,7 +1099,14 @@ parameters from the URL and the gateway verifies them in-process — §12); a st
|
|||||||
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||||
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
|
static file serving and never reaches the Go edge. The landing shell carries the site's
|
||||||
|
static SEO head — Russian title/description, the Open Graph card (Telegram/VK link
|
||||||
|
previews), JSON-LD and a canonical link — with absolute URLs pinned to the production
|
||||||
|
origin, so the test contour canonicalises to production instead of being indexed as a
|
||||||
|
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
|
||||||
|
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||||
|
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
||||||
|
`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served
|
||||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||||
|
|||||||
+7
-1
@@ -23,7 +23,13 @@ variant's alphabet, remembers answers within the session and rate-limits repeats
|
|||||||
A public **landing page** at the site root introduces the game, switches language and
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
|
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
|
||||||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
(it follows the system scheme, not the saved preference); its language choice is saved. The
|
||||||
|
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
|
||||||
|
render with arbitrary languages, so detection would make the indexed content
|
||||||
|
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
||||||
|
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
||||||
|
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
||||||
|
shell is `noindex` — the landing is the only indexable page.
|
||||||
|
|
||||||
### First-run onboarding
|
### First-run onboarding
|
||||||
The first time a player opens the app it walks them through the interface with a light
|
The first time a player opens the app it walks them through the interface with a light
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
|
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
|
||||||
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
|
||||||
|
открывается на **русском** — язык браузера не определяется автоматически (роботы
|
||||||
|
рендерят страницу с произвольным языком, и автоопределение сделало бы
|
||||||
|
проиндексированный контент недетерминированным); сохранённый выбор 🌐 по-прежнему
|
||||||
|
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
||||||
|
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
||||||
|
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||||
|
`noindex` — индексируется только посадочная страница.
|
||||||
|
|
||||||
### Первый запуск: онбординг
|
### Первый запуск: онбординг
|
||||||
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
||||||
|
|||||||
+21
-8
@@ -2,16 +2,29 @@ import { expect, test } from './fixtures';
|
|||||||
|
|
||||||
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
|
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
|
||||||
// the game SPA lives at /app/ and /telegram/. In dev it is reachable at /landing.html.
|
// the game SPA lives at /app/ and /telegram/. In dev it is reachable at /landing.html.
|
||||||
test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => {
|
test('landing defaults to Russian, switches language via the dropdown, and toggles theme', async ({ page }) => {
|
||||||
await page.goto('/landing.html');
|
await page.goto('/landing.html');
|
||||||
|
|
||||||
// The tagline renders (English in the default test browser).
|
// Russian by default regardless of the browser language (the test browser is en-US):
|
||||||
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
|
// crawlers render with arbitrary navigator.language, so the landing never auto-detects.
|
||||||
|
|
||||||
// The language dropdown switches the copy to Russian.
|
|
||||||
await page.getByRole('button', { name: 'Language' }).click();
|
|
||||||
await page.getByRole('menuitem', { name: /Русский/ }).click();
|
|
||||||
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible();
|
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible();
|
||||||
|
await expect(page).toHaveTitle(/Эрудит \(Скрэббл\)/);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.lang)).toBe('ru');
|
||||||
|
|
||||||
|
// The static SEO head is present (the page is client-rendered; this layer is what
|
||||||
|
// non-rendering crawlers and link-preview bots see).
|
||||||
|
expect(await page.locator('meta[name="description"]').getAttribute('content')).toContain('Эрудит');
|
||||||
|
expect(await page.locator('meta[property="og:image"]').getAttribute('content')).toBe(
|
||||||
|
'https://erudit-game.ru/og-image.png',
|
||||||
|
);
|
||||||
|
const jsonLd = await page.locator('script[type="application/ld+json"]').textContent();
|
||||||
|
expect(JSON.parse(jsonLd!)['@type']).toBe('WebApplication');
|
||||||
|
|
||||||
|
// The language dropdown switches the copy to English, and the document language follows.
|
||||||
|
await page.getByRole('button', { name: 'Language' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /English/ }).click();
|
||||||
|
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => document.documentElement.lang)).toBe('en');
|
||||||
|
|
||||||
// The theme toggle flips the document theme (ephemeral, light<->dark).
|
// The theme toggle flips the document theme (ephemeral, light<->dark).
|
||||||
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
@@ -24,7 +37,7 @@ test('landing shows the pitch, switches language via the dropdown, and toggles t
|
|||||||
// adds .app-shell); the landing is a normal scrolling document and must keep scrolling.
|
// adds .app-shell); the landing is a normal scrolling document and must keep scrolling.
|
||||||
test('the landing is a normal scrolling document (the SPA document-pin does not apply)', async ({ page }) => {
|
test('the landing is a normal scrolling document (the SPA document-pin does not apply)', async ({ page }) => {
|
||||||
await page.goto('/landing.html');
|
await page.goto('/landing.html');
|
||||||
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
|
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible();
|
||||||
|
|
||||||
const state = await page.evaluate(() => ({
|
const state = await page.evaluate(() => ({
|
||||||
shell: document.documentElement.classList.contains('app-shell'),
|
shell: document.documentElement.classList.contains('app-shell'),
|
||||||
|
|||||||
+10
-2
@@ -1,7 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
|
||||||
|
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
|
||||||
|
reach this tag). -->
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
|
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
|
||||||
render-blocking <script> to telegram.org hangs the whole page on a network that blocks
|
render-blocking <script> to telegram.org hangs the whole page on a network that blocks
|
||||||
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even
|
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even
|
||||||
@@ -13,7 +17,11 @@
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<title>Scrabble</title>
|
<title>Эрудит (Скрэббл)</title>
|
||||||
|
<!-- Relative icon hrefs: the gateway serves the same build under /app/, /telegram/ and /vk/. -->
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+43
-2
@@ -1,10 +1,51 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- A normal scrollable page (no board, no Telegram SDK), so allow the browser's zoom. -->
|
<!-- A normal scrollable page (no board, no Telegram SDK), so allow the browser's zoom. -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>Scrabble</title>
|
<!-- The SEO head is static and Russian-only: the page is client-rendered, so this layer is
|
||||||
|
what non-rendering crawlers and link-preview bots (Telegram/VK use Open Graph) see. The
|
||||||
|
absolute URLs are the production origin on purpose — the test contour then canonicalises
|
||||||
|
to production instead of being indexed as a separate site. -->
|
||||||
|
<title>Эрудит (Скрэббл) — играть онлайн с друзьями или роботом</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Бесплатная онлайн-игра в слова «Эрудит» (Скрэббл): играйте со случайным соперником, с друзьями или против робота — в Telegram, ВКонтакте или прямо в браузере."
|
||||||
|
/>
|
||||||
|
<link rel="canonical" href="https://erudit-game.ru/" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Эрудит" />
|
||||||
|
<meta property="og:title" content="Эрудит (Скрэббл) — играть онлайн" />
|
||||||
|
<meta property="og:description" content="Игра в слова: со случайным соперником, с друзьями или против робота." />
|
||||||
|
<meta property="og:url" content="https://erudit-game.ru/" />
|
||||||
|
<meta property="og:image" content="https://erudit-game.ru/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:locale" content="ru_RU" />
|
||||||
|
<meta property="og:locale:alternate" content="en_US" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<!-- Relative icon hrefs, like the in-page assets: the same build serves under any path. -->
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f3f4f6" />
|
||||||
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115" />
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
"name": "Эрудит (Скрэббл)",
|
||||||
|
"alternateName": "Erudit (Scrabble)",
|
||||||
|
"url": "https://erudit-game.ru/",
|
||||||
|
"image": "https://erudit-game.ru/og-image.png",
|
||||||
|
"description": "Бесплатная онлайн-игра в слова «Эрудит» (Скрэббл): играйте со случайным соперником, с друзьями или против робота — в Telegram, ВКонтакте или прямо в браузере.",
|
||||||
|
"applicationCategory": "GameApplication",
|
||||||
|
"operatingSystem": "Any",
|
||||||
|
"inLanguage": ["ru", "en"],
|
||||||
|
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "RUB" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect x="5.52" y="5.52" width="84.95" height="84.95" rx="10.15" fill="#D9B978" stroke="#B49559" stroke-width="3.05"/><path d="M46.51 25.06C46.51 25.06 46.51 25.06 46.51 25.06C42.72 25.06 39.47 25.9 36.76 27.58C34.04 29.26 32.13 31.57 31.01 34.51C31.01 34.51 31.01 34.51 31.01 34.51C31.01 34.51 24.25 32.27 24.25 32.27C26.01 27.96 28.78 24.71 32.54 22.52C36.3 20.33 40.98 19.23 46.58 19.23C46.58 19.23 46.58 19.23 46.58 19.23C54.6 19.23 60.87 21.61 65.4 26.36C69.94 31.12 72.2 37.69 72.2 46.08C72.2 46.08 72.2 46.08 72.2 46.08C72.2 51.64 71.19 56.47 69.18 60.57C67.16 64.68 64.23 67.84 60.38 70.06C56.53 72.28 51.93 73.38 46.58 73.38C46.58 73.38 46.58 73.38 46.58 73.38C35.97 73.38 28.38 68.75 23.8 59.49C23.8 59.49 23.8 59.49 23.8 59.49C23.8 59.49 29.63 56.58 29.63 56.58C31.34 60.06 33.63 62.76 36.5 64.66C39.36 66.57 42.6 67.52 46.21 67.52C46.21 67.52 46.21 67.52 46.21 67.52C51.51 67.52 55.82 65.83 59.15 62.46C62.47 59.09 64.37 54.54 64.84 48.81C64.84 48.81 64.84 48.81 64.84 48.81C64.84 48.81 40.42 48.81 40.42 48.81C40.42 48.81 40.42 43.06 40.42 43.06C40.42 43.06 64.84 43.06 64.84 43.06C64.32 37.36 62.46 32.93 59.26 29.78C56.06 26.63 51.81 25.06 46.51 25.06Z" fill="#1A1A1A" stroke="#1A1A1A" stroke-width="1.69"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
+10
-3
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { applyTheme } from './lib/theme';
|
import { applyTheme } from './lib/theme';
|
||||||
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
|
import { i18n, setLocale, t, type Locale } from './lib/i18n/index.svelte';
|
||||||
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
||||||
import { aboutContent } from './lib/aboutContent';
|
import { aboutContent } from './lib/aboutContent';
|
||||||
import { telegramChannelLink, vkAppLink } from './lib/landing';
|
import { telegramChannelLink, vkAppLink } from './lib/landing';
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
// Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and
|
// Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and
|
||||||
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
|
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
|
||||||
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
|
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
|
||||||
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app).
|
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the
|
||||||
|
// app) but defaults to Russian, never to the browser language — see landing.ts.
|
||||||
|
|
||||||
let theme = $state<'light' | 'dark'>('light');
|
let theme = $state<'light' | 'dark'>('light');
|
||||||
let langOpen = $state(false);
|
let langOpen = $state(false);
|
||||||
@@ -31,7 +32,13 @@
|
|||||||
prefs = await loadPrefs();
|
prefs = await loadPrefs();
|
||||||
theme = systemTheme();
|
theme = systemTheme();
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
|
setLocale(prefs.locale ?? 'ru');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the document language honest for assistive tech and crawlers: the static shell
|
||||||
|
// declares lang="ru" (the default), and a 🌐 switch to English must follow.
|
||||||
|
$effect(() => {
|
||||||
|
document.documentElement.lang = i18n.locale;
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleTheme(): void {
|
function toggleTheme(): void {
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { mount } from 'svelte';
|
import { mount } from 'svelte';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
import { setLocale } from './lib/i18n/index.svelte';
|
||||||
import Landing from './Landing.svelte';
|
import Landing from './Landing.svelte';
|
||||||
|
|
||||||
// Entry for the standalone landing page (served at "/" by the gateway; the game SPA lives at
|
// Entry for the standalone landing page (served at "/" by the gateway; the game SPA lives at
|
||||||
// /app/ and /telegram/). Mounts into the same #app node as the SPA's main.ts.
|
// /app/ and /telegram/). Mounts into the same #app node as the SPA's main.ts.
|
||||||
|
|
||||||
|
// The landing always starts in Russian — the primary audience and the language of the
|
||||||
|
// static SEO head (crawlers render with arbitrary navigator.language, e.g. Googlebot's
|
||||||
|
// en-US, so browser detection would make the indexed content nondeterministic). Set
|
||||||
|
// before mount so the very first paint is Russian; a saved 🌐 choice is applied in
|
||||||
|
// Landing's onMount once prefs load.
|
||||||
|
setLocale('ru');
|
||||||
|
|
||||||
export default mount(Landing, { target: document.getElementById('app')! });
|
export default mount(Landing, { target: document.getElementById('app')! });
|
||||||
|
|||||||
Reference in New Issue
Block a user