133 lines
4.8 KiB
JavaScript
133 lines
4.8 KiB
JavaScript
// Every route this site serves, in one place.
|
|
//
|
|
// `scripts/prerender.js` used to build its own list while `src/routes.jsx` built
|
|
// the router's, and nothing compared them. A route added to one and not the
|
|
// other is not a small bug: the page is never prerendered, so `server/index.js`
|
|
// answers a direct request for it with `dist/404.html`, and every visitor
|
|
// following a link and every crawler reading the sitemap gets a 404 on a page
|
|
// the site's own navigation points at.
|
|
//
|
|
// Plain JavaScript with no side effects, because prerender imports it in Node
|
|
// before Vite exists, and so does the validator runner.
|
|
import { execFileSync } from 'child_process'
|
|
import path from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import { services } from '../../src/data/services.js'
|
|
import { industries } from '../../src/data/industries.js'
|
|
|
|
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
|
|
|
export const STATIC_ROUTES = [
|
|
'/',
|
|
'/about',
|
|
'/services',
|
|
'/industries',
|
|
'/contact',
|
|
'/support',
|
|
'/privacy-policy',
|
|
]
|
|
|
|
export const ROUTES = [
|
|
...STATIC_ROUTES,
|
|
...services.map((service) => `/services/${service.id}`),
|
|
...industries.map((industry) => `/industries/${industry.id}`),
|
|
]
|
|
|
|
const join = (base, path) => `${base}/${path}`.replace(/\/{2,}/g, '/')
|
|
|
|
/** Flattens the router's nested table into the paths it declares. */
|
|
export const routerPaths = (table, base = '') =>
|
|
table.flatMap((route) => {
|
|
const self = route.index ? base || '/' : route.path === '/' ? '/' : join(base, route.path ?? '')
|
|
const children = route.children ? routerPaths(route.children, self === '/' ? '' : self) : []
|
|
return [self, ...children]
|
|
})
|
|
|
|
/**
|
|
* Routes the router declares that nothing prerenders. A path with a parameter
|
|
* (`/services/:slug`) or the catch-all is covered by the data lists above
|
|
* rather than by a literal, so neither counts as drift.
|
|
*/
|
|
export const routeDrift = (table) => [
|
|
...new Set(
|
|
routerPaths(table)
|
|
.filter((route) => !route.includes(':') && !route.includes('*'))
|
|
.filter((route) => !ROUTES.includes(route)),
|
|
),
|
|
]
|
|
|
|
// --- sitemap dates -----------------------------------------------------------
|
|
//
|
|
// The files that produce each page. A page's `lastmod` is the newest commit
|
|
// date among them, never the build timestamp: a sitemap that marks every page
|
|
// as changed on every deploy is one search engines learn to ignore.
|
|
|
|
const ROUTE_SOURCES = {
|
|
'/': ['src/pages/Home.jsx'],
|
|
'/about': ['src/pages/About.jsx'],
|
|
'/services': ['src/pages/Services.jsx', 'src/data/services.js'],
|
|
'/industries': ['src/pages/Industries.jsx', 'src/data/industries.js'],
|
|
'/contact': ['src/pages/Contact.jsx'],
|
|
'/support': ['src/pages/Support.jsx'],
|
|
'/privacy-policy': ['src/pages/PrivacyPolicy.jsx', 'src/data/privacyPolicy.js'],
|
|
}
|
|
|
|
export const sourcesFor = (url) => {
|
|
if (ROUTE_SOURCES[url]) return ROUTE_SOURCES[url]
|
|
if (url.startsWith('/services/')) {
|
|
// A page with owner-approved copy has its own content file, so editing that
|
|
// copy moves that page's date and no other.
|
|
const slug = url.slice('/services/'.length)
|
|
return ['src/pages/ServiceDetail.jsx', 'src/data/services.js', `src/data/serviceContent/${slug}.js`]
|
|
}
|
|
return ['src/pages/IndustryDetail.jsx', 'src/data/industries.js']
|
|
}
|
|
|
|
const gitLastModified = (files) => {
|
|
let newest = null
|
|
for (const file of files) {
|
|
try {
|
|
const iso = execFileSync('git', ['log', '-1', '--format=%cI', '--', file], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
}).trim()
|
|
if (iso && (!newest || iso > newest)) newest = iso
|
|
} catch {
|
|
// git is unavailable, or the file is untracked. Either way, no date from
|
|
// this file. Whether that leaves the ROUTE undated is the caller's
|
|
// problem to report, and it must not pass silently: the production
|
|
// sitemap carried no dates at all for months because this catch was the
|
|
// end of the story.
|
|
}
|
|
}
|
|
return newest ? newest.slice(0, 10) : null
|
|
}
|
|
|
|
/**
|
|
* Route to YYYY-MM-DD, for the sitemap.
|
|
*
|
|
* The image build has no git: `.dockerignore` excludes `.git` and node:alpine
|
|
* ships no git binary. So a map computed where git DOES exist can be injected
|
|
* through SITEMAP_LASTMOD, which is what scripts/release.sh does.
|
|
*/
|
|
export const lastModByRoute = () => {
|
|
const injected = process.env.SITEMAP_LASTMOD
|
|
if (injected) {
|
|
try {
|
|
const parsed = JSON.parse(injected)
|
|
if (parsed && typeof parsed === 'object') return parsed
|
|
console.warn('routes: SITEMAP_LASTMOD is not an object, so falling back to git.')
|
|
} catch (error) {
|
|
console.warn(`routes: SITEMAP_LASTMOD is not valid JSON (${error.message}), so falling back to git.`)
|
|
}
|
|
}
|
|
|
|
const dates = {}
|
|
for (const route of ROUTES) {
|
|
const date = gitLastModified(sourcesFor(route))
|
|
if (date) dates[route] = date
|
|
}
|
|
return dates
|
|
}
|