Centralize public route authority
This commit is contained in:
parent
8d6334a34d
commit
d498131dd8
10 changed files with 260 additions and 38 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
24
website/scripts/render-public-sitemap.mjs
Normal file
24
website/scripts/render-public-sitemap.mjs
Normal file
|
|
@ -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 = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...crawlableRoutes.map((route) => (
|
||||
` <url>\n <loc>${baseUrl}${route.path === '/' ? '/' : route.path}</loc>\n </url>`
|
||||
)),
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
await writeFile(sitemapPath, xml, 'utf8')
|
||||
console.log(`Rendered sitemap for ${crawlableRoutes.length} crawlable routes to ${sitemapPath}`)
|
||||
46
website/src/__tests__/public-route-registry.test.ts
Normal file
46
website/src/__tests__/public-route-registry.test.ts
Normal file
|
|
@ -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('<loc>https://hypertwist.app/</loc>')
|
||||
expect(xml).toContain('<loc>https://hypertwist.app/features</loc>')
|
||||
expect(xml).not.toContain('/login')
|
||||
expect(xml).not.toContain('/register')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
122
website/src/public-route-registry.json
Normal file
122
website/src/public-route-registry.json
Normal file
|
|
@ -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
|
||||
}
|
||||
]
|
||||
37
website/src/public-route-registry.ts
Normal file
37
website/src/public-route-registry.ts
Normal file
|
|
@ -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) => ` <url>\n <loc>${normalizedBaseUrl}${route.path === '/' ? '/' : route.path}</loc>\n </url>`)
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urlEntries}\n</urlset>\n`
|
||||
}
|
||||
|
|
@ -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<string, ReactNode> = {
|
||||
'/': <HomeLanding />,
|
||||
'/features': <FeaturesPage />,
|
||||
'/about': <AboutPage />,
|
||||
'/resources': <ResourcesPage />,
|
||||
'/docs': <DocsPage />,
|
||||
'/support': <SupportPage />,
|
||||
'/changelog': <ChangelogPage />,
|
||||
'/pricing': <PricingPage />,
|
||||
'/download': <DownloadPage />,
|
||||
'/open-source-notices': <OpenSourceNoticesPage />,
|
||||
'/privacy': <PrivacyPage />,
|
||||
'/terms': <TermsPage />,
|
||||
'/shipping-payment': <ShippingPaymentPage />,
|
||||
'/login': <LoginPage />,
|
||||
'/register': <RegisterPage />,
|
||||
}
|
||||
|
||||
function withGeneralLoader(title: string, element: ReactNode) {
|
||||
return <Suspense fallback={<GeneralPageLoader title={title} />}>{element}</Suspense>
|
||||
}
|
||||
|
|
@ -25,21 +44,13 @@ function withGeneralLoader(title: string, element: ReactNode) {
|
|||
export function renderPublicRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/" element={withGeneralLoader('Loading HyperTwist...', <HomeLanding />)} />
|
||||
<Route path="/features" element={withGeneralLoader('Feature atlas', <FeaturesPage />)} />
|
||||
<Route path="/about" element={withGeneralLoader('About HyperTwist', <AboutPage />)} />
|
||||
<Route path="/resources" element={withGeneralLoader('Resources', <ResourcesPage />)} />
|
||||
<Route path="/docs" element={withGeneralLoader('Documentation', <DocsPage />)} />
|
||||
<Route path="/support" element={withGeneralLoader('Support', <SupportPage />)} />
|
||||
<Route path="/changelog" element={withGeneralLoader('Release notes', <ChangelogPage />)} />
|
||||
<Route path="/pricing" element={withGeneralLoader('Pricing', <PricingPage />)} />
|
||||
<Route path="/download" element={withGeneralLoader('Download center', <DownloadPage />)} />
|
||||
<Route path="/open-source-notices" element={withGeneralLoader('Open source notices', <OpenSourceNoticesPage />)} />
|
||||
<Route path="/privacy" element={withGeneralLoader('Privacy', <PrivacyPage />)} />
|
||||
<Route path="/terms" element={withGeneralLoader('Terms', <TermsPage />)} />
|
||||
<Route path="/shipping-payment" element={withGeneralLoader('Shipping & payment', <ShippingPaymentPage />)} />
|
||||
<Route path="/login" element={withGeneralLoader('Log in', <LoginPage />)} />
|
||||
<Route path="/register" element={withGeneralLoader('Create account', <RegisterPage />)} />
|
||||
{publicRouteRegistry.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={withGeneralLoader(route.loaderTitle, publicRouteElements[route.path])}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue