Public page SEO and production analytics (#125)
Deploy Beta / unit-tests (push) Successful in 13s
Unit Tests / test (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 3m50s

## 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:
westfarn committed 2026-10-19 08:01:44 -07:00
1 parent 8b58192026
commit 7a8f413483
45 files changed
+1880 -174

No files matched your search

+113
View File
@@ -19,6 +19,7 @@
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@heycatch/sdk": "^0.8.3",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11",
"@testing-library/jest-dom": "^6.6.3",
@@ -3083,6 +3084,103 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@heycatch/sdk": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@heycatch/sdk/-/sdk-0.8.3.tgz",
"integrity": "sha512-7knqXfptgctAIHu5IXwd/WnivY+wwYAws2Xr0ZuARottBnbQteFbxZP7TyEDWCyz6A5+PcGlzlPxeTdq5uyGoA==",
"license": "MIT",
"dependencies": {
"posthog-react-native": "^4.61.1"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"expo-router": "*",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@heycatch/sdk/node_modules/posthog-react-native": {
"version": "4.81.2",
"resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.81.2.tgz",
"integrity": "sha512-w9UUocpnS9u+CO+37Q32W/zSgL8/fSFhIB9tUZfSDc1D4rE7XShyfCwwXD6efjmP19Gu941kuXdRgTbuV8Mkig==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.59.1",
"@posthog/types": "^1.416.0"
},
"bin": {
"posthog-react-native-xcode": "tooling/posthog-xcode.sh"
},
"peerDependencies": {
"@posthog/react-native-plugin": ">= 2.9.3",
"@react-native-async-storage/async-storage": ">=1.0.0",
"@react-navigation/native": ">= 5.0.0",
"expo-application": ">= 4.0.0",
"expo-device": ">= 4.0.0",
"expo-file-system": ">= 13.0.0",
"expo-localization": ">= 11.0.0",
"expo-updates": ">= 0.25.0",
"posthog-react-native-session-replay": ">= 1.6.0",
"react-native-device-info": ">= 10.0.0",
"react-native-localize": ">= 3.0.0",
"react-native-navigation": ">= 6.0.0",
"react-native-safe-area-context": ">= 4.0.0",
"react-native-svg": ">= 15.0.0"
},
"peerDependenciesMeta": {
"@posthog/react-native-plugin": {
"optional": true
},
"@react-native-async-storage/async-storage": {
"optional": true
},
"@react-navigation/native": {
"optional": true
},
"expo-application": {
"optional": true
},
"expo-device": {
"optional": true
},
"expo-file-system": {
"optional": true
},
"expo-localization": {
"optional": true
},
"expo-updates": {
"optional": true
},
"posthog-react-native-session-replay": {
"optional": true
},
"react-native-device-info": {
"optional": true
},
"react-native-localize": {
"optional": true
},
"react-native-navigation": {
"optional": true
},
"react-native-safe-area-context": {
"optional": true
},
"react-native-svg": {
"optional": true
}
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -5155,6 +5253,21 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@posthog/core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.59.1.tgz",
"integrity": "sha512-P39YgzQVDh6oz6wAzQXEnoHgTYpM7gifonmVKq1scAJco79h31XmmNVurRq9tTmmyIrr1uX8FHT0riCsyfV9AQ==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.416.0"
}
},
"node_modules/@posthog/types": {
"version": "1.416.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz",
"integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==",
"license": "MIT"
},
"node_modules/@prettier/plugin-xml": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-2.2.0.tgz",
+6 -5
View File
@@ -11,10 +11,10 @@
"@capacitor/keyboard": "^7.0.6",
"@capacitor/preferences": "^7.0.4",
"@capacitor/status-bar": "^7.0.6",
"native-store-billing": "file:plugins/native-store-billing",
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@heycatch/sdk": "^0.8.3",
"@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11",
"@testing-library/jest-dom": "^6.6.3",
@@ -34,6 +34,7 @@
"lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1",
"native-store-billing": "file:plugins/native-store-billing",
"papaparse": "^5.5.4",
"pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6",
@@ -53,9 +54,9 @@
},
"scripts": {
"start": "NODE_ENV=development react-scripts start",
"build": "NODE_ENV=production react-scripts build",
"build:prod": "NODE_ENV=production react-scripts build && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/",
"build:beta": "bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/",
"build": "node scripts/writeSitemap.js && NODE_ENV=production react-scripts build && node scripts/stampCanonicals.js",
"build:prod": "node scripts/writeSitemap.js && NODE_ENV=production react-scripts build && node scripts/stampCanonicals.js && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/ && node scripts/pingIndexNow.js",
"build:beta": "node scripts/writeSitemap.js && bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && node scripts/stampCanonicals.js && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/",
"build:mobile": "bash -c 'set -a; source .env.mobile; set +a; NODE_ENV=production react-scripts build' && npx cap sync",
"android:open": "npx cap open android",
"android:sync": "npx cap sync android",
@@ -63,7 +64,7 @@
"ios:sync": "npx cap sync ios",
"assets:generate": "npx capacitor-assets generate --ios --android",
"test": "NODE_ENV=development react-scripts test",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false && node --test scripts/stampCanonicals.test.js scripts/sitemap.test.js",
"eject": "react-scripts eject"
},
"eslintConfig": {
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>The page is not found — Hesychia</title>
<meta name="description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta property="og:title" content="The page is not found — Hesychia" />
<meta property="og:description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta property="og:url" content="https://hesychia.ai/404.html" />
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="The page is not found — Hesychia" />
<meta name="twitter:description" content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account." />
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"headline": "The page is not found — Hesychia",
"url": "https://hesychia.ai/404.html",
"mainEntityOfPage": "https://hesychia.ai/404.html"
},
{
"@type": "Organization",
"name": "Hesychia",
"url": "https://hesychia.ai",
"logo": "https://hesychia.ai/apple-touch-icon.png",
"parentOrganization": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
}
]
}
</script>
</head>
<body>
<h1>Not Found</h1>
<p>The page is not found, and this address is not a Hesychia page.</p>
<p>Open the home page to read what the product does, what it costs, and how to start an account.</p>
<nav aria-label="Related pages"><a href="/">What Hesychia is</a><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a></nav>
</body>
</html>
@@ -0,0 +1 @@
8d1667dd71c6ef137171989c26eeb632
+25 -11
View File
@@ -8,36 +8,48 @@
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Hesychia is a calm AI assistant. Quiet, focused AI chat — private by default. $10/month from AI ML Operations, LLC." />
<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." />
<meta name="application-name" content="Hesychia" />
<link rel="canonical" href="https://hesychia.ai/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Hesychia — quiet, focused AI chat" />
<meta property="og:description" content="Hesychia is a calm AI assistant. Quiet, focused AI chat — private by default. $10/month." />
<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 property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<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" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"headline": "Hesychia — quiet, focused AI chat",
"url": "https://hesychia.ai/",
"mainEntityOfPage": "https://hesychia.ai/"
},
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Hesychia",
"url": "https://hesychia.ai/",
"url": "https://hesychia.ai",
"logo": "https://hesychia.ai/apple-touch-icon.png",
"parentOrganization": {
"@type": "Organization",
"name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com"
}
}
</script>
<script type="application/ld+json">
},
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Hesychia",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Web",
"url": "https://hesychia.ai/",
"description": "A calm AI assistant. Quiet, focused AI chat — private by default.",
"description": "Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files.",
"offers": {
"@type": "Offer",
"price": "10.00",
@@ -49,7 +61,9 @@
"url": "https://www.aimloperations.com"
}
}
</script>
]
}
</script>
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
@@ -69,7 +83,7 @@
</head>
<body>
<div id="root"></div>
<div id="root"><h1>Hesychia is a calm AI assistant.</h1><div id="crawler-prose"><p>Quiet, focused AI chat — private by default — is how this Hesychia product is built.</p><p>It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.</p><p>We store your account, your conversations, and the documents you upload.</p><p>We do not sell your data, and we do not use advertising cookies.</p><p>Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.</p><p>It remembers what you told it and your documents, so you never start over.</p><p>Your documents stay stored and searchable, and answers are grounded in your own files.</p><p>Business accounts are simple management for your team, and company managers can manage team accounts.</p></div><nav aria-label="Related pages"><a href="/pricing/">Hesychia pricing — $10/month, no ads</a><a href="/privacy/">What Hesychia stores and does not sell</a><a href="/changelog/">Dated Hesychia product updates</a><a href="/terms_of_service/">Hesychia terms of service</a><a href="/account-deletion/">How to request Hesychia account deletion</a></nav></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
+2 -2
View File
@@ -1,8 +1,8 @@
# Hesychia
> Hesychia is a calm AI assistant. Quiet, focused AI chat — private by default.
> Hesychia is a calm AI assistant with quiet, focused AI chat that stays private by default.
Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files, and company managers can manage team accounts. Conversation memory is a setting you control. Canceling a subscription does not delete documents. Account deletion is a soft-delete.
Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files, and company managers can manage team accounts. Conversation memory is a setting you control, and canceling a subscription does not delete documents. Account deletion is a soft-delete, and stored account data may remain.
## Pages
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+4 -4
View File
@@ -2,19 +2,19 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://hesychia.ai/</loc>
<lastmod>2026-10-17</lastmod>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/pricing/</loc>
<lastmod>2026-10-17</lastmod>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/privacy/</loc>
<lastmod>2026-10-17</lastmod>
<lastmod>2026-10-19</lastmod>
</url>
<url>
<loc>https://hesychia.ai/changelog/</loc>
<lastmod>2026-10-17</lastmod>
<lastmod>2026-08-04</lastmod>
</url>
<url>
<loc>https://hesychia.ai/terms_of_service/</loc>
+40
View File
@@ -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);
});
}
+153
View File
@@ -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,
};
+87
View File
@@ -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);
});
+175
View File
@@ -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);
}
+134
View File
@@ -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, '\\$&')}"`),
);
}
});
+3
View File
@@ -0,0 +1,3 @@
const { writeSitemap } = require('./sitemap');
writeSitemap();
+1
View File
@@ -1,3 +1,4 @@
import './llm-fe/utils/heycatch';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
@@ -3,6 +3,7 @@ import { AccountContext } from '../../contexts/AccountContext';
import { AuthContext } from '../../contexts/AuthContext';
import { useAnalyticsConsent } from '../../contexts/AnalyticsConsentContext';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { identifyHeyCatch, resetHeyCatch } from '../../utils/heycatch';
/**
* Identify on authenticated session; track Logout when session ends.
@@ -15,7 +16,18 @@ const AnalyticsSession = (): null => {
const wasAuthenticatedRef = useRef<boolean | null>(null);
useEffect(() => {
if (!hasConsent || loading) return;
if (loading) return;
if (account) {
identifyHeyCatch(account);
} else if (wasAuthenticatedRef.current && !authenticated) {
resetHeyCatch();
}
if (!hasConsent) {
wasAuthenticatedRef.current = authenticated;
return;
}
if (account) {
identifyAccount(account);
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import ParticleBackground from '../ParticleBackground/ParticleBackground';
import RelatedPages from './RelatedPages';
import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav';
const text = ({ theme }: { theme?: { colors?: { text?: string } } }) =>
@@ -111,7 +112,10 @@ const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => {
<SignInLink to="/signin/">Sign in</SignInLink>
</Nav>
</Header>
<Main>{children}</Main>
<Main>
{children}
<RelatedPages />
</Main>
<FooterBar>
<FooterNav aria-label="Footer">
{PUBLIC_FOOTER_LINKS.map((link) => (
@@ -0,0 +1,36 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { linksForPath } from '../../utils/internalLinks';
import { publicAccent } from './publicColors';
const Nav = styled.nav`
margin-top: 2.5rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
`;
const Item = styled(Link)`
color: ${publicAccent};
`;
/** Same neighbours the static shell exposes to crawlers that do not run JavaScript. */
const RelatedPages = (): JSX.Element | null => {
const { pathname } = useLocation();
const links = linksForPath(pathname);
if (links.length < 2) return null;
return (
<Nav aria-label="Related pages">
{links.map((link) => (
<Item key={link.href} to={link.href}>
{link.label}
</Item>
))}
</Nav>
);
};
export default RelatedPages;
@@ -1,14 +1,25 @@
import React from 'react';
import { Capacitor } from '@capacitor/core';
import { render } from '@testing-library/react';
import Tracker from './Tracker';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
const GTAG_SCRIPT_ID = 'hesychia-gtag';
const GTAG_SRC = 'https://www.googletagmanager.com/gtag/js?id=G-V12DLRLPWR';
jest.mock('@capacitor/core', () => ({
Capacitor: {
getPlatform: jest.fn(() => 'web'),
},
}));
describe('Tracker', () => {
const originalEnv = process.env;
beforeEach(() => {
(Capacitor.getPlatform as jest.Mock).mockReturnValue('web');
document.body.innerHTML = '';
document.head.innerHTML = '';
process.env = {
...originalEnv,
REACT_APP_DEPLOY_ENV: 'production',
@@ -28,6 +39,11 @@ describe('Tracker', () => {
expect(script).not.toBeNull();
expect(script?.src).toBe('https://tianji.aimloperations.com/tracker.js');
expect(script?.getAttribute('data-website-id')).toBe('cm7x7m52m03kbddswbswrt17y');
const gtag = document.getElementById(GTAG_SCRIPT_ID) as HTMLScriptElement | null;
expect(gtag?.src).toBe(GTAG_SRC);
expect(gtag?.async).toBe(true);
expect(window.gtag).toEqual(expect.any(Function));
});
it('loads distinct website id for beta builds', () => {
@@ -41,6 +57,21 @@ describe('Tracker', () => {
const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null;
expect(script?.getAttribute('data-website-id')).toBe('beta-website-id-test');
expect(document.getElementById(GTAG_SCRIPT_ID)).toBeNull();
});
it('loads Google tag on the iOS app even when the web env is beta', () => {
(Capacitor.getPlatform as jest.Mock).mockReturnValue('ios');
process.env = {
...originalEnv,
REACT_APP_DEPLOY_ENV: 'beta',
REACT_APP_TIANJI_WEBSITE_ID: 'beta-website-id-test',
};
render(<Tracker />);
const gtag = document.getElementById(GTAG_SCRIPT_ID) as HTMLScriptElement | null;
expect(gtag?.src).toBe(GTAG_SRC);
});
it('does not load Tianji tracker outside analytics environments', () => {
@@ -54,5 +85,6 @@ describe('Tracker', () => {
render(<Tracker />);
expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull();
expect(document.getElementById(GTAG_SCRIPT_ID)).toBeNull();
});
});
@@ -1,8 +1,28 @@
import React, { useEffect } from 'react';
import { flushAnalyticsQueue } from '../../utils/analytics';
import { isAnalyticsEnvironment } from '../../utils/analyticsConsent';
import { isProductAnalyticsEnabled } from '../../utils/productAnalytics';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
const GTAG_SCRIPT_ID = 'hesychia-gtag';
const GTAG_ID = 'G-V12DLRLPWR';
function loadGoogleTag(): void {
if (document.getElementById(GTAG_SCRIPT_ID)) return;
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag(...args: unknown[]) {
window.dataLayer?.push(args);
};
window.gtag('js', new Date());
window.gtag('config', GTAG_ID);
const script = document.createElement('script');
script.id = GTAG_SCRIPT_ID;
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${GTAG_ID}`;
document.head.appendChild(script);
}
/**
* Loads Tianji tracker.js for automatic page views on prod/beta builds.
@@ -10,29 +30,35 @@ const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker';
*/
const Tracker: React.FC = () => {
useEffect(() => {
if (!isAnalyticsEnvironment()) return;
if (isProductAnalyticsEnabled()) {
loadGoogleTag();
}
const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || '';
if (!websiteId || document.getElementById(TRACKER_SCRIPT_ID)) return;
if (isAnalyticsEnvironment() && websiteId && !document.getElementById(TRACKER_SCRIPT_ID)) {
const script = document.createElement('script');
script.id = TRACKER_SCRIPT_ID;
script.src = 'https://tianji.aimloperations.com/tracker.js';
script.async = true;
script.defer = true;
script.setAttribute('data-website-id', websiteId);
script.setAttribute('data-do-not-track', 'true');
script.onload = () => {
flushAnalyticsQueue();
};
const script = document.createElement('script');
script.id = TRACKER_SCRIPT_ID;
script.src = 'https://tianji.aimloperations.com/tracker.js';
script.async = true;
script.defer = true;
script.setAttribute('data-website-id', websiteId);
script.setAttribute('data-do-not-track', 'true');
script.onload = () => {
flushAnalyticsQueue();
};
document.body.appendChild(script);
document.body.appendChild(script);
}
return () => {
const loaded = document.getElementById(TRACKER_SCRIPT_ID);
if (loaded?.parentNode) {
loaded.parentNode.removeChild(loaded);
}
const gtag = document.getElementById(GTAG_SCRIPT_ID);
if (gtag?.parentNode) {
gtag.parentNode.removeChild(gtag);
}
};
}, []);
@@ -1,6 +1,8 @@
import React from 'react';
import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import RelatedPages from '../../components/PublicSite/RelatedPages';
import { ACCOUNT_DELETION } from '../../utils/pageProse';
/**
* Public Play / App Store account-deletion instructions (#103).
@@ -17,8 +19,7 @@ const AccountDeletionPage = (): JSX.Element => (
Hesychia (ai.hesychia.chat) · hesychia.ai · AI ML Operations, LLC
</Typography>
<Typography variant="body1" color="text.secondary">
Use the steps below to request deletion of your Hesychia account. This page does not
require sign-in to read.
{ACCOUNT_DELETION.intro}
</Typography>
</CardContent>
@@ -35,17 +36,12 @@ const AccountDeletionPage = (): JSX.Element => (
Open <strong>Hesychia</strong> in the mobile app or at{' '}
<a href="https://hesychia.ai" rel="noopener noreferrer">
hesychia.ai
</a>
.
</li>
<li>Sign in with the account you want deleted.</li>
<li>
Go to <strong>Account</strong>.
</li>
<li>
Choose <strong>Delete my account</strong>, then confirm by typing your account
email.
</a>{' '}
for your account.
</li>
<li>{ACCOUNT_DELETION.signIn}</li>
<li>{ACCOUNT_DELETION.goToAccount}</li>
<li>{ACCOUNT_DELETION.confirm}</li>
</Typography>
</Box>
@@ -58,31 +54,24 @@ const AccountDeletionPage = (): JSX.Element => (
Deactivated immediately
</Typography>
<Typography variant="body1" component="ul" sx={{ pl: 2.5, m: 0 }}>
<li>Your account is marked deleted and set inactive.</li>
<li>You lose access to Hesychia and cannot sign in again with that account.</li>
<li>Your conversations are hidden from the product.</li>
<li>Active sessions are invalidated (refresh tokens are blacklisted).</li>
<li>{ACCOUNT_DELETION.deactivated}</li>
<li>{ACCOUNT_DELETION.loseAccess}</li>
<li>{ACCOUNT_DELETION.sessions}</li>
</Typography>
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
Data retained after soft-delete
</Typography>
<Typography variant="body1" paragraph>
Hesychia currently uses a <strong>soft-delete</strong> process. After you delete your
account, personal data associated with the account (including profile details,
conversation content, uploaded documents / RAG vectors, and authentication event
records) may remain in our systems for administration, security, and audit purposes.
This is not a full erasure of all stored data.
{ACCOUNT_DELETION.softDelete} {ACCOUNT_DELETION.remain} {ACCOUNT_DELETION.includes}{' '}
{ACCOUNT_DELETION.purposes}
</Typography>
<Typography variant="h6" component="h3" gutterBottom>
Retention period
</Typography>
<Typography variant="body1" paragraph sx={{ mb: 0 }}>
There is currently <strong>no automatic hard-purge schedule</strong>. Soft-deleted
data is retained until a future purge process is implemented or until we process a
separate retention / erasure request through the contact path below. We will not claim
complete erasure while soft-delete-only remains in effect.
{ACCOUNT_DELETION.retention} {ACCOUNT_DELETION.request} {ACCOUNT_DELETION.noClaim}
</Typography>
</Box>
@@ -94,12 +83,11 @@ const AccountDeletionPage = (): JSX.Element => (
If you cannot sign in to complete in-app deletion, contact AI ML Operations, LLC via{' '}
<a href="https://www.aimloperations.com" rel="noopener noreferrer">
aimloperations.com
</a>{' '}
and include the email address used for your Hesychia account. We aim to respond to
account-deletion requests within <strong>30 days</strong>.
</a>
. {ACCOUNT_DELETION.email} {ACCOUNT_DELETION.days}
</Typography>
<Typography variant="body2" color="text.secondary">
Staff accounts cannot self-delete in the app; use the contact path above.
{ACCOUNT_DELETION.staff}
</Typography>
</Box>
</Stack>
@@ -124,6 +112,7 @@ const AccountDeletionPage = (): JSX.Element => (
</Typography>
</CardContent>
</Card>
<RelatedPages />
</Box>
);
@@ -1,3 +1,5 @@
import { CHANGELOG } from '../../utils/pageProse';
export type ChangelogEntry = {
date: string;
title: string;
@@ -9,21 +11,21 @@ export const CHANGELOG_ENTRIES: readonly ChangelogEntry[] = [
{
date: '2026-08-04',
title: 'Conversation memory',
note: 'Account settings can use earlier turns in a chat as context, so you do not re-explain the same project on every message. It stays off until you turn it on.',
note: `${CHANGELOG.memory} ${CHANGELOG.memoryOff}`,
},
{
date: '2026-08-04',
title: 'Account deletion',
note: 'A public page explains how to delete an account and that deletion is a soft-delete.',
note: CHANGELOG.deletion,
},
{
date: '2026-08-01',
title: 'Documents',
note: 'Upload documents and search them from chat. Personal and company libraries stay separate.',
note: CHANGELOG.documents,
},
{
date: '2026-08-01',
title: 'Subscriptions',
note: 'Upgrade, change, or cancel from the account page. The public price is $10 a month.',
note: CHANGELOG.subscriptions,
},
];
@@ -49,7 +49,7 @@ describe('LandingPage', () => {
expect(screen.getByText(/Quiet, focused AI chat — private by default/i)).toBeInTheDocument();
expect(screen.getByAltText('Hesychia logo')).toBeInTheDocument();
expect(screen.getByText(/Your documents, stored and searchable/i)).toBeInTheDocument();
expect(screen.getByText(/Business accounts/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Business accounts' })).toBeInTheDocument();
expect(screen.getByText(/We do not sell your data/i)).toBeInTheDocument();
expect(screen.getByText(/Canceling does not delete documents/i)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.toBeInTheDocument();
@@ -6,6 +6,7 @@ import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import UsageCounts from '../../components/PublicSite/UsageCounts';
import { publicAccent, publicCard, publicLine, publicText } from '../../components/PublicSite/publicColors';
import { HOME } from '../../utils/pageProse';
const Hero = styled.header`
max-width: 40rem;
@@ -87,21 +88,15 @@ const LandingPage = (): JSX.Element => {
<Hero>
<Mark src={hesychiaMark} alt="Hesychia logo" />
<Title>Hesychia is a calm AI assistant.</Title>
<Lede>Quiet, focused AI chat — private by default.</Lede>
<Memory>
It remembers what you told it — and your documents — so you never start over.
Conversation memory is a setting you control.
</Memory>
<Lede>{HOME.lede}</Lede>
<Memory>{HOME.memory}</Memory>
<GetStarted source="landing" />
<Privacy aria-label="Privacy">
<h2>What we do with your data</h2>
<PrivacyList>
<li>We store your account, your conversations, and the documents you upload.</li>
<li>We do not sell your data, and we do not use advertising cookies.</li>
<li>
Canceling does not delete documents. They stay until you delete them or delete the
account. Account deletion is a soft-delete.
</li>
<li>{HOME.stored}</li>
<li>{HOME.notSold}</li>
<li>{HOME.cancel}</li>
</PrivacyList>
<TextLink to="/privacy/">Privacy details</TextLink>
</Privacy>
@@ -110,17 +105,15 @@ const LandingPage = (): JSX.Element => {
<Features id="product" aria-label="Product">
<Feature>
<FeatureTitle>Memory that stays with the work</FeatureTitle>
<FeatureBody>
It remembers what you told it — and your documents — so you never start over.
</FeatureBody>
<FeatureBody>{HOME.memoryFeature}</FeatureBody>
</Feature>
<Feature>
<FeatureTitle>Your documents, stored and searchable</FeatureTitle>
<FeatureBody>Answers grounded in your own files.</FeatureBody>
<FeatureBody>{HOME.documentsFeature}</FeatureBody>
</Feature>
<Feature>
<FeatureTitle>Business accounts</FeatureTitle>
<FeatureBody>Simple management for your team.</FeatureBody>
<FeatureBody>{HOME.businessFeature}</FeatureBody>
</Feature>
</Features>
</PublicSiteLayout>
@@ -21,7 +21,7 @@ describe('PricingPage', () => {
);
expect(screen.getByRole('heading', { name: 'Pricing' })).toBeInTheDocument();
expect(screen.getByText(/\$10\/month, flat\. No ads, no upsells, one price/)).toBeInTheDocument();
expect(screen.getByText(/\$10\/month, flat, with no ads, no upsells, and one price/)).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'See pricing' })).not.toBeInTheDocument();
});
@@ -4,6 +4,7 @@ import styled from 'styled-components';
import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
import { PRICING } from '../../utils/pageProse';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
@@ -29,10 +30,9 @@ const PricingPage = (): JSX.Element => {
return (
<PublicSiteLayout>
<Title>Pricing</Title>
<Price>$10/month, flat. No ads, no upsells, one price.</Price>
<Price>{PRICING.price}</Price>
<Body>
One public plan. Quiet, focused AI chat, your documents, and business accounts for your
team. Hard to justify $20 a month — this is $10.
{PRICING.plan} {PRICING.compare}
</Body>
<GetStarted source="pricing" idPrefix="pricing-waitlist" showPricingLink={false} />
<p>
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
import { PRIVACY } from '../../utils/pageProse';
const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem);
@@ -36,18 +37,13 @@ const PrivacyPage = (): JSX.Element => {
<Section>
<Heading>What is stored</Heading>
<Body>
Your account (email, name, and an optional company), your conversations, and the
documents you upload so answers can use your files. Optional product analytics, if you
accept them, record actions such as sign-in and checkout. They do not include message or
document contents.
{PRIVACY.stored} {PRIVACY.analytics}
</Body>
</Section>
<Section>
<Heading>What we do not do</Heading>
<Body>
We do not sell your information. We do not use advertising cookies. Information you enter
is not shared with third parties without your consent, except to operate the service or
when the law requires it. The{' '}
{PRIVACY.notSold} {PRIVACY.thirdParties} The{' '}
<TextLink to="/terms_of_service/#data-confidentiality">Terms of Service</TextLink> and
the <TextLink to="/terms_of_service/#analytics">analytics section</TextLink> are the
binding description.
@@ -56,12 +52,9 @@ const PrivacyPage = (): JSX.Element => {
<Section>
<Heading>If you cancel</Heading>
<Body>
Canceling stops paid access at the end of the current period. It does not delete
uploaded documents. Documents stay in your workspace until you delete them, or until you
delete the account. Account deletion is a soft-delete: profile details, conversations,
uploaded documents, and authentication records may remain for administration, security,
and audit. There is no automatic hard purge. See{' '}
<TextLink to="/account-deletion/">how account deletion works</TextLink>.
{PRIVACY.cancel} {PRIVACY.documents} {PRIVACY.softDelete} {PRIVACY.purge} See{' '}
<TextLink to="/account-deletion/">how account deletion works</TextLink>, including the
soft-delete steps and what data may remain.
</Body>
</Section>
</PublicSiteLayout>
@@ -10,6 +10,7 @@ import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { trackHeyCatch } from '../../utils/heycatch';
import { checkoutReturnUrls } from '../../utils/finance';
import { isNativePlatform } from '../../platform/nativePlatform';
import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat';
@@ -245,6 +246,7 @@ const SignUp = (): JSX.Element => {
const account = await loadAccount();
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
identifyAccount(account);
trackHeyCatch('signup_completed');
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
console.warn('RevenueCat logIn', err)
);
@@ -6,6 +6,7 @@ import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext';
import { Account } from '../../data';
import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument';
import RelatedPages from '../../components/PublicSite/RelatedPages';
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
@@ -103,13 +104,17 @@ const TermsOfService = ({ publicView = false }: TermsOfServiceProps): JSX.Elemen
return (
<Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}>
{documentBody}
<RelatedPages />
</Box>
);
}
return (
<PageWrapperLayout>
<Box sx={{ margin: '0 auto', width: { xs: '95%', md: '80%' }, py: 2 }}>{documentBody}</Box>
<Box sx={{ margin: '0 auto', width: { xs: '95%', md: '80%' }, py: 2 }}>
{documentBody}
<RelatedPages />
</Box>
</PageWrapperLayout>
);
};
@@ -29,10 +29,55 @@ describe('public crawler files', () => {
expect(llms).not.toContain('<html');
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>');
expect(html.match(/<h1\b/g)).toHaveLength(1);
expect(html).toContain('<h1>Hesychia is a calm AI assistant.</h1>');
expect(html).toContain('id="crawler-prose"');
expect(html).toContain(
'Quiet, focused AI chat — private by default — is how this Hesychia product is built.',
);
expect(html).toContain(
'content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files."',
);
expect(html).toContain('rel="canonical" href="https://hesychia.ai/"');
expect(html).toContain('property="og:title" content="Hesychia — quiet, focused AI chat"');
expect(html).toContain('property="og:image" content="https://hesychia.ai/og-image.png"');
expect(html).toContain('property="og:image:width" content="1200"');
expect(html).toContain('property="og:image:height" content="630"');
expect(html).toContain('name="twitter:card" content="summary_large_image"');
expect(html).toContain('aria-label="Related pages"');
expect(html).toContain('href="/pricing/">Hesychia pricing — $10/month, no ads');
expect(html).toContain('href="/privacy/">What Hesychia stores and does not sell');
expect(html).toContain('href="/account-deletion/">How to request Hesychia account deletion');
expect(html).not.toContain('>here<');
expect(html.match(/application\/ld\+json/g)).toHaveLength(1);
expect(html).toContain('"@type": "WebPage"');
expect(html).toContain('"headline": "Hesychia — quiet, focused AI chat"');
expect(html).toContain('"@type": "Organization"');
expect(html).toContain('"@type": "SoftwareApplication"');
expect(html).toContain('"price": "10.00"');
expect(html).toContain('AI ML Operations, LLC');
});
it('ships a 404 document that is not the app shell', () => {
const notFound = fs.readFileSync(path.join(publicDir, '404.html'), 'utf8');
expect(notFound).toContain('<title>The page is not found — Hesychia</title>');
expect(notFound).toContain('"headline": "The page is not found — Hesychia"');
expect(notFound).toContain('"name": "Hesychia"');
expect(notFound).toContain('"logo": "https://hesychia.ai/apple-touch-icon.png"');
expect(notFound).toContain(
'content="The page is not found. This address is not a Hesychia page. Open the home page to read what the product does, what it costs, and how to start an account."',
);
expect(notFound).toContain('<h1>Not Found</h1>');
expect(notFound).toContain(
'The page is not found, and this address is not a Hesychia page.',
);
expect(notFound).toContain(
'Open the home page to read what the product does, what it costs, and how to start an account.',
);
expect(notFound).not.toContain('id="root"');
expect(notFound).toContain('property="og:image" content="https://hesychia.ai/og-image.png"');
expect(notFound).toContain('name="twitter:card" content="summary_large_image"');
expect(notFound).toContain('href="/">What Hesychia is');
expect(notFound).toContain('aria-label="Related pages"');
});
});
+2
View File
@@ -6,5 +6,7 @@ declare global {
track: (eventName: string, data?: Record<string, unknown>) => void;
identify: (userInfo: Record<string, unknown>) => void;
};
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
}
}
@@ -1,6 +1,12 @@
import { flushAnalyticsQueue, identifyAccount, identifyUser, trackEvent } from './analytics';
import { Account } from '../data';
jest.mock('./heycatch', () => ({
identifyHeyCatch: jest.fn(),
resetHeyCatch: jest.fn(),
trackHeyCatch: jest.fn(),
}));
describe('analytics event helpers', () => {
const originalEnv = process.env;
+2
View File
@@ -1,5 +1,6 @@
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
import type { Account } from '../data';
import { identifyHeyCatch } from './heycatch';
type QueuedCall =
| { type: 'track'; eventName: string; data?: Record<string, unknown> }
@@ -116,6 +117,7 @@ export const identifyUser = (userInfo: Record<string, unknown>): void => {
/** Identify with stable non-sensitive traits only (no names / prompts). */
export const identifyAccount = (account: Account): void => {
identifyHeyCatch(account);
const traits: Record<string, unknown> = {
userId: account.email,
};
+10
View File
@@ -0,0 +1,10 @@
export const ORIGIN: string;
export const OG_IMAGE: string;
export const PAGE_PATHS: readonly string[];
export const PAGE_H1: Readonly<Record<string, string>>;
export function canonicalForPath(pathname: string): string | null;
export function h1ForPath(pathname: string): string | null;
export const PAGE_TITLES: Readonly<Record<string, string>>;
export function titleForPath(pathname: string): string | null;
export const PAGE_DESCRIPTIONS: Readonly<Record<string, string>>;
export function descriptionForPath(pathname: string): string | null;
+166
View File
@@ -0,0 +1,166 @@
/** Public origin. Each page's canonical is this origin plus that page's own path. */
const ORIGIN = 'https://hesychia.ai';
const OG_IMAGE = `${ORIGIN}/og-image.png`;
/** Real routes from App.tsx. `/` is the shared index.html; other paths get their own file at build. */
const PAGE_PATHS = [
'/',
'/pricing/',
'/privacy/',
'/changelog/',
'/terms_of_service/',
'/legal/terms-of-service',
'/account-deletion/',
'/signin/',
'/signup/',
'/auth/callback/',
'/billing/success',
'/billing/cancel',
'/password_reset/',
'/password_reset_confirmation/',
'/set_password/',
'/account/',
'/document_storage',
'/feedback/',
'/analytics/',
];
/**
* The one headline already on that page. Static HTML must use this text;
* the React page renders the same words in its single h1.
* @type {Record<string, string>}
*/
const PAGE_H1 = {
'/': 'Hesychia is a calm AI assistant.',
'/pricing/': 'Pricing',
'/privacy/': 'Privacy',
'/changelog/': 'Changelog',
'/terms_of_service/': 'Terms of Service',
'/legal/terms-of-service': 'Terms of Service',
'/account-deletion/': 'Hesychia — Request account deletion',
'/signin/': 'Hesychia.ai',
'/signup/': 'Sign Up',
'/auth/callback/': 'Sign In',
'/billing/success': 'Payment received',
'/billing/cancel': 'Checkout canceled',
'/password_reset/': 'Reset Password',
'/password_reset_confirmation/': 'Check Your Email',
'/set_password/': 'Hesychia',
'/account/': 'Account — Hesychia',
'/document_storage': 'Documents — Hesychia',
'/feedback/': 'Feedback — Hesychia',
'/analytics/': 'Analytics — Hesychia',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function canonicalForPath(pathname) {
if (!PAGE_PATHS.includes(pathname)) return null;
return `${ORIGIN}${pathname}`;
}
/**
* @param {string} pathname
* @returns {string | null}
*/
function h1ForPath(pathname) {
return PAGE_H1[pathname] || null;
}
/**
* Unique document titles, 30–60 characters as rendered. Main term first.
* Too short or too long throws. Nothing is sliced to fit.
* @type {Record<string, string>}
*/
const PAGE_TITLES = {
'/': 'Hesychia — quiet, focused AI chat',
'/pricing/': 'Hesychia pricing — $10/month, no ads',
'/privacy/': 'Hesychia privacy — stored, never sold',
'/changelog/': 'Hesychia changelog — dated product updates',
'/terms_of_service/': 'Hesychia terms of service — binding terms',
'/legal/terms-of-service': 'Hesychia legal terms — terms of service',
'/account-deletion/': 'Hesychia account deletion — soft-delete',
'/signin/': 'Sign in to Hesychia — quiet, focused chat',
'/signup/': 'Sign up for Hesychia — $10/month, no ads',
'/auth/callback/': 'Hesychia sign-in — returning you to chat',
'/billing/success': 'Hesychia payment received — subscription on',
'/billing/cancel': 'Hesychia checkout canceled — no charge made',
'/password_reset/': 'Reset your Hesychia password — email link',
'/password_reset_confirmation/': 'Hesychia password reset — check your email',
'/set_password/': 'Set a new Hesychia password — account access',
'/account/': 'Hesychia account — profile and subscription',
'/document_storage': 'Hesychia documents — stored and searchable',
'/feedback/': 'Hesychia feedback — tell us what to change',
'/analytics/': 'Hesychia analytics — usage for your account',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function titleForPath(pathname) {
const title = PAGE_TITLES[pathname];
if (!title) return null;
if (title.length < 30 || title.length > 60) {
throw new Error(
`title for ${pathname} is ${title.length} characters (want 30-60), left uncut: ${title}`,
);
}
return title;
}
/**
* What the reader gets on that page, 110–160 characters. No silent trim.
* @type {Record<string, string>}
*/
const PAGE_DESCRIPTIONS = {
'/': 'Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files.',
'/pricing/': '$10/month, flat. No ads, no upsells, one price. One public plan includes AI chat, your documents, and business accounts for your team.',
'/privacy/': 'Hesychia stores your account, conversations, and uploaded documents so answers can use your files. It does not sell your information or use advertising cookies.',
'/changelog/': 'Dated notes of shipped Hesychia changes: conversation memory, account deletion, document upload, and subscriptions you can upgrade, change, or cancel.',
'/terms_of_service/': 'These Terms of Service are a legally binding agreement between you and AI ML Operations, LLC governing access to and use of the Hesychia chat service.',
'/legal/terms-of-service': 'Public copy of the Hesychia Terms of Service, last updated July 27, 2026. The terms govern use of the chat service operated by AI ML Operations, LLC.',
'/account-deletion/': 'Steps to request deletion of a Hesychia account without signing in. Deletion is a soft-delete: profile, conversations, and documents may remain.',
'/signin/': 'Sign in to an existing Hesychia account with email and password, or use the SSO buttons when registration is open. New visitors can create an account.',
'/signup/': 'Create a Hesychia account, then complete payment on Stripe. The public price is $10/month with no ads and no upsells. Sign in if you already have an account.',
'/auth/callback/': 'Hesychia is finishing sign-in and sending you back to the app. If the provider returns an error, this page shows it and links back to the sign-in form.',
'/billing/success': 'Your Hesychia subscription checkout completed and no further payment step is required here. Sign in with the account you just created to continue into the app.',
'/billing/cancel': 'No charge was made. Your Hesychia account may already exist. Sign in and open billing again, or return to sign up to retry Stripe Checkout.',
'/password_reset/': 'Enter the email on your Hesychia account and we will send a link to set a new password. The link opens a page where you choose that password.',
'/password_reset_confirmation/': 'If an account exists for that address, Hesychia sent a link to set a new password. Check the inbox for that email, then return to sign in.',
'/set_password/': 'Choose a new password for your Hesychia account using the link from the reset email. After it is saved, sign in with that password.',
'/account/': 'View the Hesychia account profile and subscription. Upgrade, change, or cancel the plan, and company managers can invite or update team accounts.',
'/document_storage': 'Upload documents to Hesychia and search them from chat. Personal and company libraries stay separate. Files remain until you delete them or delete the account.',
'/feedback/': 'Submit feedback about Hesychia and read feedback you already sent. The form records the note you write. It does not change your subscription or documents.',
'/analytics/': 'See Hesychia usage for your account: prompt volume, conversation volume, team seat activity, and response times. Company managers can open company insights.',
};
/**
* @param {string} pathname
* @returns {string | null}
*/
function descriptionForPath(pathname) {
const description = PAGE_DESCRIPTIONS[pathname];
if (!description) return null;
if (description.length < 110 || description.length > 160) {
throw new Error(
`description for ${pathname} is ${description.length} characters (want 110-160), left uncut: ${description}`,
);
}
return description;
}
module.exports = {
ORIGIN,
OG_IMAGE,
PAGE_PATHS,
PAGE_H1,
PAGE_TITLES,
PAGE_DESCRIPTIONS,
canonicalForPath,
h1ForPath,
titleForPath,
descriptionForPath,
};
+31
View File
@@ -0,0 +1,31 @@
import { analytics } from '@heycatch/sdk';
import type { Account } from '../data';
import { isProductAnalyticsEnabled } from './productAnalytics';
if (isProductAnalyticsEnabled()) {
analytics.init({
projectKey: 'hck_pk_jTFCp-wijbruu8IV50prV0c9W3Sb_kYd',
install: {
framework: 'react',
frameworkVersion: '18',
agent: 'cursor',
},
});
}
export function identifyHeyCatch(account: Account): void {
if (account.id == null) return;
const name = [account.first_name, account.last_name].filter(Boolean).join(' ');
analytics.setIdentity(String(account.id), {
email: account.email,
...(name ? { name } : {}),
});
}
export function resetHeyCatch(): void {
analytics.resetIdentity();
}
export function trackHeyCatch(eventName: string): void {
analytics.trackEvent(eventName);
}
+7
View File
@@ -0,0 +1,7 @@
export type InternalLink = {
href: string;
label: string;
};
export function linksForPath(pathname: string): InternalLink[];
export function relatedNavHtml(pathname: string): string;
+87
View File
@@ -0,0 +1,87 @@
/**
* Neighbours for the public cluster. Anchors name the page; none say "here".
* App routes fall back to the overview's neighbours so a stamped shell is not an orphan.
*/
/** @typedef {{ href: string, label: string }} InternalLink */
/** @type {Record<string, InternalLink[]>} */
const RELATED = {
'/': [
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/changelog/', label: 'Dated Hesychia product updates' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/pricing/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/changelog/', label: 'Dated Hesychia product updates' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
],
'/privacy/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/changelog/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
],
'/terms_of_service/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/account-deletion/', label: 'How to request Hesychia account deletion' },
],
'/account-deletion/': [
{ href: '/', label: 'What Hesychia is' },
{ href: '/privacy/', label: 'What Hesychia stores and does not sell' },
{ href: '/terms_of_service/', label: 'Hesychia terms of service' },
{ href: '/pricing/', label: 'Hesychia pricing — $10/month, no ads' },
],
};
const GENERIC_ANCHOR = /^(here|click here|read more|learn more)$/i;
/**
* @param {string} pathname
* @returns {InternalLink[]}
*/
function linksForPath(pathname) {
if (pathname === '/legal/terms-of-service') return RELATED['/terms_of_service/'];
return RELATED[pathname] || RELATED['/'].slice(0, 4);
}
/**
* @param {string} pathname
* @returns {string}
*/
function relatedNavHtml(pathname) {
const links = linksForPath(pathname);
if (links.length < 2 || links.length > 5) {
throw new Error(`related links for ${pathname} must be 2-5, found ${links.length}`);
}
const inner = links
.map((link) => {
if (link.href === pathname) {
throw new Error(`related nav on ${pathname} links to itself`);
}
if (GENERIC_ANCHOR.test(link.label.trim())) {
throw new Error(`generic anchor on ${pathname}: ${link.label}`);
}
return `<a href="${link.href}">${link.label}</a>`;
})
.join('');
return `<nav aria-label="Related pages">${inner}</nav>`;
}
module.exports = {
RELATED,
linksForPath,
relatedNavHtml,
};
+63
View File
@@ -0,0 +1,63 @@
export const HOME: {
lede: string;
memory: string;
stored: string;
notSold: string;
cancel: string;
memoryFeature: string;
documentsFeature: string;
businessFeature: string;
};
export const PRICING: {
price: string;
plan: string;
compare: string;
};
export const PRIVACY: {
stored: string;
analytics: string;
notSold: string;
thirdParties: string;
binding: string;
cancel: string;
documents: string;
softDelete: string;
purge: string;
};
export const CHANGELOG: {
memory: string;
memoryOff: string;
deletion: string;
documents: string;
subscriptions: string;
};
export const ACCOUNT_DELETION: {
intro: string;
open: string;
signIn: string;
goToAccount: string;
confirm: string;
deactivated: string;
loseAccess: string;
sessions: string;
softDelete: string;
remain: string;
includes: string;
purposes: string;
retention: string;
request: string;
noClaim: string;
contact: string;
email: string;
days: string;
staff: string;
};
export const NOT_FOUND_SENTENCES: readonly string[];
export function sentencesForPath(pathname: string): string[];
export function proseHtml(pathname: string): string;
export function wordCount(sentence: string): number;
+229
View File
@@ -0,0 +1,229 @@
/**
* Visible sentences for each public route. Each one is 10–25 words.
* Wording is the existing page, split or joined. No new claims.
*/
const { PAGE_PATHS } = require('./canonicals');
/** @param {string} sentence */
function wordCount(sentence) {
return sentence
.replace(/[—–]/g, ' ')
.replace(/[^A-Za-z0-9$/'’.+-]+/g, ' ')
.split(/\s+/)
.filter(Boolean).length;
}
const HOME = {
lede: 'Quiet, focused AI chat — private by default — is how this Hesychia product is built.',
memory:
'It remembers what you told it and your documents, so you never start over, and conversation memory is a setting you control.',
stored: 'We store your account, your conversations, and the documents you upload.',
notSold: 'We do not sell your data, and we do not use advertising cookies.',
cancel:
'Canceling does not delete documents, and they stay until you delete them or delete the account, and account deletion is a soft-delete.',
memoryFeature: 'It remembers what you told it and your documents, so you never start over.',
documentsFeature:
'Your documents stay stored and searchable, and answers are grounded in your own files.',
businessFeature:
'Business accounts are simple management for your team, and company managers can manage team accounts.',
};
const PRICING = {
price: '$10/month, flat, with no ads, no upsells, and one price.',
plan: 'One public plan covers quiet, focused AI chat, your documents, and business accounts for your team.',
compare: 'Hard to justify $20 a month, and this is $10.',
};
const PRIVACY = {
stored:
'Your account (email, name, and an optional company), your conversations, and the documents you upload so answers can use your files.',
analytics:
'Optional product analytics, if you accept them, record actions such as sign-in and checkout, and they do not include message or document contents.',
notSold: 'We do not sell your information, and we do not use advertising cookies.',
thirdParties:
'Information you enter is not shared with third parties without your consent, except to operate the service or when the law requires it.',
binding: 'The Terms of Service and the analytics section are the binding description.',
cancel: 'Canceling stops paid access at the end of the current period.',
documents:
'It does not delete uploaded documents, and documents stay in your workspace until you delete them or until you delete the account.',
softDelete:
'Account deletion is a soft-delete of profile details, conversations, uploaded documents, and authentication records.',
purge:
'Those records may remain for administration, security, and audit, and there is no automatic hard purge.',
};
const CHANGELOG = {
memory:
'Account settings can use earlier turns in a chat as context, so you do not re-explain the same project on every message.',
memoryOff: 'It stays off until you turn it on in account settings.',
deletion: 'A public page explains how to delete an account and that deletion is a soft-delete.',
documents:
'Upload documents and search them from chat, and personal and company libraries stay separate.',
subscriptions:
'Upgrade, change, or cancel from the account page, and the public price is $10 a month.',
};
const ACCOUNT_DELETION = {
intro:
'Use the steps below to request deletion of your Hesychia account, and this page does not require sign-in to read.',
open: 'Open Hesychia in the mobile app or at hesychia.ai for your account.',
signIn: 'Sign in with the account you want deleted before you continue.',
goToAccount: 'Go to the Account page after you sign in to Hesychia.',
confirm: 'Choose Delete my account, then confirm by typing your account email.',
deactivated:
'Your account is marked deleted and set inactive, and your conversations are hidden from the product.',
loseAccess: 'You lose access to Hesychia and cannot sign in again with that account.',
sessions: 'Active sessions are invalidated, and the refresh tokens are blacklisted.',
softDelete:
'Hesychia currently uses a soft-delete process, and this is not a full erasure of all stored data.',
remain: 'After you delete your account, personal data associated with the account may remain.',
includes:
'That data includes profile details, conversation content, uploaded documents, RAG vectors, and authentication event records.',
purposes: 'The data may remain for administration, security, and audit purposes.',
retention:
'There is currently no automatic hard-purge schedule, and soft-deleted data is retained until a future purge process is implemented.',
request:
'It is also retained until we process a separate retention or erasure request through the contact path below.',
noClaim: 'We will not claim complete erasure while soft-delete-only remains in effect.',
contact:
'If you cannot sign in to complete in-app deletion, contact AI ML Operations, LLC via aimloperations.com.',
email: 'Include the email address used for your Hesychia account in that request.',
days: 'We aim to respond to account-deletion requests within 30 days.',
staff: 'Staff accounts cannot self-delete in the app, so use the contact path above.',
};
const TERMS = [
'These Terms of Service are a legally binding agreement between you and AI ML Operations, LLC.',
'The agreement governs access to and use of the Hesychia chat service.',
];
const LEGAL_TERMS = [
'This is the public copy of the Hesychia Terms of Service, last updated July 27, 2026.',
'The terms govern use of the chat service operated by AI ML Operations, LLC.',
];
/** @type {Record<string, string[]>} */
const PAGE_SENTENCES = {
'/': Object.values(HOME),
'/pricing/': Object.values(PRICING),
'/privacy/': Object.values(PRIVACY),
'/changelog/': Object.values(CHANGELOG),
'/terms_of_service/': TERMS,
'/legal/terms-of-service': LEGAL_TERMS,
'/account-deletion/': Object.values(ACCOUNT_DELETION),
'/signin/': [
'Sign in to an existing Hesychia account with email and password, or use the SSO buttons when registration is open.',
'New visitors can create an account on the sign-up page.',
],
'/signup/': [
'Create a Hesychia account, then complete payment on Stripe for the public price of $10/month with no ads and no upsells.',
'Sign in on the sign-in page if you already have a Hesychia account.',
],
'/auth/callback/': [
'Hesychia is finishing sign-in and sending you back to the app.',
'If the provider returns an error, this page shows it and links back to the sign-in form.',
],
'/billing/success': [
'Your Hesychia subscription checkout completed and no further payment step is required here.',
'Sign in with the account you just created to continue into the app.',
],
'/billing/cancel': [
'No charge was made, and your Hesychia account may already exist.',
'Sign in and open billing again, or return to sign up to retry Stripe Checkout.',
],
'/password_reset/': [
'Enter the email on your Hesychia account and we will send a link to set a new password.',
'The link opens a page where you choose that password.',
],
'/password_reset_confirmation/': [
'If an account exists for that address, Hesychia sent a link to set a new password.',
'Check the inbox for that email, then return to sign in.',
],
'/set_password/': [
'Choose a new password for your Hesychia account using the link from the reset email.',
'After the new password is saved, sign in with that password.',
],
'/account/': [
'View the Hesychia account profile and subscription on this page.',
'Upgrade, change, or cancel the plan, and company managers can invite or update team accounts.',
],
'/document_storage': [
'Upload documents to Hesychia and search them from chat, and personal and company libraries stay separate.',
'Files remain until you delete them or delete the account.',
],
'/feedback/': [
'Submit feedback about Hesychia and read feedback you already sent.',
'The form records the note you write, and it does not change your subscription or documents.',
],
'/analytics/': [
'See Hesychia usage for your account, including prompt volume, conversation volume, team seat activity, and response times.',
'Company managers can open the company insights for this account.',
],
};
const NOT_FOUND_SENTENCES = [
'The page is not found, and this address is not a Hesychia page.',
'Open the home page to read what the product does, what it costs, and how to start an account.',
];
/**
* @param {string} pathname
* @param {string[]} sentences
*/
function assertSentenceBand(pathname, sentences) {
if (sentences.length < 2) {
throw new Error(`${pathname} needs at least two sentences`);
}
const counts = sentences.map(wordCount);
counts.forEach((count, index) => {
if (count < 10 || count > 25) {
throw new Error(
`${pathname} sentence ${index + 1} is ${count} words (want 10-25): ${sentences[index]}`,
);
}
});
const average = counts.reduce((sum, count) => sum + count, 0) / counts.length;
if (average < 10 || average > 25) {
throw new Error(`${pathname} average sentence length is ${average}`);
}
}
for (const pathname of PAGE_PATHS) {
const sentences = PAGE_SENTENCES[pathname];
if (!sentences) throw new Error(`no prose for ${pathname}`);
assertSentenceBand(pathname, sentences);
}
assertSentenceBand('/404.html', NOT_FOUND_SENTENCES);
/**
* @param {string} pathname
* @returns {string[]}
*/
function sentencesForPath(pathname) {
const sentences = PAGE_SENTENCES[pathname];
if (!sentences) throw new Error(`no prose for ${pathname}`);
return sentences;
}
/**
* @param {string} pathname
* @returns {string}
*/
function proseHtml(pathname) {
const inner = sentencesForPath(pathname)
.map((sentence) => `<p>${sentence}</p>`)
.join('');
return `<div id="crawler-prose">${inner}</div>`;
}
module.exports = {
HOME,
PRICING,
PRIVACY,
CHANGELOG,
ACCOUNT_DELETION,
NOT_FOUND_SENTENCES,
sentencesForPath,
proseHtml,
wordCount,
};
@@ -0,0 +1,9 @@
import { Capacitor } from '@capacitor/core';
/** Production website, or the iOS / Android app. Beta and local web stay off. */
export function isProductAnalyticsEnabled(): boolean {
const platform = Capacitor.getPlatform();
const nativeApp = platform === 'ios' || platform === 'android';
const productionWeb = process.env.REACT_APP_DEPLOY_ENV === 'production';
return nativeApp || productionWeb;
}
+40 -7
View File
@@ -1,9 +1,12 @@
import { PAGE_PATHS } from './canonicals';
import { applyRouteMeta, metaForPath } from './routeMeta';
describe('metaForPath', () => {
it('uses the marketing title for anonymous home', () => {
expect(metaForPath('/', false)).toEqual({
title: 'Hesychia — quiet, focused AI chat',
description:
'Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files.',
canonical: 'https://hesychia.ai/',
});
});
@@ -14,12 +17,20 @@ describe('metaForPath', () => {
it('gives public pages their own titles and canonicals', () => {
expect(metaForPath('/pricing/', false)).toEqual({
title: 'Pricing — Hesychia',
title: 'Hesychia pricing — $10/month, no ads',
description:
'$10/month, flat. No ads, no upsells, one price. One public plan includes AI chat, your documents, and business accounts for your team.',
canonical: 'https://hesychia.ai/pricing/',
});
expect(metaForPath('/privacy/', false)?.canonical).toBe('https://hesychia.ai/privacy/');
expect(metaForPath('/changelog/', false)?.title).toBe('Changelog — Hesychia');
expect(metaForPath('/signin/', false)?.canonical).toBeNull();
expect(metaForPath('/changelog/', false)?.title).toBe('Hesychia changelog — dated product updates');
expect(metaForPath('/signin/', false)?.canonical).toBe('https://hesychia.ai/signin/');
});
it('gives every real page a self-referencing canonical', () => {
for (const pathname of PAGE_PATHS) {
expect(metaForPath(pathname, false)?.canonical).toBe(`https://hesychia.ai${pathname}`);
}
});
it('does not invent a title for an unknown path', () => {
@@ -30,21 +41,43 @@ describe('metaForPath', () => {
describe('applyRouteMeta', () => {
afterEach(() => {
document.title = '';
document.head.querySelectorAll('meta').forEach((node) => node.remove());
document.querySelector('link[rel="canonical"]')?.remove();
});
it('sets the title and canonical', () => {
applyRouteMeta({ title: 'Pricing — Hesychia', canonical: 'https://hesychia.ai/pricing/' });
it('sets the title, canonical, and social card tags', () => {
applyRouteMeta({
title: 'Pricing — Hesychia',
description: 'Pricing page description that states the public plan.',
canonical: 'https://hesychia.ai/pricing/',
});
expect(document.title).toBe('Pricing — Hesychia');
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
'https://hesychia.ai/pricing/',
);
expect(document.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'Pricing — Hesychia',
);
expect(document.querySelector('meta[property="og:image"]')?.getAttribute('content')).toBe(
'https://hesychia.ai/og-image.png',
);
expect(document.querySelector('meta[name="twitter:card"]')?.getAttribute('content')).toBe(
'summary_large_image',
);
expect(document.querySelector('meta[property="og:url"]')?.getAttribute('content')).toBe(
'https://hesychia.ai/pricing/',
);
});
it('removes a canonical when the route should not have one', () => {
applyRouteMeta({ title: 'Pricing — Hesychia', canonical: 'https://hesychia.ai/pricing/' });
applyRouteMeta({ title: 'Sign in — Hesychia', canonical: null });
applyRouteMeta({
title: 'Pricing — Hesychia',
description: null,
canonical: 'https://hesychia.ai/pricing/',
});
applyRouteMeta({ title: 'Sign in — Hesychia', description: null, canonical: null });
expect(document.title).toBe('Sign in — Hesychia');
expect(document.querySelector('link[rel="canonical"]')).toBeNull();
expect(document.querySelector('meta[property="og:url"]')).toBeNull();
});
});
+66 -61
View File
@@ -1,86 +1,90 @@
import { canonicalForPath, descriptionForPath, OG_IMAGE, titleForPath } from './canonicals';
export type RouteMeta = {
title: string;
/** Null leaves the current document title alone. */
title: string | null;
/** Null leaves the current meta description alone. */
description: string | null;
/** Absolute canonical URL. Null means this path should not advertise a canonical. */
canonical: string | null;
};
const ORIGIN = 'https://hesychia.ai';
const page = (pathname: string): RouteMeta => ({
title: titleForPath(pathname),
description: descriptionForPath(pathname),
canonical: canonicalForPath(pathname),
});
const PUBLIC_PAGES: Record<string, RouteMeta> = {
'/': {
title: 'Hesychia — quiet, focused AI chat',
canonical: `${ORIGIN}/`,
},
'/pricing/': {
title: 'Pricing — Hesychia',
canonical: `${ORIGIN}/pricing/`,
},
'/privacy/': {
title: 'Privacy — Hesychia',
canonical: `${ORIGIN}/privacy/`,
},
'/changelog/': {
title: 'Changelog — Hesychia',
canonical: `${ORIGIN}/changelog/`,
},
'/terms_of_service/': {
title: 'Terms of Service — Hesychia',
canonical: `${ORIGIN}/terms_of_service/`,
},
'/legal/terms-of-service': {
title: 'Terms of Service — Hesychia',
canonical: `${ORIGIN}/legal/terms-of-service`,
},
'/account-deletion/': {
title: 'Account deletion — Hesychia',
canonical: `${ORIGIN}/account-deletion/`,
},
'/signin/': {
title: 'Sign in — Hesychia',
canonical: null,
},
'/signup/': {
title: 'Create account — Hesychia',
canonical: null,
},
'/password_reset/': {
title: 'Reset password — Hesychia',
canonical: null,
},
'/account/': {
title: 'Account — Hesychia',
canonical: null,
},
'/document_storage': {
title: 'Documents — Hesychia',
canonical: null,
},
'/feedback/': {
title: 'Feedback — Hesychia',
canonical: null,
},
'/analytics/': {
title: 'Analytics — Hesychia',
canonical: null,
},
'/': page('/'),
'/pricing/': page('/pricing/'),
'/privacy/': page('/privacy/'),
'/changelog/': page('/changelog/'),
'/terms_of_service/': page('/terms_of_service/'),
'/legal/terms-of-service': page('/legal/terms-of-service'),
'/account-deletion/': page('/account-deletion/'),
'/signin/': page('/signin/'),
'/signup/': page('/signup/'),
'/auth/callback/': page('/auth/callback/'),
'/billing/success': page('/billing/success'),
'/billing/cancel': page('/billing/cancel'),
'/password_reset/': page('/password_reset/'),
'/password_reset_confirmation/': page('/password_reset_confirmation/'),
'/set_password/': page('/set_password/'),
'/account/': page('/account/'),
'/document_storage': page('/document_storage'),
'/feedback/': page('/feedback/'),
'/analytics/': page('/analytics/'),
};
/** Title and canonical for a path. Signed-in `/` keeps the app title. */
export function metaForPath(pathname: string, authenticated: boolean): RouteMeta | null {
if (pathname === '/' && authenticated) {
return { title: 'Hesychia', canonical: `${ORIGIN}/` };
return { title: 'Hesychia', description: null, canonical: canonicalForPath('/') };
}
return PUBLIC_PAGES[pathname] ?? null;
const known = PUBLIC_PAGES[pathname];
if (known) return known;
const canonical = canonicalForPath(pathname);
if (!canonical) return null;
return { title: null, description: null, canonical };
}
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
const selector = `meta[${attr}="${key}"]`;
const current = document.querySelector<HTMLMetaElement>(selector);
const el = current ?? document.createElement('meta');
el.setAttribute(attr, key);
el.content = content;
if (!current) document.head.appendChild(el);
}
export function applyRouteMeta(meta: RouteMeta | null): void {
if (!meta || typeof document === 'undefined') return;
document.title = meta.title;
if (meta.title) {
document.title = meta.title;
upsertMeta('property', 'og:title', meta.title);
upsertMeta('name', 'twitter:title', meta.title);
}
if (meta.description) {
const current = document.querySelector<HTMLMetaElement>('meta[name="description"]');
const description = current ?? document.createElement('meta');
description.name = 'description';
description.content = meta.description;
if (!current) document.head.appendChild(description);
upsertMeta('property', 'og:description', meta.description);
upsertMeta('name', 'twitter:description', meta.description);
}
upsertMeta('property', 'og:image', OG_IMAGE);
upsertMeta('name', 'twitter:image', OG_IMAGE);
upsertMeta('name', 'twitter:card', 'summary_large_image');
const existing = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!meta.canonical) {
existing?.parentNode?.removeChild(existing);
document.querySelector('meta[property="og:url"]')?.remove();
return;
}
@@ -90,4 +94,5 @@ export function applyRouteMeta(meta: RouteMeta | null): void {
if (!existing) {
document.head.appendChild(link);
}
upsertMeta('property', 'og:url', meta.canonical);
}
+4
View File
@@ -0,0 +1,4 @@
export function graphForPath(pathname: string): object;
export function jsonLdHtml(pathname: string): string;
export function notFoundJsonLd(): object;
export function changelogEntries(): { date: string; title: string }[];
+143
View File
@@ -0,0 +1,143 @@
/**
* One JSON-LD block per route, built from that route's title.
* Homepage also carries the site Organization and the product.
* Changelog notes are the only dated posts, so they are BlogPostings.
* No sameAs: the company site lists no public profiles.
*/
const fs = require('fs');
const path = require('path');
const { ORIGIN, descriptionForPath, titleForPath } = require('./canonicals');
const COMPANY = {
'@type': 'Organization',
name: 'AI ML Operations, LLC',
url: 'https://www.aimloperations.com',
};
/** Brand spelling used in Organization markup. Do not abbreviate. */
const ORGANIZATION_NAME = 'Hesychia';
const ORGANIZATION_URL = 'https://hesychia.ai';
const ORGANIZATION_LOGO = `${ORIGIN}/apple-touch-icon.png`;
/**
* Profiles the company actually owns. Empty on purpose: the site and
* aimloperations.com do not link a LinkedIn, GitHub, or X profile.
* @type {string[]}
*/
const ORGANIZATION_SAME_AS = [];
function changelogEntries() {
const file = path.join(__dirname, '../pages/Changelog/changelogEntries.ts');
const source = fs.readFileSync(file, 'utf8');
const entries = [...source.matchAll(/date:\s*'(\d{4}-\d{2}-\d{2})',\s*title:\s*'([^']+)'/g)].map(
(match) => ({ date: match[1], title: match[2] }),
);
if (entries.length === 0) {
throw new Error('changelog entries missing for JSON-LD');
}
return entries;
}
function webPage(pathname) {
const title = titleForPath(pathname);
if (!title) throw new Error(`no title for JSON-LD ${pathname}`);
const url = `${ORIGIN}${pathname}`;
return {
'@type': 'WebPage',
headline: title,
url,
mainEntityOfPage: url,
};
}
function organization() {
const org = {
'@type': 'Organization',
name: ORGANIZATION_NAME,
url: ORGANIZATION_URL,
logo: ORGANIZATION_LOGO,
parentOrganization: COMPANY,
};
if (ORGANIZATION_SAME_AS.length > 0) {
org.sameAs = ORGANIZATION_SAME_AS;
}
return org;
}
function softwareApplication() {
return {
'@type': 'SoftwareApplication',
name: 'Hesychia',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
url: `${ORIGIN}/`,
description: descriptionForPath('/'),
offers: {
'@type': 'Offer',
price: '10.00',
priceCurrency: 'USD',
},
provider: COMPANY,
};
}
function blogPostings() {
return changelogEntries().map((entry) => ({
'@type': 'BlogPosting',
headline: entry.title,
author: COMPANY,
datePublished: entry.date,
dateModified: entry.date,
mainEntityOfPage: `${ORIGIN}/changelog/`,
}));
}
/**
* @param {string} pathname
* @returns {object}
*/
function graphForPath(pathname) {
const graph = [webPage(pathname), organization()];
if (pathname === '/') {
graph.push(softwareApplication());
}
if (pathname === '/changelog/') {
graph.push(...blogPostings());
}
return {
'@context': 'https://schema.org',
'@graph': graph,
};
}
/**
* @param {string} pathname
* @returns {string}
*/
function jsonLdHtml(pathname) {
return `<script type="application/ld+json">\n${JSON.stringify(graphForPath(pathname), null, 2)}\n</script>`;
}
function notFoundJsonLd() {
const url = `${ORIGIN}/404.html`;
const headline = 'The page is not found — Hesychia';
return {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebPage',
headline,
url,
mainEntityOfPage: url,
},
organization(),
],
};
}
module.exports = {
graphForPath,
jsonLdHtml,
notFoundJsonLd,
changelogEntries,
};