diff --git a/scripts/run-hypertwist-web-surface-validation.sh b/scripts/run-hypertwist-web-surface-validation.sh
index 8ad70f3..0c70c98 100644
--- a/scripts/run-hypertwist-web-surface-validation.sh
+++ b/scripts/run-hypertwist-web-surface-validation.sh
@@ -144,6 +144,7 @@ run_step \
src/__tests__/protected-app-pages.test.tsx \
src/__tests__/DashboardOverviewPage.test.tsx \
src/__tests__/app-route-tree.test.tsx \
+ src/__tests__/public-route-registry.test.ts \
src/__tests__/route-shells.test.tsx \
src/__tests__/release-manifest.test.ts \
src/__tests__/public-marketing-pages.test.tsx \
diff --git a/website/README.md b/website/README.md
index bc4d331..1c3f25c 100644
--- a/website/README.md
+++ b/website/README.md
@@ -14,6 +14,7 @@ First-party `hypertwist.app` surface for HyperTwist:
- public launch-status callouts across pricing, download, and notices surfaces
- route-aware metadata, canonical, Open Graph, Twitter-card, and protected-route noindex posture for the live `hypertwist.app` surface
- first-party `robots.txt` plus `sitemap.xml` for the public crawlable route set while keeping `/app`, `/login`, and `/register` out of crawler posture
+- a shared public-route registry that now drives marketing navigation, footer links, router entries, and sitemap generation from one source of truth
- runtime diagnostics that distinguish local, mixed, and public auth deployment posture
- public open-source notices surface required by HyperTwist's MPL distribution doctrine
diff --git a/website/package.json b/website/package.json
index 66afdc2..9edafcd 100644
--- a/website/package.json
+++ b/website/package.json
@@ -6,10 +6,11 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc && vite build",
+ "build": "npm run render:sitemap && tsc && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"test": "vitest run",
+ "render:sitemap": "node scripts/render-public-sitemap.mjs",
"check:runtime-readiness": "node scripts/check-runtime-readiness.mjs",
"render:same-origin-deployment": "node scripts/render-same-origin-deployment.mjs",
"render:same-origin-bundle": "node scripts/render-same-origin-bundle.mjs",
diff --git a/website/scripts/render-public-sitemap.mjs b/website/scripts/render-public-sitemap.mjs
new file mode 100644
index 0000000..b92257b
--- /dev/null
+++ b/website/scripts/render-public-sitemap.mjs
@@ -0,0 +1,24 @@
+import { readFile, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const registryPath = path.join(__dirname, '..', 'src', 'public-route-registry.json')
+const sitemapPath = path.join(__dirname, '..', 'public', 'sitemap.xml')
+const baseUrl = 'https://hypertwist.app'
+
+const routeRegistry = JSON.parse(await readFile(registryPath, 'utf8'))
+const crawlableRoutes = routeRegistry.filter((route) => route && route.crawlable === true)
+
+const xml = [
+ '',
+ '',
+ ...crawlableRoutes.map((route) => (
+ ` \n ${baseUrl}${route.path === '/' ? '/' : route.path}\n `
+ )),
+ '',
+ '',
+].join('\n')
+
+await writeFile(sitemapPath, xml, 'utf8')
+console.log(`Rendered sitemap for ${crawlableRoutes.length} crawlable routes to ${sitemapPath}`)
diff --git a/website/src/__tests__/public-route-registry.test.ts b/website/src/__tests__/public-route-registry.test.ts
new file mode 100644
index 0000000..22cd67c
--- /dev/null
+++ b/website/src/__tests__/public-route-registry.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildPublicSitemapXml,
+ crawlablePublicRoutes,
+ footerLinks,
+ marketingNavLinks,
+ publicRouteRegistry,
+} from '../public-route-registry'
+
+describe('public route registry', () => {
+ it('keeps unique route definitions for the public shell', () => {
+ const uniquePaths = new Set(publicRouteRegistry.map((route) => route.path))
+
+ expect(uniquePaths.size).toBe(publicRouteRegistry.length)
+ })
+
+ it('exposes the intended primary navigation and footer surfaces', () => {
+ expect(marketingNavLinks).toEqual([
+ { to: '/', label: 'Home' },
+ { to: '/features', label: 'Features' },
+ { to: '/about', label: 'About' },
+ { to: '/resources', label: 'Resources' },
+ { to: '/pricing', label: 'Pricing' },
+ { to: '/download', label: 'Download' },
+ ])
+
+ expect(footerLinks).toContainEqual({
+ to: '/open-source-notices',
+ label: 'Open Source Notices',
+ })
+ })
+
+ it('keeps auth routes out of crawlable sitemap posture', () => {
+ expect(crawlablePublicRoutes.map((route) => route.path)).not.toContain('/login')
+ expect(crawlablePublicRoutes.map((route) => route.path)).not.toContain('/register')
+ })
+
+ it('renders sitemap XML from the shared crawlable route registry', () => {
+ const xml = buildPublicSitemapXml()
+
+ expect(xml).toContain('https://hypertwist.app/')
+ expect(xml).toContain('https://hypertwist.app/features')
+ expect(xml).not.toContain('/login')
+ expect(xml).not.toContain('/register')
+ })
+})
diff --git a/website/src/components/layout/MarketingShell.tsx b/website/src/components/layout/MarketingShell.tsx
index 642ee81..62cb000 100644
--- a/website/src/components/layout/MarketingShell.tsx
+++ b/website/src/components/layout/MarketingShell.tsx
@@ -2,7 +2,7 @@ import { Link, NavLink } from 'react-router-dom'
import { usePlatformAuth } from '../../auth/platform-auth'
import { MarketingLaunchStatusBanner } from '../ui/PublicLaunchStatus'
import { brandConfig } from '../../site-config'
-import { footerLinks, marketingNavLinks } from '../../site-data'
+import { footerLinks, marketingNavLinks } from '../../public-route-registry'
export function MarketingShell({
title,
diff --git a/website/src/public-route-registry.json b/website/src/public-route-registry.json
new file mode 100644
index 0000000..ffb0baa
--- /dev/null
+++ b/website/src/public-route-registry.json
@@ -0,0 +1,122 @@
+[
+ {
+ "path": "/",
+ "label": "Home",
+ "loaderTitle": "Loading HyperTwist...",
+ "nav": true,
+ "footer": false,
+ "crawlable": true
+ },
+ {
+ "path": "/features",
+ "label": "Features",
+ "loaderTitle": "Feature atlas",
+ "nav": true,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/about",
+ "label": "About",
+ "loaderTitle": "About HyperTwist",
+ "nav": true,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/resources",
+ "label": "Resources",
+ "loaderTitle": "Resources",
+ "nav": true,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/docs",
+ "label": "Docs",
+ "loaderTitle": "Documentation",
+ "nav": false,
+ "footer": false,
+ "crawlable": true
+ },
+ {
+ "path": "/support",
+ "label": "Support",
+ "loaderTitle": "Support",
+ "nav": false,
+ "footer": false,
+ "crawlable": true
+ },
+ {
+ "path": "/changelog",
+ "label": "Release notes",
+ "loaderTitle": "Release notes",
+ "nav": false,
+ "footer": false,
+ "crawlable": true
+ },
+ {
+ "path": "/pricing",
+ "label": "Pricing",
+ "loaderTitle": "Pricing",
+ "nav": true,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/download",
+ "label": "Download",
+ "loaderTitle": "Download center",
+ "nav": true,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/open-source-notices",
+ "label": "Open Source Notices",
+ "loaderTitle": "Open source notices",
+ "nav": false,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/privacy",
+ "label": "Privacy",
+ "loaderTitle": "Privacy",
+ "nav": false,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/terms",
+ "label": "Terms",
+ "loaderTitle": "Terms",
+ "nav": false,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/shipping-payment",
+ "label": "Shipping & Payment",
+ "loaderTitle": "Shipping & payment",
+ "nav": false,
+ "footer": true,
+ "crawlable": true
+ },
+ {
+ "path": "/login",
+ "label": "Log in",
+ "loaderTitle": "Log in",
+ "nav": false,
+ "footer": false,
+ "crawlable": false
+ },
+ {
+ "path": "/register",
+ "label": "Create account",
+ "loaderTitle": "Create account",
+ "nav": false,
+ "footer": false,
+ "crawlable": false
+ }
+]
diff --git a/website/src/public-route-registry.ts b/website/src/public-route-registry.ts
new file mode 100644
index 0000000..172ee9a
--- /dev/null
+++ b/website/src/public-route-registry.ts
@@ -0,0 +1,37 @@
+import routeRegistry from './public-route-registry.json'
+
+export type PublicRouteDefinition = {
+ path: string
+ label: string
+ loaderTitle: string
+ nav: boolean
+ footer: boolean
+ crawlable: boolean
+}
+
+export const publicRouteRegistry = routeRegistry as PublicRouteDefinition[]
+
+export const marketingNavLinks = publicRouteRegistry
+ .filter((route) => route.nav)
+ .map((route) => ({
+ to: route.path,
+ label: route.label,
+ }))
+
+export const footerLinks = publicRouteRegistry
+ .filter((route) => route.footer)
+ .map((route) => ({
+ to: route.path,
+ label: route.label,
+ }))
+
+export const crawlablePublicRoutes = publicRouteRegistry.filter((route) => route.crawlable)
+
+export function buildPublicSitemapXml(baseUrl = 'https://hypertwist.app') {
+ const normalizedBaseUrl = baseUrl.replace(/\/$/, '')
+ const urlEntries = crawlablePublicRoutes
+ .map((route) => ` \n ${normalizedBaseUrl}${route.path === '/' ? '/' : route.path}\n `)
+ .join('\n')
+
+ return `\n\n${urlEntries}\n\n`
+}
diff --git a/website/src/router/PublicRoutes.tsx b/website/src/router/PublicRoutes.tsx
index 76238e3..a2e25a6 100644
--- a/website/src/router/PublicRoutes.tsx
+++ b/website/src/router/PublicRoutes.tsx
@@ -1,6 +1,7 @@
import { Suspense, lazy, type ReactNode } from 'react'
import { Route } from 'react-router-dom'
import { GeneralPageLoader } from '../components/ui/Skeletons'
+import { publicRouteRegistry } from '../public-route-registry'
const HomeLanding = lazy(() => import('../pages/public-pages-marketing').then((m) => ({ default: m.HomeLanding })))
const FeaturesPage = lazy(() => import('../pages/public-pages-features').then((m) => ({ default: m.FeaturesPage })))
@@ -18,6 +19,24 @@ const ShippingPaymentPage = lazy(() => import('../pages/public-pages-commerce').
const LoginPage = lazy(() => import('../pages/auth-pages').then((m) => ({ default: m.LoginPage })))
const RegisterPage = lazy(() => import('../pages/auth-pages').then((m) => ({ default: m.RegisterPage })))
+const publicRouteElements: Record = {
+ '/': ,
+ '/features': ,
+ '/about': ,
+ '/resources': ,
+ '/docs': ,
+ '/support': ,
+ '/changelog': ,
+ '/pricing': ,
+ '/download': ,
+ '/open-source-notices': ,
+ '/privacy': ,
+ '/terms': ,
+ '/shipping-payment': ,
+ '/login': ,
+ '/register': ,
+}
+
function withGeneralLoader(title: string, element: ReactNode) {
return }>{element}
}
@@ -25,21 +44,13 @@ function withGeneralLoader(title: string, element: ReactNode) {
export function renderPublicRoutes() {
return (
<>
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
- )} />
+ {publicRouteRegistry.map((route) => (
+
+ ))}
>
)
}
diff --git a/website/src/site-data.ts b/website/src/site-data.ts
index 69020e7..dc83cac 100644
--- a/website/src/site-data.ts
+++ b/website/src/site-data.ts
@@ -26,15 +26,6 @@ export const capabilityPillars = [
},
] as const
-export const marketingNavLinks = [
- { to: '/', label: 'Home' },
- { to: '/features', label: 'Features' },
- { to: '/about', label: 'About' },
- { to: '/resources', label: 'Resources' },
- { to: '/pricing', label: 'Pricing' },
- { to: '/download', label: 'Download' },
-] as const
-
export const deliverySurfaceCards = [
{
title: 'Public website',
@@ -648,18 +639,6 @@ export const sourceAvailability = {
releaseNotesUrl,
}
-export const footerLinks = [
- { to: '/features', label: 'Features' },
- { to: '/about', label: 'About' },
- { to: '/resources', label: 'Resources' },
- { to: '/pricing', label: 'Pricing' },
- { to: '/download', label: 'Download' },
- { to: '/open-source-notices', label: 'Open Source Notices' },
- { to: '/privacy', label: 'Privacy' },
- { to: '/terms', label: 'Terms' },
- { to: '/shipping-payment', label: 'Shipping & Payment' },
-] as const
-
export const supportFaqs = [
{
question: 'Is the simulator fully in the browser?',