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
@@ -135,17 +135,72 @@ export async function buildServer() {
|
|||||||
// Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when
|
// Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when
|
||||||
// @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a
|
// @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a
|
||||||
// non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production.
|
// non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production.
|
||||||
|
// Only paths WITH a file extension are served by the plugin (hashed assets, favicons,
|
||||||
|
// sitemap.xml, ...). Page-shaped requests — '/', '/blog/<slug>', '/blog/<slug>/' — fall
|
||||||
|
// through to the not-found handler below (allowedPath:false → reply.callNotFound()),
|
||||||
|
// which serves the matching prerendered index.html or the app shell with
|
||||||
|
// Cache-Control: no-cache. Without this, the plugin's directory-index handling serves
|
||||||
|
// prerendered HTML itself, stamped with the 1y immutable header above — meant only for
|
||||||
|
// hashed assets — so browsers/crawlers would pin a year-stale page after every deploy.
|
||||||
|
index: false,
|
||||||
|
allowedPath: (pathName) => path.extname(pathName) !== '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public SPA route prefixes — keep in sync with apps/web/src/App.tsx routes.
|
||||||
|
const KNOWN_SPA_PREFIXES = ['/login', '/signup', '/forgot-password', '/reset-password', '/billing/', '/tools', '/blog', '/legal', '/app', '/admin'];
|
||||||
|
|
||||||
// SPA fallback: any non-/api path returns index.html. index.html itself must not be cached
|
// SPA fallback: any non-/api path returns index.html. index.html itself must not be cached
|
||||||
// for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy.
|
// for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy.
|
||||||
|
// Known SPA paths get 200; anything else (including missing static files) still gets
|
||||||
|
// index.html — so the client renders its NotFound page — but with a 404 status so
|
||||||
|
// crawlers don't index junk URLs as soft-200s.
|
||||||
app.setNotFoundHandler((req, reply) => {
|
app.setNotFoundHandler((req, reply) => {
|
||||||
if (req.raw.url?.startsWith('/api/')) {
|
if (req.raw.url?.startsWith('/api/')) {
|
||||||
return reply.code(404).send({ error: 'not_found' });
|
return reply.code(404).send({ error: 'not_found' });
|
||||||
}
|
}
|
||||||
|
const pathname = (req.raw.url ?? '').split('?')[0] ?? '';
|
||||||
|
|
||||||
|
// Prerendered pages: an extensionless path like /blog/some-post misses @fastify/static
|
||||||
|
// (no trailing slash → no directory index lookup) and lands here. If the build produced
|
||||||
|
// dist/blog/some-post/index.html, serve THAT file — crawlers must get the route-specific
|
||||||
|
// head tags, not the root app shell. Decode + resolve and require the result to stay
|
||||||
|
// inside webDist so encoded traversal (/..%2f..) can never escape the dist root.
|
||||||
|
let decodedPath: string | null = null;
|
||||||
|
try {
|
||||||
|
decodedPath = decodeURIComponent(pathname);
|
||||||
|
} catch {
|
||||||
|
decodedPath = null; // malformed percent-encoding → fall through to the SPA fallback
|
||||||
|
}
|
||||||
|
if (decodedPath && !decodedPath.includes('\0')) {
|
||||||
|
const relDir = decodedPath.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
const distRoot = path.resolve(webDist);
|
||||||
|
const candidate = path.resolve(distRoot, relDir, 'index.html');
|
||||||
|
if (
|
||||||
|
relDir.length > 0 &&
|
||||||
|
candidate.startsWith(distRoot + path.sep) &&
|
||||||
|
fs.existsSync(candidate)
|
||||||
|
) {
|
||||||
|
return reply
|
||||||
|
.code(200)
|
||||||
|
.header('Cache-Control', 'no-cache')
|
||||||
|
.type('text/html')
|
||||||
|
.sendFile(path.relative(distRoot, candidate).split(path.sep).join('/'), webDist, {
|
||||||
|
cacheControl: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isKnown =
|
||||||
|
pathname === '/' ||
|
||||||
|
KNOWN_SPA_PREFIXES.some((prefix) =>
|
||||||
|
prefix.endsWith('/')
|
||||||
|
? pathname.startsWith(prefix)
|
||||||
|
: pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||||
|
);
|
||||||
// cacheControl:false stops @fastify/static from stamping its own 1y immutable header
|
// cacheControl:false stops @fastify/static from stamping its own 1y immutable header
|
||||||
// (which would otherwise override the no-cache below and pin a stale app shell).
|
// (which would otherwise override the no-cache below and pin a stale app shell).
|
||||||
return reply
|
return reply
|
||||||
|
.code(isKnown ? 200 : 404)
|
||||||
.header('Cache-Control', 'no-cache')
|
.header('Cache-Control', 'no-cache')
|
||||||
.type('text/html')
|
.type('text/html')
|
||||||
.sendFile('index.html', webDist, { cacheControl: false });
|
.sendFile('index.html', webDist, { cacheControl: false });
|
||||||
|
|||||||
+13
-2
@@ -2,19 +2,30 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="/favicon.png" />
|
<link rel="icon" type="image/png" sizes="512x512" href="/favicon.png" />
|
||||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0052FF" />
|
<meta name="theme-color" content="#0052FF" />
|
||||||
<title>eLegal Software - All-in-One Practice Management for Law Firms</title>
|
<title>eLegal Software — Practice Management for Law Firms & Attorneys</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
|
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
|
||||||
/>
|
/>
|
||||||
|
<meta property="og:site_name" content="eLegal Software" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://elegalsoftware.com/" />
|
||||||
|
<meta property="og:title" content="eLegal Software — Practice Management for Law Firms & Attorneys" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="https://elegalsoftware.com/logo-dark.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Poppins:wght@500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@600;700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build && npx tsx scripts/prerender.mts && npx tsx scripts/generate-sitemap.mts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b --noEmit"
|
"typecheck": "tsc -b --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /app
|
||||||
|
Disallow: /admin
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /billing/
|
||||||
|
Disallow: /forgot-password
|
||||||
|
Disallow: /reset-password
|
||||||
|
|
||||||
|
Sitemap: https://elegalsoftware.com/sitemap.xml
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Generates dist/sitemap.xml from the public, indexable routes in src/seo/routes-meta.ts.
|
||||||
|
// Run after `vite build` (see the "build" script in package.json): npx tsx scripts/generate-sitemap.mts
|
||||||
|
// Paths are resolved from import.meta.url so the script works regardless of cwd.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SITEMAP_ROUTES, SITE_URL } from '../src/seo/routes-meta';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const distDir = path.resolve(__dirname, '../dist');
|
||||||
|
const outFile = path.join(distDir, 'sitemap.xml');
|
||||||
|
|
||||||
|
const lastmod = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
|
||||||
|
const urlEntries = SITEMAP_ROUTES.map((route) => {
|
||||||
|
// Root must be the origin with a trailing slash: https://elegalsoftware.com/
|
||||||
|
const loc = route.path === '/' ? `${SITE_URL}/` : `${SITE_URL}${route.path}`;
|
||||||
|
return [
|
||||||
|
' <url>',
|
||||||
|
` <loc>${loc}</loc>`,
|
||||||
|
` <lastmod>${lastmod}</lastmod>`,
|
||||||
|
' </url>',
|
||||||
|
].join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const xml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...urlEntries,
|
||||||
|
'</urlset>',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
fs.writeFileSync(outFile, xml, 'utf8');
|
||||||
|
|
||||||
|
console.log(`sitemap: wrote ${SITEMAP_ROUTES.length} URLs to ${outFile}`);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Prerenders every route in PRERENDER_ROUTES to static HTML under dist/, so crawlers
|
||||||
|
// receive full markup with per-route head tags without executing JS.
|
||||||
|
// Run AFTER `vite build` (see the "build" script in package.json): npx tsx scripts/prerender.mts
|
||||||
|
//
|
||||||
|
// How it works: a Vite dev server in middleware mode gives us ssrLoadModule — TSX,
|
||||||
|
// the '@/' alias, and CSS imports all resolve exactly as in the app build, so no
|
||||||
|
// separate SSR bundle is needed. The built dist/index.html is the template: its
|
||||||
|
// default <title>/description/og:/twitter: fallback tags are stripped (the per-route
|
||||||
|
// tags from renderHeadTags() would otherwise duplicate them), the route's head tags
|
||||||
|
// are injected before </head>, and the rendered app HTML is placed inside #root.
|
||||||
|
//
|
||||||
|
// NOTE: the client does NOT hydrate — main.tsx keeps createRoot().render(), which
|
||||||
|
// replaces the prerendered DOM on load. That is intentional (no mismatch risk).
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import type { RouteMeta } from '../src/seo/routes-meta';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webRoot = path.resolve(__dirname, '..');
|
||||||
|
const distDir = path.join(webRoot, 'dist');
|
||||||
|
const templatePath = path.join(distDir, 'index.html');
|
||||||
|
|
||||||
|
function fail(msg: string): never {
|
||||||
|
console.error(`prerender: FAILED — ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(templatePath)) {
|
||||||
|
fail(`${templatePath} not found — run \`vite build\` first`);
|
||||||
|
}
|
||||||
|
const rawTemplate = fs.readFileSync(templatePath, 'utf8');
|
||||||
|
|
||||||
|
// Strip the template's default SEO fallback tags (title, meta description, og:*,
|
||||||
|
// twitter:*). renderHeadTags() emits the per-route versions of all of them; leaving
|
||||||
|
// the defaults in would give crawlers duplicate/conflicting tags. [^>]* also matches
|
||||||
|
// newlines, covering the multi-line <meta> formatting in index.html.
|
||||||
|
const template = rawTemplate
|
||||||
|
.replace(/[ \t]*<title>[\s\S]*?<\/title>\s*\n?/i, '')
|
||||||
|
.replace(/[ \t]*<meta[^>]*name="description"[^>]*>\s*\n?/gi, '')
|
||||||
|
.replace(/[ \t]*<meta[^>]*property="og:[^"]*"[^>]*>\s*\n?/gi, '')
|
||||||
|
.replace(/[ \t]*<meta[^>]*name="twitter:[^"]*"[^>]*>\s*\n?/gi, '');
|
||||||
|
|
||||||
|
if (/<title>|property="og:|name="twitter:/i.test(template)) {
|
||||||
|
fail('template still contains default <title>/og:/twitter: tags after stripping — index.html format changed?');
|
||||||
|
}
|
||||||
|
if (!template.includes('<div id="root"></div>')) {
|
||||||
|
fail('template is missing `<div id="root"></div>` — cannot inject app HTML');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware mode + appType 'custom' = no HTTP server, no HTML middlewares — we only
|
||||||
|
// want ssrLoadModule. Root is apps/web so vite.config.ts (alias, envDir) applies.
|
||||||
|
const vite = await createServer({
|
||||||
|
root: webRoot,
|
||||||
|
logLevel: 'error',
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
let exitCode = 0;
|
||||||
|
try {
|
||||||
|
const { render } = (await vite.ssrLoadModule('/src/entry-server.tsx')) as {
|
||||||
|
render: (url: string) => string;
|
||||||
|
};
|
||||||
|
const { PRERENDER_ROUTES, metaForPath, renderHeadTags } = (await vite.ssrLoadModule(
|
||||||
|
'/src/seo/routes-meta.ts',
|
||||||
|
)) as {
|
||||||
|
PRERENDER_ROUTES: string[];
|
||||||
|
metaForPath: (p: string) => RouteMeta | undefined;
|
||||||
|
renderHeadTags: (m: RouteMeta) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (PRERENDER_ROUTES.length === 0) fail('PRERENDER_ROUTES is empty');
|
||||||
|
|
||||||
|
for (const route of PRERENDER_ROUTES) {
|
||||||
|
try {
|
||||||
|
const meta = metaForPath(route);
|
||||||
|
if (!meta) throw new Error(`no meta registered for ${route}`);
|
||||||
|
|
||||||
|
const appHtml = render(route);
|
||||||
|
if (!appHtml.trim()) throw new Error('rendered app HTML is empty');
|
||||||
|
|
||||||
|
const doc = template
|
||||||
|
.replace('</head>', ` ${renderHeadTags(meta)}\n </head>`)
|
||||||
|
.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`);
|
||||||
|
|
||||||
|
const outFile =
|
||||||
|
route === '/' ? templatePath : path.join(distDir, ...route.slice(1).split('/'), 'index.html');
|
||||||
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||||
|
fs.writeFileSync(outFile, doc, 'utf8');
|
||||||
|
console.log(`prerender: ok ${route} -> ${path.relative(webRoot, outFile)}`);
|
||||||
|
} catch (err) {
|
||||||
|
exitCode = 1;
|
||||||
|
console.error(`prerender: ERROR rendering ${route}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await vite.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitCode !== 0) fail('one or more routes failed (see errors above)');
|
||||||
|
console.log('prerender: all routes rendered');
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import { Seo } from '@/components/Seo';
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
|
import NotFoundPage from './pages/NotFoundPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import SignupPage from './pages/SignupPage';
|
import SignupPage from './pages/SignupPage';
|
||||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
||||||
@@ -44,6 +46,7 @@ import DpaPage from './pages/legal/DpaPage';
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Seo />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
@@ -94,7 +97,7 @@ export default function App() {
|
|||||||
<Route path="audit" element={<AdminAuditPage />} />
|
<Route path="audit" element={<AdminAuditPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<LandingPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CookieBanner />
|
<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 ? (
|
{p.coverImage ? (
|
||||||
<img
|
<img
|
||||||
src={p.coverImage}
|
src={p.coverImage}
|
||||||
alt=""
|
alt={`Cover image for article: ${p.title}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="absolute inset-0 h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
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 { useState } from 'react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
// FAQ content lives in routes-meta so the FAQPage JSON-LD stays in lockstep with the UI.
|
||||||
const ITEMS = [
|
import { FAQ_ITEMS as ITEMS } from '@/seo/routes-meta';
|
||||||
{
|
|
||||||
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 function Faq() {
|
export function Faq() {
|
||||||
const [open, setOpen] = useState<number | null>(0);
|
const [open, setOpen] = useState<number | null>(0);
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-04-08',
|
publishedAt: '2026-04-08',
|
||||||
readMinutes: 8,
|
readMinutes: 8,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
coverImage:
|
|
||||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/maximize-billable-hours-without-burnout.jpg',
|
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
@@ -92,8 +90,6 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-03-21',
|
publishedAt: '2026-03-21',
|
||||||
readMinutes: 12,
|
readMinutes: 12,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
coverImage:
|
|
||||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/client-intake-best-practices-2026.jpg',
|
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
@@ -162,8 +158,6 @@ export const POSTS: Post[] = [
|
|||||||
publishedAt: '2026-02-14',
|
publishedAt: '2026-02-14',
|
||||||
readMinutes: 10,
|
readMinutes: 10,
|
||||||
author: 'eLegal Software Team',
|
author: 'eLegal Software Team',
|
||||||
coverImage:
|
|
||||||
'https://elegalsoftware.nyc3.digitaloceanspaces.com/media/blog/legal-billing-software-comparison-2026.jpg',
|
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
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>
|
||||||
<div className="p-6 flex flex-col flex-1">
|
<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">
|
<h3 className="mt-2 text-lg font-semibold text-ink-900 group-hover:text-brand-700 transition leading-snug">
|
||||||
{p.title}
|
{p.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function BlogPostPage() {
|
|||||||
<div className="mt-6 flex items-center gap-3 text-sm text-ink-500">
|
<div className="mt-6 flex items-center gap-3 text-sm text-ink-500">
|
||||||
<span>{post.author}</span>
|
<span>{post.author}</span>
|
||||||
<span className="text-ink-300">·</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="text-ink-300">·</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.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