feat(legal): bilingual (RU + EN) legal pages with a language + theme switcher
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Add English versions of the privacy policy, EULA and offer (ui/legal/*_en.md) and render each legal page as one self-contained bilingual document: both language bodies plus a header language toggle and theme toggle (no back), a small inline ES5 script that switches language client-side (no reload, default Russian + persisted) and applies the theme (system default + persisted override) — mirroring the landing. The offer's English view transliterates the Russian product names spliced from the live catalog. renderLegalHtml now takes both language sources; renderOffer splices the price list into both; the renderer bakes and reads the _en sources and pre-renders the static pages at boot. Also wrap the /offer/ contour probe in the same retry+timeout as the legal probe (the offer page is now bilingual and larger). Docs + renderer/ui tests updated.
This commit is contained in:
+42
-32
@@ -1,52 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderOfferHtml, renderLegalHtml } from './offer';
|
||||
|
||||
describe('renderOfferHtml', () => {
|
||||
it('wraps rendered markdown in a standalone Russian HTML document', () => {
|
||||
const html = renderOfferHtml('# Публичная оферта\n\nо заключении договора купли-продажи');
|
||||
expect(html).toContain('<!doctype html>');
|
||||
expect(html).toContain('lang="ru"');
|
||||
expect(html).toContain('<title>Публичная оферта — Эрудит</title>');
|
||||
// The markdown heading is rendered, not left as literal source.
|
||||
expect(html).toContain('<h1>Публичная оферта</h1>');
|
||||
expect(html).not.toContain('# Публичная оферта');
|
||||
// No in-page navigation: the standalone offer carries no "back" link.
|
||||
expect(html).not.toContain('class="back"');
|
||||
});
|
||||
|
||||
it('renders headings, bold clause numbers and lists', () => {
|
||||
const html = renderOfferHtml('## 2. Предмет\n\n**2.1.** Текст пункта.\n\n- первый\n- второй');
|
||||
expect(html).toContain('<h2>2. Предмет</h2>');
|
||||
expect(html).toContain('<strong>2.1.</strong>');
|
||||
expect(html).toContain('<li>первый</li>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderLegalHtml', () => {
|
||||
it('applies the given title and canonical URL to the shared chrome', () => {
|
||||
const html = renderLegalHtml('# Политика конфиденциальности\n\nтекст', {
|
||||
title: 'Политика конфиденциальности — Эрудит',
|
||||
it('renders both language bodies into one standalone document with a switcher', () => {
|
||||
const html = renderLegalHtml({
|
||||
ru: { md: '# Политика\n\nрусский текст', title: 'Политика — Эрудит' },
|
||||
en: { md: '# Policy\n\nenglish text', title: 'Policy — Erudit' },
|
||||
canonical: 'https://erudit-game.ru/privacy/',
|
||||
});
|
||||
expect(html).toContain('<!doctype html>');
|
||||
expect(html).toContain('<title>Политика конфиденциальности — Эрудит</title>');
|
||||
expect(html).toContain('<title>Политика — Эрудит</title>'); // Russian is the default
|
||||
expect(html).toContain('href="https://erudit-game.ru/privacy/"');
|
||||
expect(html).toContain('<h1>Политика конфиденциальности</h1>');
|
||||
// Both bodies are present; English is hidden until toggled.
|
||||
expect(html).toContain('<main data-lang="ru">');
|
||||
expect(html).toContain('<main data-lang="en" hidden>');
|
||||
expect(html).toContain('<h1>Политика</h1>');
|
||||
expect(html).toContain('<h1>Policy</h1>');
|
||||
// The language + theme switcher and its script.
|
||||
expect(html).toContain('id="legal-lang"');
|
||||
expect(html).toContain('id="legal-theme"');
|
||||
expect(html).toContain('erudit.legal.lang');
|
||||
// Titles carried on <html> so the toggle can update document.title.
|
||||
expect(html).toContain('data-title-ru="Политика — Эрудит"');
|
||||
expect(html).toContain('data-title-en="Policy — Erudit"');
|
||||
});
|
||||
|
||||
it('renderOfferHtml is the offer preset over it', () => {
|
||||
const html = renderOfferHtml('# Публичная оферта');
|
||||
expect(html).toContain('<title>Публичная оферта — Эрудит</title>');
|
||||
expect(html).toContain('href="https://erudit-game.ru/offer/"');
|
||||
it('renders markdown headings, bold clause numbers and lists in both languages', () => {
|
||||
const html = renderLegalHtml({
|
||||
ru: { md: '## 2. Предмет\n\n**2.1.** Текст.\n\n- первый\n- второй', title: 't' },
|
||||
en: { md: '## 2. Subject\n\n**2.1.** Text.', title: 't' },
|
||||
canonical: 'c',
|
||||
});
|
||||
expect(html).toContain('<h2>2. Предмет</h2>');
|
||||
expect(html).toContain('<strong>2.1.</strong>');
|
||||
expect(html).toContain('<li>первый</li>');
|
||||
expect(html).toContain('<h2>2. Subject</h2>');
|
||||
});
|
||||
|
||||
it('scopes the shrink-to-fit first-column table rule to the offer only', () => {
|
||||
// The offer opts into the nowrap product-name column via body.offer; other legal pages (the
|
||||
// privacy data table) must NOT get it, or their prose first column cannot wrap.
|
||||
expect(renderOfferHtml('# x')).toContain('<body class="offer">');
|
||||
const generic = renderLegalHtml('# x', { title: 't', canonical: 'c' });
|
||||
const generic = renderLegalHtml({ ru: { md: '# x', title: 't' }, en: { md: '# x', title: 't' }, canonical: 'c' });
|
||||
expect(generic).toContain('<body>');
|
||||
expect(generic).not.toContain('<body class=');
|
||||
expect(generic).toContain('.offer td:first-child');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderOfferHtml', () => {
|
||||
it('is the offer preset: both languages, body.offer, offer title + canonical, EN transliteration', () => {
|
||||
const html = renderOfferHtml('# Публичная оферта\n\nрусский', '# Public offer\n\nenglish');
|
||||
expect(html).toContain('<body class="offer">');
|
||||
expect(html).toContain('<title>Публичная оферта — Эрудит</title>');
|
||||
expect(html).toContain('data-title-en="Public offer — Erudit"');
|
||||
expect(html).toContain('href="https://erudit-game.ru/offer/"');
|
||||
expect(html).toContain('<h1>Публичная оферта</h1>');
|
||||
expect(html).toContain('<h1>Public offer</h1>');
|
||||
// The offer page transliterates the Russian product names left in the English (price-list) view.
|
||||
expect(html).toContain('translitEn');
|
||||
});
|
||||
});
|
||||
|
||||
+148
-28
@@ -1,29 +1,36 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
/** LegalDoc is one language variant of a legal document: its markdown source and page title. */
|
||||
export interface LegalDoc {
|
||||
md: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderLegalHtml renders a legal-document markdown source into a standalone,
|
||||
* self-contained HTML document. It backs every public legal page — the offer at
|
||||
* `/offer/`, the privacy policy at `/privacy/` and the EULA at `/eula/` — served by
|
||||
* the render sidecar (`renderer/src/{offer,legal}.mjs`), which reads the owner-edited
|
||||
* markdown under `ui/legal/` and, for the offer only, splices the live catalog price
|
||||
* list in before calling this. One renderer shared with the browser build, kept
|
||||
* unit-tested here (`offer.test.ts`).
|
||||
* renderLegalHtml renders a bilingual (RU + EN) legal document as a standalone, self-contained HTML
|
||||
* page with a header language + theme switcher, mirroring the landing: a 🌐 language toggle and a
|
||||
* ☼/☾ theme toggle, no "back". Both language bodies are rendered into the page; a small inline
|
||||
* ES5 script toggles between them client-side (no reload), defaults to Russian (never the browser
|
||||
* language, like the landing) and persists the choice, and applies the theme (system default plus a
|
||||
* persisted manual override). On the offer page (`bodyClass === 'offer'`) it also transliterates the
|
||||
* Cyrillic left in the EN body — the live price list is spliced in Russian, so the product names are
|
||||
* transliterated for the English view. It backs every public legal page — the offer at `/offer/`, the
|
||||
* privacy policy at `/privacy/`, the EULA at `/eula/` — served by the render sidecar.
|
||||
*
|
||||
* `title` becomes the document `<title>`; `canonical` its absolute canonical URL.
|
||||
* The input `markdown` is trusted repository content plus the backend's own catalog
|
||||
* projection, not user input, so the rendered HTML is deliberately not sanitised. The
|
||||
* page carries its own minimal light/dark styling so it needs neither the app bundle
|
||||
* nor `app.css`.
|
||||
* The markdown is trusted repository content plus the backend's own catalog projection, not user
|
||||
* input, so the rendered HTML is deliberately not sanitised. The page carries its own minimal styling
|
||||
* and script, so it needs neither the app bundle nor `app.css`.
|
||||
*/
|
||||
export function renderLegalHtml(markdown: string, opts: { title: string; canonical: string; bodyClass?: string }): string {
|
||||
const body = marked.parse(markdown, { async: false }) as string;
|
||||
export function renderLegalHtml(doc: { ru: LegalDoc; en: LegalDoc; canonical: string; bodyClass?: string }): string {
|
||||
const ruBody = marked.parse(doc.ru.md, { async: false }) as string;
|
||||
const enBody = marked.parse(doc.en.md, { async: false }) as string;
|
||||
return `<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="ru" data-title-ru="${attr(doc.ru.title)}" data-title-en="${attr(doc.en.title)}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${opts.title}</title>
|
||||
<link rel="canonical" href="${opts.canonical}" />
|
||||
<title>${doc.ru.title}</title>
|
||||
<link rel="canonical" href="${doc.canonical}" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f3f4f6" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115" />
|
||||
<style>
|
||||
@@ -35,17 +42,24 @@ export function renderLegalHtml(markdown: string, opts: { title: string; canonic
|
||||
--rule: #e5e7eb;
|
||||
--cell-border: #d1d5db;
|
||||
}
|
||||
/* Dark palette: the system default (unless the reader forced light) and the manual override. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme='light']) {
|
||||
--bg: #0f1115;
|
||||
--text: #e7e9ee;
|
||||
--accent: #6ea8fe;
|
||||
--rule: #242832;
|
||||
/* A muted grey — the section rules (--rule) vanish on the dark background, so table cells
|
||||
get a slightly firmer border to stay legible without being loud. */
|
||||
/* A muted grey — the section rules vanish on dark, so table cells get a firmer border. */
|
||||
--cell-border: #3a4250;
|
||||
}
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #0f1115;
|
||||
--text: #e7e9ee;
|
||||
--accent: #6ea8fe;
|
||||
--rule: #242832;
|
||||
--cell-border: #3a4250;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -55,11 +69,40 @@ export function renderLegalHtml(markdown: string, opts: { title: string; canonic
|
||||
color: var(--text);
|
||||
font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
/* The language + theme switcher sits at the top and stays reachable down a long document. */
|
||||
.legalbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.tgl {
|
||||
min-width: 40px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: 600 14px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 1px solid var(--cell-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
main {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 64px;
|
||||
}
|
||||
main[hidden] {
|
||||
display: none;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -113,22 +156,99 @@ export function renderLegalHtml(markdown: string, opts: { title: string; canonic
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body${opts.bodyClass ? ' class="' + opts.bodyClass + '"' : ''}>
|
||||
<main>
|
||||
${body}
|
||||
<body${doc.bodyClass ? ' class="' + doc.bodyClass + '"' : ''}>
|
||||
<header class="legalbar">
|
||||
<button type="button" id="legal-lang" class="tgl"></button>
|
||||
<button type="button" id="legal-theme" class="tgl" aria-label="Theme"></button>
|
||||
</header>
|
||||
<main data-lang="ru">
|
||||
${ruBody}
|
||||
</main>
|
||||
<main data-lang="en" hidden>
|
||||
${enBody}
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var html = document.documentElement;
|
||||
var K_LANG = 'erudit.legal.lang', K_THEME = 'erudit.legal.theme';
|
||||
function get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
||||
function set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||||
var langBtn = document.getElementById('legal-lang');
|
||||
var themeBtn = document.getElementById('legal-theme');
|
||||
|
||||
// Offer only: the price list is spliced in Russian, so transliterate the Cyrillic that
|
||||
// survives in the English body (the product names) to keep it in-language.
|
||||
function translit(s) {
|
||||
var m = { 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i',
|
||||
'й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u',
|
||||
'ф':'f','х':'kh','ц':'ts','ч':'ch','ш':'sh','щ':'shch','ъ':'','ы':'y','ь':'','э':'e',
|
||||
'ю':'yu','я':'ya' };
|
||||
return s.replace(/[а-яё]/gi, function (c) {
|
||||
var lower = c.toLowerCase(), r = m[lower];
|
||||
if (r === undefined) return c;
|
||||
return c !== lower && r ? r.charAt(0).toUpperCase() + r.slice(1) : r;
|
||||
});
|
||||
}
|
||||
function translitEn() {
|
||||
var en = document.querySelector('main[data-lang="en"]');
|
||||
if (!en || !document.createTreeWalker) return;
|
||||
var w = document.createTreeWalker(en, NodeFilter.SHOW_TEXT, null, false), n;
|
||||
while ((n = w.nextNode())) {
|
||||
if (/[а-яё]/i.test(n.nodeValue)) n.nodeValue = translit(n.nodeValue);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLang(l) {
|
||||
html.lang = l;
|
||||
set(K_LANG, l);
|
||||
var mains = document.querySelectorAll('main[data-lang]');
|
||||
for (var i = 0; i < mains.length; i++) {
|
||||
mains[i].hidden = mains[i].getAttribute('data-lang') !== l;
|
||||
}
|
||||
var t = html.getAttribute('data-title-' + l);
|
||||
if (t) document.title = t;
|
||||
// The button shows the OTHER language — what a click switches to.
|
||||
langBtn.textContent = l === 'ru' ? 'English' : 'Русский';
|
||||
}
|
||||
function applyTheme(t) {
|
||||
html.setAttribute('data-theme', t);
|
||||
set(K_THEME, t);
|
||||
themeBtn.textContent = t === 'light' ? '☼' : '☾';
|
||||
}
|
||||
|
||||
// Default language is Russian (never the browser language), like the landing; persisted wins.
|
||||
var lang = get(K_LANG) === 'en' ? 'en' : 'ru';
|
||||
// Theme: a persisted choice wins, else the system scheme.
|
||||
var theme = get(K_THEME);
|
||||
if (theme !== 'light' && theme !== 'dark') {
|
||||
theme = window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
if (document.body.classList.contains('offer')) translitEn();
|
||||
applyLang(lang);
|
||||
applyTheme(theme);
|
||||
langBtn.onclick = function () { applyLang(html.lang === 'ru' ? 'en' : 'ru'); };
|
||||
themeBtn.onclick = function () { applyTheme(html.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); };
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/** attr escapes a trusted string for use inside a double-quoted HTML attribute value. */
|
||||
function attr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
|
||||
/**
|
||||
* renderOfferHtml renders the public-offer markdown (its price list already spliced
|
||||
* in) as the standalone `/offer/` page — a thin preset over {@link renderLegalHtml}.
|
||||
* renderOfferHtml renders the public-offer page (its Russian price list already spliced into both
|
||||
* language sources) as the standalone `/offer/` page — the offer preset over {@link renderLegalHtml}.
|
||||
*/
|
||||
export function renderOfferHtml(markdown: string): string {
|
||||
return renderLegalHtml(markdown, {
|
||||
title: 'Публичная оферта — Эрудит',
|
||||
export function renderOfferHtml(ruMd: string, enMd: string): string {
|
||||
return renderLegalHtml({
|
||||
ru: { md: ruMd, title: 'Публичная оферта — Эрудит' },
|
||||
en: { md: enMd, title: 'Public offer — Erudit' },
|
||||
canonical: 'https://erudit-game.ru/offer/',
|
||||
bodyClass: 'offer',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user