Files
lockbitchat 414c27fda6 v6.7.3: faster loading, and pages search engines can read
The bundles carried all thirteen translations at once and a page fetched them a
third time as raw source; each page now loads only its own language. Alongside
that: JavaScript is minified, the eight stylesheets are served as one file, the
QR scanner is fetched after the app is up instead of on every visit, Inter ships
once rather than five copies of the same file, and Font Awesome is subset to the
82 icons this app draws instead of all 2468.

1.85 MB across 43 requests becomes under 700 KB across 33. On mobile the page
starts drawing in 1.6 s instead of 6.3 s and is usable in 4.5 s instead of 11 s.

Pages also carry their text in the HTML now. Everything was drawn by JavaScript
into an empty div, so crawlers saw correct metadata around nothing, and twelve of
the thirteen language pages had never been shown to anyone. The documentation is
published under /docs/ with a new FAQ, and unknown addresses return a real 404.

Separately: the localized shells were served with the year-long immutable cache
header meant for static assets, which pinned anyone who opened /de/ or /ru/ to
that build. The header is fixed and the service worker refreshes what it cached.

Claude-Session: https://claude.ai/code/session_014KjzTXxrhzYoDDWChYQ4u2
2026-09-04 00:41:46 -04:00

114 lines
5.8 KiB
JavaScript

// The documentation is the only text on this site written to answer a question someone
// actually types. It spent its life as raw Markdown behind Disallow: /doc/, which is a
// large part of why every category query lands on page three. What has to keep holding
// once it is published: one page per document, each one addressable, describable and
// reachable — a page Google cannot find a link to, or cannot tell apart from another,
// is back where it started.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
// Regenerate first, so the committed pages are checked as *output* and a stale docs/
// fails here rather than shipping.
execFileSync('node', [path.join(ROOT, 'scripts/build-docs.js')], { stdio: 'pipe' });
const sources = readdirSync(path.join(ROOT, 'doc')).filter((f) => f.endsWith('.md'));
assert.ok(sources.length >= 2, 'doc/ should hold the documentation this test is about');
const urlFor = (file) =>
file === 'README.md' ? '/docs/' : `/docs/${path.basename(file, '.md').toLowerCase()}/`;
const fileFor = (url) => path.join('docs', url.slice('/docs/'.length), 'index.html');
const titles = new Set();
const descriptions = new Set();
for (const source of sources) {
const url = urlFor(source);
const rel = fileFor(url);
assert.ok(existsSync(path.join(ROOT, rel)), `${source} has no page at ${url}`);
const html = read(rel);
// Every page must be addressable as itself. A shared canonical would collapse all
// nine into one result, which is the failure this whole exercise is undoing.
assert.ok(html.includes(`<link rel="canonical" href="https://securebit.chat${url}">`),
`${rel}: canonical must point at ${url}`);
const title = html.match(/<title>([\s\S]*?)<\/title>/);
assert.ok(title, `${rel}: no <title>`);
assert.ok(!titles.has(title[1]), `${rel}: duplicate <title> ${title[1]}`);
titles.add(title[1]);
const description = html.match(/<meta name="description" content="([^"]*)">/);
assert.ok(description && description[1].length > 40, `${rel}: description is missing or too thin`);
assert.ok(description[1].length <= 170,
`${rel}: description is ${description[1].length} chars — Google truncates near 155`);
assert.ok(!descriptions.has(description[1]), `${rel}: duplicate description`);
descriptions.add(description[1]);
assert.ok(html.includes('<h1'), `${rel}: no <h1>`);
// Links carried over from Markdown must have been rewritten. A surviving .md href
// is a 404 for a reader and a dead end for a crawler.
const dangling = [...html.matchAll(/href="([^"]*\.md[^"]*)"/g)]
.map((m) => m[1])
.filter((href) => !href.startsWith('https://github.com/'));
assert.deepEqual(dangling, [], `${rel}: unrewritten Markdown links`);
// Nothing may reference an asset relatively: /docs/cryptography/ + "logo/x.png"
// resolves under the document's own directory and 404s.
const relative = [...html.matchAll(/\b(?:src|href)="(?!https?:|data:|mailto:|#|\/)([^"]+)"/g)];
assert.deepEqual(relative.map((m) => m[1]), [], `${rel}: relative asset path`);
assert.ok(html.includes('href="/docs/'), `${rel}: no link back into the documentation`);
assert.ok(html.includes('href="/"'), `${rel}: no link back to the app`);
}
// The FAQ is the one document that is a list of questions, and the only one that may
// say so. An earlier version detected FAQ shape from "two or more <h2>", which every
// document here satisfies, and published ARCHITECTURE.md as a FAQPage — structured data
// describing a page as something it is not is worse than shipping none.
{
const faq = read(fileFor('/docs/faq/'));
const schemaOf = (html) => JSON.parse(html.match(/application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
const faqSchema = schemaOf(faq);
assert.equal(faqSchema['@type'], 'FAQPage', 'the FAQ must be marked up as one');
assert.ok(faqSchema.mainEntity.length >= 5, 'the FAQ schema lost its questions');
for (const entry of faqSchema.mainEntity) {
assert.equal(entry['@type'], 'Question');
assert.ok(entry.name.length > 5 && entry.acceptedAnswer.text.length > 40,
`FAQ entry "${entry.name}" is missing its question or answer`);
}
// Every question in the schema must be a heading a reader can actually see.
for (const entry of faqSchema.mainEntity) {
assert.ok(faq.includes(entry.name.replace(/&/g, '&amp;')),
`FAQ schema claims a question the page does not show: ${entry.name}`);
}
for (const source of sources.filter((f) => f !== 'FAQ.md')) {
assert.equal(schemaOf(read(fileFor(urlFor(source))))['@type'], 'TechArticle',
`${source} must not be published as a FAQPage`);
}
}
// Discoverability: the sitemap lists them, and the landing page links to them. The
// sitemap alone only says the pages exist; the link is what gets them crawled.
const sitemap = read('sitemap.xml');
const landing = read('index.html');
for (const source of sources) {
const url = urlFor(source);
assert.ok(sitemap.includes(`<loc>https://securebit.chat${url}</loc>`), `sitemap is missing ${url}`);
assert.ok(landing.includes(`href="${url}"`), `the landing page does not link to ${url}`);
}
assert.ok(/<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/.test(sitemap), 'sitemap entries need a lastmod');
// The raw Markdown must stay out of the index: same text, second address.
assert.ok(read('robots.txt').includes('Disallow: /doc/'),
'the raw doc/*.md files must stay disallowed now that /docs/ carries the same text');
console.log(`docs-build.test.mjs: ${sources.length} documentation pages checked`);