279 lines
12 KiB
JavaScript
279 lines
12 KiB
JavaScript
|
|
// What must be true of the content layer before a page is built from it.
|
|||
|
|
//
|
|||
|
|
// ## Why this exists
|
|||
|
|
//
|
|||
|
|
// `src/data/*.js` is prose in a data structure, and nothing checked it. A
|
|||
|
|
// misspelt block type, a link to a route that does not exist, a section whose
|
|||
|
|
// answer paragraph is missing, a missing `benefits` list that crashes the
|
|||
|
|
// prerender with `Cannot read properties of undefined`: each was found by a
|
|||
|
|
// person looking at a page, or not found at all.
|
|||
|
|
//
|
|||
|
|
// The long-form service pages made that worse in a specific way. Their copy is
|
|||
|
|
// owner-approved and arrives as a markdown sheet that MIXES DIRECTIONS TO THE
|
|||
|
|
// WEBSITE MANAGER INTO THE COPY: "Do not promise that every number is always
|
|||
|
|
// portable", "Keep this factual:", "Place an official screenshot". Those lines
|
|||
|
|
// look exactly like copy. Publishing one puts an internal instruction on a
|
|||
|
|
// customer-facing page, and no build step would have noticed.
|
|||
|
|
//
|
|||
|
|
// So this refuses to build rather than publish. It runs inside prerender.js,
|
|||
|
|
// before any page renders, which means every `npm run build` enforces it: the
|
|||
|
|
// pre-commit hook, `npm run verify`, and the Docker image build.
|
|||
|
|
//
|
|||
|
|
// Exit codes for the runner: 0 clean, 1 findings, 2 nothing was checked.
|
|||
|
|
import { existsSync } from 'fs'
|
|||
|
|
import path from 'path'
|
|||
|
|
import { fileURLToPath } from 'url'
|
|||
|
|
import { ROUTES } from './routes.js'
|
|||
|
|
|
|||
|
|
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
|||
|
|
|
|||
|
|
// A block type, and the field it cannot be missing.
|
|||
|
|
const BLOCK_FIELDS = {
|
|||
|
|
p: 'text',
|
|||
|
|
h3: 'text',
|
|||
|
|
ul: 'items',
|
|||
|
|
ol: 'items',
|
|||
|
|
callout: 'text',
|
|||
|
|
image: 'alt',
|
|||
|
|
links: 'items',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ID_SHAPE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
|
|||
|
|
// Ids already used by the layout. A section id that collides with one of these
|
|||
|
|
// makes `#contact-form` scroll to the wrong element.
|
|||
|
|
const RESERVED_IDS = new Set(['contact-form', 'mobile-nav-content', 'support-portal', 'root'])
|
|||
|
|
|
|||
|
|
// Directions to the website manager, which the sheets mix into their copy.
|
|||
|
|
const SHEET_DIRECTIONS = [
|
|||
|
|
/website manager/i,
|
|||
|
|
/not for the (public )?webpage/i,
|
|||
|
|
/place an official/i,
|
|||
|
|
/do not add faq/i,
|
|||
|
|
/this section is (important|highly useful)/i,
|
|||
|
|
/\bQ\d+:/,
|
|||
|
|
/approved visual/i,
|
|||
|
|
/suggested (alt text|caption)/i,
|
|||
|
|
/before publication/i,
|
|||
|
|
/avoid (unsupported|promising|implying)/i,
|
|||
|
|
/link naturally to/i,
|
|||
|
|
/keep this factual/i,
|
|||
|
|
/factual differentiators/i,
|
|||
|
|
/reliability wording/i,
|
|||
|
|
/^direct[- ]answer/i,
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
// Markdown that would print literally on the page, and characters that must not
|
|||
|
|
// reach it. React escapes text, so pasted markup shows up as itself.
|
|||
|
|
const TEXT_FAULTS = [
|
|||
|
|
[/—/, 'an em dash (U+2014), which Null asked for nowhere in site copy'],
|
|||
|
|
[/<2F>/, 'a U+FFFD replacement character, so the source was decoded with the wrong encoding'],
|
|||
|
|
[/\*\*/, 'markdown bold markers'],
|
|||
|
|
[/`/, 'a backtick'],
|
|||
|
|
[/\]\(/, 'a markdown link'],
|
|||
|
|
[/<\/?[a-z][^>]*>/i, 'an HTML tag'],
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
export const validateContent = ({ services, industries }) => {
|
|||
|
|
const errors = []
|
|||
|
|
const warnings = []
|
|||
|
|
let checked = 0
|
|||
|
|
|
|||
|
|
const fail = (where, detail) => errors.push(`${where}: ${detail}`)
|
|||
|
|
|
|||
|
|
// Every string that reaches a page, wherever it sits in the tree.
|
|||
|
|
const checkText = (where, value) => {
|
|||
|
|
if (typeof value !== 'string') return
|
|||
|
|
checked += 1
|
|||
|
|
for (const [pattern, detail] of TEXT_FAULTS) {
|
|||
|
|
if (pattern.test(value)) fail(where, `${detail} in ${JSON.stringify(value.slice(0, 60))}`)
|
|||
|
|
}
|
|||
|
|
for (const pattern of SHEET_DIRECTIONS) {
|
|||
|
|
if (pattern.test(value)) {
|
|||
|
|
fail(where, `reads as a direction to the website manager, not page copy: ${JSON.stringify(value.slice(0, 80))}`)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const checkLink = (where, to, ownIds) => {
|
|||
|
|
checked += 1
|
|||
|
|
if (typeof to !== 'string' || !to) return fail(where, 'link has no destination')
|
|||
|
|
const [route, fragment] = to.split('#')
|
|||
|
|
if (route && !ROUTES.includes(route)) fail(where, `links to ${route}, which is not a route this site serves`)
|
|||
|
|
if (fragment && !route && !ownIds.has(fragment) && !RESERVED_IDS.has(fragment)) {
|
|||
|
|
fail(where, `links to #${fragment}, which is not a section on this page`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const checkBlocks = (where, section, ownIds) => {
|
|||
|
|
const blocks = section.blocks
|
|||
|
|
if (!Array.isArray(blocks) || blocks.length === 0) return fail(where, 'has no blocks')
|
|||
|
|
|
|||
|
|
blocks.forEach((block, index) => {
|
|||
|
|
const at = `${where} blocks[${index}]`
|
|||
|
|
const field = BLOCK_FIELDS[block?.type]
|
|||
|
|
if (!field) {
|
|||
|
|
return fail(at, `unknown block type ${JSON.stringify(block?.type)}. Known types: ${Object.keys(BLOCK_FIELDS).join(', ')}`)
|
|||
|
|
}
|
|||
|
|
if (block[field] == null) return fail(at, `a ${block.type} block has no ${field}`)
|
|||
|
|
checked += 1
|
|||
|
|
|
|||
|
|
if (block.type === 'p') {
|
|||
|
|
if (Array.isArray(block.text)) {
|
|||
|
|
block.text.forEach((part, i) => {
|
|||
|
|
if (typeof part === 'string') checkText(`${at}.text[${i}]`, part)
|
|||
|
|
else {
|
|||
|
|
checkText(`${at}.text[${i}].text`, part?.text)
|
|||
|
|
checkLink(`${at}.text[${i}]`, part?.to, ownIds)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
} else checkText(`${at}.text`, block.text)
|
|||
|
|
} else if (block.type === 'ul' || block.type === 'ol') {
|
|||
|
|
if (!Array.isArray(block.items) || !block.items.length) fail(at, 'a list with no items')
|
|||
|
|
block.items?.forEach((item, i) => {
|
|||
|
|
if (typeof item === 'string') checkText(`${at}.items[${i}]`, item)
|
|||
|
|
else {
|
|||
|
|
checkText(`${at}.items[${i}].text`, item?.text)
|
|||
|
|
checkText(`${at}.items[${i}].detail`, item?.detail)
|
|||
|
|
if (item?.text == null) fail(`${at}.items[${i}]`, 'a step with no text')
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
} else if (block.type === 'links') {
|
|||
|
|
block.items?.forEach((item, i) => {
|
|||
|
|
checkText(`${at}.items[${i}].label`, item?.label)
|
|||
|
|
checkLink(`${at}.items[${i}]`, item?.to, ownIds)
|
|||
|
|
})
|
|||
|
|
} else if (block.type === 'image') {
|
|||
|
|
checkText(`${at}.alt`, block.alt)
|
|||
|
|
checkText(`${at}.caption`, block.caption)
|
|||
|
|
if (!block.alt) fail(at, 'an image with no alt text')
|
|||
|
|
// A slot waiting for a file the owner has to supply carries src: null and
|
|||
|
|
// renders nothing. A src that IS set must point at a file that exists,
|
|||
|
|
// or the page ships a broken image nobody sees until a visitor does.
|
|||
|
|
if (block.src) {
|
|||
|
|
if (!block.src.startsWith('/') || block.src.includes('..')) {
|
|||
|
|
fail(at, `image src ${JSON.stringify(block.src)} must be a site-absolute path under public/`)
|
|||
|
|
} else if (!existsSync(path.join(publicDir, block.src.replace(/^\//, '')))) {
|
|||
|
|
fail(at, `image src ${JSON.stringify(block.src)} does not exist under public/`)
|
|||
|
|
}
|
|||
|
|
if (!block.width || !block.height) fail(at, 'an image with a src needs width and height, or the page shifts as it loads')
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
checkText(`${at}.text`, block.text)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// The opening block decides whether an answer engine can quote the section.
|
|||
|
|
const first = blocks[0]?.type
|
|||
|
|
if (section.kind === 'faq') {
|
|||
|
|
blocks.forEach((block, index) => {
|
|||
|
|
if (block.type !== 'h3') return
|
|||
|
|
if (blocks[index + 1]?.type !== 'p') fail(`${where} blocks[${index}]`, 'a FAQ question with no answer paragraph after it')
|
|||
|
|
})
|
|||
|
|
if (first !== 'h3') fail(where, 'a FAQ section must open with its first question')
|
|||
|
|
} else if (section.kind === 'list') {
|
|||
|
|
if (first !== 'ul' && first !== 'ol') fail(where, "kind 'list' says the section opens with a list, and it does not")
|
|||
|
|
} else if (first !== 'p') {
|
|||
|
|
fail(where, `opens with a ${first} block. A section opens with its direct answer, which is what a search result or an AI answer quotes. Use kind 'list' when the approved copy genuinely has no answer paragraph.`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const checkPage = (where, page) => {
|
|||
|
|
const ownIds = new Set((page.sections || []).map((section) => section.id))
|
|||
|
|
|
|||
|
|
if (!page.seo?.title) fail(`${where}.page.seo`, 'no title')
|
|||
|
|
if (!page.seo?.description) fail(`${where}.page.seo`, 'no description')
|
|||
|
|
checkText(`${where}.page.seo.title`, page.seo?.title)
|
|||
|
|
checkText(`${where}.page.seo.description`, page.seo?.description)
|
|||
|
|
if (page.seo?.description && page.seo.description.length > 160) {
|
|||
|
|
warnings.push(
|
|||
|
|
`${where}.page.seo.description is ${page.seo.description.length} characters. Search results show about 155 to 160, so the tail may not be seen. Owner-approved copy is published as written.`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const field of ['h1', 'subheading']) {
|
|||
|
|
if (!page.hero?.[field]) fail(`${where}.page.hero`, `no ${field}`)
|
|||
|
|
checkText(`${where}.page.hero.${field}`, page.hero?.[field])
|
|||
|
|
}
|
|||
|
|
if (!Array.isArray(page.hero?.intro) || !page.hero.intro.length) fail(`${where}.page.hero`, 'no intro copy')
|
|||
|
|
page.hero?.intro?.forEach((text, i) => checkText(`${where}.page.hero.intro[${i}]`, text))
|
|||
|
|
|
|||
|
|
for (const cta of ['primaryCta', 'secondaryCta']) {
|
|||
|
|
const value = page.hero?.[cta]
|
|||
|
|
if (!value) {
|
|||
|
|
if (cta === 'primaryCta') fail(`${where}.page.hero`, 'no primaryCta')
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
checkText(`${where}.page.hero.${cta}.label`, value.label)
|
|||
|
|
checkLink(`${where}.page.hero.${cta}`, value.to, ownIds)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Array.isArray(page.sections) || !page.sections.length) return fail(`${where}.page`, 'no sections')
|
|||
|
|
|
|||
|
|
const seen = new Set()
|
|||
|
|
page.sections.forEach((section, index) => {
|
|||
|
|
const at = `${where}.page.sections[${index}]${section?.id ? ` (${section.id})` : ''}`
|
|||
|
|
if (!ID_SHAPE.test(section?.id || '')) {
|
|||
|
|
fail(at, `id ${JSON.stringify(section?.id)} must be lowercase, start with a letter and join words with single hyphens. An id starting with a digit cannot be used as a CSS selector`)
|
|||
|
|
}
|
|||
|
|
if (RESERVED_IDS.has(section?.id)) fail(at, `id ${JSON.stringify(section.id)} is already used by the page layout`)
|
|||
|
|
if (seen.has(section?.id)) fail(at, `id ${JSON.stringify(section.id)} appears twice on this page`)
|
|||
|
|
seen.add(section?.id)
|
|||
|
|
if (!section?.title) fail(at, 'no title')
|
|||
|
|
checkText(`${at}.title`, section?.title)
|
|||
|
|
checkBlocks(at, section, ownIds)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
page.related?.forEach((link, i) => {
|
|||
|
|
checkText(`${where}.page.related[${i}].label`, link?.label)
|
|||
|
|
checkLink(`${where}.page.related[${i}]`, link?.to, ownIds)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const service of services || []) {
|
|||
|
|
const where = `services/${service?.id}`
|
|||
|
|
for (const field of ['id', 'name', 'shortDesc', 'homeDesc', 'icon']) {
|
|||
|
|
if (!service?.[field]) fail(where, `no ${field}`)
|
|||
|
|
}
|
|||
|
|
checkText(`${where}.shortDesc`, service.shortDesc)
|
|||
|
|
checkText(`${where}.homeDesc`, service.homeDesc)
|
|||
|
|
|
|||
|
|
if (service.page) checkPage(where, service.page)
|
|||
|
|
else {
|
|||
|
|
// The layout used by every service without long-form copy maps these with
|
|||
|
|
// no guard, so a missing one is a build crash with no useful message.
|
|||
|
|
if (!service.fullDesc) fail(where, 'no fullDesc, and no page copy either')
|
|||
|
|
for (const field of ['benefits', 'idealFor']) {
|
|||
|
|
if (!Array.isArray(service[field]) || !service[field].length) fail(where, `no ${field}, which the service layout maps without checking`)
|
|||
|
|
}
|
|||
|
|
checkText(`${where}.fullDesc`, service.fullDesc)
|
|||
|
|
}
|
|||
|
|
// Every service is a card on the Services index, which shows idealFor[0].
|
|||
|
|
if (!Array.isArray(service.idealFor) || !service.idealFor.length) {
|
|||
|
|
fail(where, 'no idealFor. The Services index prints idealFor[0] as "Best fit"')
|
|||
|
|
}
|
|||
|
|
service.related?.forEach((link, i) => {
|
|||
|
|
checkText(`${where}.related[${i}].label`, link?.label)
|
|||
|
|
checkLink(`${where}.related[${i}]`, link?.to, new Set())
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const industry of industries || []) {
|
|||
|
|
const where = `industries/${industry?.id}`
|
|||
|
|
for (const field of ['id', 'name', 'shortDesc', 'fullDesc', 'icon']) {
|
|||
|
|
if (!industry?.[field]) fail(where, `no ${field}`)
|
|||
|
|
}
|
|||
|
|
for (const field of ['painPoints', 'solutions']) {
|
|||
|
|
if (!Array.isArray(industry?.[field]) || !industry[field].length) fail(where, `no ${field}, which the industry layout maps without checking`)
|
|||
|
|
}
|
|||
|
|
checkText(`${where}.shortDesc`, industry?.shortDesc)
|
|||
|
|
checkText(`${where}.fullDesc`, industry?.fullDesc)
|
|||
|
|
industry?.related?.forEach((link, i) => {
|
|||
|
|
checkText(`${where}.related[${i}].label`, link?.label)
|
|||
|
|
checkLink(`${where}.related[${i}]`, link?.to, new Set())
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { errors, warnings, checked }
|
|||
|
|
}
|