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,40 @@
|
||||
/**
|
||||
* One IndexNow POST for the URLs in the sitemap. Run after the prod files are published.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { INDEXNOW_KEY, indexNowBody, sitemapEntries } = require('./sitemap');
|
||||
|
||||
async function pingIndexNow(fetchImpl = fetch, urls) {
|
||||
const keyPath = path.join(__dirname, '..', 'public', `${INDEXNOW_KEY}.txt`);
|
||||
const key = fs.readFileSync(keyPath, 'utf8').trim();
|
||||
if (key !== INDEXNOW_KEY) {
|
||||
throw new Error(`IndexNow key file does not match ${INDEXNOW_KEY}`);
|
||||
}
|
||||
const urlList = urls || sitemapEntries().map((entry) => entry.loc);
|
||||
const body = indexNowBody(key, urlList);
|
||||
const response = await fetchImpl('https://api.indexnow.org/indexnow', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (response.status !== 200 && response.status !== 202) {
|
||||
throw new Error(`IndexNow ${response.status}: ${text}`);
|
||||
}
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await pingIndexNow();
|
||||
process.stdout.write(`IndexNow ${result.status}\n`);
|
||||
}
|
||||
|
||||
module.exports = { pingIndexNow };
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Sitemap lastmod comes from the page source, not from the build clock.
|
||||
* Changelog uses the newest entry date. Terms use the "Last Updated" line.
|
||||
* Other public pages use the last git commit that touched that file.
|
||||
*/
|
||||
const { execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ORIGIN = 'https://hesychia.ai';
|
||||
const INDEXNOW_KEY = '8d1667dd71c6ef137171989c26eeb632';
|
||||
const INDEXNOW_HOST = 'hesychia.ai';
|
||||
|
||||
const llmFe = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(llmFe, '..');
|
||||
|
||||
/** Public pages only. Same URLs the site already lists for crawlers. */
|
||||
const SITEMAP_PAGES = [
|
||||
{
|
||||
path: '/',
|
||||
file: 'llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx',
|
||||
lastmod: 'git',
|
||||
},
|
||||
{
|
||||
path: '/pricing/',
|
||||
file: 'llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx',
|
||||
lastmod: 'git',
|
||||
},
|
||||
{
|
||||
path: '/privacy/',
|
||||
file: 'llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx',
|
||||
lastmod: 'git',
|
||||
},
|
||||
{
|
||||
path: '/changelog/',
|
||||
file: 'llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts',
|
||||
lastmod: 'changelog',
|
||||
},
|
||||
{
|
||||
path: '/terms_of_service/',
|
||||
file: 'llm-fe/src/llm-fe/components/legal/TermsOfServiceDocument.tsx',
|
||||
lastmod: 'terms',
|
||||
},
|
||||
{
|
||||
path: '/account-deletion/',
|
||||
file: 'llm-fe/src/llm-fe/pages/AccountDeletion/AccountDeletionPage.tsx',
|
||||
lastmod: 'git',
|
||||
},
|
||||
];
|
||||
|
||||
const MONTHS = {
|
||||
January: '01',
|
||||
February: '02',
|
||||
March: '03',
|
||||
April: '04',
|
||||
May: '05',
|
||||
June: '06',
|
||||
July: '07',
|
||||
August: '08',
|
||||
September: '09',
|
||||
October: '10',
|
||||
November: '11',
|
||||
December: '12',
|
||||
};
|
||||
|
||||
function newestChangelogDate(source) {
|
||||
const dates = [...source.matchAll(/date:\s*'(\d{4}-\d{2}-\d{2})'/g)].map((match) => match[1]);
|
||||
if (dates.length === 0) {
|
||||
throw new Error('changelog entries have no dates');
|
||||
}
|
||||
return dates.sort().at(-1);
|
||||
}
|
||||
|
||||
function termsUpdated(source) {
|
||||
const match = source.match(/Last Updated:\s+([A-Z][a-z]+)\s+(\d{1,2}),\s+(\d{4})/);
|
||||
if (!match) {
|
||||
throw new Error('terms have no Last Updated date');
|
||||
}
|
||||
const month = MONTHS[match[1]];
|
||||
if (!month) {
|
||||
throw new Error(`unknown month in terms date: ${match[1]}`);
|
||||
}
|
||||
return `${match[3]}-${month}-${match[2].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function gitLastmod(file) {
|
||||
const out = execFileSync('git', ['log', '-1', '--format=%cs', '--', file], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(out)) {
|
||||
throw new Error(`no git commit date for ${file}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lastmodFor(page) {
|
||||
const abs = path.join(repoRoot, page.file);
|
||||
const source = fs.readFileSync(abs, 'utf8');
|
||||
if (page.lastmod === 'changelog') return newestChangelogDate(source);
|
||||
if (page.lastmod === 'terms') return termsUpdated(source);
|
||||
return gitLastmod(page.file);
|
||||
}
|
||||
|
||||
function sitemapEntries() {
|
||||
return SITEMAP_PAGES.map((page) => ({
|
||||
loc: `${ORIGIN}${page.path}`,
|
||||
lastmod: lastmodFor(page),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderSitemap(entries) {
|
||||
const urls = entries
|
||||
.map(
|
||||
(entry) => ` <url>
|
||||
<loc>${entry.loc}</loc>
|
||||
<lastmod>${entry.lastmod}</lastmod>
|
||||
</url>`,
|
||||
)
|
||||
.join('\n');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>
|
||||
`;
|
||||
}
|
||||
|
||||
function writeSitemap() {
|
||||
const xml = renderSitemap(sitemapEntries());
|
||||
fs.writeFileSync(path.join(llmFe, 'public', 'sitemap.xml'), xml);
|
||||
return xml;
|
||||
}
|
||||
|
||||
function indexNowBody(key, urls) {
|
||||
return {
|
||||
host: INDEXNOW_HOST,
|
||||
key,
|
||||
keyLocation: `https://${INDEXNOW_HOST}/${key}.txt`,
|
||||
urlList: urls,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INDEXNOW_KEY,
|
||||
SITEMAP_PAGES,
|
||||
newestChangelogDate,
|
||||
termsUpdated,
|
||||
gitLastmod,
|
||||
sitemapEntries,
|
||||
renderSitemap,
|
||||
writeSitemap,
|
||||
indexNowBody,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
newestChangelogDate,
|
||||
termsUpdated,
|
||||
renderSitemap,
|
||||
gitLastmod,
|
||||
indexNowBody,
|
||||
INDEXNOW_KEY,
|
||||
} = require('./sitemap');
|
||||
const { pingIndexNow } = require('./pingIndexNow');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
test('changelog lastmod is the newest entry date', () => {
|
||||
const source = `
|
||||
{ date: '2026-08-01', title: 'Documents' },
|
||||
{ date: '2026-08-04', title: 'Account deletion' },
|
||||
`;
|
||||
assert.equal(newestChangelogDate(source), '2026-08-04');
|
||||
});
|
||||
|
||||
test('terms lastmod is the Last Updated line, not today', () => {
|
||||
assert.equal(
|
||||
termsUpdated('Last Updated: July 27, 2026'),
|
||||
'2026-07-27',
|
||||
);
|
||||
});
|
||||
|
||||
test('live page sources supply lastmod', () => {
|
||||
const changelog = fs.readFileSync(
|
||||
path.join(repoRoot, 'llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const terms = fs.readFileSync(
|
||||
path.join(repoRoot, 'llm-fe/src/llm-fe/components/legal/TermsOfServiceDocument.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(newestChangelogDate(changelog), '2026-08-04');
|
||||
assert.equal(termsUpdated(terms), '2026-07-27');
|
||||
|
||||
for (const file of [
|
||||
'llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx',
|
||||
'llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx',
|
||||
'llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx',
|
||||
'llm-fe/src/llm-fe/pages/AccountDeletion/AccountDeletionPage.tsx',
|
||||
]) {
|
||||
try {
|
||||
assert.match(gitLastmod(file), /^\d{4}-\d{2}-\d{2}$/);
|
||||
} catch (error) {
|
||||
if (!String(error.message).includes('no git commit date')) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const xml = renderSitemap([
|
||||
{ loc: 'https://hesychia.ai/changelog/', lastmod: newestChangelogDate(changelog) },
|
||||
{ loc: 'https://hesychia.ai/terms_of_service/', lastmod: termsUpdated(terms) },
|
||||
]);
|
||||
assert.match(xml, /<loc>https:\/\/hesychia\.ai\/changelog\/<\/loc>\n\s*<lastmod>2026-08-04<\/lastmod>/);
|
||||
assert.match(xml, /<lastmod>2026-07-27<\/lastmod>/);
|
||||
assert.equal(xml.includes('<html'), false);
|
||||
});
|
||||
|
||||
test('IndexNow POST body lists the published URLs and the hosted key', async () => {
|
||||
const urls = ['https://hesychia.ai/', 'https://hesychia.ai/pricing/'];
|
||||
assert.deepEqual(indexNowBody(INDEXNOW_KEY, urls), {
|
||||
host: 'hesychia.ai',
|
||||
key: INDEXNOW_KEY,
|
||||
keyLocation: `https://hesychia.ai/${INDEXNOW_KEY}.txt`,
|
||||
urlList: urls,
|
||||
});
|
||||
|
||||
let posted;
|
||||
const result = await pingIndexNow(async (url, init) => {
|
||||
posted = { url, init };
|
||||
return { status: 202, text: async () => '' };
|
||||
}, urls);
|
||||
assert.equal(posted.url, 'https://api.indexnow.org/indexnow');
|
||||
assert.equal(posted.init.method, 'POST');
|
||||
assert.equal(posted.init.headers['Content-Type'], 'application/json');
|
||||
const body = JSON.parse(posted.init.body);
|
||||
assert.equal(body.keyLocation, `https://hesychia.ai/${INDEXNOW_KEY}.txt`);
|
||||
assert.deepEqual(body.urlList, urls);
|
||||
assert.equal(result.status, 202);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '\\$&')}"`),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
const { writeSitemap } = require('./sitemap');
|
||||
|
||||
writeSitemap();
|
||||
Reference in new issue
Block a user