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

## Summary
- Closes the public-page gaps from #124: each route gets its own title, description, canonical, heading, links, and JSON-LD, and unknown URLs have a real 404 document.
- Adds HeyCatch and the Google tag (`G-V12DLRLPWR`) only on the production site and the iOS and Android apps. Tianji still loads on beta.
- Needs the server-infra change that stops serving the app shell for missing paths.

## Test plan
- [ ] Build production and confirm `/` and `/pricing/` HTML include a unique title, description, canonical, one h1, social tags, related links, and Organization JSON-LD.
- [ ] Confirm a nonsense path is served as `404.html` once the nginx change is deployed.
- [ ] Confirm beta and local web do not load HeyCatch or gtag, and that a production or native build does.
- [ ] Open the live site after deploy and click around, then mark HeyCatch installed in its dashboard.

Reviewed-on: #125
This commit was merged in pull request #125.
This commit is contained in:
westfarn committed 2026-10-19 08:01:44 -07:00
1 parent 8b58192026
commit 7a8f413483
45 files changed
+1880 -174

No files matched your search

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