Public page SEO and production analytics (#125)
## Summary - Closes the public-page gaps from #124: each route gets its own title, description, canonical, heading, links, and JSON-LD, and unknown URLs have a real 404 document. - Adds HeyCatch and the Google tag (`G-V12DLRLPWR`) only on the production site and the iOS and Android apps. Tianji still loads on beta. - Needs the server-infra change that stops serving the app shell for missing paths. ## Test plan - [ ] Build production and confirm `/` and `/pricing/` HTML include a unique title, description, canonical, one h1, social tags, related links, and Organization JSON-LD. - [ ] Confirm a nonsense path is served as `404.html` once the nginx change is deployed. - [ ] Confirm beta and local web do not load HeyCatch or gtag, and that a production or native build does. - [ ] Open the live site after deploy and click around, then mark HeyCatch installed in its dashboard. Reviewed-on: #125
This commit was merged in pull request #125.
This commit is contained in:
1 parent
8b58192026
commit
7a8f413483
45 files changed
+1866
-160
No files matched your search
Generated
+113
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "WebPage",
|
||||||
|
"headline": "Hesychia — quiet, focused AI chat",
|
||||||
|
"url": "https://hesychia.ai/",
|
||||||
|
"mainEntityOfPage": "https://hesychia.ai/"
|
||||||
|
},
|
||||||
|
{
|
||||||
"@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,6 +61,8 @@
|
|||||||
"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" />
|
||||||
<!--
|
<!--
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* One IndexNow POST for the URLs in the sitemap. Run after the prod files are published.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { INDEXNOW_KEY, indexNowBody, sitemapEntries } = require('./sitemap');
|
||||||
|
|
||||||
|
async function pingIndexNow(fetchImpl = fetch, urls) {
|
||||||
|
const keyPath = path.join(__dirname, '..', 'public', `${INDEXNOW_KEY}.txt`);
|
||||||
|
const key = fs.readFileSync(keyPath, 'utf8').trim();
|
||||||
|
if (key !== INDEXNOW_KEY) {
|
||||||
|
throw new Error(`IndexNow key file does not match ${INDEXNOW_KEY}`);
|
||||||
|
}
|
||||||
|
const urlList = urls || sitemapEntries().map((entry) => entry.loc);
|
||||||
|
const body = indexNowBody(key, urlList);
|
||||||
|
const response = await fetchImpl('https://api.indexnow.org/indexnow', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (response.status !== 200 && response.status !== 202) {
|
||||||
|
throw new Error(`IndexNow ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return { status: response.status, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const result = await pingIndexNow();
|
||||||
|
process.stdout.write(`IndexNow ${result.status}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { pingIndexNow };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(`${error.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Sitemap lastmod comes from the page source, not from the build clock.
|
||||||
|
* Changelog uses the newest entry date. Terms use the "Last Updated" line.
|
||||||
|
* Other public pages use the last git commit that touched that file.
|
||||||
|
*/
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ORIGIN = 'https://hesychia.ai';
|
||||||
|
const INDEXNOW_KEY = '8d1667dd71c6ef137171989c26eeb632';
|
||||||
|
const INDEXNOW_HOST = 'hesychia.ai';
|
||||||
|
|
||||||
|
const llmFe = path.resolve(__dirname, '..');
|
||||||
|
const repoRoot = path.resolve(llmFe, '..');
|
||||||
|
|
||||||
|
/** Public pages only. Same URLs the site already lists for crawlers. */
|
||||||
|
const SITEMAP_PAGES = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
file: 'llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx',
|
||||||
|
lastmod: 'git',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/pricing/',
|
||||||
|
file: 'llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx',
|
||||||
|
lastmod: 'git',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/privacy/',
|
||||||
|
file: 'llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx',
|
||||||
|
lastmod: 'git',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/changelog/',
|
||||||
|
file: 'llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts',
|
||||||
|
lastmod: 'changelog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/terms_of_service/',
|
||||||
|
file: 'llm-fe/src/llm-fe/components/legal/TermsOfServiceDocument.tsx',
|
||||||
|
lastmod: 'terms',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/account-deletion/',
|
||||||
|
file: 'llm-fe/src/llm-fe/pages/AccountDeletion/AccountDeletionPage.tsx',
|
||||||
|
lastmod: 'git',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTHS = {
|
||||||
|
January: '01',
|
||||||
|
February: '02',
|
||||||
|
March: '03',
|
||||||
|
April: '04',
|
||||||
|
May: '05',
|
||||||
|
June: '06',
|
||||||
|
July: '07',
|
||||||
|
August: '08',
|
||||||
|
September: '09',
|
||||||
|
October: '10',
|
||||||
|
November: '11',
|
||||||
|
December: '12',
|
||||||
|
};
|
||||||
|
|
||||||
|
function newestChangelogDate(source) {
|
||||||
|
const dates = [...source.matchAll(/date:\s*'(\d{4}-\d{2}-\d{2})'/g)].map((match) => match[1]);
|
||||||
|
if (dates.length === 0) {
|
||||||
|
throw new Error('changelog entries have no dates');
|
||||||
|
}
|
||||||
|
return dates.sort().at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function termsUpdated(source) {
|
||||||
|
const match = source.match(/Last Updated:\s+([A-Z][a-z]+)\s+(\d{1,2}),\s+(\d{4})/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('terms have no Last Updated date');
|
||||||
|
}
|
||||||
|
const month = MONTHS[match[1]];
|
||||||
|
if (!month) {
|
||||||
|
throw new Error(`unknown month in terms date: ${match[1]}`);
|
||||||
|
}
|
||||||
|
return `${match[3]}-${month}-${match[2].padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitLastmod(file) {
|
||||||
|
const out = execFileSync('git', ['log', '-1', '--format=%cs', '--', file], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
}).trim();
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(out)) {
|
||||||
|
throw new Error(`no git commit date for ${file}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastmodFor(page) {
|
||||||
|
const abs = path.join(repoRoot, page.file);
|
||||||
|
const source = fs.readFileSync(abs, 'utf8');
|
||||||
|
if (page.lastmod === 'changelog') return newestChangelogDate(source);
|
||||||
|
if (page.lastmod === 'terms') return termsUpdated(source);
|
||||||
|
return gitLastmod(page.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitemapEntries() {
|
||||||
|
return SITEMAP_PAGES.map((page) => ({
|
||||||
|
loc: `${ORIGIN}${page.path}`,
|
||||||
|
lastmod: lastmodFor(page),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSitemap(entries) {
|
||||||
|
const urls = entries
|
||||||
|
.map(
|
||||||
|
(entry) => ` <url>
|
||||||
|
<loc>${entry.loc}</loc>
|
||||||
|
<lastmod>${entry.lastmod}</lastmod>
|
||||||
|
</url>`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSitemap() {
|
||||||
|
const xml = renderSitemap(sitemapEntries());
|
||||||
|
fs.writeFileSync(path.join(llmFe, 'public', 'sitemap.xml'), xml);
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexNowBody(key, urls) {
|
||||||
|
return {
|
||||||
|
host: INDEXNOW_HOST,
|
||||||
|
key,
|
||||||
|
keyLocation: `https://${INDEXNOW_HOST}/${key}.txt`,
|
||||||
|
urlList: urls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
INDEXNOW_KEY,
|
||||||
|
SITEMAP_PAGES,
|
||||||
|
newestChangelogDate,
|
||||||
|
termsUpdated,
|
||||||
|
gitLastmod,
|
||||||
|
sitemapEntries,
|
||||||
|
renderSitemap,
|
||||||
|
writeSitemap,
|
||||||
|
indexNowBody,
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const {
|
||||||
|
newestChangelogDate,
|
||||||
|
termsUpdated,
|
||||||
|
renderSitemap,
|
||||||
|
gitLastmod,
|
||||||
|
indexNowBody,
|
||||||
|
INDEXNOW_KEY,
|
||||||
|
} = require('./sitemap');
|
||||||
|
const { pingIndexNow } = require('./pingIndexNow');
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '../..');
|
||||||
|
|
||||||
|
test('changelog lastmod is the newest entry date', () => {
|
||||||
|
const source = `
|
||||||
|
{ date: '2026-08-01', title: 'Documents' },
|
||||||
|
{ date: '2026-08-04', title: 'Account deletion' },
|
||||||
|
`;
|
||||||
|
assert.equal(newestChangelogDate(source), '2026-08-04');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terms lastmod is the Last Updated line, not today', () => {
|
||||||
|
assert.equal(
|
||||||
|
termsUpdated('Last Updated: July 27, 2026'),
|
||||||
|
'2026-07-27',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live page sources supply lastmod', () => {
|
||||||
|
const changelog = fs.readFileSync(
|
||||||
|
path.join(repoRoot, 'llm-fe/src/llm-fe/pages/Changelog/changelogEntries.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const terms = fs.readFileSync(
|
||||||
|
path.join(repoRoot, 'llm-fe/src/llm-fe/components/legal/TermsOfServiceDocument.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.equal(newestChangelogDate(changelog), '2026-08-04');
|
||||||
|
assert.equal(termsUpdated(terms), '2026-07-27');
|
||||||
|
|
||||||
|
for (const file of [
|
||||||
|
'llm-fe/src/llm-fe/pages/Landing/LandingPage.tsx',
|
||||||
|
'llm-fe/src/llm-fe/pages/Pricing/PricingPage.tsx',
|
||||||
|
'llm-fe/src/llm-fe/pages/Privacy/PrivacyPage.tsx',
|
||||||
|
'llm-fe/src/llm-fe/pages/AccountDeletion/AccountDeletionPage.tsx',
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
assert.match(gitLastmod(file), /^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
} catch (error) {
|
||||||
|
if (!String(error.message).includes('no git commit date')) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = renderSitemap([
|
||||||
|
{ loc: 'https://hesychia.ai/changelog/', lastmod: newestChangelogDate(changelog) },
|
||||||
|
{ loc: 'https://hesychia.ai/terms_of_service/', lastmod: termsUpdated(terms) },
|
||||||
|
]);
|
||||||
|
assert.match(xml, /<loc>https:\/\/hesychia\.ai\/changelog\/<\/loc>\n\s*<lastmod>2026-08-04<\/lastmod>/);
|
||||||
|
assert.match(xml, /<lastmod>2026-07-27<\/lastmod>/);
|
||||||
|
assert.equal(xml.includes('<html'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IndexNow POST body lists the published URLs and the hosted key', async () => {
|
||||||
|
const urls = ['https://hesychia.ai/', 'https://hesychia.ai/pricing/'];
|
||||||
|
assert.deepEqual(indexNowBody(INDEXNOW_KEY, urls), {
|
||||||
|
host: 'hesychia.ai',
|
||||||
|
key: INDEXNOW_KEY,
|
||||||
|
keyLocation: `https://hesychia.ai/${INDEXNOW_KEY}.txt`,
|
||||||
|
urlList: urls,
|
||||||
|
});
|
||||||
|
|
||||||
|
let posted;
|
||||||
|
const result = await pingIndexNow(async (url, init) => {
|
||||||
|
posted = { url, init };
|
||||||
|
return { status: 202, text: async () => '' };
|
||||||
|
}, urls);
|
||||||
|
assert.equal(posted.url, 'https://api.indexnow.org/indexnow');
|
||||||
|
assert.equal(posted.init.method, 'POST');
|
||||||
|
assert.equal(posted.init.headers['Content-Type'], 'application/json');
|
||||||
|
const body = JSON.parse(posted.init.body);
|
||||||
|
assert.equal(body.keyLocation, `https://hesychia.ai/${INDEXNOW_KEY}.txt`);
|
||||||
|
assert.deepEqual(body.urlList, urls);
|
||||||
|
assert.equal(result.status, 202);
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* CRA serves one index.html for every route. A canonical in that shared file
|
||||||
|
* would point every page at the homepage. After `react-scripts build`, write a
|
||||||
|
* copy per route whose canonical href is that route's own absolute URL.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { ORIGIN, OG_IMAGE, PAGE_PATHS, h1ForPath, titleForPath, descriptionForPath } = require('../src/llm-fe/utils/canonicals');
|
||||||
|
const { relatedNavHtml } = require('../src/llm-fe/utils/internalLinks');
|
||||||
|
const { proseHtml } = require('../src/llm-fe/utils/pageProse');
|
||||||
|
const { jsonLdHtml } = require('../src/llm-fe/utils/structuredData');
|
||||||
|
|
||||||
|
const CANONICAL_RE = /<link\s+rel=["']canonical["']\s+href=["'][^"']*["']\s*\/?>/i;
|
||||||
|
const H1_RE = /<h1[^>]*>[\s\S]*?<\/h1>/i;
|
||||||
|
const TITLE_RE = /<title>[^<]*<\/title>/i;
|
||||||
|
const DESCRIPTION_RE = /<meta\s+name=["']description["']\s+content=["'][^"']*["']\s*\/?>/i;
|
||||||
|
const RELATED_NAV_RE = /<nav\s+aria-label=["']Related pages["']>[\s\S]*?<\/nav>/i;
|
||||||
|
const PROSE_RE = /<div id="crawler-prose">[\s\S]*?<\/div>/i;
|
||||||
|
const JSONLD_SOURCE = '<script type="application\\/ld\\+json">[\\s\\S]*?<\\/script>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} html
|
||||||
|
* @param {'property' | 'name'} attr
|
||||||
|
* @param {string} key
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function replaceSocial(html, attr, key, content) {
|
||||||
|
const re = new RegExp(
|
||||||
|
`<meta\\s+${attr}=["']${key}["']\\s+content=["'][^"']*["']\\s*\\/?>`,
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
if (!re.test(html)) {
|
||||||
|
throw new Error(`missing meta ${attr}="${key}"`);
|
||||||
|
}
|
||||||
|
return html.replace(re, () => `<meta ${attr}="${key}" content="${content}" />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} homeHtml
|
||||||
|
* @param {string} pathname
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function htmlForPath(homeHtml, pathname) {
|
||||||
|
const found = homeHtml.match(new RegExp(CANONICAL_RE.source, 'gi'));
|
||||||
|
if (!found || found.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`expected exactly one <link rel="canonical"> in index.html, found ${found ? found.length : 0}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = `${ORIGIN}${pathname}`;
|
||||||
|
let next = homeHtml.replace(CANONICAL_RE, `<link rel="canonical" href="${url}" />`);
|
||||||
|
const h1s = next.match(/<h1\b/gi);
|
||||||
|
if (!h1s || h1s.length !== 1) {
|
||||||
|
throw new Error(`expected exactly one <h1> in index.html, found ${h1s ? h1s.length : 0}`);
|
||||||
|
}
|
||||||
|
const headline = h1ForPath(pathname);
|
||||||
|
if (!headline) {
|
||||||
|
throw new Error(`no h1 for ${pathname}`);
|
||||||
|
}
|
||||||
|
next = next.replace(H1_RE, `<h1>${headline}</h1>`);
|
||||||
|
if (next.match(/<h1\b/gi).length !== 1) {
|
||||||
|
throw new Error(`stamped ${pathname} does not have exactly one h1`);
|
||||||
|
}
|
||||||
|
const titles = next.match(/<title\b/gi);
|
||||||
|
if (!titles || titles.length !== 1) {
|
||||||
|
throw new Error(`expected exactly one <title> in index.html, found ${titles ? titles.length : 0}`);
|
||||||
|
}
|
||||||
|
const title = titleForPath(pathname);
|
||||||
|
if (!title) {
|
||||||
|
throw new Error(`no title for ${pathname}`);
|
||||||
|
}
|
||||||
|
next = next.replace(TITLE_RE, `<title>${title}</title>`);
|
||||||
|
if (next.match(/<title\b/gi).length !== 1) {
|
||||||
|
throw new Error(`stamped ${pathname} does not have exactly one title`);
|
||||||
|
}
|
||||||
|
const descriptions = next.match(/<meta\s+name=["']description["']/gi);
|
||||||
|
if (!descriptions || descriptions.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`expected exactly one meta description in index.html, found ${descriptions ? descriptions.length : 0}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const description = descriptionForPath(pathname);
|
||||||
|
if (!description) {
|
||||||
|
throw new Error(`no description for ${pathname}`);
|
||||||
|
}
|
||||||
|
next = next.replace(DESCRIPTION_RE, `<meta name="description" content="${description}" />`);
|
||||||
|
if (next.match(/<meta\s+name=["']description["']/gi).length !== 1) {
|
||||||
|
throw new Error(`stamped ${pathname} does not have exactly one meta description`);
|
||||||
|
}
|
||||||
|
next = replaceSocial(next, 'property', 'og:title', title);
|
||||||
|
next = replaceSocial(next, 'property', 'og:description', description);
|
||||||
|
next = replaceSocial(next, 'property', 'og:url', url);
|
||||||
|
next = replaceSocial(next, 'name', 'twitter:title', title);
|
||||||
|
next = replaceSocial(next, 'name', 'twitter:description', description);
|
||||||
|
if (!next.includes(`property="og:image" content="${OG_IMAGE}"`)) {
|
||||||
|
throw new Error(`stamped ${pathname} is missing the 1200x630 og:image`);
|
||||||
|
}
|
||||||
|
if (!next.includes('name="twitter:card" content="summary_large_image"')) {
|
||||||
|
throw new Error(`stamped ${pathname} is missing twitter:card`);
|
||||||
|
}
|
||||||
|
const prose = proseHtml(pathname);
|
||||||
|
if (PROSE_RE.test(next)) {
|
||||||
|
next = next.replace(PROSE_RE, () => prose);
|
||||||
|
} else {
|
||||||
|
next = next.replace(/<\/h1>/i, () => `</h1>${prose}`);
|
||||||
|
}
|
||||||
|
const nav = relatedNavHtml(pathname);
|
||||||
|
if (RELATED_NAV_RE.test(next)) {
|
||||||
|
next = next.replace(RELATED_NAV_RE, () => nav);
|
||||||
|
} else {
|
||||||
|
next = next.replace(/<\/h1>/i, () => `</h1>${nav}`);
|
||||||
|
}
|
||||||
|
if (next.match(/<nav\s+aria-label=["']Related pages["']/gi)?.length !== 1) {
|
||||||
|
throw new Error(`stamped ${pathname} does not have exactly one related-pages nav`);
|
||||||
|
}
|
||||||
|
const block = jsonLdHtml(pathname);
|
||||||
|
const jsonLdRe = new RegExp(JSONLD_SOURCE, 'gi');
|
||||||
|
const existing = next.match(jsonLdRe);
|
||||||
|
if (existing) {
|
||||||
|
let replaced = false;
|
||||||
|
next = next.replace(new RegExp(JSONLD_SOURCE, 'gi'), () => {
|
||||||
|
if (replaced) return '';
|
||||||
|
replaced = true;
|
||||||
|
return block;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next = next.replace(/<\/head>/i, () => `${block}\n</head>`);
|
||||||
|
}
|
||||||
|
const scripts = next.match(/<script type="application\/ld\+json">/gi);
|
||||||
|
if (!scripts || scripts.length !== 1) {
|
||||||
|
throw new Error(`stamped ${pathname} should have one JSON-LD script, found ${scripts ? scripts.length : 0}`);
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(next.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/i)[1]);
|
||||||
|
const page = parsed['@graph'] ? parsed['@graph'].find((node) => node['@type'] === 'WebPage') : parsed;
|
||||||
|
if (!page || page.headline !== title) {
|
||||||
|
throw new Error(`JSON-LD headline for ${pathname} does not match the title`);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} buildDir
|
||||||
|
* @param {string} pathname
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function destForPath(buildDir, pathname) {
|
||||||
|
if (pathname === '/') return path.join(buildDir, 'index.html');
|
||||||
|
if (pathname.endsWith('/')) {
|
||||||
|
return path.join(buildDir, pathname.slice(1), 'index.html');
|
||||||
|
}
|
||||||
|
// Exact URI with no extension. nginx serves these with default_type text/html
|
||||||
|
// so /document_storage is this file, not the SPA fallback.
|
||||||
|
return path.join(buildDir, pathname.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} buildDir
|
||||||
|
*/
|
||||||
|
function stampCanonicals(buildDir) {
|
||||||
|
const indexPath = path.join(buildDir, 'index.html');
|
||||||
|
const homeHtml = fs.readFileSync(indexPath, 'utf8');
|
||||||
|
for (const pathname of PAGE_PATHS) {
|
||||||
|
const dest = destForPath(buildDir, pathname);
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.writeFileSync(dest, htmlForPath(homeHtml, pathname));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { htmlForPath, destForPath, stampCanonicals, CANONICAL_RE };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const buildDir = process.argv[2] || path.resolve(__dirname, '../build');
|
||||||
|
stampCanonicals(buildDir);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { PAGE_PATHS, h1ForPath, titleForPath, descriptionForPath } = require('../src/llm-fe/utils/canonicals');
|
||||||
|
const { htmlForPath, stampCanonicals } = require('./stampCanonicals');
|
||||||
|
|
||||||
|
const HOME = `<!DOCTYPE html><html><head>
|
||||||
|
<link rel="canonical" href="https://hesychia.ai/" />
|
||||||
|
<meta property="og:title" content="Hesychia — quiet, focused AI chat" />
|
||||||
|
<meta property="og:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
|
||||||
|
<meta property="og:url" content="https://hesychia.ai/" />
|
||||||
|
<meta property="og:image" content="https://hesychia.ai/og-image.png" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Hesychia — quiet, focused AI chat" />
|
||||||
|
<meta name="twitter:description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
|
||||||
|
<meta name="twitter:image" content="https://hesychia.ai/og-image.png" />
|
||||||
|
<meta name="description" content="Hesychia is a $10/month AI chat product from AI ML Operations, LLC. It stores your documents so answers can use your files." />
|
||||||
|
<title>Hesychia — quiet, focused AI chat</title>
|
||||||
|
</head><body><div id="root"><h1>Hesychia is a calm AI assistant.</h1></div></body></html>`;
|
||||||
|
|
||||||
|
test('each page canonical is that page, and other head tags stay', () => {
|
||||||
|
const html = htmlForPath(HOME, '/pricing/');
|
||||||
|
assert.match(html, /<link rel="canonical" href="https:\/\/hesychia\.ai\/pricing\/" \/>/);
|
||||||
|
assert.equal(html.match(/rel="canonical"/g).length, 1);
|
||||||
|
assert.match(html, /property="og:url" content="https:\/\/hesychia\.ai\/pricing\/"/);
|
||||||
|
assert.match(html, /property="og:title" content="Hesychia pricing — \$10\/month, no ads"/);
|
||||||
|
assert.match(html, /property="og:description" content="\$10\/month, flat\. No ads, no upsells, one price\./);
|
||||||
|
assert.match(html, /property="og:image" content="https:\/\/hesychia\.ai\/og-image\.png"/);
|
||||||
|
assert.match(html, /name="twitter:card" content="summary_large_image"/);
|
||||||
|
assert.match(html, /name="twitter:title" content="Hesychia pricing — \$10\/month, no ads"/);
|
||||||
|
const nav = html.match(/<nav aria-label="Related pages">([\s\S]*?)<\/nav>/);
|
||||||
|
assert.ok(nav);
|
||||||
|
const anchors = nav[1].match(/<a href=/g);
|
||||||
|
assert.ok(anchors && anchors.length >= 2 && anchors.length <= 5);
|
||||||
|
assert.match(nav[1], /href="\/">What Hesychia is</);
|
||||||
|
assert.doesNotMatch(nav[1], /href="\/pricing\/"/);
|
||||||
|
assert.doesNotMatch(nav[1], />here</);
|
||||||
|
assert.match(html, /id="crawler-prose"/);
|
||||||
|
assert.equal(html.match(/application\/ld\+json/g).length, 1);
|
||||||
|
const pricingLd = JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
|
||||||
|
const pricingPage = pricingLd['@graph'].find((node) => node['@type'] === 'WebPage');
|
||||||
|
assert.equal(pricingPage.headline, 'Hesychia pricing — $10/month, no ads');
|
||||||
|
assert.equal(pricingPage.url, 'https://hesychia.ai/pricing/');
|
||||||
|
assert.match(html, /\$10\/month, flat, with no ads, no upsells, and one price/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
html.match(/<div id="crawler-prose">([\s\S]*?)<\/div>/)[1],
|
||||||
|
/Quiet, focused AI chat/,
|
||||||
|
);
|
||||||
|
assert.match(html, /<title>Hesychia pricing — \$10\/month, no ads<\/title>/);
|
||||||
|
assert.equal(html.match(/<title\b/g).length, 1);
|
||||||
|
assert.match(html, /name="description" content="\$10\/month, flat\. No ads, no upsells, one price\./);
|
||||||
|
assert.equal(html.match(/name="description"/g).length, 1);
|
||||||
|
assert.equal(html.match(/<h1\b/g).length, 1);
|
||||||
|
assert.match(html, /<h1>Pricing<\/h1>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stamp writes a shell per route and leaves the homepage self-referencing', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'canon-'));
|
||||||
|
fs.writeFileSync(path.join(dir, 'index.html'), HOME);
|
||||||
|
stampCanonicals(dir);
|
||||||
|
|
||||||
|
const home = fs.readFileSync(path.join(dir, 'index.html'), 'utf8');
|
||||||
|
assert.match(home, /href="https:\/\/hesychia\.ai\/"/);
|
||||||
|
const homeLd = JSON.parse(home.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
|
||||||
|
const homeOrg = homeLd['@graph'].find((node) => node['@type'] === 'Organization' && node.name === 'Hesychia');
|
||||||
|
assert.equal(homeOrg.url, 'https://hesychia.ai');
|
||||||
|
assert.equal(homeOrg.logo, 'https://hesychia.ai/apple-touch-icon.png');
|
||||||
|
assert.doesNotMatch(home, /href="https:\/\/hesychia\.ai\/pricing\/"/);
|
||||||
|
assert.equal(home.match(/<h1\b/g).length, 1);
|
||||||
|
assert.match(home, /<h1>Hesychia is a calm AI assistant.<\/h1>/);
|
||||||
|
|
||||||
|
const titles = PAGE_PATHS.map((pathname) => titleForPath(pathname));
|
||||||
|
assert.equal(new Set(titles).size, titles.length);
|
||||||
|
const descriptions = PAGE_PATHS.map((pathname) => descriptionForPath(pathname));
|
||||||
|
assert.equal(new Set(descriptions).size, descriptions.length);
|
||||||
|
|
||||||
|
for (const pathname of PAGE_PATHS) {
|
||||||
|
if (pathname === '/') continue;
|
||||||
|
const rel = pathname.endsWith('/')
|
||||||
|
? path.join(pathname.slice(1), 'index.html')
|
||||||
|
: pathname.slice(1);
|
||||||
|
const html = fs.readFileSync(path.join(dir, rel), 'utf8');
|
||||||
|
const title = titleForPath(pathname);
|
||||||
|
assert.match(html, new RegExp(`href="https://hesychia\\.ai${pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
|
||||||
|
assert.match(html, /name="twitter:card" content="summary_large_image"/);
|
||||||
|
const related = html.match(/<nav aria-label="Related pages">([\s\S]*?)<\/nav>/);
|
||||||
|
assert.ok(related, `${pathname} missing related nav`);
|
||||||
|
const relatedAnchors = related[1].match(/<a href=/g);
|
||||||
|
assert.ok(
|
||||||
|
relatedAnchors && relatedAnchors.length >= 2 && relatedAnchors.length <= 5,
|
||||||
|
`${pathname} related link count`,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(related[1], new RegExp(`href="${pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
|
||||||
|
assert.match(html, /property="og:image" content="https:\/\/hesychia\.ai\/og-image\.png"/);
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
new RegExp(`property="og:title" content="${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
|
||||||
|
);
|
||||||
|
assert.equal(html.match(/rel="canonical"/g).length, 1);
|
||||||
|
assert.equal(html.match(/<h1\b/g).length, 1);
|
||||||
|
assert.match(html, new RegExp(`<h1>${h1ForPath(pathname).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</h1>`));
|
||||||
|
assert.ok(title.length >= 30 && title.length <= 60, `${pathname} title length ${title.length}`);
|
||||||
|
assert.match(html, new RegExp(`<title>${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}</title>`));
|
||||||
|
assert.equal(html.match(/application\/ld\+json/g).length, 1);
|
||||||
|
const ld = JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
|
||||||
|
const webPage = ld['@graph'].find((node) => node['@type'] === 'WebPage');
|
||||||
|
assert.equal(webPage.headline, title, `${pathname} JSON-LD headline`);
|
||||||
|
const org = ld['@graph'].find((node) => node['@type'] === 'Organization' && node.name === 'Hesychia');
|
||||||
|
assert.ok(org, `${pathname} missing Organization`);
|
||||||
|
assert.equal(org.url, 'https://hesychia.ai');
|
||||||
|
assert.equal(org.logo, 'https://hesychia.ai/apple-touch-icon.png');
|
||||||
|
if (pathname === '/changelog/') {
|
||||||
|
const posts = ld['@graph'].filter((node) => node['@type'] === 'BlogPosting');
|
||||||
|
assert.ok(posts.length >= 2);
|
||||||
|
for (const post of posts) {
|
||||||
|
assert.ok(post.headline);
|
||||||
|
assert.equal(post.author.name, 'AI ML Operations, LLC');
|
||||||
|
assert.match(post.datePublished, /^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
assert.equal(post.dateModified, post.datePublished);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const description = descriptionForPath(pathname);
|
||||||
|
assert.ok(
|
||||||
|
description.length >= 110 && description.length <= 160,
|
||||||
|
`${pathname} description length ${description.length}`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
new RegExp(`content="${description.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
const { writeSitemap } = require('./sitemap');
|
||||||
|
|
||||||
|
writeSitemap();
|
||||||
@@ -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,11 +30,12 @@ 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');
|
const script = document.createElement('script');
|
||||||
script.id = TRACKER_SCRIPT_ID;
|
script.id = TRACKER_SCRIPT_ID;
|
||||||
script.src = 'https://tianji.aimloperations.com/tracker.js';
|
script.src = 'https://tianji.aimloperations.com/tracker.js';
|
||||||
@@ -27,12 +48,17 @@ const Tracker: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
document.body.appendChild(script);
|
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"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Vendored
+2
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
export type InternalLink = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function linksForPath(pathname: string): InternalLink[];
|
||||||
|
export function relatedNavHtml(pathname: string): string;
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
if (meta.title) {
|
||||||
document.title = 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
@@ -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 }[];
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in new issue
Block a user