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/cache": "^11.14.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@heycatch/sdk": "^0.8.3",
"@mui/icons-material": "^5.16.11", "@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11", "@mui/material": "^5.16.11",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
@@ -3083,6 +3084,103 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -5155,6 +5253,21 @@
"url": "https://opencollective.com/popperjs" "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": { "node_modules/@prettier/plugin-xml": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-2.2.0.tgz", "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/keyboard": "^7.0.6",
"@capacitor/preferences": "^7.0.4", "@capacitor/preferences": "^7.0.4",
"@capacitor/status-bar": "^7.0.6", "@capacitor/status-bar": "^7.0.6",
"native-store-billing": "file:plugins/native-store-billing",
"@emotion/cache": "^11.14.0", "@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@heycatch/sdk": "^0.8.3",
"@mui/icons-material": "^5.16.11", "@mui/icons-material": "^5.16.11",
"@mui/material": "^5.16.11", "@mui/material": "^5.16.11",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
@@ -34,6 +34,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-to-jsx": "^7.7.2", "markdown-to-jsx": "^7.7.2",
"mini.css": "^3.0.1", "mini.css": "^3.0.1",
"native-store-billing": "file:plugins/native-store-billing",
"papaparse": "^5.5.4", "papaparse": "^5.5.4",
"pdfmake": "^0.3.11", "pdfmake": "^0.3.11",
"react-bootstrap": "^2.10.6", "react-bootstrap": "^2.10.6",
@@ -53,9 +54,9 @@
}, },
"scripts": { "scripts": {
"start": "NODE_ENV=development react-scripts start", "start": "NODE_ENV=development react-scripts start",
"build": "NODE_ENV=production react-scripts build", "build": "node scripts/writeSitemap.js && NODE_ENV=production react-scripts build && node scripts/stampCanonicals.js",
"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: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": "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: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", "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:open": "npx cap open android",
"android:sync": "npx cap sync android", "android:sync": "npx cap sync android",
@@ -63,7 +64,7 @@
"ios:sync": "npx cap sync ios", "ios:sync": "npx cap sync ios",
"assets:generate": "npx capacitor-assets generate --ios --android", "assets:generate": "npx capacitor-assets generate --ios --android",
"test": "NODE_ENV=development react-scripts test", "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" "eject": "react-scripts eject"
}, },
"eslintConfig": { "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" /> <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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" /> <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" /> <meta name="application-name" content="Hesychia" />
<link rel="canonical" href="https://hesychia.ai/" /> <link rel="canonical" href="https://hesychia.ai/" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Hesychia — quiet, focused AI chat" /> <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: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"> <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", "@type": "Organization",
"name": "Hesychia", "name": "Hesychia",
"url": "https://hesychia.ai/", "url": "https://hesychia.ai",
"logo": "https://hesychia.ai/apple-touch-icon.png", "logo": "https://hesychia.ai/apple-touch-icon.png",
"parentOrganization": { "parentOrganization": {
"@type": "Organization", "@type": "Organization",
"name": "AI ML Operations, LLC", "name": "AI ML Operations, LLC",
"url": "https://www.aimloperations.com" "url": "https://www.aimloperations.com"
} }
} },
</script>
<script type="application/ld+json">
{ {
"@context": "https://schema.org",
"@type": "SoftwareApplication", "@type": "SoftwareApplication",
"name": "Hesychia", "name": "Hesychia",
"applicationCategory": "BusinessApplication", "applicationCategory": "BusinessApplication",
"operatingSystem": "Web", "operatingSystem": "Web",
"url": "https://hesychia.ai/", "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": { "offers": {
"@type": "Offer", "@type": "Offer",
"price": "10.00", "price": "10.00",
@@ -49,7 +61,9 @@
"url": "https://www.aimloperations.com" "url": "https://www.aimloperations.com"
} }
} }
</script> ]
}
</script>
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" /> <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 manifest.json provides metadata used when your web app is installed on a
@@ -69,7 +83,7 @@
</head> </head>
<body> <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. This HTML file is a template.
If you open it directly in the browser, you will see an empty page. If you open it directly in the browser, you will see an empty page.
+2 -2
View File
@@ -1,8 +1,8 @@
# Hesychia # 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 ## 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"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://hesychia.ai/</loc> <loc>https://hesychia.ai/</loc>
<lastmod>2026-10-17</lastmod> <lastmod>2026-10-19</lastmod>
</url> </url>
<url> <url>
<loc>https://hesychia.ai/pricing/</loc> <loc>https://hesychia.ai/pricing/</loc>
<lastmod>2026-10-17</lastmod> <lastmod>2026-10-19</lastmod>
</url> </url>
<url> <url>
<loc>https://hesychia.ai/privacy/</loc> <loc>https://hesychia.ai/privacy/</loc>
<lastmod>2026-10-17</lastmod> <lastmod>2026-10-19</lastmod>
</url> </url>
<url> <url>
<loc>https://hesychia.ai/changelog/</loc> <loc>https://hesychia.ai/changelog/</loc>
<lastmod>2026-10-17</lastmod> <lastmod>2026-08-04</lastmod>
</url> </url>
<url> <url>
<loc>https://hesychia.ai/terms_of_service/</loc> <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 React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
@@ -3,6 +3,7 @@ import { AccountContext } from '../../contexts/AccountContext';
import { AuthContext } from '../../contexts/AuthContext'; import { AuthContext } from '../../contexts/AuthContext';
import { useAnalyticsConsent } from '../../contexts/AnalyticsConsentContext'; import { useAnalyticsConsent } from '../../contexts/AnalyticsConsentContext';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { identifyHeyCatch, resetHeyCatch } from '../../utils/heycatch';
/** /**
* Identify on authenticated session; track Logout when session ends. * Identify on authenticated session; track Logout when session ends.
@@ -15,7 +16,18 @@ const AnalyticsSession = (): null => {
const wasAuthenticatedRef = useRef<boolean | null>(null); const wasAuthenticatedRef = useRef<boolean | null>(null);
useEffect(() => { 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) { if (account) {
identifyAccount(account); identifyAccount(account);
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import ParticleBackground from '../ParticleBackground/ParticleBackground'; import ParticleBackground from '../ParticleBackground/ParticleBackground';
import RelatedPages from './RelatedPages';
import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav'; import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav';
const text = ({ theme }: { theme?: { colors?: { text?: string } } }) => const text = ({ theme }: { theme?: { colors?: { text?: string } } }) =>
@@ -111,7 +112,10 @@ const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => {
<SignInLink to="/signin/">Sign in</SignInLink> <SignInLink to="/signin/">Sign in</SignInLink>
</Nav> </Nav>
</Header> </Header>
<Main>{children}</Main> <Main>
{children}
<RelatedPages />
</Main>
<FooterBar> <FooterBar>
<FooterNav aria-label="Footer"> <FooterNav aria-label="Footer">
{PUBLIC_FOOTER_LINKS.map((link) => ( {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 React from 'react';
import { Capacitor } from '@capacitor/core';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import Tracker from './Tracker'; import Tracker from './Tracker';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-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', () => { describe('Tracker', () => {
const originalEnv = process.env; const originalEnv = process.env;
beforeEach(() => { beforeEach(() => {
(Capacitor.getPlatform as jest.Mock).mockReturnValue('web');
document.body.innerHTML = ''; document.body.innerHTML = '';
document.head.innerHTML = '';
process.env = { process.env = {
...originalEnv, ...originalEnv,
REACT_APP_DEPLOY_ENV: 'production', REACT_APP_DEPLOY_ENV: 'production',
@@ -28,6 +39,11 @@ describe('Tracker', () => {
expect(script).not.toBeNull(); expect(script).not.toBeNull();
expect(script?.src).toBe('https://tianji.aimloperations.com/tracker.js'); expect(script?.src).toBe('https://tianji.aimloperations.com/tracker.js');
expect(script?.getAttribute('data-website-id')).toBe('cm7x7m52m03kbddswbswrt17y'); 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', () => { it('loads distinct website id for beta builds', () => {
@@ -41,6 +57,21 @@ describe('Tracker', () => {
const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null; const script = document.getElementById(TRACKER_SCRIPT_ID) as HTMLScriptElement | null;
expect(script?.getAttribute('data-website-id')).toBe('beta-website-id-test'); 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', () => { it('does not load Tianji tracker outside analytics environments', () => {
@@ -54,5 +85,6 @@ describe('Tracker', () => {
render(<Tracker />); render(<Tracker />);
expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull(); expect(document.getElementById(TRACKER_SCRIPT_ID)).toBeNull();
expect(document.getElementById(GTAG_SCRIPT_ID)).toBeNull();
}); });
}); });
@@ -1,8 +1,28 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { flushAnalyticsQueue } from '../../utils/analytics'; import { flushAnalyticsQueue } from '../../utils/analytics';
import { isAnalyticsEnvironment } from '../../utils/analyticsConsent'; import { isAnalyticsEnvironment } from '../../utils/analyticsConsent';
import { isProductAnalyticsEnabled } from '../../utils/productAnalytics';
const TRACKER_SCRIPT_ID = 'hesychia-tianji-tracker'; 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. * 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 = () => { const Tracker: React.FC = () => {
useEffect(() => { useEffect(() => {
if (!isAnalyticsEnvironment()) return; if (isProductAnalyticsEnabled()) {
loadGoogleTag();
}
const websiteId = process.env.REACT_APP_TIANJI_WEBSITE_ID || ''; 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'); document.body.appendChild(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);
return () => { return () => {
const loaded = document.getElementById(TRACKER_SCRIPT_ID); const loaded = document.getElementById(TRACKER_SCRIPT_ID);
if (loaded?.parentNode) { if (loaded?.parentNode) {
loaded.parentNode.removeChild(loaded); 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 React from 'react';
import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material'; import { Box, Button, Card, CardContent, Divider, Stack, Typography } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom'; 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). * 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 Hesychia (ai.hesychia.chat) · hesychia.ai · AI ML Operations, LLC
</Typography> </Typography>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
Use the steps below to request deletion of your Hesychia account. This page does not {ACCOUNT_DELETION.intro}
require sign-in to read.
</Typography> </Typography>
</CardContent> </CardContent>
@@ -35,17 +36,12 @@ const AccountDeletionPage = (): JSX.Element => (
Open <strong>Hesychia</strong> in the mobile app or at{' '} Open <strong>Hesychia</strong> in the mobile app or at{' '}
<a href="https://hesychia.ai" rel="noopener noreferrer"> <a href="https://hesychia.ai" rel="noopener noreferrer">
hesychia.ai hesychia.ai
</a> </a>{' '}
. for your account.
</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.
</li> </li>
<li>{ACCOUNT_DELETION.signIn}</li>
<li>{ACCOUNT_DELETION.goToAccount}</li>
<li>{ACCOUNT_DELETION.confirm}</li>
</Typography> </Typography>
</Box> </Box>
@@ -58,31 +54,24 @@ const AccountDeletionPage = (): JSX.Element => (
Deactivated immediately Deactivated immediately
</Typography> </Typography>
<Typography variant="body1" component="ul" sx={{ pl: 2.5, m: 0 }}> <Typography variant="body1" component="ul" sx={{ pl: 2.5, m: 0 }}>
<li>Your account is marked deleted and set inactive.</li> <li>{ACCOUNT_DELETION.deactivated}</li>
<li>You lose access to Hesychia and cannot sign in again with that account.</li> <li>{ACCOUNT_DELETION.loseAccess}</li>
<li>Your conversations are hidden from the product.</li> <li>{ACCOUNT_DELETION.sessions}</li>
<li>Active sessions are invalidated (refresh tokens are blacklisted).</li>
</Typography> </Typography>
<Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}> <Typography variant="h6" component="h3" gutterBottom sx={{ mt: 2 }}>
Data retained after soft-delete Data retained after soft-delete
</Typography> </Typography>
<Typography variant="body1" paragraph> <Typography variant="body1" paragraph>
Hesychia currently uses a <strong>soft-delete</strong> process. After you delete your {ACCOUNT_DELETION.softDelete} {ACCOUNT_DELETION.remain} {ACCOUNT_DELETION.includes}{' '}
account, personal data associated with the account (including profile details, {ACCOUNT_DELETION.purposes}
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.
</Typography> </Typography>
<Typography variant="h6" component="h3" gutterBottom> <Typography variant="h6" component="h3" gutterBottom>
Retention period Retention period
</Typography> </Typography>
<Typography variant="body1" paragraph sx={{ mb: 0 }}> <Typography variant="body1" paragraph sx={{ mb: 0 }}>
There is currently <strong>no automatic hard-purge schedule</strong>. Soft-deleted {ACCOUNT_DELETION.retention} {ACCOUNT_DELETION.request} {ACCOUNT_DELETION.noClaim}
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.
</Typography> </Typography>
</Box> </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{' '} 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"> <a href="https://www.aimloperations.com" rel="noopener noreferrer">
aimloperations.com aimloperations.com
</a>{' '} </a>
and include the email address used for your Hesychia account. We aim to respond to . {ACCOUNT_DELETION.email} {ACCOUNT_DELETION.days}
account-deletion requests within <strong>30 days</strong>.
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Staff accounts cannot self-delete in the app; use the contact path above. {ACCOUNT_DELETION.staff}
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
@@ -124,6 +112,7 @@ const AccountDeletionPage = (): JSX.Element => (
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
<RelatedPages />
</Box> </Box>
); );
@@ -1,3 +1,5 @@
import { CHANGELOG } from '../../utils/pageProse';
export type ChangelogEntry = { export type ChangelogEntry = {
date: string; date: string;
title: string; title: string;
@@ -9,21 +11,21 @@ export const CHANGELOG_ENTRIES: readonly ChangelogEntry[] = [
{ {
date: '2026-08-04', date: '2026-08-04',
title: 'Conversation memory', 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', date: '2026-08-04',
title: 'Account deletion', 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', date: '2026-08-01',
title: 'Documents', title: 'Documents',
note: 'Upload documents and search them from chat. Personal and company libraries stay separate.', note: CHANGELOG.documents,
}, },
{ {
date: '2026-08-01', date: '2026-08-01',
title: 'Subscriptions', 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.getByText(/Quiet, focused AI chat — private by default/i)).toBeInTheDocument();
expect(screen.getByAltText('Hesychia logo')).toBeInTheDocument(); expect(screen.getByAltText('Hesychia logo')).toBeInTheDocument();
expect(screen.getByText(/Your documents, stored and searchable/i)).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(/We do not sell your data/i)).toBeInTheDocument();
expect(screen.getByText(/Canceling does not delete documents/i)).toBeInTheDocument(); expect(screen.getByText(/Canceling does not delete documents/i)).toBeInTheDocument();
expect(screen.queryByText(/founder/i)).not.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 PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import UsageCounts from '../../components/PublicSite/UsageCounts'; import UsageCounts from '../../components/PublicSite/UsageCounts';
import { publicAccent, publicCard, publicLine, publicText } from '../../components/PublicSite/publicColors'; import { publicAccent, publicCard, publicLine, publicText } from '../../components/PublicSite/publicColors';
import { HOME } from '../../utils/pageProse';
const Hero = styled.header` const Hero = styled.header`
max-width: 40rem; max-width: 40rem;
@@ -87,21 +88,15 @@ const LandingPage = (): JSX.Element => {
<Hero> <Hero>
<Mark src={hesychiaMark} alt="Hesychia logo" /> <Mark src={hesychiaMark} alt="Hesychia logo" />
<Title>Hesychia is a calm AI assistant.</Title> <Title>Hesychia is a calm AI assistant.</Title>
<Lede>Quiet, focused AI chat — private by default.</Lede> <Lede>{HOME.lede}</Lede>
<Memory> <Memory>{HOME.memory}</Memory>
It remembers what you told it — and your documents — so you never start over.
Conversation memory is a setting you control.
</Memory>
<GetStarted source="landing" /> <GetStarted source="landing" />
<Privacy aria-label="Privacy"> <Privacy aria-label="Privacy">
<h2>What we do with your data</h2> <h2>What we do with your data</h2>
<PrivacyList> <PrivacyList>
<li>We store your account, your conversations, and the documents you upload.</li> <li>{HOME.stored}</li>
<li>We do not sell your data, and we do not use advertising cookies.</li> <li>{HOME.notSold}</li>
<li> <li>{HOME.cancel}</li>
Canceling does not delete documents. They stay until you delete them or delete the
account. Account deletion is a soft-delete.
</li>
</PrivacyList> </PrivacyList>
<TextLink to="/privacy/">Privacy details</TextLink> <TextLink to="/privacy/">Privacy details</TextLink>
</Privacy> </Privacy>
@@ -110,17 +105,15 @@ const LandingPage = (): JSX.Element => {
<Features id="product" aria-label="Product"> <Features id="product" aria-label="Product">
<Feature> <Feature>
<FeatureTitle>Memory that stays with the work</FeatureTitle> <FeatureTitle>Memory that stays with the work</FeatureTitle>
<FeatureBody> <FeatureBody>{HOME.memoryFeature}</FeatureBody>
It remembers what you told it — and your documents — so you never start over.
</FeatureBody>
</Feature> </Feature>
<Feature> <Feature>
<FeatureTitle>Your documents, stored and searchable</FeatureTitle> <FeatureTitle>Your documents, stored and searchable</FeatureTitle>
<FeatureBody>Answers grounded in your own files.</FeatureBody> <FeatureBody>{HOME.documentsFeature}</FeatureBody>
</Feature> </Feature>
<Feature> <Feature>
<FeatureTitle>Business accounts</FeatureTitle> <FeatureTitle>Business accounts</FeatureTitle>
<FeatureBody>Simple management for your team.</FeatureBody> <FeatureBody>{HOME.businessFeature}</FeatureBody>
</Feature> </Feature>
</Features> </Features>
</PublicSiteLayout> </PublicSiteLayout>
@@ -21,7 +21,7 @@ describe('PricingPage', () => {
); );
expect(screen.getByRole('heading', { name: 'Pricing' })).toBeInTheDocument(); 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(await screen.findByRole('button', { name: 'Join the waitlist' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'See pricing' })).not.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 GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout'; import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors'; import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
import { PRICING } from '../../utils/pageProse';
const Title = styled.h1` const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem); font-size: clamp(2rem, 4vw, 2.75rem);
@@ -29,10 +30,9 @@ const PricingPage = (): JSX.Element => {
return ( return (
<PublicSiteLayout> <PublicSiteLayout>
<Title>Pricing</Title> <Title>Pricing</Title>
<Price>$10/month, flat. No ads, no upsells, one price.</Price> <Price>{PRICING.price}</Price>
<Body> <Body>
One public plan. Quiet, focused AI chat, your documents, and business accounts for your {PRICING.plan} {PRICING.compare}
team. Hard to justify $20 a month — this is $10.
</Body> </Body>
<GetStarted source="pricing" idPrefix="pricing-waitlist" showPricingLink={false} /> <GetStarted source="pricing" idPrefix="pricing-waitlist" showPricingLink={false} />
<p> <p>
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout'; import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors'; import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
import { PRIVACY } from '../../utils/pageProse';
const Title = styled.h1` const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem); font-size: clamp(2rem, 4vw, 2.75rem);
@@ -36,18 +37,13 @@ const PrivacyPage = (): JSX.Element => {
<Section> <Section>
<Heading>What is stored</Heading> <Heading>What is stored</Heading>
<Body> <Body>
Your account (email, name, and an optional company), your conversations, and the {PRIVACY.stored} {PRIVACY.analytics}
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.
</Body> </Body>
</Section> </Section>
<Section> <Section>
<Heading>What we do not do</Heading> <Heading>What we do not do</Heading>
<Body> <Body>
We do not sell your information. We do not use advertising cookies. Information you enter {PRIVACY.notSold} {PRIVACY.thirdParties} The{' '}
is not shared with third parties without your consent, except to operate the service or
when the law requires it. The{' '}
<TextLink to="/terms_of_service/#data-confidentiality">Terms of Service</TextLink> and <TextLink to="/terms_of_service/#data-confidentiality">Terms of Service</TextLink> and
the <TextLink to="/terms_of_service/#analytics">analytics section</TextLink> are the the <TextLink to="/terms_of_service/#analytics">analytics section</TextLink> are the
binding description. binding description.
@@ -56,12 +52,9 @@ const PrivacyPage = (): JSX.Element => {
<Section> <Section>
<Heading>If you cancel</Heading> <Heading>If you cancel</Heading>
<Body> <Body>
Canceling stops paid access at the end of the current period. It does not delete {PRIVACY.cancel} {PRIVACY.documents} {PRIVACY.softDelete} {PRIVACY.purge} See{' '}
uploaded documents. Documents stay in your workspace until you delete them, or until you <TextLink to="/account-deletion/">how account deletion works</TextLink>, including the
delete the account. Account deletion is a soft-delete: profile details, conversations, soft-delete steps and what data may remain.
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>.
</Body> </Body>
</Section> </Section>
</PublicSiteLayout> </PublicSiteLayout>
@@ -10,6 +10,7 @@ import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground'; import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons'; import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics'; import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
import { trackHeyCatch } from '../../utils/heycatch';
import { checkoutReturnUrls } from '../../utils/finance'; import { checkoutReturnUrls } from '../../utils/finance';
import { isNativePlatform } from '../../platform/nativePlatform'; import { isNativePlatform } from '../../platform/nativePlatform';
import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat'; import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat';
@@ -245,6 +246,7 @@ const SignUp = (): JSX.Element => {
const account = await loadAccount(); const account = await loadAccount();
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' }); trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
identifyAccount(account); identifyAccount(account);
trackHeyCatch('signup_completed');
void Promise.resolve(logInRevenueCat(account)).catch((err) => void Promise.resolve(logInRevenueCat(account)).catch((err) =>
console.warn('RevenueCat logIn', err) console.warn('RevenueCat logIn', err)
); );
@@ -6,6 +6,7 @@ import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext'; import { AccountContext } from '../../contexts/AccountContext';
import { Account } from '../../data'; import { Account } from '../../data';
import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument'; import TermsOfServiceDocument from '../../components/legal/TermsOfServiceDocument';
import RelatedPages from '../../components/PublicSite/RelatedPages';
import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout'; import PageWrapperLayout from '../../components/PageWrapperLayout/PageWrapperLayout';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
@@ -103,13 +104,17 @@ const TermsOfService = ({ publicView = false }: TermsOfServiceProps): JSX.Elemen
return ( return (
<Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}> <Box sx={{ py: 4, px: 2, minHeight: '100vh', bgcolor: 'background.default' }}>
{documentBody} {documentBody}
<RelatedPages />
</Box> </Box>
); );
} }
return ( return (
<PageWrapperLayout> <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> </PageWrapperLayout>
); );
}; };
@@ -29,10 +29,55 @@ describe('public crawler files', () => {
expect(llms).not.toContain('<html'); expect(llms).not.toContain('<html');
expect(html).toContain('<title>Hesychia — quiet, focused AI chat</title>'); 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('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": "Organization"');
expect(html).toContain('"@type": "SoftwareApplication"'); expect(html).toContain('"@type": "SoftwareApplication"');
expect(html).toContain('"price": "10.00"'); expect(html).toContain('"price": "10.00"');
expect(html).toContain('AI ML Operations, LLC'); 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; track: (eventName: string, data?: Record<string, unknown>) => void;
identify: (userInfo: 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 { flushAnalyticsQueue, identifyAccount, identifyUser, trackEvent } from './analytics';
import { Account } from '../data'; import { Account } from '../data';
jest.mock('./heycatch', () => ({
identifyHeyCatch: jest.fn(),
resetHeyCatch: jest.fn(),
trackHeyCatch: jest.fn(),
}));
describe('analytics event helpers', () => { describe('analytics event helpers', () => {
const originalEnv = process.env; const originalEnv = process.env;
+2
View File
@@ -1,5 +1,6 @@
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent'; import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
import type { Account } from '../data'; import type { Account } from '../data';
import { identifyHeyCatch } from './heycatch';
type QueuedCall = type QueuedCall =
| { type: 'track'; eventName: string; data?: Record<string, unknown> } | { 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). */ /** Identify with stable non-sensitive traits only (no names / prompts). */
export const identifyAccount = (account: Account): void => { export const identifyAccount = (account: Account): void => {
identifyHeyCatch(account);
const traits: Record<string, unknown> = { const traits: Record<string, unknown> = {
userId: account.email, 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'; import { applyRouteMeta, metaForPath } from './routeMeta';
describe('metaForPath', () => { describe('metaForPath', () => {
it('uses the marketing title for anonymous home', () => { it('uses the marketing title for anonymous home', () => {
expect(metaForPath('/', false)).toEqual({ expect(metaForPath('/', false)).toEqual({
title: 'Hesychia — quiet, focused AI chat', 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/', canonical: 'https://hesychia.ai/',
}); });
}); });
@@ -14,12 +17,20 @@ describe('metaForPath', () => {
it('gives public pages their own titles and canonicals', () => { it('gives public pages their own titles and canonicals', () => {
expect(metaForPath('/pricing/', false)).toEqual({ 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/', canonical: 'https://hesychia.ai/pricing/',
}); });
expect(metaForPath('/privacy/', false)?.canonical).toBe('https://hesychia.ai/privacy/'); expect(metaForPath('/privacy/', false)?.canonical).toBe('https://hesychia.ai/privacy/');
expect(metaForPath('/changelog/', false)?.title).toBe('Changelog — Hesychia'); expect(metaForPath('/changelog/', false)?.title).toBe('Hesychia changelog — dated product updates');
expect(metaForPath('/signin/', false)?.canonical).toBeNull(); 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', () => { it('does not invent a title for an unknown path', () => {
@@ -30,21 +41,43 @@ describe('metaForPath', () => {
describe('applyRouteMeta', () => { describe('applyRouteMeta', () => {
afterEach(() => { afterEach(() => {
document.title = ''; document.title = '';
document.head.querySelectorAll('meta').forEach((node) => node.remove());
document.querySelector('link[rel="canonical"]')?.remove(); document.querySelector('link[rel="canonical"]')?.remove();
}); });
it('sets the title and canonical', () => { it('sets the title, canonical, and social card tags', () => {
applyRouteMeta({ title: 'Pricing — Hesychia', canonical: 'https://hesychia.ai/pricing/' }); 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.title).toBe('Pricing — Hesychia');
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe( expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
'https://hesychia.ai/pricing/', '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', () => { it('removes a canonical when the route should not have one', () => {
applyRouteMeta({ title: 'Pricing — Hesychia', canonical: 'https://hesychia.ai/pricing/' }); applyRouteMeta({
applyRouteMeta({ title: 'Sign in — Hesychia', canonical: null }); 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.title).toBe('Sign in — Hesychia');
expect(document.querySelector('link[rel="canonical"]')).toBeNull(); 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 = { 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. */ /** Absolute canonical URL. Null means this path should not advertise a canonical. */
canonical: string | null; 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> = { const PUBLIC_PAGES: Record<string, RouteMeta> = {
'/': { '/': page('/'),
title: 'Hesychia — quiet, focused AI chat', '/pricing/': page('/pricing/'),
canonical: `${ORIGIN}/`, '/privacy/': page('/privacy/'),
}, '/changelog/': page('/changelog/'),
'/pricing/': { '/terms_of_service/': page('/terms_of_service/'),
title: 'Pricing — Hesychia', '/legal/terms-of-service': page('/legal/terms-of-service'),
canonical: `${ORIGIN}/pricing/`, '/account-deletion/': page('/account-deletion/'),
}, '/signin/': page('/signin/'),
'/privacy/': { '/signup/': page('/signup/'),
title: 'Privacy — Hesychia', '/auth/callback/': page('/auth/callback/'),
canonical: `${ORIGIN}/privacy/`, '/billing/success': page('/billing/success'),
}, '/billing/cancel': page('/billing/cancel'),
'/changelog/': { '/password_reset/': page('/password_reset/'),
title: 'Changelog — Hesychia', '/password_reset_confirmation/': page('/password_reset_confirmation/'),
canonical: `${ORIGIN}/changelog/`, '/set_password/': page('/set_password/'),
}, '/account/': page('/account/'),
'/terms_of_service/': { '/document_storage': page('/document_storage'),
title: 'Terms of Service — Hesychia', '/feedback/': page('/feedback/'),
canonical: `${ORIGIN}/terms_of_service/`, '/analytics/': page('/analytics/'),
},
'/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,
},
}; };
/** Title and canonical for a path. Signed-in `/` keeps the app title. */ /** Title and canonical for a path. Signed-in `/` keeps the app title. */
export function metaForPath(pathname: string, authenticated: boolean): RouteMeta | null { export function metaForPath(pathname: string, authenticated: boolean): RouteMeta | null {
if (pathname === '/' && authenticated) { 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 { export function applyRouteMeta(meta: RouteMeta | null): void {
if (!meta || typeof document === 'undefined') return; 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"]'); const existing = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!meta.canonical) { if (!meta.canonical) {
existing?.parentNode?.removeChild(existing); existing?.parentNode?.removeChild(existing);
document.querySelector('meta[property="og:url"]')?.remove();
return; return;
} }
@@ -90,4 +94,5 @@ export function applyRouteMeta(meta: RouteMeta | null): void {
if (!existing) { if (!existing) {
document.head.appendChild(link); 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,
};