SEO prerender pipeline + register AI routes and tighten CSP
CI / build-and-test (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
- Static prerender + sitemap/robots for marketing, blog, and tool pages (entry-server, Seo component, prerender/sitemap scripts, NotFoundPage) - Register aiRoutes and extend CSP for Turnstile + Spaces image hosts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
304f7f30c3
commit
1f249dc126
@@ -1,5 +1,7 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Seo } from '@/components/Seo';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SignupPage from './pages/SignupPage';
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
||||
@@ -44,6 +46,7 @@ import DpaPage from './pages/legal/DpaPage';
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Seo />
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
@@ -94,7 +97,7 @@ export default function App() {
|
||||
<Route path="audit" element={<AdminAuditPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<LandingPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
<CookieBanner />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
metaForPath,
|
||||
canonicalUrl,
|
||||
NOT_FOUND_META,
|
||||
DEFAULT_OG_IMAGE,
|
||||
SITE_NAME,
|
||||
type RouteMeta,
|
||||
} from '@/seo/routes-meta';
|
||||
|
||||
// Mounted ONCE in App.tsx, above <Routes>. Keeps the document head in sync with the
|
||||
// current route from the routes-meta map. Prerendered pages ship the same tags
|
||||
// (stamped data-seo) baked into their static HTML; this component replaces them on
|
||||
// client-side navigation so the two systems never fight.
|
||||
|
||||
function upsertMeta(attr: 'name' | 'property', key: string, content: string) {
|
||||
let el = document.head.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement('meta');
|
||||
el.setAttribute(attr, key);
|
||||
el.setAttribute('data-seo', '1');
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute('content', content);
|
||||
}
|
||||
|
||||
function removeMeta(attr: 'name' | 'property', key: string) {
|
||||
document.head.querySelector(`meta[${attr}="${key}"]`)?.remove();
|
||||
}
|
||||
|
||||
export function Seo() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const meta: RouteMeta = metaForPath(pathname) ?? NOT_FOUND_META;
|
||||
const url = canonicalUrl(meta.path || pathname);
|
||||
|
||||
document.title = meta.title;
|
||||
upsertMeta('name', 'description', meta.description);
|
||||
|
||||
// Canonical for indexable pages; robots noindex otherwise (never both).
|
||||
if (meta.noindex) {
|
||||
document.head.querySelector('link[rel="canonical"]')?.remove();
|
||||
upsertMeta('name', 'robots', 'noindex, nofollow');
|
||||
} else {
|
||||
removeMeta('name', 'robots');
|
||||
let link = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.setAttribute('rel', 'canonical');
|
||||
link.setAttribute('data-seo', '1');
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.setAttribute('href', url);
|
||||
}
|
||||
|
||||
upsertMeta('property', 'og:site_name', SITE_NAME);
|
||||
upsertMeta('property', 'og:type', meta.ogType ?? 'website');
|
||||
upsertMeta('property', 'og:url', url);
|
||||
upsertMeta('property', 'og:title', meta.title);
|
||||
upsertMeta('property', 'og:description', meta.description);
|
||||
upsertMeta('property', 'og:image', DEFAULT_OG_IMAGE);
|
||||
upsertMeta('name', 'twitter:card', 'summary');
|
||||
upsertMeta('name', 'twitter:title', meta.title);
|
||||
upsertMeta('name', 'twitter:description', meta.description);
|
||||
|
||||
// JSON-LD: replace wholesale (covers both prerendered and prior-route scripts).
|
||||
document.head.querySelectorAll('script[type="application/ld+json"]').forEach((s) => s.remove());
|
||||
for (const ld of meta.jsonLd ?? []) {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'application/ld+json';
|
||||
script.setAttribute('data-seo', '1');
|
||||
script.textContent = JSON.stringify(ld);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function BlogTeaser() {
|
||||
{p.coverImage ? (
|
||||
<img
|
||||
src={p.coverImage}
|
||||
alt=""
|
||||
alt={`Cover image for article: ${p.title}`}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
/>
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
q: 'What makes eLegal Software different from other legal software?',
|
||||
a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.',
|
||||
},
|
||||
{
|
||||
q: 'Can I try eLegal Software before committing?',
|
||||
a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.',
|
||||
},
|
||||
{
|
||||
q: 'How does client billing and payment processing work?',
|
||||
a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.',
|
||||
},
|
||||
{
|
||||
q: 'Can I import my existing cases and client data?',
|
||||
a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.',
|
||||
},
|
||||
{
|
||||
q: 'Is my client data secure and compliant?',
|
||||
a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.',
|
||||
},
|
||||
{
|
||||
q: 'What happens if I need to cancel?',
|
||||
a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.',
|
||||
},
|
||||
];
|
||||
// FAQ content lives in routes-meta so the FAQPage JSON-LD stays in lockstep with the UI.
|
||||
import { FAQ_ITEMS as ITEMS } from '@/seo/routes-meta';
|
||||
|
||||
export function Faq() {
|
||||
const [open, setOpen] = useState<number | null>(0);
|
||||
|
||||
@@ -28,8 +28,6 @@ export const POSTS: Post[] = [
|
||||
publishedAt: '2026-04-08',
|
||||
readMinutes: 8,
|
||||
author: 'eLegal Software Team',
|
||||
coverImage:
|
||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/maximize-billable-hours-without-burnout.jpg',
|
||||
body: [
|
||||
{
|
||||
type: 'p',
|
||||
@@ -92,8 +90,6 @@ export const POSTS: Post[] = [
|
||||
publishedAt: '2026-03-21',
|
||||
readMinutes: 12,
|
||||
author: 'eLegal Software Team',
|
||||
coverImage:
|
||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/client-intake-best-practices-2026.jpg',
|
||||
body: [
|
||||
{
|
||||
type: 'p',
|
||||
@@ -162,8 +158,6 @@ export const POSTS: Post[] = [
|
||||
publishedAt: '2026-02-14',
|
||||
readMinutes: 10,
|
||||
author: 'eLegal Software Team',
|
||||
coverImage:
|
||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/legal-billing-software-comparison-2026.jpg',
|
||||
body: [
|
||||
{
|
||||
type: 'p',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Server-side rendering entry, used ONLY by scripts/prerender.mts at build time
|
||||
// (loaded through Vite's ssrLoadModule — never shipped to the browser).
|
||||
// Deliberately does NOT import main.tsx: that file initializes Sentry and calls
|
||||
// createRoot() at module scope, both of which are browser-only concerns.
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import { StaticRouter } from 'react-router-dom/server';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
|
||||
/** Render the app for a given URL to an HTML string (no effects run, no data fetching). */
|
||||
export function render(url: string): string {
|
||||
// Fresh client per render so no cache state leaks between prerendered routes.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
return ReactDOMServer.renderToString(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StaticRouter location={url}>
|
||||
<App />
|
||||
</StaticRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
} finally {
|
||||
queryClient.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-24 max-w-2xl text-center">
|
||||
<p className="text-xs uppercase tracking-wider text-brand-600 font-semibold">404</p>
|
||||
<h1 className="mt-2 text-3xl md:text-4xl font-bold text-ink-950 font-display">
|
||||
Page not found
|
||||
</h1>
|
||||
<p className="mt-4 text-ink-600">
|
||||
The page you're looking for doesn't exist or has moved.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link to="/" className="btn-primary text-sm">
|
||||
Home
|
||||
</Link>
|
||||
<Link to="/tools" className="btn-secondary text-sm">
|
||||
Free tools
|
||||
</Link>
|
||||
<Link to="/blog" className="btn-secondary text-sm">
|
||||
Blog
|
||||
</Link>
|
||||
<Link to="/login" className="btn-ghost text-sm">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,9 @@ export default function BlogIndexPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
|
||||
<p className="text-xs text-ink-500">
|
||||
<time dateTime={p.publishedAt}>{formatDate(p.publishedAt)}</time>
|
||||
</p>
|
||||
<h3 className="mt-2 text-lg font-semibold text-ink-900 group-hover:text-brand-700 transition leading-snug">
|
||||
{p.title}
|
||||
</h3>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function BlogPostPage() {
|
||||
<div className="mt-6 flex items-center gap-3 text-sm text-ink-500">
|
||||
<span>{post.author}</span>
|
||||
<span className="text-ink-300">·</span>
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
||||
<span className="text-ink-300">·</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// Single source of truth for public-route SEO metadata.
|
||||
// Consumed by three things — keep them in mind when editing:
|
||||
// 1. <Seo /> (client) — updates document head on navigation
|
||||
// 2. scripts/prerender.mts — injects head tags into static HTML at build time
|
||||
// 3. scripts/generate-sitemap.mts — emits sitemap.xml for indexable routes
|
||||
// IMPORTANT: imports here must stay RELATIVE (no '@/' alias) and side-effect-free,
|
||||
// because the build scripts execute this module under tsx/node outside Vite.
|
||||
import { POSTS } from '../content/posts';
|
||||
|
||||
export const SITE_URL = 'https://elegalsoftware.com';
|
||||
export const SITE_NAME = 'eLegal Software';
|
||||
export const DEFAULT_OG_IMAGE = `${SITE_URL}/logo-dark.png`;
|
||||
|
||||
// FAQ content lives here (not in Faq.tsx) so the FAQPage JSON-LD and the rendered
|
||||
// accordion can never drift apart. Faq.tsx imports this.
|
||||
export const FAQ_ITEMS = [
|
||||
{
|
||||
q: 'What makes eLegal Software different from other legal software?',
|
||||
a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.',
|
||||
},
|
||||
{
|
||||
q: 'Can I try eLegal Software before committing?',
|
||||
a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.',
|
||||
},
|
||||
{
|
||||
q: 'How does client billing and payment processing work?',
|
||||
a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.',
|
||||
},
|
||||
{
|
||||
q: 'Can I import my existing cases and client data?',
|
||||
a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.',
|
||||
},
|
||||
{
|
||||
q: 'Is my client data secure and compliant?',
|
||||
a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.',
|
||||
},
|
||||
{
|
||||
q: 'What happens if I need to cancel?',
|
||||
a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.',
|
||||
},
|
||||
];
|
||||
|
||||
export interface RouteMeta {
|
||||
path: string;
|
||||
title: string;
|
||||
description: string;
|
||||
noindex?: boolean;
|
||||
ogType?: 'website' | 'article';
|
||||
jsonLd?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const ORGANIZATION_LD = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/logo-dark.png`,
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
email: 'contact@elegalsoftware.com',
|
||||
contactType: 'customer support',
|
||||
},
|
||||
};
|
||||
|
||||
const SOFTWARE_LD = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: SITE_NAME,
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
url: SITE_URL,
|
||||
description:
|
||||
'All-in-one practice management for law firms: case management, billable-hours tracking, secure document storage, and invoicing.',
|
||||
offers: [
|
||||
{ '@type': 'Offer', name: 'Starter', price: '0', priceCurrency: 'USD' },
|
||||
{ '@type': 'Offer', name: 'Professional', price: '25', priceCurrency: 'USD' },
|
||||
{ '@type': 'Offer', name: 'Lifetime', price: '129', priceCurrency: 'USD' },
|
||||
],
|
||||
};
|
||||
|
||||
const FAQ_LD = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: item.a },
|
||||
})),
|
||||
};
|
||||
|
||||
const STATIC_ROUTES: RouteMeta[] = [
|
||||
{
|
||||
path: '/',
|
||||
title: 'eLegal Software — Practice Management for Law Firms & Attorneys',
|
||||
description:
|
||||
'Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys. Free to start — no credit card required.',
|
||||
jsonLd: [ORGANIZATION_LD, SOFTWARE_LD, FAQ_LD],
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
title: 'Sign In — eLegal Software',
|
||||
description: 'Log in to eLegal Software to manage your cases, billable hours, and invoices.',
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
title: 'Start Your Free Trial — eLegal Software',
|
||||
description:
|
||||
'Create your free eLegal Software account in under a minute. Manage cases, track billable hours, and send invoices — no credit card required.',
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
title: 'Reset Your Password — eLegal Software',
|
||||
description: 'Request a password reset link for your eLegal Software account.',
|
||||
noindex: true,
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
title: 'Choose a New Password — eLegal Software',
|
||||
description: 'Set a new password for your eLegal Software account.',
|
||||
noindex: true,
|
||||
},
|
||||
{
|
||||
path: '/billing/success',
|
||||
title: 'Payment Successful — eLegal Software',
|
||||
description: 'Your eLegal Software subscription is active.',
|
||||
noindex: true,
|
||||
},
|
||||
{
|
||||
path: '/billing/cancel',
|
||||
title: 'Checkout Canceled — eLegal Software',
|
||||
description: 'Your checkout was canceled — no charge was made.',
|
||||
noindex: true,
|
||||
},
|
||||
{
|
||||
path: '/tools',
|
||||
title: 'Free Tools for Attorneys & Law Firms — eLegal Software',
|
||||
description:
|
||||
'Free calculators and tools for legal professionals: hourly rate calculator, case profitability analyzer, billable hours tracker, and document templates.',
|
||||
},
|
||||
{
|
||||
path: '/tools/hourly-rate-calculator',
|
||||
title: 'Attorney Hourly Rate Calculator (Free) — eLegal Software',
|
||||
description:
|
||||
'Work out the hourly rate your practice actually needs — factoring target income, billable utilization, overhead, and taxes. Free, no signup required.',
|
||||
},
|
||||
{
|
||||
path: '/tools/case-profitability',
|
||||
title: 'Case Profitability Analyzer for Law Firms (Free) — eLegal Software',
|
||||
description:
|
||||
'Analyze whether a case or matter is profitable: fees, hours, effective rate, and margin — before and after write-offs. Free tool for attorneys.',
|
||||
},
|
||||
{
|
||||
path: '/tools/billable-hours-tracker',
|
||||
title: 'Free Billable Hours Tracker for Attorneys — eLegal Software',
|
||||
description:
|
||||
'Track billable time in your browser with a running timer and daily target — then see what those hours are worth. Free, no signup required.',
|
||||
},
|
||||
{
|
||||
path: '/tools/document-templates',
|
||||
title: 'Free Legal Document Templates for Small Firms — eLegal Software',
|
||||
description:
|
||||
'Starting points for engagement letters, intake forms, demand letters, and more. Copy, adapt with your attorney, and use in your practice.',
|
||||
},
|
||||
{
|
||||
path: '/blog',
|
||||
title: 'Legal Practice Management Blog — eLegal Software',
|
||||
description:
|
||||
'Practical guides for running a profitable law practice: billable hours, client intake, billing software, and firm operations.',
|
||||
},
|
||||
{
|
||||
path: '/legal',
|
||||
title: 'Legal Center — eLegal Software',
|
||||
description:
|
||||
'Every document that governs your use of eLegal Software: terms, privacy, billing, acceptable use, DMCA, and data processing.',
|
||||
},
|
||||
{
|
||||
path: '/legal/terms',
|
||||
title: 'Terms of Service — eLegal Software',
|
||||
description: 'The agreement that governs your use of eLegal Software.',
|
||||
},
|
||||
{
|
||||
path: '/legal/privacy',
|
||||
title: 'Privacy Policy — eLegal Software',
|
||||
description: 'What eLegal Software collects, why, where it lives, and the rights you have over it.',
|
||||
},
|
||||
{
|
||||
path: '/legal/cookies',
|
||||
title: 'Cookie Policy — eLegal Software',
|
||||
description: 'The essential-only cookies eLegal Software sets and how to control them.',
|
||||
},
|
||||
{
|
||||
path: '/legal/acceptable-use',
|
||||
title: 'Acceptable Use Policy — eLegal Software',
|
||||
description: 'What you may not do on the eLegal Software platform.',
|
||||
},
|
||||
{
|
||||
path: '/legal/refunds',
|
||||
title: 'Billing & Refund Policy — eLegal Software',
|
||||
description: 'How subscriptions, renewals, cancellations, and refunds work at eLegal Software.',
|
||||
},
|
||||
{
|
||||
path: '/legal/disclaimer',
|
||||
title: 'Legal Disclaimer — eLegal Software',
|
||||
description: 'eLegal Software is software, not a law firm — no legal advice, no attorney-client relationship.',
|
||||
},
|
||||
{
|
||||
path: '/legal/dmca',
|
||||
title: 'DMCA & Copyright Policy — eLegal Software',
|
||||
description: 'How to report copyright infringement on eLegal Software, and how counter-notices work.',
|
||||
},
|
||||
{
|
||||
path: '/legal/dpa',
|
||||
title: 'Data Processing Addendum — eLegal Software',
|
||||
description: 'How eLegal Software processes practice data on your behalf: security, subprocessors, breach notice.',
|
||||
},
|
||||
{
|
||||
path: '/app',
|
||||
title: 'Dashboard — eLegal Software',
|
||||
description: 'Your eLegal Software workspace.',
|
||||
noindex: true,
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
title: 'Admin — eLegal Software',
|
||||
description: 'eLegal Software administration.',
|
||||
noindex: true,
|
||||
},
|
||||
];
|
||||
|
||||
const BLOG_ROUTES: RouteMeta[] = POSTS.map((post) => ({
|
||||
path: `/blog/${post.slug}`,
|
||||
title: `${post.title} — ${SITE_NAME}`,
|
||||
description: post.description,
|
||||
ogType: 'article' as const,
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
datePublished: post.publishedAt,
|
||||
author: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL },
|
||||
publisher: { '@type': 'Organization', name: SITE_NAME, logo: { '@type': 'ImageObject', url: `${SITE_URL}/logo-dark.png` } },
|
||||
mainEntityOfPage: `${SITE_URL}/blog/${post.slug}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
export const ALL_ROUTES: RouteMeta[] = [...STATIC_ROUTES, ...BLOG_ROUTES];
|
||||
|
||||
/** Routes to prerender to static HTML at build time (everything public & static). */
|
||||
export const PRERENDER_ROUTES: string[] = ALL_ROUTES.filter(
|
||||
(r) => r.path !== '/app' && r.path !== '/admin' && r.path !== '/reset-password',
|
||||
).map((r) => r.path);
|
||||
|
||||
/** Routes that belong in sitemap.xml (public and indexable). */
|
||||
export const SITEMAP_ROUTES: RouteMeta[] = ALL_ROUTES.filter((r) => !r.noindex);
|
||||
|
||||
export function metaForPath(pathname: string): RouteMeta | undefined {
|
||||
const clean = pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
const exact = ALL_ROUTES.find((r) => r.path === clean);
|
||||
if (exact) return exact;
|
||||
// Authed sections: any nested path inherits the section's noindex meta.
|
||||
if (clean.startsWith('/app/')) return ALL_ROUTES.find((r) => r.path === '/app');
|
||||
if (clean.startsWith('/admin/')) return ALL_ROUTES.find((r) => r.path === '/admin');
|
||||
return undefined; // unknown → <Seo /> falls back to a noindex not-found meta
|
||||
}
|
||||
|
||||
export const NOT_FOUND_META: RouteMeta = {
|
||||
path: '',
|
||||
title: 'Page Not Found — eLegal Software',
|
||||
description: 'The page you were looking for does not exist.',
|
||||
noindex: true,
|
||||
};
|
||||
|
||||
// ── Static head rendering (used by scripts/prerender.mts) ──────────────────
|
||||
// Every generated tag carries data-seo so the client <Seo /> can replace them
|
||||
// wholesale on navigation without duplicating.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function canonicalUrl(path: string): string {
|
||||
return path === '/' ? `${SITE_URL}/` : `${SITE_URL}${path}`;
|
||||
}
|
||||
|
||||
export function renderHeadTags(meta: RouteMeta): string {
|
||||
const url = canonicalUrl(meta.path);
|
||||
const t = escapeHtml(meta.title);
|
||||
const d = escapeHtml(meta.description);
|
||||
const tags = [
|
||||
`<title>${t}</title>`,
|
||||
`<meta name="description" content="${d}" data-seo="1">`,
|
||||
meta.noindex
|
||||
? `<meta name="robots" content="noindex, nofollow" data-seo="1">`
|
||||
: `<link rel="canonical" href="${url}" data-seo="1">`,
|
||||
`<meta property="og:site_name" content="${escapeHtml(SITE_NAME)}" data-seo="1">`,
|
||||
`<meta property="og:type" content="${meta.ogType ?? 'website'}" data-seo="1">`,
|
||||
`<meta property="og:url" content="${url}" data-seo="1">`,
|
||||
`<meta property="og:title" content="${t}" data-seo="1">`,
|
||||
`<meta property="og:description" content="${d}" data-seo="1">`,
|
||||
`<meta property="og:image" content="${DEFAULT_OG_IMAGE}" data-seo="1">`,
|
||||
`<meta name="twitter:card" content="summary" data-seo="1">`,
|
||||
`<meta name="twitter:title" content="${t}" data-seo="1">`,
|
||||
`<meta name="twitter:description" content="${d}" data-seo="1">`,
|
||||
...(meta.jsonLd ?? []).map(
|
||||
(ld) => `<script type="application/ld+json" data-seo="1">${JSON.stringify(ld)}</script>`,
|
||||
),
|
||||
];
|
||||
return tags.join('\n ');
|
||||
}
|
||||
Reference in New Issue
Block a user