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,134 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { PAGE_PATHS, h1ForPath, titleForPath, descriptionForPath } = require('../src/llm-fe/utils/canonicals');
|
||||
const { htmlForPath, stampCanonicals } = require('./stampCanonicals');
|
||||
|
||||
const HOME = `<!DOCTYPE html><html><head>
|
||||
<link rel="canonical" href="https://hesychia.ai/" />
|
||||
<meta property="og:title" content="Hesychia — quiet, focused AI chat" />
|
||||
<meta property="og:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
|
||||
<meta property="og:url" content="https://hesychia.ai/" />
|
||||
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Hesychia — quiet, focused AI chat" />
|
||||
<meta name="twitter:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
|
||||
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
|
||||
<meta name="description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
|
||||
<title>Hesychia — quiet, focused AI chat</title>
|
||||
</head><body><div id="root"><h1>Hesychia is a calm AI assistant.</h1></div></body></html>`;
|
||||
|
||||
test('each page canonical is that page, and other head tags stay', () => {
|
||||
const html = htmlForPath(HOME, '/pricing/');
|
||||
assert.match(html, /<link rel="canonical" href="https:\/\/hesychia\.ai\/pricing\/" \/>/);
|
||||
assert.equal(html.match(/rel="canonical"/g).length, 1);
|
||||
assert.match(html, /property="og:url" content="https:\/\/hesychia\.ai\/pricing\/"/);
|
||||
assert.match(html, /property="og:title" content="Hesychia pricing — \$10\/month, no ads"/);
|
||||
assert.match(html, /property="og:description" content="\$10\/month, flat\. No ads, no upsells, one price\./);
|
||||
assert.match(html, /property="og:image" content="https:\/\/hesychia\.ai\/og-image\.png"/);
|
||||
assert.match(html, /name="twitter:card" content="summary_large_image"/);
|
||||
assert.match(html, /name="twitter:title" content="Hesychia pricing — \$10\/month, no ads"/);
|
||||
const nav = html.match(/<nav aria-label="Related pages">([\s\S]*?)<\/nav>/);
|
||||
assert.ok(nav);
|
||||
const anchors = nav[1].match(/<a href=/g);
|
||||
assert.ok(anchors && anchors.length >= 2 && anchors.length <= 5);
|
||||
assert.match(nav[1], /href="\/">What Hesychia is</);
|
||||
assert.doesNotMatch(nav[1], /href="\/pricing\/"/);
|
||||
assert.doesNotMatch(nav[1], />here</);
|
||||
assert.match(html, /id="crawler-prose"/);
|
||||
assert.equal(html.match(/application\/ld\+json/g).length, 1);
|
||||
const pricingLd = JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
|
||||
const pricingPage = pricingLd['@graph'].find((node) => node['@type'] === 'WebPage');
|
||||
assert.equal(pricingPage.headline, 'Hesychia pricing — $10/month, no ads');
|
||||
assert.equal(pricingPage.url, 'https://hesychia.ai/pricing/');
|
||||
assert.match(html, /\$10\/month, flat, with no ads, no upsells, and one price/);
|
||||
assert.doesNotMatch(
|
||||
html.match(/<div id="crawler-prose">([\s\S]*?)<\/div>/)[1],
|
||||
/Quiet, focused AI chat/,
|
||||
);
|
||||
assert.match(html, /<title>Hesychia pricing — \$10\/month, no ads<\/title>/);
|
||||
assert.equal(html.match(/<title\b/g).length, 1);
|
||||
assert.match(html, /name="description" content="\$10\/month, flat\. No ads, no upsells, one price\./);
|
||||
assert.equal(html.match(/name="description"/g).length, 1);
|
||||
assert.equal(html.match(/<h1\b/g).length, 1);
|
||||
assert.match(html, /<h1>Pricing<\/h1>/);
|
||||
});
|
||||
|
||||
test('stamp writes a shell per route and leaves the homepage self-referencing', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'canon-'));
|
||||
fs.writeFileSync(path.join(dir, 'index.html'), HOME);
|
||||
stampCanonicals(dir);
|
||||
|
||||
const home = fs.readFileSync(path.join(dir, 'index.html'), 'utf8');
|
||||
assert.match(home, /href="https:\/\/hesychia\.ai\/"/);
|
||||
const homeLd = JSON.parse(home.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
|
||||
const homeOrg = homeLd['@graph'].find((node) => node['@type'] === 'Organization' && node.name === 'Hesychia');
|
||||
assert.equal(homeOrg.url, 'https://hesychia.ai');
|
||||
assert.equal(homeOrg.logo, 'https://hesychia.ai/apple-touch-icon.png');
|
||||
assert.doesNotMatch(home, /href="https:\/\/hesychia\.ai\/pricing\/"/);
|
||||
assert.equal(home.match(/<h1\b/g).length, 1);
|
||||
assert.match(home, /<h1>Hesychia is a calm AI assistant.<\/h1>/);
|
||||
|
||||
const titles = PAGE_PATHS.map((pathname) => titleForPath(pathname));
|
||||
assert.equal(new Set(titles).size, titles.length);
|
||||
const descriptions = PAGE_PATHS.map((pathname) => descriptionForPath(pathname));
|
||||
assert.equal(new Set(descriptions).size, descriptions.length);
|
||||
|
||||
for (const pathname of PAGE_PATHS) {
|
||||
if (pathname === '/') continue;
|
||||
const rel = pathname.endsWith('/')
|
||||
? path.join(pathname.slice(1), 'index.html')
|
||||
: pathname.slice(1);
|
||||
const html = fs.readFileSync(path.join(dir, rel), 'utf8');
|
||||
const title = titleForPath(pathname);
|
||||
assert.match(html, new RegExp(`href="https://hesychia\\.ai${pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
|
||||
assert.match(html, /name="twitter:card" content="summary_large_image"/);
|
||||
const related = html.match(/<nav aria-label="Related pages">([\s\S]*?)<\/nav>/);
|
||||
assert.ok(related, `${pathname} missing related nav`);
|
||||
const relatedAnchors = related[1].match(/<a href=/g);
|
||||
assert.ok(
|
||||
relatedAnchors && relatedAnchors.length >= 2 && relatedAnchors.length <= 5,
|
||||
`${pathname} related link count`,
|
||||
);
|
||||
assert.doesNotMatch(related[1], new RegExp(`href="${pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
|
||||
assert.match(html, /property="og:image" content="https:\/\/hesychia\.ai\/og-image\.png"/);
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`property="og:title" content="${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
|
||||
);
|
||||
assert.equal(html.match(/rel="canonical"/g).length, 1);
|
||||
assert.equal(html.match(/<h1\b/g).length, 1);
|
||||
assert.match(html, new RegExp(`<h1>${h1ForPath(pathname).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</h1>`));
|
||||
assert.ok(title.length >= 30 && title.length <= 60, `${pathname} title length ${title.length}`);
|
||||
assert.match(html, new RegExp(`<title>${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</title>`));
|
||||
assert.equal(html.match(/application\/ld\+json/g).length, 1);
|
||||
const ld = JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
|
||||
const webPage = ld['@graph'].find((node) => node['@type'] === 'WebPage');
|
||||
assert.equal(webPage.headline, title, `${pathname} JSON-LD headline`);
|
||||
const org = ld['@graph'].find((node) => node['@type'] === 'Organization' && node.name === 'Hesychia');
|
||||
assert.ok(org, `${pathname} missing Organization`);
|
||||
assert.equal(org.url, 'https://hesychia.ai');
|
||||
assert.equal(org.logo, 'https://hesychia.ai/apple-touch-icon.png');
|
||||
if (pathname === '/changelog/') {
|
||||
const posts = ld['@graph'].filter((node) => node['@type'] === 'BlogPosting');
|
||||
assert.ok(posts.length >= 2);
|
||||
for (const post of posts) {
|
||||
assert.ok(post.headline);
|
||||
assert.equal(post.author.name, 'AI ML Operations, LLC');
|
||||
assert.match(post.datePublished, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.equal(post.dateModified, post.datePublished);
|
||||
}
|
||||
}
|
||||
const description = descriptionForPath(pathname);
|
||||
assert.ok(
|
||||
description.length >= 110 && description.length <= 160,
|
||||
`${pathname} description length ${description.length}`,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`content="${description.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user