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
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* build-css.js — concatenates every render-blocking stylesheet into one file.
|
||||
*
|
||||
* The page used to link eight of them: the Tailwind build, the Inter @font-face
|
||||
* declarations, five hand-written sheets, and pwa.css at the end of <body>. Together
|
||||
* they are about 21 KB compressed, which is nothing — but each one is a separate
|
||||
* request that has to complete before the browser paints, and on a throttled mobile
|
||||
* connection each cost between 400 and 900 ms of latency for a file of 1 to 7 KB.
|
||||
* GTmetrix put the whole thing at 441 ms of render-blocking time, spent almost
|
||||
* entirely on round trips rather than bytes.
|
||||
*
|
||||
* Order is the one thing this must not get wrong. The sheets are written to argue with
|
||||
* each other on source order: components.css settles layout, apple-motion.css then
|
||||
* overrides press feedback and reduced-motion behaviour on top of it, and rtl.css has
|
||||
* to win over everything because its rules only apply under [dir="rtl"]. Concatenating
|
||||
* in the same sequence the <link> tags had preserves that exactly — the cascade cannot
|
||||
* tell the difference between eight files and one.
|
||||
*
|
||||
* The only rewriting done here is url(): inter.css refers to its woff2 files relatively,
|
||||
* and those paths are resolved against the sheet's own directory before it moves.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const OUT = path.join(ROOT, 'assets', 'app.css');
|
||||
|
||||
// The exact sequence the <link> tags had, head first and pwa.css last.
|
||||
const SHEETS = [
|
||||
'assets/tailwind.css',
|
||||
// Font Awesome, cut to the 82 icons this interface draws (scripts/subset-icons.py).
|
||||
// The full sheet was 102 KB and loaded asynchronously to keep it off the critical
|
||||
// path; at 7 KB it is cheaper to have it here than to spend a request on it, and
|
||||
// icons stop arriving a beat after the text.
|
||||
'assets/fontawesome/css/subset.css',
|
||||
'assets/fonts/inter/inter.css',
|
||||
'src/styles/main.css',
|
||||
'src/styles/animations.css',
|
||||
'src/styles/components.css',
|
||||
'src/styles/apple-motion.css',
|
||||
'src/styles/rtl.css',
|
||||
'src/styles/pwa.css',
|
||||
];
|
||||
|
||||
/**
|
||||
* Make every relative url() root-absolute, resolved against the sheet it came from.
|
||||
* Absolute paths, full URLs and data: URIs are already correct and left alone.
|
||||
*/
|
||||
function absolutizeUrls(css, sheetPath) {
|
||||
const dir = path.posix.dirname(`/${sheetPath}`);
|
||||
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (whole, quote, target) => {
|
||||
if (/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(target.trim())) return whole;
|
||||
return `url(${quote}${path.posix.join(dir, target.trim())}${quote})`;
|
||||
});
|
||||
}
|
||||
|
||||
function build() {
|
||||
const parts = [];
|
||||
for (const sheet of SHEETS) {
|
||||
const file = path.join(ROOT, sheet);
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`stylesheet listed in build-css.js is missing: ${sheet}`);
|
||||
}
|
||||
parts.push(`/* ${sheet} */\n${absolutizeUrls(fs.readFileSync(file, 'utf8'), sheet)}`);
|
||||
}
|
||||
|
||||
const combined = parts.join('\n\n');
|
||||
// esbuild minifies from stdin so nothing intermediate has to be written to disk.
|
||||
const minified = execFileSync(
|
||||
'npx',
|
||||
['--no-install', 'esbuild', '--loader=css', '--minify'],
|
||||
{ input: combined, encoding: 'utf8', cwd: ROOT, maxBuffer: 32 * 1024 * 1024 }
|
||||
);
|
||||
|
||||
fs.writeFileSync(OUT, minified, 'utf8');
|
||||
return { bytes: Buffer.byteLength(minified), sheets: SHEETS.length };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const { bytes, sheets } = build();
|
||||
console.log('🎨 Bundling stylesheets...');
|
||||
console.log(` ✅ assets/app.css — ${sheets} sheets, ${bytes.toLocaleString('en-US')} bytes`);
|
||||
} catch (error) {
|
||||
console.error('❌ css build failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { build, SHEETS };
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* build-docs.js — renders doc/*.md into real pages under /docs/.
|
||||
*
|
||||
* The documentation was already written and already good; it just was not on the web.
|
||||
* It shipped in the image as raw Markdown, served as application/octet-stream, and
|
||||
* robots.txt disallowed /doc/ — so the only text the site owned that answers a real
|
||||
* question ("how does SecureBit do key exchange", "what does SAS actually verify")
|
||||
* was invisible to search. Meanwhile the site's entire indexable surface was one
|
||||
* address repeated in thirteen languages, which is why every category query lands on
|
||||
* page three or worse.
|
||||
*
|
||||
* So each document gets its own URL, its own <title> and description, and a place in
|
||||
* the sitemap. Pages are static and script-free — no bundle, no framework, no fonts to
|
||||
* fetch — because a reference page's job is to be readable and to be read by a crawler
|
||||
* on the first request.
|
||||
*
|
||||
* The raw /doc/*.md files stay disallowed in robots.txt: they are the same text at a
|
||||
* second address, and only one of the two should be indexable.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
// marked is ESM-only, so it is pulled in with a dynamic import inside build() rather
|
||||
// than require()d at the top: requiring an ES module works on current Node but only
|
||||
// behind an ExperimentalWarning, and a release build should not print one.
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const DOC_DIR = path.join(ROOT, 'doc');
|
||||
const OUT_ROOT = process.env.DOCS_OUT_ROOT || ROOT;
|
||||
|
||||
/** README.md is the section's own index, so it owns /docs/ rather than a subdirectory. */
|
||||
const INDEX_FILE = 'README.md';
|
||||
|
||||
/**
|
||||
* The one document that is a list of questions. Named explicitly rather than detected
|
||||
* from its shape: an earlier version keyed off "has two or more <h2>", which is true of
|
||||
* every document here, and shipped ARCHITECTURE.md marked up as a FAQPage. Structured
|
||||
* data that describes the page as something it is not is worse than none at all.
|
||||
*/
|
||||
const FAQ_FILE = 'FAQ.md';
|
||||
|
||||
const REPO = 'https://github.com/SecureBitChat/securebit-chat';
|
||||
const BASE = 'https://securebit.chat';
|
||||
|
||||
const esc = (value) =>
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
/** GitHub-compatible heading slug, so the #anchors already written in the docs resolve. */
|
||||
const slugify = (text) =>
|
||||
text.toLowerCase().trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-');
|
||||
|
||||
/** ARCHITECTURE.md → architecture. The file name is the URL; nothing else has to agree. */
|
||||
const slugForFile = (file) => path.basename(file, '.md').toLowerCase();
|
||||
|
||||
const urlForFile = (file) => (file === INDEX_FILE ? '/docs/' : `/docs/${slugForFile(file)}/`);
|
||||
|
||||
/**
|
||||
* The document list, in reading order rather than alphabetical: someone arriving at
|
||||
* /docs/ should meet the architecture before the wire format of the invitation.
|
||||
*/
|
||||
const ORDER = [
|
||||
'README.md',
|
||||
'FAQ.md',
|
||||
'ARCHITECTURE.md',
|
||||
'CRYPTOGRAPHY.md',
|
||||
'DESCRIPTOR-SBQ2.md',
|
||||
'CONFIGURATION.md',
|
||||
'CALLS.md',
|
||||
'API.md',
|
||||
'CONTRIBUTING.md',
|
||||
'USE-POLICY.md',
|
||||
];
|
||||
|
||||
function docFiles() {
|
||||
const present = fs.readdirSync(DOC_DIR).filter((f) => f.endsWith('.md'));
|
||||
const known = ORDER.filter((f) => present.includes(f));
|
||||
// Anything added to doc/ without being listed above still gets a page — it just
|
||||
// sorts to the end instead of silently never being published.
|
||||
const rest = present.filter((f) => !ORDER.includes(f)).sort();
|
||||
return [...known, ...rest];
|
||||
}
|
||||
|
||||
/** Rewrite the links the Markdown was written with into the URLs the site serves. */
|
||||
function rewriteLinks(html) {
|
||||
return html.replace(/href="([^"]+)"/g, (whole, href) => {
|
||||
if (/^(https?:|mailto:|#)/.test(href)) return whole;
|
||||
// Repository-root files have no page of their own; they belong on GitHub.
|
||||
if (href === '../README.md') return `href="${REPO}#readme"`;
|
||||
const rootFile = href.match(/^\.\.\/([A-Z0-9._-]+\.md)$/);
|
||||
if (rootFile) return `href="${REPO}/blob/main/${rootFile[1]}"`;
|
||||
const sibling = href.match(/^\.?\/?([A-Za-z0-9._-]+)\.md(#.*)?$/);
|
||||
if (sibling) return `href="${urlForFile(`${sibling[1]}.md`)}${sibling[2] || ''}"`;
|
||||
return whole;
|
||||
});
|
||||
}
|
||||
|
||||
/** Give every heading below the title an id, so in-page anchors and deep links work. */
|
||||
function anchorHeadings(html) {
|
||||
return html.replace(/<h([2-4])>([\s\S]*?)<\/h\1>/g, (whole, level, inner) => {
|
||||
const text = inner.replace(/<[^>]+>/g, '').trim();
|
||||
const id = slugify(text);
|
||||
if (!id) return whole;
|
||||
return `<h${level} id="${esc(id)}">${inner}</h${level}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Snippet-length summary from the document's own opening paragraph. */
|
||||
function firstParagraph(html) {
|
||||
const match = html.match(/<p>([\s\S]*?)<\/p>/);
|
||||
if (!match) return '';
|
||||
const text = match[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= 155) return text;
|
||||
const cut = text.slice(0, 155);
|
||||
return `${cut.slice(0, cut.lastIndexOf(' ')).replace(/[,;:.]$/, '')}…`;
|
||||
}
|
||||
|
||||
// Self-contained and script-free. The CSP below is stricter than the app's for the
|
||||
// same reason the page has no bundle: nothing here needs to execute.
|
||||
const STYLE = `
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f0f11;
|
||||
color: #d6d6dc;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.68;
|
||||
}
|
||||
.wrap { max-width: 46rem; margin: 0 auto; padding: 28px 24px 90px; }
|
||||
.top {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: baseline;
|
||||
padding-bottom: 16px; margin-bottom: 40px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.08);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.top a { color: #8a8a92; text-decoration: none; }
|
||||
.top a:hover, .top a:focus-visible { color: #f0892a; }
|
||||
.top .brand { color: #f0892a; font-weight: 700; letter-spacing: .04em; }
|
||||
.top .here { color: #d6d6dc; margin-inline-start: auto; }
|
||||
h1 { font-size: clamp(28px, 5vw, 36px); font-weight: 800; letter-spacing: -1px; line-height: 1.14; color: #f4f4f6; margin: 0 0 24px; }
|
||||
h2 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; color: #f4f4f6; margin: 46px 0 12px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.07); }
|
||||
h3 { font-size: 17.5px; font-weight: 700; color: #e8e8eb; margin: 30px 0 8px; }
|
||||
h4 { font-size: 15.5px; font-weight: 700; color: #e8e8eb; margin: 22px 0 6px; }
|
||||
p, li { color: #a9a9b3; }
|
||||
p { margin: 0 0 16px; }
|
||||
ul, ol { padding-inline-start: 22px; margin: 0 0 16px; }
|
||||
li { margin: 5px 0; }
|
||||
a { color: #f0892a; text-underline-offset: 2px; }
|
||||
strong { color: #e8e8eb; }
|
||||
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .88em; background: #17171c; border: 1px solid rgba(255,255,255,.07); border-radius: 4px; padding: 1px 5px; color: #e8e8eb; }
|
||||
pre { background: #0b0b0e; border: 1px solid rgba(255,255,255,.08); border-radius: 8px; padding: 14px 16px; overflow-x: auto; margin: 0 0 18px; }
|
||||
pre code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.62; color: #c9c9d1; }
|
||||
.tablewrap { overflow-x: auto; margin: 0 0 20px; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 14.5px; min-width: 30rem; }
|
||||
th { text-align: start; color: #8a8a92; font-weight: 600; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; padding: 0 14px 8px 0; border-bottom: 1px solid rgba(255,255,255,.12); }
|
||||
td { padding: 9px 14px 9px 0; border-bottom: 1px solid rgba(255,255,255,.06); vertical-align: top; color: #a9a9b3; }
|
||||
td:first-child, th:first-child { padding-inline-start: 0; }
|
||||
blockquote { margin: 0 0 18px; padding: 2px 0 2px 16px; border-inline-start: 3px solid rgba(240,137,42,.4); color: #8a8a92; }
|
||||
hr { border: 0; border-top: 1px solid rgba(255,255,255,.08); margin: 34px 0; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
a:focus-visible { outline: 2px solid #f0892a; outline-offset: 2px; border-radius: 2px; }
|
||||
.more { margin-top: 64px; padding-top: 22px; border-top: 1px solid rgba(255,255,255,.08); }
|
||||
.more h2 { font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: #6b6b73; border: 0; margin: 0 0 12px; padding: 0; font-weight: 700; }
|
||||
.more ul { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 4px 24px; }
|
||||
.more li { margin: 0; padding: 7px 0; border-bottom: 1px solid rgba(255,255,255,.05); font-size: 14.5px; }
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||||
`;
|
||||
|
||||
/**
|
||||
* FAQ markup, built from the document's own <h2> questions and the prose under each.
|
||||
*
|
||||
* Worth being honest about the payoff: Google narrowed FAQ rich results to
|
||||
* authoritative government and health sites in 2023, so this is unlikely to change how
|
||||
* the page looks in their results. It is still the correct description of what the page
|
||||
* is, and it is read by other engines and by the assistants people increasingly ask
|
||||
* "which messenger should I use" — which is the traffic this page exists for.
|
||||
*/
|
||||
function faqSchema(html, url) {
|
||||
const questions = [...html.matchAll(/<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2|$)/g)]
|
||||
.map(([, heading, answer]) => ({
|
||||
name: heading.replace(/<[^>]+>/g, '').trim(),
|
||||
// Answers keep their links and emphasis: schema.org allows a limited set of
|
||||
// HTML here, and stripping it would drop the references the answers rely on.
|
||||
text: answer.replace(/\s+/g, ' ').trim(),
|
||||
}))
|
||||
.filter((q) => q.name && q.text);
|
||||
|
||||
if (questions.length < 2) return null;
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
url: BASE + url,
|
||||
inLanguage: 'en',
|
||||
isPartOf: { '@type': 'WebSite', '@id': `${BASE}/#website` },
|
||||
publisher: { '@id': `${BASE}/#organization` },
|
||||
mainEntity: questions.map((q) => ({
|
||||
'@type': 'Question',
|
||||
name: q.name,
|
||||
acceptedAnswer: { '@type': 'Answer', text: q.text },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function page({ title, description, url, bodyHtml, siblings, schema }) {
|
||||
const related = siblings.length
|
||||
? ` <nav class="more">
|
||||
<h2>More documentation</h2>
|
||||
<ul>
|
||||
${siblings.map((s) => ` <li><a href="${s.url}">${esc(s.title)}</a></li>`).join('\n')}
|
||||
</ul>
|
||||
</nav>
|
||||
`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<!-- Generated by scripts/build-docs.js from doc/*.md. Edits here are overwritten;
|
||||
change the Markdown instead. -->
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests;">
|
||||
<meta http-equiv="X-Content-Type-Options" content="nosniff">
|
||||
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
|
||||
<title>${esc(title)} - SecureBit.chat</title>
|
||||
<meta name="description" content="${esc(description)}">
|
||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
|
||||
<link rel="canonical" href="${BASE}${url}">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico">
|
||||
<meta property="og:site_name" content="SecureBit.chat">
|
||||
<meta property="og:title" content="${esc(title)} - SecureBit.chat">
|
||||
<meta property="og:description" content="${esc(description)}">
|
||||
<meta property="og:url" content="${BASE}${url}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta property="og:image" content="${BASE}/assets/social-card.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${esc(title)} - SecureBit.chat">
|
||||
<meta name="twitter:description" content="${esc(description)}">
|
||||
<meta name="twitter:image" content="${BASE}/assets/social-card.png">
|
||||
<script type="application/ld+json">
|
||||
${JSON.stringify(schema, null, 2)}
|
||||
</script>
|
||||
<style>${STYLE} </style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<nav class="top">
|
||||
<a class="brand" href="/">SecureBit.chat</a>
|
||||
<a href="/docs/">Documentation</a>
|
||||
<a href="${REPO}" rel="noopener">GitHub</a>
|
||||
<span class="here">${esc(title)}</span>
|
||||
</nav>
|
||||
${bodyHtml}
|
||||
${related} </div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public URLs and titles of the documentation. Deliberately free of marked, so that
|
||||
* build-i18n.js can require this module for the sitemap without pulling a Markdown
|
||||
* parser — and without being async — just to learn a list of paths.
|
||||
*/
|
||||
function docPages() {
|
||||
return docFiles().map((file) => {
|
||||
const markdown = fs.readFileSync(path.join(DOC_DIR, file), 'utf8');
|
||||
const heading = markdown.match(/^#\s+(.+?)\s*$/m);
|
||||
return {
|
||||
file,
|
||||
url: urlForFile(file),
|
||||
title: heading ? heading[1] : path.basename(file, '.md'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function build() {
|
||||
const { marked } = await import('marked');
|
||||
const pages = docPages();
|
||||
const written = [];
|
||||
|
||||
for (const entry of pages) {
|
||||
const markdown = fs.readFileSync(path.join(DOC_DIR, entry.file), 'utf8');
|
||||
let html = marked.parse(markdown);
|
||||
html = anchorHeadings(rewriteLinks(html));
|
||||
// Wide tables must scroll inside their own box; 129 table rows across these
|
||||
// documents would otherwise make the page itself scroll sideways on a phone.
|
||||
html = html.replace(/<table>[\s\S]*?<\/table>/g, (t) => `<div class="tablewrap">${t}</div>`);
|
||||
|
||||
const body = html
|
||||
.split('\n')
|
||||
.map((line) => (line ? ` ${line}` : line))
|
||||
.join('\n');
|
||||
|
||||
const dest = entry.file === INDEX_FILE
|
||||
? path.join(OUT_ROOT, 'docs', 'index.html')
|
||||
: path.join(OUT_ROOT, 'docs', slugForFile(entry.file), 'index.html');
|
||||
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
const description = firstParagraph(html)
|
||||
|| `${entry.title} — SecureBit.chat technical documentation.`;
|
||||
|
||||
fs.writeFileSync(dest, page({
|
||||
title: entry.title,
|
||||
description,
|
||||
url: entry.url,
|
||||
bodyHtml: body,
|
||||
siblings: pages.filter((p) => p.url !== entry.url),
|
||||
schema: (entry.file === FAQ_FILE && faqSchema(html, entry.url)) || {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TechArticle',
|
||||
headline: entry.title,
|
||||
description,
|
||||
url: BASE + entry.url,
|
||||
inLanguage: 'en',
|
||||
isPartOf: { '@type': 'WebSite', '@id': `${BASE}/#website` },
|
||||
publisher: { '@id': `${BASE}/#organization` },
|
||||
},
|
||||
}), 'utf8');
|
||||
written.push(path.relative(OUT_ROOT, dest));
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('📄 Generating documentation pages...');
|
||||
for (const file of await build()) console.log(` ✅ ${file}`);
|
||||
console.log('✅ documentation page generation completed');
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('❌ docs build failed:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { docPages, urlForFile, slugify, build };
|
||||
+145
-64
@@ -15,6 +15,9 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { prerenderShell } = require('./prerender-shell');
|
||||
const { docPages } = require('./build-docs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const TEMPLATE = path.join(ROOT, 'templates', 'index.template.html');
|
||||
|
||||
@@ -80,6 +83,18 @@ function structuredData(site, code) {
|
||||
const graph = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
// Named and given an @id so the documentation pages under /docs/ can
|
||||
// point their publisher at the same entity instead of each declaring a
|
||||
// separate one. sameAs is the only part search engines can actually
|
||||
// check, so it lists the two places the project genuinely exists.
|
||||
'@type': 'Organization',
|
||||
'@id': `${site.baseUrl}/#organization`,
|
||||
name: site.siteName,
|
||||
url: `${site.baseUrl}/`,
|
||||
logo: `${site.baseUrl}/logo/icon-512x512.png`,
|
||||
sameAs: [site.repository, 'https://snapcraft.io/securebit-chat'],
|
||||
},
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': `${site.baseUrl}/#website`,
|
||||
@@ -87,6 +102,7 @@ function structuredData(site, code) {
|
||||
url: `${site.baseUrl}/`,
|
||||
description: locale.schema.siteDescription,
|
||||
inLanguage: locale.htmlLang,
|
||||
publisher: { '@id': `${site.baseUrl}/#organization` },
|
||||
},
|
||||
{
|
||||
'@type': 'WebApplication',
|
||||
@@ -163,7 +179,6 @@ function buildPages(site, template, version) {
|
||||
HTML_DIR: attr(locale.dir || 'ltr'),
|
||||
TITLE: attr(locale.meta.title),
|
||||
DESCRIPTION: attr(locale.meta.description),
|
||||
KEYWORDS: attr(locale.meta.keywords),
|
||||
AUTHOR: attr(site.author),
|
||||
SITE_NAME: attr(site.siteName),
|
||||
CANONICAL: attr(localeUrl(site, code)),
|
||||
@@ -179,6 +194,15 @@ function buildPages(site, template, version) {
|
||||
TWITTER_IMAGE_ALT: attr(locale.meta.twitterImageAlt),
|
||||
SOCIAL_CARD: attr(site.baseUrl + site.socialCard),
|
||||
JSONLD: structuredData(site, code),
|
||||
// The default locale's dictionary is already inside the bundles, so its
|
||||
// page must not fetch it a second time; render() drops the whole line
|
||||
// when this is empty.
|
||||
LOCALE_DICT: code === site.defaultLocale
|
||||
? ''
|
||||
: ` <script type="module" src="/src/i18n/dict/${code}.js?v=BUILD_VERSION"></script>`,
|
||||
// Static landing inside <div id="root">, so the page carries its own text
|
||||
// instead of waiting on the bundles. See scripts/prerender-shell.js.
|
||||
PRERENDER: prerenderShell(site, code, docPages()),
|
||||
}).replace(/\?v=BUILD_VERSION/g, `?v=${version}`);
|
||||
|
||||
const dest = outputPath(site, code);
|
||||
@@ -260,7 +284,23 @@ function stampServiceWorker(site) {
|
||||
return [];
|
||||
}
|
||||
const secondary = site.locales.filter((code) => code !== site.defaultLocale);
|
||||
const next = sw.replace(marker, `const SW_LOCALES = [${secondary.map((c) => `'${c}'`).join(', ')}];`);
|
||||
let next = sw.replace(marker, `const SW_LOCALES = [${secondary.map((c) => `'${c}'`).join(', ')}];`);
|
||||
|
||||
// The dictionary list, kept in step the same way. default.js is the stable alias
|
||||
// index.js imports; the default locale's own file is what it resolves to.
|
||||
const dictRegion = /([ \t]*)\/\/ BEGIN generated locale dictionaries\n[\s\S]*?[ \t]*\/\/ END generated locale dictionaries/;
|
||||
if (dictRegion.test(next)) {
|
||||
const dicts = ['/src/i18n/dict/default.js', ...site.locales.map((c) => `/src/i18n/dict/${c}.js`)];
|
||||
const precache = ['/src/i18n/dict/default.js', `/src/i18n/dict/${site.defaultLocale}.js`];
|
||||
next = next.replace(dictRegion, [
|
||||
'// BEGIN generated locale dictionaries',
|
||||
`const SW_DICTS = [${dicts.map((d) => `'${d}'`).join(', ')}];`,
|
||||
`const SW_PRECACHE_DICTS = [${precache.map((d) => `'${d}'`).join(', ')}];`,
|
||||
'// END generated locale dictionaries',
|
||||
].join('\n'));
|
||||
} else {
|
||||
console.warn(' ⚠️ dictionary markers not found in sw.js — locale strings will not be cached');
|
||||
}
|
||||
// Rendering into a scratch directory must never write back over the real sw.js.
|
||||
if (next === sw && dest === source) return [];
|
||||
fs.writeFileSync(dest, next, 'utf8');
|
||||
@@ -268,65 +308,37 @@ function stampServiceWorker(site) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the generated block between BEGIN/END markers in a served-config file.
|
||||
* Both web server configs need to know the locale list, and a list kept by hand in
|
||||
* three files is a list that drifts.
|
||||
*/
|
||||
function stampConfig(site, relPath, renderLines) {
|
||||
const source = path.join(ROOT, relPath);
|
||||
const dest = path.join(OUT_ROOT, relPath);
|
||||
if (!fs.existsSync(source)) return [];
|
||||
const text = read(source);
|
||||
const region = /([ \t]*)# BEGIN generated locale shells\n[\s\S]*?[ \t]*# END generated locale shells/;
|
||||
const match = text.match(region);
|
||||
if (!match) {
|
||||
console.warn(` ⚠️ locale-shell markers not found in ${relPath}`);
|
||||
return [];
|
||||
}
|
||||
const indent = match[1];
|
||||
const secondary = site.locales.filter((code) => code !== site.defaultLocale);
|
||||
const body = renderLines(secondary)
|
||||
.map((line) => `${indent}${line}`)
|
||||
.join('\n');
|
||||
const replacement = [
|
||||
`${indent}# BEGIN generated locale shells`,
|
||||
...(body ? [body] : []),
|
||||
`${indent}# END generated locale shells`,
|
||||
].join('\n');
|
||||
const next = text.replace(region, replacement);
|
||||
if (next === text && dest === source) return [];
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, next, 'utf8');
|
||||
return [relPath];
|
||||
}
|
||||
|
||||
function stampServerConfigs(site) {
|
||||
return [
|
||||
// One plain regex per locale rather than one clever one: a named capture or an
|
||||
// alternation that nginx rejects takes the whole site down at boot, and this
|
||||
// file cannot be syntax-checked without nginx present.
|
||||
...stampConfig(site, 'deploy/nginx.conf', (codes) =>
|
||||
codes.map((code) => `~^/${code}/ /${code}/index.html;`)
|
||||
),
|
||||
...stampConfig(site, '.htaccess', (codes) =>
|
||||
codes.flatMap((code) => [
|
||||
'RewriteCond %{REQUEST_FILENAME} !-f',
|
||||
'RewriteCond %{REQUEST_FILENAME} !-d',
|
||||
`RewriteRule ^${code}(/.*)?$ /${code}/index.html [L]`,
|
||||
])
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the locale registry and UI dictionaries as a plain ES module.
|
||||
* Emit the locale registry, and one dictionary module per locale.
|
||||
*
|
||||
* The strings live in locales/*.json next to the SEO copy, so a translator edits one
|
||||
* file per language rather than two. They reach the app through a generated module
|
||||
* instead of a direct JSON import: esbuild would handle the import, but the Node test
|
||||
* file per language rather than two. They reach the app through generated modules
|
||||
* rather than a direct JSON import: esbuild would handle the import, but the Node test
|
||||
* runner needs import attributes for it, and a generated .js file works in both
|
||||
* without anyone having to remember which.
|
||||
*
|
||||
* Why one file per locale instead of one file with all of them. The single DICTIONARIES
|
||||
* export used to be imported by src/i18n/index.js, which meant every one of the thirteen
|
||||
* languages was bundled into dist/app.js AND dist/app-boot.js AND fetched a third time as
|
||||
* raw source, because three page-level modules import the runtime directly. Lighthouse
|
||||
* measured that third copy alone at 215 KB transferred — the third largest resource on
|
||||
* the page — to hand a Russian reader twelve dictionaries they will never read.
|
||||
*
|
||||
* Now each dictionary registers itself on a global when its module runs, index.js reads
|
||||
* that registry, and a page loads exactly two: the default locale (bundled, because t()
|
||||
* falls back to it for any key a translation is missing) and its own.
|
||||
*/
|
||||
|
||||
// Keys that must answer for a locale whose dictionary was never loaded. The language
|
||||
// suggestion is the whole of it: a bar shown on the German page offering the Russian
|
||||
// one has to be written in Russian, or it is addressed to someone who cannot read it.
|
||||
// Kept as an explicit list rather than a prefix match, so adding a cross-locale string
|
||||
// is a deliberate act — every key here ships thirteen times.
|
||||
const CROSS_LOCALE_KEYS = [
|
||||
'language.suggest.text',
|
||||
'language.suggest.cta',
|
||||
'language.suggest.dismiss',
|
||||
];
|
||||
|
||||
function buildDictionaries(site) {
|
||||
const meta = {};
|
||||
const dictionaries = {};
|
||||
@@ -344,22 +356,78 @@ function buildDictionaries(site) {
|
||||
dictionaries[code] = locale.ui || {};
|
||||
}
|
||||
|
||||
const body = `// Generated by scripts/build-i18n.js from locales/*.json — do not edit by hand.
|
||||
// Add or change strings in locales/<code>.json, then run \`npm run build:i18n\`.
|
||||
const cross = {};
|
||||
for (const code of site.locales) {
|
||||
const picked = {};
|
||||
for (const key of CROSS_LOCALE_KEYS) {
|
||||
const value = dictionaries[code]?.[key];
|
||||
if (value !== undefined) picked[key] = value;
|
||||
}
|
||||
cross[code] = picked;
|
||||
}
|
||||
|
||||
const header = `// Generated by scripts/build-i18n.js from locales/*.json — do not edit by hand.
|
||||
// Add or change strings in locales/<code>.json, then run \`npm run build:i18n\`.`;
|
||||
|
||||
const body = `${header}
|
||||
|
||||
export const DEFAULT_LOCALE = ${JSON.stringify(site.defaultLocale)};
|
||||
export const SUPPORTED_LOCALES = ${JSON.stringify(site.locales)};
|
||||
export const LOCALE_META = ${JSON.stringify(meta, null, 4)};
|
||||
export const DICTIONARIES = ${JSON.stringify(dictionaries, null, 4)};
|
||||
|
||||
// Strings a page may need for a locale it did not load. See CROSS_LOCALE_KEYS in
|
||||
// scripts/build-i18n.js for why this list is short on purpose.
|
||||
export const CROSS_LOCALE_STRINGS = ${JSON.stringify(cross, null, 4)};
|
||||
`;
|
||||
|
||||
const written = [];
|
||||
const dest = path.join(OUT_ROOT, 'src', 'i18n', 'generated.js');
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, body, 'utf8');
|
||||
return [path.relative(OUT_ROOT, dest)];
|
||||
written.push(path.relative(OUT_ROOT, dest));
|
||||
|
||||
// One module per locale. It registers itself on a global rather than importing the
|
||||
// runtime, which keeps it free of a cycle (index.js -> dict/default.js -> index.js)
|
||||
// and, more importantly, makes the registry shared between the bundled copy of
|
||||
// index.js inside dist/app.js and the raw one the page modules import.
|
||||
const dictDir = path.join(OUT_ROOT, 'src', 'i18n', 'dict');
|
||||
fs.mkdirSync(dictDir, { recursive: true });
|
||||
for (const code of site.locales) {
|
||||
const module = `${header}
|
||||
|
||||
export const DICTIONARY = ${JSON.stringify(dictionaries[code], null, 4)};
|
||||
|
||||
const registry = globalThis.__SECUREBIT_I18N__ || (globalThis.__SECUREBIT_I18N__ = Object.create(null));
|
||||
registry[${JSON.stringify(code)}] = DICTIONARY;
|
||||
`;
|
||||
const file = path.join(dictDir, `${code}.js`);
|
||||
fs.writeFileSync(file, module, 'utf8');
|
||||
written.push(path.relative(OUT_ROOT, file));
|
||||
}
|
||||
|
||||
// A stable path for "whichever locale is the default", so index.js can import it
|
||||
// statically. t() falls back to the default for any key a translation is missing,
|
||||
// so this one is bundled everywhere and every other locale is not.
|
||||
const defaultModule = `${header}
|
||||
// The default locale's dictionary, under a name that does not change when the default
|
||||
// does. Imported for its side effect: loading it registers the strings t() falls back to.
|
||||
|
||||
import ${JSON.stringify(`./${site.defaultLocale}.js`)};
|
||||
`;
|
||||
const defaultFile = path.join(dictDir, 'default.js');
|
||||
fs.writeFileSync(defaultFile, defaultModule, 'utf8');
|
||||
written.push(path.relative(OUT_ROOT, defaultFile));
|
||||
return written;
|
||||
}
|
||||
|
||||
function buildSitemap(site) {
|
||||
function buildSitemap(site, version) {
|
||||
// <lastmod> from the build stamp rather than the clock: the sitemap is regenerated
|
||||
// by `npm run build` and committed with the release, so the date it carries is the
|
||||
// date the pages actually changed. A W3C date (no time) is what Google reads here.
|
||||
const stamp = Number(version);
|
||||
const lastmod = Number.isFinite(stamp) && stamp > 0
|
||||
? new Date(stamp).toISOString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const entries = site.locales
|
||||
.map((code) => {
|
||||
const alternates = site.locales.length < 2
|
||||
@@ -370,16 +438,30 @@ function buildSitemap(site) {
|
||||
+ `\n <xhtml:link rel="alternate" hreflang="x-default" href="${attr(localeUrl(site, site.defaultLocale))}"/>`;
|
||||
return ` <url>
|
||||
<loc>${attr(localeUrl(site, code))}</loc>${alternates}
|
||||
<lastmod>${lastmod}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// The documentation pages are English-only, so they carry no hreflang cluster —
|
||||
// a cluster that points thirteen ways at one language is worse than none. Lower
|
||||
// priority than the app itself: they support it rather than replace it.
|
||||
const docs = docPages()
|
||||
.map((page) => ` <url>
|
||||
<loc>${attr(site.baseUrl + page.url)}</loc>
|
||||
<lastmod>${lastmod}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>`)
|
||||
.join('\n');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
${entries}
|
||||
${docs}
|
||||
</urlset>
|
||||
`;
|
||||
fs.writeFileSync(path.join(OUT_ROOT, 'sitemap.xml'), xml, 'utf8');
|
||||
@@ -415,8 +497,7 @@ function main() {
|
||||
...buildManifests(site),
|
||||
...buildDictionaries(site),
|
||||
...stampServiceWorker(site),
|
||||
...stampServerConfigs(site),
|
||||
buildSitemap(site),
|
||||
buildSitemap(site, version),
|
||||
buildRobots(site),
|
||||
];
|
||||
|
||||
@@ -435,4 +516,4 @@ if (require.main === module) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { loadSite, buildManifests, localeUrl, outputPath, hreflangLinks, structuredData, render };
|
||||
module.exports = { loadSite, buildManifests, localeUrl, outputPath, hreflangLinks, structuredData, prerenderShell, render };
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* prerender-shell.js — the static landing that ships inside <div id="root">.
|
||||
*
|
||||
* Why this exists: everything on the site is drawn by React after ~800 KB of JS has
|
||||
* downloaded and run, so until now a crawler fetching / got a <head> full of correct
|
||||
* metadata wrapped around an empty div. Search Console shows exactly what that costs —
|
||||
* the site ranks for the spelling of its domain (chatbit, chatyourbit, keepbit) rather
|
||||
* than for anything it does, and twelve of the thirteen localized pages have never been
|
||||
* shown to anyone, because a page whose only difference from the English one is its
|
||||
* <meta> tags gives Google no reason to swap it in.
|
||||
*
|
||||
* So each page is built carrying its own text: the same strings the app renders, from
|
||||
* the same locales/<code>.json, in real headings and paragraphs.
|
||||
*
|
||||
* It is never shown to a visitor who has JavaScript. The block defaults to display:none
|
||||
* and only a <noscript> stylesheet turns it back on, which means a browser that is going
|
||||
* to run the app never paints it for even one frame — an earlier version left it visible
|
||||
* until React mounted, and what that produced was a plain wall of English text on screen
|
||||
* for as long as the bundles took to arrive. Doing this in CSS rather than with a script
|
||||
* is deliberate: a script would have to be inline to beat first paint, and the page's CSP
|
||||
* allows no inline script.
|
||||
*
|
||||
* What a visitor does see is the mark, and only the mark. Hiding the text left nothing on
|
||||
* screen at all until React mounted, and "nothing" is not a first paint: Lighthouse put
|
||||
* First Contentful Paint at 6.3 s on mobile, because the first contentful thing was the
|
||||
* app itself. The shield below is the same one the header shows, inline so it costs no
|
||||
* request, and it is contentful the moment the stylesheet lands. React empties the
|
||||
* container on mount, so it leaves without being told to.
|
||||
*
|
||||
* What still reads it: every crawler that does not execute JavaScript, which is Bing,
|
||||
* Yandex, DuckDuckGo, the social unfurlers and the AI crawlers, plus Google's own first
|
||||
* pass over the raw HTML before it queues the page for rendering. Google's renderer sees
|
||||
* the same text a second time anyway, because the app itself draws these sections once
|
||||
* it mounts. Nothing here is hidden from a reader that is not also shown to them: the
|
||||
* block is a fallback for clients that cannot run the app, not a second version of the
|
||||
* page. Styling is inline and self-contained so it is carried away with the markup when
|
||||
* React empties the container, instead of lingering in the stylesheet.
|
||||
*/
|
||||
|
||||
/** Escape for HTML *text*, not attributes — build-i18n's attr() covers those. */
|
||||
const esc = (value) =>
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Hidden first, shown only under <noscript>. Everything after that is scoped under
|
||||
// .sb-pre so nothing here can reach the app, and the whole subtree — rules included —
|
||||
// is removed when React empties the container. Spacing is logical (-inline-) so the
|
||||
// Arabic, Hebrew, Farsi and Urdu builds mirror correctly off the dir already on <html>.
|
||||
const STYLE = `<style>
|
||||
.sb-pre{display:none}
|
||||
.sb-boot{display:flex;align-items:center;justify-content:center;min-height:100vh;min-height:100svh;margin:0;background:#0f0f11}
|
||||
.sb-boot svg{width:62px;height:auto;display:block;animation:sbBootPulse 1.8s ease-in-out infinite}
|
||||
@keyframes sbBootPulse{0%,100%{opacity:.32;transform:scale(.97)}50%{opacity:1;transform:scale(1)}}
|
||||
@media (prefers-reduced-motion:reduce){.sb-boot svg{animation:none;opacity:.75}}
|
||||
</style>
|
||||
<noscript><style>
|
||||
.sb-pre{display:block}
|
||||
.sb-boot{display:none}
|
||||
</style></noscript>
|
||||
<style>
|
||||
.sb-pre{background:#0f0f11;color:#e8e8eb;font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.6;padding:56px 24px 72px;margin:0}
|
||||
.sb-pre .sb-pre-in{max-width:940px;margin:0 auto;display:flex;flex-direction:column;gap:56px}
|
||||
.sb-pre .sb-pre-brand{font-size:13px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#f0892a;margin:0 0 18px}
|
||||
.sb-pre h1{font-size:clamp(28px,5vw,40px);font-weight:800;letter-spacing:-1.1px;line-height:1.12;color:#f4f4f6;margin:0 0 14px}
|
||||
.sb-pre h2{font-size:23px;font-weight:700;letter-spacing:-.5px;color:#f4f4f6;margin:0 0 8px}
|
||||
.sb-pre h3{font-size:17px;font-weight:700;letter-spacing:-.2px;color:#e8e8eb;margin:0 0 6px}
|
||||
.sb-pre p{margin:0 0 10px;color:#8a8a92;font-size:15px;max-width:62ch}
|
||||
.sb-pre .sb-pre-lead{font-size:16px;color:#a6a6ae;max-width:52ch}
|
||||
.sb-pre .sb-pre-eyebrow{font-size:11px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:#6b6b73;margin:0 0 6px}
|
||||
.sb-pre .sb-pre-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px;margin-top:22px;padding:0;list-style:none}
|
||||
.sb-pre .sb-pre-card{background:#141417;border:1px solid rgba(255,255,255,.07);border-radius:10px;padding:18px 20px}
|
||||
.sb-pre .sb-pre-tags{margin:10px 0 0;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:6px}
|
||||
.sb-pre .sb-pre-tags li{font-size:11.5px;color:#3ecf8e;background:rgba(62,207,142,.09);border:1px solid rgba(62,207,142,.18);border-radius:4px;padding:2px 7px}
|
||||
.sb-pre .sb-pre-steps{margin:22px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px}
|
||||
.sb-pre .sb-pre-steps li{border-top:1px solid rgba(255,255,255,.06);padding:13px 0;display:flex;flex-wrap:wrap;gap:2px 14px;align-items:baseline}
|
||||
.sb-pre .sb-pre-steps h3{margin:0;font-size:15.5px}
|
||||
.sb-pre .sb-pre-steps .sb-pre-when{font-size:12px;color:#6b6b73;margin-inline-start:auto;white-space:nowrap}
|
||||
.sb-pre .sb-pre-steps p{flex:1 1 100%;margin:2px 0 0;font-size:14px}
|
||||
.sb-pre .sb-pre-docs{margin:14px 0 0;padding:0;list-style:none;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px 24px}
|
||||
.sb-pre .sb-pre-docs li{padding:8px 0;border-top:1px solid rgba(255,255,255,.06);font-size:14.5px}
|
||||
.sb-pre a{color:#f0892a;text-underline-offset:2px}
|
||||
@media (prefers-reduced-motion:reduce){.sb-pre *{animation:none!important;transition:none!important}}
|
||||
</style>`;
|
||||
|
||||
/**
|
||||
* Build the block for one locale.
|
||||
*
|
||||
* Strings are read through a lookup that falls back to the default locale and then to
|
||||
* nothing: a locale that gains a key before its translation lands should render one
|
||||
* English line, not an empty heading or the literal key.
|
||||
*/
|
||||
function prerenderShell(site, code, docs = []) {
|
||||
const ui = site.byCode[code].ui || {};
|
||||
const fallback = site.byCode[site.defaultLocale].ui || {};
|
||||
const s = (key) => {
|
||||
const value = ui[key] !== undefined ? ui[key] : fallback[key];
|
||||
return typeof value === 'string' ? value : '';
|
||||
};
|
||||
const list = (key) => {
|
||||
const value = ui[key] !== undefined ? ui[key] : fallback[key];
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
|
||||
const out = [];
|
||||
|
||||
// Hero. One <h1> per page, carrying the product's own claim rather than its name —
|
||||
// the name is already in <title>, the claim is what a category query matches.
|
||||
const headline = [s('hero.headlineTop'), s('hero.headlineBottom')].filter(Boolean);
|
||||
out.push(
|
||||
' <header>',
|
||||
` <p class="sb-pre-brand">${esc(site.siteName)}</p>`,
|
||||
` <h1>${headline.map(esc).join('<br>')}</h1>`,
|
||||
` <p class="sb-pre-lead">${esc(s('hero.subheading'))}</p>`,
|
||||
' </header>'
|
||||
);
|
||||
|
||||
// What the product is, in its own terms. These five cards are the densest piece of
|
||||
// vocabulary the site owns — ECDH, DTLS, forward secrecy, packet padding — and the
|
||||
// only place a category search has anything to match on.
|
||||
const cards = ['s1', 's2', 's3', 's4', 's5']
|
||||
.map((id) => {
|
||||
const title = [s(`unique.${id}.titleTop`), s(`unique.${id}.titleBottom`)].filter(Boolean).join(' ');
|
||||
const desc = s(`unique.${id}.desc`);
|
||||
if (!title && !desc) return '';
|
||||
const tags = list(`unique.${id}.tags`);
|
||||
return [
|
||||
' <li class="sb-pre-card">',
|
||||
title ? ` <h3>${esc(title)}</h3>` : '',
|
||||
desc ? ` <p>${esc(desc)}</p>` : '',
|
||||
tags.length
|
||||
? ` <ul class="sb-pre-tags">${tags.map((tag) => `<li>${esc(tag)}</li>`).join('')}</ul>`
|
||||
: '',
|
||||
' </li>',
|
||||
].filter(Boolean).join('\n');
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (cards.length) {
|
||||
out.push(
|
||||
' <section>',
|
||||
` <p class="sb-pre-eyebrow">${esc(s('unique.eyebrow'))}</p>`,
|
||||
` <h2>${esc(s('unique.heading'))}</h2>`,
|
||||
' <ul class="sb-pre-grid">',
|
||||
...cards,
|
||||
' </ul>',
|
||||
' </section>'
|
||||
);
|
||||
}
|
||||
|
||||
// The roadmap is a genuine timeline, so it is a numbered list and nothing else is.
|
||||
// Feature bullets are left to the app: thirteen releases of them would triple this
|
||||
// block's weight to say what the titles already say.
|
||||
const releases = [];
|
||||
for (let i = 1; i <= 40; i += 1) {
|
||||
const title = s(`roadmap.r${i}.title`);
|
||||
if (!title) break;
|
||||
const when = s(`roadmap.r${i}.date`);
|
||||
const sub = s(`roadmap.r${i}.sub`);
|
||||
releases.push(
|
||||
[
|
||||
' <li>',
|
||||
` <h3>${esc(title)}</h3>`,
|
||||
when ? ` <span class="sb-pre-when">${esc(when)}</span>` : '',
|
||||
sub ? ` <p>${esc(sub)}</p>` : '',
|
||||
' </li>',
|
||||
].filter(Boolean).join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
if (releases.length) {
|
||||
out.push(
|
||||
' <section>',
|
||||
` <p class="sb-pre-eyebrow">${esc(s('roadmap.eyebrow'))}</p>`,
|
||||
` <h2>${esc(s('roadmap.heading'))}</h2>`,
|
||||
` <p>${esc(s('roadmap.subheading'))}</p>`,
|
||||
' <ol class="sb-pre-steps">',
|
||||
...releases,
|
||||
' </ol>',
|
||||
' </section>'
|
||||
);
|
||||
}
|
||||
|
||||
// Internal links to the documentation. A sitemap tells Google the pages exist; a
|
||||
// link from the site's most-crawled page is what actually gets them fetched and
|
||||
// gives them anchor text to be ranked on. The whole block is marked lang="en"
|
||||
// because the documents are English on every locale — an untagged English list
|
||||
// inside a German page is a quality signal working against itself.
|
||||
if (docs.length) {
|
||||
out.push(
|
||||
' <section lang="en" dir="ltr">',
|
||||
' <p class="sb-pre-eyebrow">Documentation</p>',
|
||||
' <ul class="sb-pre-docs">',
|
||||
...docs.map((doc) =>
|
||||
` <li><a href="${esc(doc.url)}" hreflang="en">${esc(doc.title)}</a></li>`),
|
||||
' </ul>',
|
||||
' </section>'
|
||||
);
|
||||
}
|
||||
|
||||
// One outbound link, to the repository. It is the site's only real corroboration —
|
||||
// the thing a reader checks when a privacy claim needs backing.
|
||||
out.push(
|
||||
' <section>',
|
||||
` <h2>${esc(s('community.title'))}</h2>`,
|
||||
` <p>${esc(s('community.description'))}</p>`,
|
||||
` <p><a href="${esc(site.repository)}" rel="noopener">${esc(s('community.github'))}</a></p>`,
|
||||
' </section>'
|
||||
);
|
||||
|
||||
// The mark, and nothing else — logo/securebit-mark.svg, the same one the header
|
||||
// shows, inlined so it costs no request and is contentful the moment the stylesheet
|
||||
// lands. Its gradient ids are prefixed here: an inline <svg> puts them in the page's
|
||||
// id namespace, and the app has SVGs of its own.
|
||||
// aria-hidden because it says nothing a screen reader needs; the page it stands in
|
||||
// for announces itself once it is there.
|
||||
const boot = `<div class="sb-boot" aria-hidden="true">
|
||||
<svg viewBox="276 240 700 760" xmlns="http://www.w3.org/2000/svg" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id="sbBootSilver" x1="0" y1="0" x2="0.35" y2="1"><stop offset="0" stop-color="#fdfdff"/><stop offset="0.20" stop-color="#e7e7ec"/><stop offset="0.46" stop-color="#c4c4cb"/><stop offset="0.72" stop-color="#a4a4ac"/><stop offset="1" stop-color="#86868d"/></linearGradient>
|
||||
<linearGradient id="sbBootOrange" x1="0" y1="0" x2="0.25" y2="1"><stop offset="0" stop-color="#ffb84d"/><stop offset="0.27" stop-color="#ff9a33"/><stop offset="0.58" stop-color="#fb7d16"/><stop offset="1" stop-color="#db5d04"/></linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#sbBootSilver)" fill-rule="nonzero" d="m 835.26446,352.56633 102.39051,-103.90366 -418.64101,1.00877 c 0,0 -171.69323,1.22309 -222.43455,167.96079 -52.34251,171.99925 77.67556,253.20215 77.67556,253.20215 0,0 35.54922,23.82856 79.77792,31.68982 15.73869,2.39372 79.16695,1.09532 79.16695,1.09532 54.47377,-10.08773 41.40629,-81.22528 -10.65516,-77.67557 C 492.06451,630.5166 372.5156,615.45079 386.86464,469.07968 415.02639,353.31661 520.52712,353.57511 520.52712,353.57511 Z"/>
|
||||
<path fill="url(#sbBootOrange)" fill-rule="nonzero" d="m 289.24744,881.29522 95.22696,-95.94027 369.13823,1.06997 C 873.15229,774.31964 863.51011,647.63259 863.51011,647.63259 846.7608,546.1216 749.51871,545.49427 749.51871,545.49427 l -232.01791,0.25219 c -37.80546,-8.91638 -37.85435,-49.4299 -37.85435,-49.4299 0,0 -1.56131,-38.07813 40.52401,-46.63785 l 260.62023,-0.7745 c 170.83788,24.60922 185.61432,187.63187 185.61432,187.63187 0,0 18.85523,117.07655 -90.63794,200.89054 l -0.62454,154.4184 -144.79052,-110.68137 z"/>
|
||||
<path fill="url(#sbBootOrange)" d="m 658.38568,658.74237 a 27.462458,27.462458 0 0 1 -27.43073,27.46244 27.462458,27.462458 0 0 1 -27.49412,-27.39898 27.462458,27.462458 0 0 1 27.36719,-27.52575 27.462458,27.462458 0 0 1 27.55736,27.33536 z"/>
|
||||
<path fill="url(#sbBootOrange)" d="m 748.42871,659.07971 a 27.462458,27.462458 0 0 1 -27.43073,27.46244 27.462458,27.462458 0 0 1 -27.49412,-27.39898 27.462458,27.462458 0 0 1 27.36719,-27.52575 27.462458,27.462458 0 0 1 27.55736,27.33537 z"/>
|
||||
</svg>
|
||||
</div>`;
|
||||
|
||||
return `${STYLE}
|
||||
${boot}
|
||||
<div class="sb-pre">
|
||||
<div class="sb-pre-in">
|
||||
${out.join('\n')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
module.exports = { prerenderShell };
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
subset-icons.py — cut the FontAwesome webfonts down to the icons this app draws.
|
||||
|
||||
The full set is 2468 icons across three files, 299 KB of woff2, and fa-solid-900 alone
|
||||
was 157 KB — the second largest thing on the page after the app bundle. The interface
|
||||
uses 82 of them. Everything else was being downloaded so it could not be used.
|
||||
|
||||
Run this after adding or removing an icon:
|
||||
|
||||
pip install fonttools brotli
|
||||
python3 scripts/subset-icons.py
|
||||
|
||||
It writes the subset fonts, a stylesheet carrying only the rules for the icons kept, and
|
||||
a manifest of those names. tests/icon-subset.test.mjs reads the manifest and fails if a
|
||||
`fa-` class appears in src/ that the subset does not cover — which is the failure this
|
||||
whole approach risks, and it looks like an empty box rather than an error.
|
||||
|
||||
Kept as Python because that is what fontTools is. The Node build does not depend on it:
|
||||
the outputs are committed, and this only has to run when the icon set changes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FA = os.path.join(ROOT, 'assets', 'fontawesome')
|
||||
CSS = os.path.join(FA, 'css', 'all.min.css')
|
||||
WEBFONTS = os.path.join(FA, 'webfonts')
|
||||
|
||||
# Where the interface names its icons. index.html is generated, but the prerendered
|
||||
# landing could name one, so it is scanned too.
|
||||
SOURCES = [os.path.join(ROOT, 'src'), os.path.join(ROOT, 'index.html')]
|
||||
|
||||
# fa- prefixed classes that select a family or an animation rather than a glyph.
|
||||
NOT_ICONS = {'solid', 'regular', 'brands', 'light', 'thin', 'duotone', 'sharp',
|
||||
'spin', 'pulse', 'beat', 'fade', 'flip', 'shake', 'bounce',
|
||||
'fw', 'lg', 'xs', 'sm', 'border', 'pull-left', 'pull-right',
|
||||
'stack', 'inverse', 'li', 'ul', 'rotate', 'solid-900', 'fallback'}
|
||||
|
||||
|
||||
def used_icon_names():
|
||||
names = set()
|
||||
for source in SOURCES:
|
||||
files = []
|
||||
if os.path.isfile(source):
|
||||
files = [source]
|
||||
else:
|
||||
for base, _, filenames in os.walk(source):
|
||||
files += [os.path.join(base, f) for f in filenames
|
||||
if f.endswith(('.js', '.jsx', '.css', '.html'))]
|
||||
for path in files:
|
||||
with open(path, encoding='utf8', errors='ignore') as handle:
|
||||
for match in re.findall(r'\bfa-([a-z0-9-]+)', handle.read()):
|
||||
if match not in NOT_ICONS:
|
||||
names.add(match)
|
||||
return names
|
||||
|
||||
|
||||
def icon_codepoints():
|
||||
"""Every `.fa-name:before{content:"\\fXXX"}` rule in the shipped stylesheet."""
|
||||
with open(CSS, encoding='utf8') as handle:
|
||||
css = handle.read()
|
||||
mapping = {}
|
||||
for selectors, content in re.findall(r'((?:\.fa-[a-z0-9-]+(?:::?before)?,?)+)\{content:"([^"]+)"\}', css):
|
||||
points = [int(c, 16) for c in re.findall(r'\\([0-9a-fA-F]{2,6})', content)]
|
||||
if not points:
|
||||
continue
|
||||
for name in re.findall(r'\.fa-([a-z0-9-]+?)(?:::?before)?(?:,|$)', selectors):
|
||||
mapping.setdefault(name, points[0])
|
||||
return mapping
|
||||
|
||||
|
||||
def font_family_faces():
|
||||
"""The @font-face blocks, so the subset stylesheet can restate them verbatim."""
|
||||
with open(CSS, encoding='utf8') as handle:
|
||||
css = handle.read()
|
||||
return re.findall(r'@font-face\{[^}]*\}', css)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from fontTools.ttLib import TTFont # noqa: F401
|
||||
except ImportError:
|
||||
sys.exit('fontTools is not installed — run: pip install fonttools brotli')
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
mapping = icon_codepoints()
|
||||
used = used_icon_names()
|
||||
known = {name: mapping[name] for name in used if name in mapping}
|
||||
unknown = sorted(used - set(known))
|
||||
if unknown:
|
||||
print(f' note: {len(unknown)} fa- classes are not icons in this build: {", ".join(unknown)}')
|
||||
|
||||
wanted = set(known.values())
|
||||
kept_per_file = {}
|
||||
|
||||
for filename in sorted(os.listdir(WEBFONTS)):
|
||||
if not filename.endswith('.woff2') or filename.endswith('.subset.woff2'):
|
||||
continue
|
||||
source = os.path.join(WEBFONTS, filename)
|
||||
with TTFont(source) as font:
|
||||
available = set(font.getBestCmap())
|
||||
keep = sorted(available & wanted)
|
||||
if not keep:
|
||||
print(f' {filename}: no icons in use, not subset')
|
||||
continue
|
||||
|
||||
target = source.replace('.woff2', '.subset.woff2')
|
||||
subprocess.run([
|
||||
sys.executable, '-m', 'fontTools.subset', source,
|
||||
'--unicodes=' + ','.join(f'U+{c:04X}' for c in keep),
|
||||
'--flavor=woff2',
|
||||
'--layout-features=', # icon fonts need no shaping
|
||||
'--no-hinting',
|
||||
'--desubroutinize',
|
||||
'--output-file=' + target,
|
||||
], check=True, capture_output=True)
|
||||
|
||||
before, after = os.path.getsize(source), os.path.getsize(target)
|
||||
print(f' {filename}: {len(keep)} icons, {before:,} → {after:,} B '
|
||||
f'({100 - after * 100 // before}% smaller)')
|
||||
kept_per_file[filename] = keep
|
||||
|
||||
# A stylesheet with the faces and only the rules for the icons kept, so the 102 KB
|
||||
# original stops being fetched as well.
|
||||
#
|
||||
# The base rules are named here rather than sliced out of the minified original,
|
||||
# whose 81 KB preamble is 1505 selectors of sizing, rotation, stacking and animation
|
||||
# utilities this interface never uses. These are copied verbatim from all.min.css
|
||||
# (Font Awesome Free 6.5.1) — the families, and the two animations the app applies.
|
||||
covered = {cp for keep in kept_per_file.values() for cp in keep}
|
||||
rules = [f'.fa-{name}:before{{content:"\\{point:x}"}}'
|
||||
for name, point in sorted(known.items()) if point in covered]
|
||||
# Only the faces whose file was actually produced. fa-v4compatibility carries the
|
||||
# old v4 aliases and none of them are used here, so it is not subset — and a
|
||||
# @font-face pointing at a file that was never written is a 404 waiting for the
|
||||
# first glyph that asks for it.
|
||||
produced = {name.replace('.woff2', '.subset.woff2') for name in kept_per_file}
|
||||
faces = []
|
||||
for face in font_family_faces():
|
||||
face = face.replace('.woff2', '.subset.woff2')
|
||||
if not any(name in face for name in produced):
|
||||
continue
|
||||
# The upstream src also names a .ttf fallback, and this build ships none: only
|
||||
# woff2 is in webfonts/. No current browser would reach for it, but a src entry
|
||||
# pointing at a file that does not exist is a 404 waiting for the one that does.
|
||||
face = re.sub(r',\s*url\([^)]*\.ttf\)\s*format\(["\']truetype["\']\)', '', face)
|
||||
faces.append(face)
|
||||
|
||||
base = (
|
||||
'.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}'
|
||||
'.fa,.fa-brands,.fa-regular,.fa-solid,.fab,.far,.fas{-moz-osx-font-smoothing:grayscale;'
|
||||
'-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;'
|
||||
'font-variant:normal;line-height:1;text-rendering:auto}'
|
||||
'.fa-regular,.fa-solid,.far,.fas{font-family:"Font Awesome 6 Free"}'
|
||||
'.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}'
|
||||
'.fa-solid,.fas{font-weight:900}'
|
||||
'.fa-regular,.far{font-weight:400}'
|
||||
'.fa-brands,.fab{font-weight:400}'
|
||||
'.fa-spin{animation-name:fa-spin;animation-duration:var(--fa-animation-duration,2s);'
|
||||
'animation-iteration-count:var(--fa-animation-iteration-count,infinite);'
|
||||
'animation-timing-function:var(--fa-animation-timing,linear)}'
|
||||
'.fa-pulse,.fa-spin-pulse{animation-name:fa-spin;animation-duration:var(--fa-animation-duration,1s);'
|
||||
'animation-iteration-count:var(--fa-animation-iteration-count,infinite);'
|
||||
'animation-timing-function:var(--fa-animation-timing,steps(8))}'
|
||||
'@keyframes fa-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}'
|
||||
'@media (prefers-reduced-motion:reduce){.fa-pulse,.fa-spin,.fa-spin-pulse{'
|
||||
'animation-delay:-1ms;animation-duration:1ms;animation-iteration-count:1}}'
|
||||
)
|
||||
|
||||
out_css = os.path.join(FA, 'css', 'subset.css')
|
||||
with open(out_css, 'w', encoding='utf8') as handle:
|
||||
handle.write('/* Generated by scripts/subset-icons.py — do not edit by hand.\n'
|
||||
' Font Awesome Free 6.5.1 (CC BY 4.0 icons, SIL OFL 1.1 fonts, MIT code).\n'
|
||||
' The families and animations this app uses, plus only the icons it draws. */\n')
|
||||
handle.write(base)
|
||||
handle.write(''.join(faces))
|
||||
handle.write(''.join(rules))
|
||||
|
||||
manifest = os.path.join(FA, 'subset-icons.json')
|
||||
with open(manifest, 'w', encoding='utf8') as handle:
|
||||
json.dump({'icons': sorted(known), 'notIcons': sorted(NOT_ICONS)}, handle, indent=2)
|
||||
handle.write('\n')
|
||||
|
||||
print(f' ✅ {len(known)} icons kept, stylesheet {os.path.getsize(out_css):,} B')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user