## Summary - Closes the public-page gaps from #124: each route gets its own title, description, canonical, heading, links, and JSON-LD, and unknown URLs have a real 404 document. - Adds HeyCatch and the Google tag (`G-V12DLRLPWR`) only on the production site and the iOS and Android apps. Tianji still loads on beta. - Needs the server-infra change that stops serving the app shell for missing paths. ## Test plan - [ ] Build production and confirm `/` and `/pricing/` HTML include a unique title, description, canonical, one h1, social tags, related links, and Organization JSON-LD. - [ ] Confirm a nonsense path is served as `404.html` once the nginx change is deployed. - [ ] Confirm beta and local web do not load HeyCatch or gtag, and that a production or native build does. - [ ] Open the live site after deploy and click around, then mark HeyCatch installed in its dashboard. Reviewed-on: #125
176 lines
6.8 KiB
JavaScript
176 lines
6.8 KiB
JavaScript
/**
|
|
* CRA serves one index.html for every route. A canonical in that shared file
|
|
* would point every page at the homepage. After `react-scripts build`, write a
|
|
* copy per route whose canonical href is that route's own absolute URL.
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { ORIGIN, OG_IMAGE, PAGE_PATHS, h1ForPath, titleForPath, descriptionForPath } = require('../src/llm-fe/utils/canonicals');
|
|
const { relatedNavHtml } = require('../src/llm-fe/utils/internalLinks');
|
|
const { proseHtml } = require('../src/llm-fe/utils/pageProse');
|
|
const { jsonLdHtml } = require('../src/llm-fe/utils/structuredData');
|
|
|
|
const CANONICAL_RE = /<link\s+rel=["']canonical["']\s+href=["'][^"']*["']\s*\/?>/i;
|
|
const H1_RE = /<h1[^>]*>[\s\S]*?<\/h1>/i;
|
|
const TITLE_RE = /<title>[^<]*<\/title>/i;
|
|
const DESCRIPTION_RE = /<meta\s+name=["']description["']\s+content=["'][^"']*["']\s*\/?>/i;
|
|
const RELATED_NAV_RE = /<nav\s+aria-label=["']Related pages["']>[\s\S]*?<\/nav>/i;
|
|
const PROSE_RE = /<div id="crawler-prose">[\s\S]*?<\/div>/i;
|
|
const JSONLD_SOURCE = '<script type="application\\/ld\\+json">[\\s\\S]*?<\\/script>';
|
|
|
|
/**
|
|
* @param {string} html
|
|
* @param {'property' | 'name'} attr
|
|
* @param {string} key
|
|
* @param {string} content
|
|
* @returns {string}
|
|
*/
|
|
function replaceSocial(html, attr, key, content) {
|
|
const re = new RegExp(
|
|
`<meta\\s+${attr}=["']${key}["']\\s+content=["'][^"']*["']\\s*\\/?>`,
|
|
'i',
|
|
);
|
|
if (!re.test(html)) {
|
|
throw new Error(`missing meta ${attr}="${key}"`);
|
|
}
|
|
return html.replace(re, () => `<meta ${attr}="${key}" content="${content}" />`);
|
|
}
|
|
|
|
/**
|
|
* @param {string} homeHtml
|
|
* @param {string} pathname
|
|
* @returns {string}
|
|
*/
|
|
function htmlForPath(homeHtml, pathname) {
|
|
const found = homeHtml.match(new RegExp(CANONICAL_RE.source, 'gi'));
|
|
if (!found || found.length !== 1) {
|
|
throw new Error(
|
|
`expected exactly one <link rel="canonical"> in index.html, found ${found ? found.length : 0}`,
|
|
);
|
|
}
|
|
const url = `${ORIGIN}${pathname}`;
|
|
let next = homeHtml.replace(CANONICAL_RE, `<link rel="canonical" href="${url}" />`);
|
|
const h1s = next.match(/<h1\b/gi);
|
|
if (!h1s || h1s.length !== 1) {
|
|
throw new Error(`expected exactly one <h1> in index.html, found ${h1s ? h1s.length : 0}`);
|
|
}
|
|
const headline = h1ForPath(pathname);
|
|
if (!headline) {
|
|
throw new Error(`no h1 for ${pathname}`);
|
|
}
|
|
next = next.replace(H1_RE, `<h1>${headline}</h1>`);
|
|
if (next.match(/<h1\b/gi).length !== 1) {
|
|
throw new Error(`stamped ${pathname} does not have exactly one h1`);
|
|
}
|
|
const titles = next.match(/<title\b/gi);
|
|
if (!titles || titles.length !== 1) {
|
|
throw new Error(`expected exactly one <title> in index.html, found ${titles ? titles.length : 0}`);
|
|
}
|
|
const title = titleForPath(pathname);
|
|
if (!title) {
|
|
throw new Error(`no title for ${pathname}`);
|
|
}
|
|
next = next.replace(TITLE_RE, `<title>${title}</title>`);
|
|
if (next.match(/<title\b/gi).length !== 1) {
|
|
throw new Error(`stamped ${pathname} does not have exactly one title`);
|
|
}
|
|
const descriptions = next.match(/<meta\s+name=["']description["']/gi);
|
|
if (!descriptions || descriptions.length !== 1) {
|
|
throw new Error(
|
|
`expected exactly one meta description in index.html, found ${descriptions ? descriptions.length : 0}`,
|
|
);
|
|
}
|
|
const description = descriptionForPath(pathname);
|
|
if (!description) {
|
|
throw new Error(`no description for ${pathname}`);
|
|
}
|
|
next = next.replace(DESCRIPTION_RE, `<meta name="description" content="${description}" />`);
|
|
if (next.match(/<meta\s+name=["']description["']/gi).length !== 1) {
|
|
throw new Error(`stamped ${pathname} does not have exactly one meta description`);
|
|
}
|
|
next = replaceSocial(next, 'property', 'og:title', title);
|
|
next = replaceSocial(next, 'property', 'og:description', description);
|
|
next = replaceSocial(next, 'property', 'og:url', url);
|
|
next = replaceSocial(next, 'name', 'twitter:title', title);
|
|
next = replaceSocial(next, 'name', 'twitter:description', description);
|
|
if (!next.includes(`property="og:image" content="${OG_IMAGE}"`)) {
|
|
throw new Error(`stamped ${pathname} is missing the 1200x630 og:image`);
|
|
}
|
|
if (!next.includes('name="twitter:card" content="summary_large_image"')) {
|
|
throw new Error(`stamped ${pathname} is missing twitter:card`);
|
|
}
|
|
const prose = proseHtml(pathname);
|
|
if (PROSE_RE.test(next)) {
|
|
next = next.replace(PROSE_RE, () => prose);
|
|
} else {
|
|
next = next.replace(/<\/h1>/i, () => `</h1>${prose}`);
|
|
}
|
|
const nav = relatedNavHtml(pathname);
|
|
if (RELATED_NAV_RE.test(next)) {
|
|
next = next.replace(RELATED_NAV_RE, () => nav);
|
|
} else {
|
|
next = next.replace(/<\/h1>/i, () => `</h1>${nav}`);
|
|
}
|
|
if (next.match(/<nav\s+aria-label=["']Related pages["']/gi)?.length !== 1) {
|
|
throw new Error(`stamped ${pathname} does not have exactly one related-pages nav`);
|
|
}
|
|
const block = jsonLdHtml(pathname);
|
|
const jsonLdRe = new RegExp(JSONLD_SOURCE, 'gi');
|
|
const existing = next.match(jsonLdRe);
|
|
if (existing) {
|
|
let replaced = false;
|
|
next = next.replace(new RegExp(JSONLD_SOURCE, 'gi'), () => {
|
|
if (replaced) return '';
|
|
replaced = true;
|
|
return block;
|
|
});
|
|
} else {
|
|
next = next.replace(/<\/head>/i, () => `${block}\n</head>`);
|
|
}
|
|
const scripts = next.match(/<script type="application\/ld\+json">/gi);
|
|
if (!scripts || scripts.length !== 1) {
|
|
throw new Error(`stamped ${pathname} should have one JSON-LD script, found ${scripts ? scripts.length : 0}`);
|
|
}
|
|
const parsed = JSON.parse(next.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/i)[1]);
|
|
const page = parsed['@graph'] ? parsed['@graph'].find((node) => node['@type'] === 'WebPage') : parsed;
|
|
if (!page || page.headline !== title) {
|
|
throw new Error(`JSON-LD headline for ${pathname} does not match the title`);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* @param {string} buildDir
|
|
* @param {string} pathname
|
|
* @returns {string}
|
|
*/
|
|
function destForPath(buildDir, pathname) {
|
|
if (pathname === '/') return path.join(buildDir, 'index.html');
|
|
if (pathname.endsWith('/')) {
|
|
return path.join(buildDir, pathname.slice(1), 'index.html');
|
|
}
|
|
// Exact URI with no extension. nginx serves these with default_type text/html
|
|
// so /document_storage is this file, not the SPA fallback.
|
|
return path.join(buildDir, pathname.slice(1));
|
|
}
|
|
|
|
/**
|
|
* @param {string} buildDir
|
|
*/
|
|
function stampCanonicals(buildDir) {
|
|
const indexPath = path.join(buildDir, 'index.html');
|
|
const homeHtml = fs.readFileSync(indexPath, 'utf8');
|
|
for (const pathname of PAGE_PATHS) {
|
|
const dest = destForPath(buildDir, pathname);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.writeFileSync(dest, htmlForPath(homeHtml, pathname));
|
|
}
|
|
}
|
|
|
|
module.exports = { htmlForPath, destForPath, stampCanonicals, CANONICAL_RE };
|
|
|
|
if (require.main === module) {
|
|
const buildDir = process.argv[2] || path.resolve(__dirname, '../build');
|
|
stampCanonicals(buildDir);
|
|
}
|