Repository navigation
Expand file tree
/
Copy pathentry.tsx
More file actions
348 lines (328 loc) · 13.6 KB
/
Copy pathentry.tsx
File metadata and controls
348 lines (328 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
// Server entry for scripts/prerender.mjs, which loads it through Vite in Node
// after `vite build` and writes each page's HTML into dist/.
import type { ComponentType } from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import i18n, { applyAppLanguage } from '@/i18n';
import { TooltipProvider } from '@/components/ui/tooltip';
import { AuthContext, type AuthContextValue } from '@/lib/supabase/auth-context';
import site from '@/config/site.json';
import { FREE_PLAN } from '@/lib/seo/site-text';
import { SOFTWARE_DESCRIPTION, ORGANIZATION_ID, PAGE_HEAD_IDS, PAGE_HEADS, PRICING, PRODUCT, SITE_ORIGIN, factsAsOf, organizationNode, type PageHead, type PrerenderedRoute } from '@/lib/seo/pages';
import { HEAD_NAMES } from '@/lib/seo/head-text';
import { hreflangAlternates, isLocalizedRoute, LOCALIZED_PATHS, RouteLanguageProvider, type PageLanguage } from '@/lib/seo/localized-routes';
import { TOOL_PAGES as HU_TOOL_PAGES } from '@/pages/tools/localized/hu';
import { TOOL_PAGES as ES_TOOL_PAGES } from '@/pages/tools/localized/es';
import LandingPage from '@/pages/LandingPage';
import TermsPage from '@/pages/TermsPage';
import PrivacyPage from '@/pages/PrivacyPage';
import AboutPage from '@/pages/AboutPage';
import ContactPage from '@/pages/ContactPage';
import PressPage from '@/pages/PressPage';
import AiDebatePage from '@/pages/AiDebatePage';
import NotFound from '@/pages/NotFound';
import SourcesPage from '@/pages/SourcesPage';
import { PRACTICE_TOOLS, PREPARE_TOOLS, PREP_MODE_COUNT, UTILITY_TOOLS } from '@/components/features/tools/tool-links';
import { TOOL_PAGES } from './tool-pages';
const unavailable = () => Promise.reject(new Error('Not available while prerendering'));
// Prerendered pages are what a signed-out visitor sees.
const SIGNED_OUT: AuthContextValue = {
session: null,
user: null,
loading: false,
callbackError: null,
clearCallbackError: () => undefined,
signInWithGoogle: unavailable,
signInWithEmailOtp: unavailable,
verifyEmailOtp: unavailable,
signInWithDemoCode: unavailable,
signOut: unavailable,
leaveEnvironment: unavailable,
discardTabState: () => undefined,
};
export interface PrerenderedPage {
/** The address, which #root's data-prerendered names: /hu for the Hungarian landing page. */
route: string;
/** The <html lang>; English when absent. */
language?: PageLanguage;
/** hreflang links for a page that exists in every language. */
alternates?: { hreflang: string; href: string }[];
/** Output path inside dist/. */
file: string;
/** Source path of the page module, the key of its chunk in the Vite manifest. */
module: string;
head: PageHead;
body: string;
structuredData?: object;
}
const WEBSITE_ID = `${SITE_ORIGIN}/#website`;
/** "May 1, 2026" in the locale file, as 2026-05-01. */
function isoDate(englishDate: string): string {
const time = Date.parse(`${englishDate} UTC`);
if (Number.isNaN(time)) throw new Error(`Cannot read "${englishDate}" as a date`);
return new Date(time).toISOString().slice(0, 10);
}
function webPage(route: PrerenderedRoute, dateModified: string | undefined, head: PageHead = PAGE_HEADS[route], language?: PageLanguage) {
return {
'@type': 'WebPage',
'@id': `${SITE_ORIGIN}${head.canonicalPath}#webpage`,
url: `${SITE_ORIGIN}${head.canonicalPath}`,
name: head.title,
description: head.description,
...(language ? { inLanguage: language } : {}),
isPartOf: { '@id': WEBSITE_ID },
...(dateModified ? { dateModified } : {}),
};
}
/** The landing page's visible questions and answers, in the order the page shows them, in i18n's current language. */
function landingQuestions(language: PageLanguage = 'en'): Array<[string, string]> {
const asOf = factsAsOf(language);
return [
[i18n.t('shortAnswer.question'), i18n.t('shortAnswer.answer', { asOf })],
// The visible answers link a phrase; the JSON-LD keeps the words without the tag.
[i18n.t('faq.q11'), i18n.t('faq.a11').replace(/<\/?link>/g, '')],
[i18n.t('faq.q10'), i18n.t('faq.a10').replace(/<\/?link>/g, '')],
[i18n.t('faq.q6'), i18n.t('faq.a6', { modes: PREP_MODE_COUNT })],
[i18n.t('faq.q8'), i18n.t('faq.a8')],
[i18n.t('faq.q7'), i18n.t('faq.a7', { asOf })],
[i18n.t('faq.q1'), i18n.t('faq.a1', { asOf })],
[i18n.t('faq.q9'), i18n.t('faq.a9')],
...[2, 3, 4, 5].map((n): [string, string] => [i18n.t(`faq.q${n}`), i18n.t(`faq.a${n}`)]),
];
}
const APP_TOOLS = [...PREPARE_TOOLS, ...PRACTICE_TOOLS, ...UTILITY_TOOLS];
// Word, PDF and Google Docs exports lay a case out in the same sections the app shows.
const EXPORT_FEATURE = 'Google Docs and PDF export in the case format';
function landingStructuredData(dateModified: string | undefined) {
return {
'@context': 'https://schema.org',
'@graph': [
organizationNode(),
{
'@type': 'WebSite',
'@id': WEBSITE_ID,
url: `${SITE_ORIGIN}/`,
name: PRODUCT,
// A real alternate spelling, not the name again.
alternateName: [site.alternateName, new URL(SITE_ORIGIN).host],
inLanguage: ['en', 'hu', 'es'],
publisher: { '@id': ORGANIZATION_ID },
},
{
'@type': 'SoftwareApplication',
'@id': `${SITE_ORIGIN}/#software`,
name: PRODUCT,
alternateName: site.alternateName,
applicationCategory: 'EducationalApplication',
operatingSystem: 'Web',
url: `${SITE_ORIGIN}/`,
image: `${SITE_ORIGIN}/hero-visual.jpg?v=2`,
description: SOFTWARE_DESCRIPTION,
disambiguatingDescription: site.organization.disambiguatingDescription,
// The same listings as the Organization: each is a page about the app itself.
...(site.organization.sameAs.length ? { sameAs: site.organization.sameAs } : {}),
inLanguage: ['en', 'es', 'hu'],
featureList: [...APP_TOOLS.map((tool) => i18n.t(`features.${tool.key}`)), EXPORT_FEATURE],
// One paid offer. Google reads an Offer priced 0 as an app available without
// payment, so the trial is stated in the Pro offer's text, not as an offer of its own.
offers: [
{
'@type': 'Offer',
name: 'Pro',
price: PRICING.proMonthly,
priceCurrency: PRICING.currency,
description: `Monthly subscription, billed plus tax. New accounts get a ${PRICING.trialDays}-day free trial first, no credit card required. ${FREE_PLAN}`,
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: PRICING.proMonthly,
priceCurrency: PRICING.currency,
billingDuration: 'P1M',
valueAddedTaxIncluded: false,
},
},
],
publisher: { '@id': ORGANIZATION_ID },
},
webPage('/', dateModified),
faqPage('en'),
],
};
}
function faqPage(language: PageLanguage) {
return {
'@type': 'FAQPage',
mainEntity: landingQuestions(language).map(([name, text]) => ({
'@type': 'Question',
name,
acceptedAnswer: { '@type': 'Answer', text },
})),
};
}
/** /hu and /es: their own page and FAQ, about the product the English landing page describes. */
function localizedLandingStructuredData(head: PageHead, language: PageLanguage, dateModified: string | undefined) {
return {
'@context': 'https://schema.org',
'@graph': [
{ ...webPage('/', dateModified, head, language), about: { '@id': `${SITE_ORIGIN}/#software` } },
faqPage(language),
],
};
}
/** Home, then the page: the trail the guides and tool pages already carry. */
function breadcrumbList(route: '/terms' | '/privacy' | '/about' | '/contact' | '/press' | '/expocit/sources', name: string) {
return {
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: PRODUCT, item: `${SITE_ORIGIN}/` },
{ '@type': 'ListItem', position: 2, name, item: `${SITE_ORIGIN}${route}` },
],
};
}
function legalStructuredData(route: '/terms' | '/privacy', page: 'termsPage' | 'privacyPage') {
return {
'@context': 'https://schema.org',
'@graph': [
webPage(route, isoDate(i18n.t(`${page}.date`, { lng: 'en' }))),
breadcrumbList(route, i18n.t(`${page}.title`, { lng: 'en' })),
],
};
}
// The day each page's copy last changed; move it when its text does.
const PAGE_MODIFIED = { '/about': '2026-11-03', '/contact': '2026-10-28', '/press': '2026-11-03' };
/**
* /about as an AboutPage and /press as a WebPage, each about the Organization,
* which they define in full as the home page does; /contact as a ContactPage.
*/
function companyStructuredData(route: '/about' | '/contact' | '/press') {
const types = { '/about': 'AboutPage', '/contact': 'ContactPage', '/press': 'WebPage' };
const labels = { '/about': 'footer.about', '/contact': 'footer.contact', '/press': 'footer.press' };
return {
'@context': 'https://schema.org',
'@graph': [
...(route === '/contact' ? [] : [organizationNode()]),
{ ...webPage(route, PAGE_MODIFIED[route]), '@type': types[route], about: { '@id': ORGANIZATION_ID } },
breadcrumbList(route, i18n.t(labels[route], { lng: 'en' })),
],
};
}
function render(route: string, Page: ComponentType, language: PageLanguage = 'en'): string {
return renderToString(
<AuthContext.Provider value={SIGNED_OUT}>
<TooltipProvider delayDuration={200}>
<StaticRouter location={route === '*' ? '/404' : route}>
<RouteLanguageProvider value={language}>
<Page />
</RouteLanguageProvider>
</StaticRouter>
</TooltipProvider>
</AuthContext.Provider>,
);
}
const TRANSLATED_TOOL_PAGES = { hu: HU_TOOL_PAGES, es: ES_TOOL_PAGES };
/** The Hungarian and Spanish pages: the landing page and the free tools, each in its own language. */
async function localizedPages(landingModified: string | undefined): Promise<PrerenderedPage[]> {
const pages: PrerenderedPage[] = [];
for (const { path, route, language } of LOCALIZED_PATHS) {
await applyAppLanguage(language);
const id = PAGE_HEAD_IDS[route];
const head: PageHead = {
title: i18n.t(`pageHead.${id}.title`, HEAD_NAMES),
description: i18n.t(`pageHead.${id}.description`, HEAD_NAMES),
canonicalPath: path,
};
const common = { route: path, file: `${path.slice(1)}/index.html`, language, alternates: hreflangAlternates(route), head };
if (route === '/') {
pages.push({
...common,
module: 'src/pages/LandingPage.tsx',
body: render(path, LandingPage, language),
structuredData: localizedLandingStructuredData(head, language, landingModified),
});
} else {
// The tool pages render their own JSON-LD in the body.
pages.push({ ...common, module: `src/pages/tools/localized/${language}.tsx`, body: render(path, TRANSLATED_TOOL_PAGES[language][route], language) });
}
}
await applyAppLanguage('en');
return pages;
}
const alternatesOf = (route: string) => (isLocalizedRoute(route) ? { alternates: hreflangAlternates(route) } : {});
/**
* Renders the public pages in English, signed out. `landingModified` is the
* date the landing page's source last changed, when the build can tell.
*/
export async function prerenderPages({ landingModified }: { landingModified?: string }): Promise<PrerenderedPage[]> {
await i18n.changeLanguage('en');
return [
{
...alternatesOf('/'),
route: '/',
file: 'index.html',
module: 'src/pages/LandingPage.tsx',
head: PAGE_HEADS['/'],
body: render('/', LandingPage),
structuredData: landingStructuredData(landingModified),
},
{
route: '/terms',
file: 'terms/index.html',
module: 'src/pages/TermsPage.tsx',
head: PAGE_HEADS['/terms'],
body: render('/terms', TermsPage),
structuredData: legalStructuredData('/terms', 'termsPage'),
},
{
route: '/privacy',
file: 'privacy/index.html',
module: 'src/pages/PrivacyPage.tsx',
head: PAGE_HEADS['/privacy'],
body: render('/privacy', PrivacyPage),
structuredData: legalStructuredData('/privacy', 'privacyPage'),
},
...([['/about', 'AboutPage', AboutPage], ['/contact', 'ContactPage', ContactPage], ['/press', 'PressPage', PressPage]] as const).map(([route, name, Page]) => ({
route,
file: `${route.slice(1)}/index.html`,
module: `src/pages/${name}.tsx`,
head: PAGE_HEADS[route],
body: render(route, Page),
structuredData: companyStructuredData(route),
})),
// Like the tool pages, /ai-debate renders its own JSON-LD in the body.
{
route: '/ai-debate',
file: 'ai-debate/index.html',
module: 'src/pages/AiDebatePage.tsx',
head: PAGE_HEADS['/ai-debate'],
body: render('/ai-debate', AiDebatePage),
},
// The tool pages render their own JSON-LD in the body.
...TOOL_PAGES.map(({ route, module, Page }) => ({
...alternatesOf(route),
route,
file: `${route.slice(1)}/index.html`,
module,
head: PAGE_HEADS[route],
body: render(route, Page),
})),
{
route: '/expocit/sources',
file: 'expocit/sources/index.html',
module: 'src/pages/SourcesPage.tsx',
head: PAGE_HEADS['/expocit/sources'],
body: render('/expocit/sources', SourcesPage),
structuredData: {
'@context': 'https://schema.org',
'@graph': [webPage('/expocit/sources', undefined), breadcrumbList('/expocit/sources', i18n.t('sourcesPage.title'))],
},
},
{
route: '*',
file: '404.html',
module: 'src/pages/NotFound.tsx',
head: PAGE_HEADS['*'],
body: render('*', NotFound),
},
// Last: they switch i18n to each language in turn, then back to English.
...(await localizedPages(landingModified)),
];
}