Public page SEO and production analytics (#125)
## 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
This commit was merged in pull request #125.
This commit is contained in:
1 parent
8b58192026
commit
7a8f413483
45 files changed
+1880
-174
No files matched your search
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in new issue
Block a user