// Builds the site and checks what search engines, link previews and visitors get from it, in every
// language the site is published in: every page has its own title and description, a canonical
// address, hreflang alternates that answer each other, Open Graph and Twitter tags, valid JSON-LD and
// exactly one h1; the sitemap lists every page of every language and nothing that is not in the
// build; every catalog item has its page; no link or picture on any page points at something
// missing; a translated page links only to pages of its own language; and no page is half
// translated (every catalog has every key, and no English sentence is left on a translated page).
// Part of `bun run test`, so a catalog or a translation change that breaks a page fails before it ships.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, posix, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
import { checkCatalogs, loadI18nConfig } from '../../../waybetter/packages/waybetter/cli/i18n.mjs';
import { CODE_PREFIXES, extractPages, languages, loadCatalogs } from '../i18n.ts';
import { DESCRIPTION_MAX, DESCRIPTION_MIN, TITLE_MAX, clip, itemPath, itemSlugs, langPath, type Catalog } from '../seo.ts';
import { LANGUAGES, MIN_APP_VERSION, SITE_URL } from '../site.config.ts';
import { latestItems } from '../store-pages.ts';
import { areaItems, areaPath } from '../areas.ts';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// The pages read the fuller list when it is published (catalog.v3.json), the one every app knows otherwise.
const FULL = existsSync(join(ROOT, 'public/store/catalog.v3.json')) ? 'catalog.v3.json' : 'catalog.json';
const catalog = (() => {
const signed = JSON.parse(readFileSync(join(ROOT, 'public/store', FULL), 'utf8')) as Catalog;
return { ...signed, items: signed.items.filter(item => (item.kind as string) !== 'builtin') };
})();
const LANGS = languages(ROOT);
const CODES = LANGS.map(language => language.code);
const OTHER = CODES.filter(code => code !== 'en');
/** Every test reads every page of every language: generous on a busy machine. */
const SLOW = 120_000;
const BASES = ['/', '/x/', '/store/', '/store/submit/', '/themes/', '/plugins/'];
/** The area plugins' pages (built into the app, not catalog items). */
const AREA_PATHS = areaItems().map(area => areaPath(area.id));
let out = '';
type Page = { file: string; path: string; lang: string; base: string; html: string };
let pages: Page[] = [];
function htmlFiles(dir: string): string[] {
return readdirSync(dir).flatMap(name => {
const path = join(dir, name);
return statSync(path).isDirectory() ? htmlFiles(path) : name.endsWith('.html') ? [path] : [];
}, SLOW);
}
const decode = (value: string) => value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'").replace(/ /g, '\u00a0').replace(/&/g, '&');
const attr = (html: string, pattern: RegExp) => { const value = pattern.exec(html)?.[1]; return value === undefined ? undefined : decode(value); };
const meta = (html: string, key: string) => attr(html, new RegExp(` attr(html, /
([^<]*)<\/title>/);
const canonical = (html: string) => attr(html, / new Map([...html.matchAll(/ [match[1]!, match[2]!]));
const isNotFound = (page: Page) => page.base === '/404.html';
const known = new Map();
/** Whether `file` is a file of the build (remembered: every page asks for the same few hundred). */
function isFile(file: string): boolean {
let answer = known.get(file);
if (answer === undefined) known.set(file, answer = existsSync(file) && !statSync(file).isDirectory());
return answer;
}
const pageAt = (path: string) => pages.find(page => page.path === path)!;
const inLanguage = (lang: string) => pages.filter(page => page.lang === lang);
/** What a visitor reads: no scripts, styles or code, no tags, and without what is marked as not this page's language. */
function visibleText(html: string): string {
let body = html.slice(html.indexOf('/g, ' ');
// An item's own words stay English (lang="en"), and brand marks, commands and names are not translated.
for (let again = true; again;) {
const next = body.replace(/<([a-z0-9]+)\b[^>]*\s(?:lang="en"|translate="no")[^>]*>(?:(?!<\1\b)[\s\S])*?<\/\1>/g, ' ');
again = next !== body;
body = next;
}
const attrs = [...body.matchAll(/\s(?:aria-label|alt|placeholder|title)="([^"]*)"/g)].map(match => match[1]!).join(' \n ');
return decode(`${body.replace(/<[^>]+>/g, ' ')} \n ${attrs}`).replace(/\s+/g, ' ');
}
/** Whether `text` has `words` as whole words ("için wayBetter" does not have "in wayBetter"). */
function hasWords(text: string, words: string): boolean {
for (let at = text.indexOf(words); at >= 0; at = text.indexOf(words, at + 1)) {
const before = text[at - 1] ?? ' ';
const after = text[at + words.length] ?? ' ';
if (!/[\p{L}\p{N}]/u.test(before) && !/[\p{L}\p{N}]/u.test(after)) return true;
}
return false;
}
/** The literal text of a message: what is left when the placeholders and the plural/select syntax are taken out. */
const literals = (message: string): string[] => message.replace(/\{[^{}]*\{[^{}]*\}[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, '\u0000').split(/\{[^{}]*\}|\u0000/).map(part => part.replace(/\s+/g, ' ').trim());
beforeAll(async () => {
out = mkdtempSync(join(tmpdir(), 'wb-site-'));
await build({ root: ROOT, configFile: join(ROOT, 'vite.config.ts'), logLevel: 'silent', build: { outDir: out, emptyOutDir: true } });
pages = htmlFiles(out).map(file => {
const path = `/${relative(out, file).split('\\').join('/').replace(/index\.html$/, '')}`;
const first = path.split('/')[1]!;
const lang = OTHER.includes(first) ? first : 'en';
return { file, path, lang, base: lang === 'en' ? path : path.slice(lang.length + 1), html: readFileSync(file, 'utf8') };
}, SLOW);
}, 600_000);
afterAll(() => { if (out) rmSync(out, { recursive: true, force: true }); });
describe('the catalogs', () => {
test('en.json is what the pages say, and every language has every key (no mixed-language pages)', () => {
const all = loadCatalogs(ROOT);
const english = all.get('en')!.messages;
const fromPages = extractPages(ROOT).messages;
const byHand = (key: string) => CODE_PREFIXES.some(prefix => key.startsWith(prefix));
// `node scripts/i18n-site.mjs --write` regenerates the pages' part of en.json.
expect(Object.fromEntries(Object.entries(english).filter(([key]) => !byHand(key)))).toEqual(fromPages);
for (const [code, entry] of all) {
const missing = Object.keys(english).filter(key => !(key in entry.messages));
expect([code, missing]).toEqual([code, []]);
const unknown = Object.keys(entry.messages).filter(key => !(key in english));
expect([code, unknown]).toEqual([code, []]);
}
}, SLOW);
test('the framework\'s catalog check finds no errors (syntax, placeholders, markup, length limits)', async () => {
const result = await checkCatalogs(loadI18nConfig(ROOT, 'waybetter.i18n.json'));
expect(result.lines.filter((line: string) => line.startsWith('ERROR'))).toEqual([]);
expect(result.errors).toBe(0);
}, SLOW);
test('every site language has a catalog, and the API serves the same list', () => {
expect(CODES).toEqual([...LANGUAGES]);
const api = readFileSync(join(ROOT, '../api/src/app.ts'), 'utf8');
const listed = /export const SITE_LANGUAGES = (\[[^\]]*\])/.exec(api)![1]!;
expect([...listed.matchAll(/'([^']+)'/g)].map(match => match[1]!).sort()).toEqual(OTHER.slice().sort());
}, SLOW);
test('the minimum app version is one constant: no catalog spells a version, and the pages show the constant', () => {
for (const [code, entry] of loadCatalogs(ROOT)) {
const spelled = Object.entries(entry.messages).filter(([, value]) => /\b\d+\.\d+\.\d+\b/.test(value)).map(([key]) => key);
expect([code, spelled]).toEqual([code, []]);
}
expect(MIN_APP_VERSION).toBe('1.0.0');
for (const lang of CODES) {
const store = pageAt(langPath(lang, '/store/'));
expect([lang, store.html.includes(MIN_APP_VERSION)]).toEqual([lang, true]);
for (const page of inLanguage(lang)) {
for (const match of page.html.matchAll(/data-install-since="([^"]*)"/g)) expect([page.path, match[1]]).toEqual([page.path, MIN_APP_VERSION]);
}
}
}, SLOW);
test('every key the scripts look up is in en.json, and every script message is looked up', () => {
const english = loadCatalogs(ROOT).get('en')!.messages;
const used = new Set();
for (const file of readdirSync(join(ROOT, 'src')).filter(name => name.endsWith('.ts'))) {
for (const match of readFileSync(join(ROOT, 'src', file), 'utf8').matchAll(/\bt\('(site\.js\.[\w.]+)'/g)) used.add(match[1]!);
}
for (const key of used) expect([key, key in english]).toEqual([key, true]);
for (const key of Object.keys(english).filter(name => name.startsWith('site.js.'))) expect([key, used.has(key)]).toEqual([key, true]);
}, SLOW);
});
describe('every built page', () => {
test('there is a page for the home, the app, the Store, the submit page, both galleries and every item, in every language', () => {
const paths = new Set(pages.map(page => page.path));
const slugs = itemSlugs(catalog.items);
for (const code of CODES) {
for (const base of [...BASES, '/404.html']) expect([code, base, paths.has(langPath(code, base))]).toEqual([code, base, true]);
for (const item of catalog.items) expect(paths.has(langPath(code, itemPath(item, slugs.get(item)!)))).toBe(true);
for (const path of AREA_PATHS) expect([code, path, paths.has(langPath(code, path))]).toEqual([code, path, true]);
}
expect(pages.length).toBe((7 + catalog.items.length + AREA_PATHS.length) * CODES.length);
}, SLOW);
test('says its language and direction', () => {
for (const page of pages) {
const tag = /]*>/.exec(page.html)![0];
const language = LANGS.find(entry => entry.code === page.lang)!;
expect([page.path, /lang="([^"]+)"/.exec(tag)?.[1]]).toEqual([page.path, page.lang]);
// English pages say nothing about direction; every translated page says its own.
expect([page.path, /dir="([^"]+)"/.exec(tag)?.[1] ?? 'ltr']).toEqual([page.path, language.dir]);
if (page.lang !== 'en') expect([page.path, /dir="/.test(tag)]).toEqual([page.path, true]);
}
}, SLOW);
test('has a title, a description, a canonical address and exactly one h1', () => {
for (const page of pages) {
const where = page.path;
const name = title(page.html) ?? '';
const description = meta(page.html, 'description') ?? '';
expect([where, name, name.length > 0 && [...name].length <= TITLE_MAX]).toEqual([where, name, true]);
expect([where, description, [...description].length >= DESCRIPTION_MIN]).toEqual([where, description, true]);
// Pages generated from the catalog stay inside what a search result shows.
if (page.base.startsWith('/store/') || page.base === '/themes/' || page.base === '/plugins/') expect([where, description, [...description].length <= DESCRIPTION_MAX]).toEqual([where, description, true]);
expect([where, (page.html.match(/
]/g) ?? []).length]).toEqual([where, 1]);
// No markers, tokens or translation marks left behind by the generator.
expect([where, /