Queue-North-Website/scripts/lib/routes.js

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
}