Merge pull request 'feat(landing): Russian default + SEO head, icons and OG card' (#157) from feature/landing-seo into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
This commit was merged in pull request #157.
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
|
||||
`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
|
||||
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
|
||||
`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
|
||||
|
||||
+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
|
||||
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
|
||||
(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
|
||||
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; сама игра живёт по адресам
|
||||
`/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 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');
|
||||
|
||||
// The tagline renders (English in the default test browser).
|
||||
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
|
||||
|
||||
// The language dropdown switches the copy to Russian.
|
||||
await page.getByRole('button', { name: 'Language' }).click();
|
||||
await page.getByRole('menuitem', { name: /Русский/ }).click();
|
||||
// Russian by default regardless of the browser language (the test browser is en-US):
|
||||
// crawlers render with arbitrary navigator.language, so the landing never auto-detects.
|
||||
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).
|
||||
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.
|
||||
test('the landing is a normal scrolling document (the SPA document-pin does not apply)', async ({ page }) => {
|
||||
await page.goto('/landing.html');
|
||||
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
|
||||
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible();
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
shell: document.documentElement.classList.contains('app-shell'),
|
||||
|
||||
+10
-2
@@ -1,7 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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
|
||||
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
|
||||
@@ -13,7 +17,11 @@
|
||||
name="viewport"
|
||||
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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+43
-2
@@ -1,10 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 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" />
|
||||
<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>
|
||||
<body>
|
||||
<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">
|
||||
import { onMount } from 'svelte';
|
||||
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 { aboutContent } from './lib/aboutContent';
|
||||
import { telegramChannelLink, vkAppLink } from './lib/landing';
|
||||
@@ -9,7 +9,8 @@
|
||||
// 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
|
||||
// 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 langOpen = $state(false);
|
||||
@@ -31,7 +32,13 @@
|
||||
prefs = await loadPrefs();
|
||||
theme = systemTheme();
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { mount } from 'svelte';
|
||||
import './app.css';
|
||||
import { setLocale } from './lib/i18n/index.svelte';
|
||||
import Landing from './Landing.svelte';
|
||||
|
||||
// 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.
|
||||
|
||||
// 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')! });
|
||||
|
||||
Reference in New Issue
Block a user