From 130ab72d24856703be559d915d315c72fefa513f Mon Sep 17 00:00:00 2001 From: Ibukun Dairo Date: Sun, 4 Oct 2026 08:57:53 +0100 Subject: [PATCH 1/2] fix: address engineering audit findings --- .github/workflows/pages.yml | 2 + .prettierignore | 14 ++ .prettierrc.json | 4 + CHANGELOG.md | 8 +- README.md | 12 +- package-lock.json | 19 +- package.json | 7 +- scripts/build.mjs | 311 +++++++++++++++++++++++++++------ scripts/check-dashes.mjs | 45 +++-- scripts/check-descriptions.mjs | 6 +- scripts/check-links.mjs | 27 ++- scripts/check-not-found.mjs | 33 +++- scripts/check-titles.mjs | 34 +++- templates/page.html | 29 ++- 14 files changed, 445 insertions(+), 106 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 8f8f8f3..a185179 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -34,6 +34,8 @@ jobs: run: npm ci env: NODE_AUTH_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }} + - name: Check formatting + run: npm run format:check - name: Build run: npm run build env: diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7075340 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,14 @@ +node_modules/ +.next/ +coverage/ +playwright-report/ +test-results/ +_site/ +package-lock.json +**/package-lock.json +**/bun.lock +codex-review-*.md +public/design-system/ +public/mockServiceWorker.js +next-env.d.ts +assets/design-system/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e29d501 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "jsxSingleQuote": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3095421..173e5f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases before 1.1.0 predate this file. +## [Unreleased] + +### Fixed + +- Theme initialisation survives formatted document templates. +- Formatting checks enforce consistent repository layout. + ## [1.2.10] - 2026-10-03 ### Changed @@ -73,7 +80,6 @@ Releases before 1.1.0 predate this file. - The Websites definition names account.cuesoft.io. - ## [1.2.1] - 2026-10-02 ### Changed diff --git a/README.md b/README.md index c8d29de..f794c72 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,13 @@ under a signed agreement or invoice. ## How these Terms are organised -| Section | What it covers | -| --- | --- | -| [Using the Websites](websites/) | Acceptable use, content, third-party links, and Ace. | -| [Programmes](programmes/cuehire/) | CueTA™, CueLABS™, CueHIRE™ and the media properties. | -| [Cueprise™ Terms](cueprise/) | A standalone document covering the Cueprise™ website and the licensed platform behind it. | +| Section | What it covers | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| [Using the Websites](websites/) | Acceptable use, content, third-party links, and Ace. | +| [Programmes](programmes/cuehire/) | CueTA™, CueLABS™, CueHIRE™ and the media properties. | +| [Cueprise™ Terms](cueprise/) | A standalone document covering the Cueprise™ website and the licensed platform behind it. | | [Legal terms](legal/confidentiality/) | Intellectual property, confidentiality, liability, termination and force majeure, and the general clauses. | -| [Jurisdictions](jurisdictions/eu-uk/) | Your governing law and forum in Nigeria, the United States and the EU/UK. | +| [Jurisdictions](jurisdictions/eu-uk/) | Your governing law and forum in Nigeria, the United States and the EU/UK. | ## Privacy diff --git a/package-lock.json b/package-lock.json index d5d5c70..dde2c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "react-dom": "^19.3.0" }, "devDependencies": { - "@playwright/test": "^1.63.0" + "@playwright/test": "^1.63.0", + "prettier": "3.9.9" } }, "node_modules/@cuesoftinc/design-system": { @@ -939,6 +940,22 @@ "node": ">=20" } }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", diff --git a/package.json b/package.json index 16dc80b..5a3ecd9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "check:og": "node scripts/check-og-card.mjs", "check:not-found": "node scripts/check-not-found.mjs", "check:titles": "node scripts/check-titles.mjs", - "lint": "npm run check:design-system" + "lint": "npm run check:design-system", + "format": "prettier --write . --ignore-unknown", + "format:check": "prettier --check . --ignore-unknown" }, "dependencies": { "@cuesoftinc/design-system": "1.46.10", @@ -26,6 +28,7 @@ "react-dom": "^19.3.0" }, "devDependencies": { - "@playwright/test": "^1.63.0" + "@playwright/test": "^1.63.0", + "prettier": "3.9.9" } } diff --git a/scripts/build.mjs b/scripts/build.mjs index 044987a..8b96828 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,7 +7,15 @@ // // node scripts/build.mjs → writes _site/ import { execFileSync } from 'node:child_process'; -import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { + cpSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, + existsSync, +} from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createElement as h } from 'react'; @@ -36,7 +44,13 @@ const DESCRIPTION = process.env.SITE_DESCRIPTION || 'The terms governing the Cuesoft websites and programmes (CueTA™, CueLABS™, CueHIRE™ and Cueprise™) across Nigeria, the US and the EU/UK.'; // Sections in reading order; anything not listed sorts after, alphabetically. -const SECTION_ORDER = ['websites', 'programmes', 'cueprise', 'legal', 'jurisdictions']; +const SECTION_ORDER = [ + 'websites', + 'programmes', + 'cueprise', + 'legal', + 'jurisdictions', +]; // Routes that moved or retired: each key becomes a redirect stub so old // bookmarks and inbound links keep landing. const REDIRECTS = {}; @@ -87,8 +101,13 @@ const BASE = existsSync(path.join(ROOT, 'CNAME')) // workflow as NEXT_PUBLIC_DD_*. A build without both (a fork's pull request, a // local run) ships no RUM and says so. The SDK is the exact pin in // package.json, copied from node_modules, so pages load no third-party script. -const PACKAGE = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')); -const RUM_SDK = path.join(ROOT, 'node_modules/@datadog/browser-rum/bundle/datadog-rum.js'); +const PACKAGE = JSON.parse( + readFileSync(path.join(ROOT, 'package.json'), 'utf8'), +); +const RUM_SDK = path.join( + ROOT, + 'node_modules/@datadog/browser-rum/bundle/datadog-rum.js', +); const RUM = { applicationId: (process.env.NEXT_PUBLIC_DD_APPLICATION_ID ?? '').trim(), clientToken: (process.env.NEXT_PUBLIC_DD_CLIENT_TOKEN ?? '').trim(), @@ -97,7 +116,9 @@ const rumOn = Boolean(RUM.applicationId && RUM.clientToken && BASE); if (!rumOn) { const note = 'Datadog RUM is not in this build: NEXT_PUBLIC_DD_APPLICATION_ID and NEXT_PUBLIC_DD_CLIENT_TOKEN are not both set'; - console.log(process.env.GITHUB_ACTIONS === 'true' ? `::warning::${note}` : note); + console.log( + process.env.GITHUB_ACTIONS === 'true' ? `::warning::${note}` : note, + ); } const escapeHtml = (s) => @@ -118,7 +139,11 @@ function sentencesOf(text) { const sentences = []; let start = 0; for (const match of text.matchAll(/[.!?]+["')\]’”]*(?=\s(?!\p{Ll})|$)/gu)) { - const word = text.slice(start, match.index).split(' ').pop().replace(/^[^\p{L}\p{N}]+/u, ''); + const word = text + .slice(start, match.index) + .split(' ') + .pop() + .replace(/^[^\p{L}\p{N}]+/u, ''); if (ABBREVIATION.test(word)) continue; const end = match.index + match[0].length; sentences.push(text.slice(start, end).trim()); @@ -138,7 +163,9 @@ function clipDescription(text) { run = next; } if (run) return run; - return sentences.length > 0 && sentences[0].length <= DESCRIPTION_CEILING ? sentences[0] : DESCRIPTION; + return sentences.length > 0 && sentences[0].length <= DESCRIPTION_CEILING + ? sentences[0] + : DESCRIPTION; } // A page describes itself: its first body paragraph, stripped of markdown, @@ -175,8 +202,24 @@ function findPages(dir = ROOT, rel = '') { if (existsSync(path.join(dir, 'README.md'))) pages.push(rel); for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; - if (['.git', '.github', '_site', 'node_modules', 'templates', 'scripts', 'assets'].includes(entry.name)) continue; - pages.push(...findPages(path.join(dir, entry.name), rel ? `${rel}/${entry.name}` : entry.name)); + if ( + [ + '.git', + '.github', + '_site', + 'node_modules', + 'templates', + 'scripts', + 'assets', + ].includes(entry.name) + ) + continue; + pages.push( + ...findPages( + path.join(dir, entry.name), + rel ? `${rel}/${entry.name}` : entry.name, + ), + ); } return pages; } @@ -198,7 +241,29 @@ const label = (slug) => // Every word is capitalised but these connectives, which stay lower case unless one opens or // closes a title or a clause; both parts of a hyphenated compound are capitalised; acronyms, // marks and the first word of a trade mark (The CueBlog™) stay as written. -const MINOR_WORDS = new Set(['a', 'an', 'the', 'and', 'but', 'or', 'nor', 'for', 'so', 'yet', 'as', 'at', 'by', 'in', 'of', 'on', 'to', 'up', 'via', 'per', 'vs']); +const MINOR_WORDS = new Set([ + 'a', + 'an', + 'the', + 'and', + 'but', + 'or', + 'nor', + 'for', + 'so', + 'yet', + 'as', + 'at', + 'by', + 'in', + 'of', + 'on', + 'to', + 'up', + 'via', + 'per', + 'vs', +]); function titleCase(text) { const tokens = text.split(/(\s+)/); const words = tokens.filter((token) => token && !/^\s+$/.test(token)); @@ -209,19 +274,32 @@ function titleCase(text) { index += 1; const previous = words[index - 1] ?? ''; const edge = - index === 0 || index === words.length - 1 || /[:?!.]$/.test(previous) || !/[\p{L}\p{N}]/u.test(previous); + index === 0 || + index === words.length - 1 || + /[:?!.]$/.test(previous) || + !/[\p{L}\p{N}]/u.test(previous); const markLead = words[index + 1]?.includes('™') ?? false; const parts = token.split(/([-/])/); return parts .map((part) => { - const [, lead, core, tail] = part.match(/^([^\p{L}\p{N}]*)(.*?)([^\p{L}\p{N}]*)$/su); + const [, lead, core, tail] = part.match( + /^([^\p{L}\p{N}]*)(.*?)([^\p{L}\p{N}]*)$/su, + ); if (!/^\p{L}/u.test(core) || /[.@]/.test(core)) return part; if (parts.length === 1 && MINOR_WORDS.has(core.toLowerCase())) { if (!edge && markLead) return part; - return lead + (edge ? core[0].toUpperCase() + core.slice(1) : core.toLowerCase()) + tail; + return ( + lead + + (edge + ? core[0].toUpperCase() + core.slice(1) + : core.toLowerCase()) + + tail + ); } const first = core[0]; - return first === first.toLowerCase() && !/\p{Lu}/u.test(core.slice(1)) ? lead + first.toUpperCase() + core.slice(1) + tail : part; + return first === first.toLowerCase() && !/\p{Lu}/u.test(core.slice(1)) + ? lead + first.toUpperCase() + core.slice(1) + tail + : part; }) .join(''); }) @@ -232,7 +310,13 @@ function titleCase(text) { const meta = new Map( pages.map((page) => { const markdown = readFileSync(path.join(ROOT, page, 'README.md'), 'utf8'); - return [page, { markdown, title: titleOf(markdown, page ? label(path.basename(page)) : SITE) }]; + return [ + page, + { + markdown, + title: titleOf(markdown, page ? label(path.basename(page)) : SITE), + }, + ]; }), ); @@ -263,17 +347,28 @@ function groups() { return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.localeCompare(b); }); - return ordered.map((section) => ({ section, pages: sections.get(section).sort() })); + return ordered.map((section) => ({ + section, + pages: sections.get(section).sort(), + })); } /** The contents down the side: the home page, then one group per section, the page itself marked current. */ function sectionsFor(current) { - const home = { label: meta.get('').title, href: relLink(current, '') || './', current: current === '' }; + const home = { + label: meta.get('').title, + href: relLink(current, '') || './', + current: current === '', + }; return [ { links: [home] }, ...groups().map(({ section, pages: members }) => ({ heading: label(section), - links: members.map((page) => ({ label: meta.get(page).title, href: relLink(current, page), current: page === current })), + links: members.map((page) => ({ + label: meta.get(page).title, + href: relLink(current, page), + current: page === current, + })), })), ]; } @@ -282,7 +377,9 @@ function gitDate(page) { try { // Argument array, not a shell: page paths never reach an interpreter. const file = path.join(ROOT, page, 'README.md'); - return execFileSync('git', ['log', '-1', '--format=%as', '--', file], { cwd: ROOT }) + return execFileSync('git', ['log', '-1', '--format=%as', '--', file], { + cwd: ROOT, + }) .toString() .trim(); } catch { @@ -290,7 +387,20 @@ function gitDate(page) { } } -const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; function britishDate(iso) { const [year, month, day] = iso.split('-').map(Number); @@ -302,10 +412,23 @@ function lastUpdated(page) { return date ? `Last updated ${britishDate(date)}.` : ''; } -const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' }; +const ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: '\u00a0', +}; const decodeEntities = (text) => - text.replaceAll(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (whole, dec, hex, name) => - dec ? String.fromCodePoint(Number(dec)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : (ENTITIES[name] ?? whole), + text.replaceAll( + /&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, + (whole, dec, hex, name) => + dec + ? String.fromCodePoint(Number(dec)) + : hex + ? String.fromCodePoint(parseInt(hex, 16)) + : (ENTITIES[name] ?? whole), ); // The text of an HTML fragment: everything outside a tag. It is scanned rather @@ -326,7 +449,9 @@ function plainText(html) { // heading renderer gave it. A page with a single heading has nothing to list. const MIN_RAIL_HEADINGS = 2; function headingsOf(html) { - const headings = [...html.matchAll(/([\s\S]*?)<\/h\1>/g)].map(([, depth, id, inner]) => ({ + const headings = [ + ...html.matchAll(/([\s\S]*?)<\/h\1>/g), + ].map(([, depth, id, inner]) => ({ label: decodeEntities(plainText(inner)).trim(), href: `#${id}`, depth: Number(depth), @@ -355,14 +480,26 @@ marked.use({ * the chain is resolved here and the font URLs point at the published copy. */ function packageStylesheet() { - const entry = fileURLToPath(import.meta.resolve('@cuesoftinc/design-system/styles.css')); + const entry = fileURLToPath( + import.meta.resolve('@cuesoftinc/design-system/styles.css'), + ); const inline = (file) => - readFileSync(file, 'utf8').replaceAll(/@import\s+['"]([^'"]+)['"]\s*;/g, (_, spec) => - inline(path.resolve(path.dirname(file), spec)), + readFileSync(file, 'utf8').replaceAll( + /@import\s+['"]([^'"]+)['"]\s*;/g, + (_, spec) => inline(path.resolve(path.dirname(file), spec)), + ); + const css = inline(entry).replaceAll( + "url('../assets/fonts/", + `url('${ASSETS}/fonts/`, + ); + if (/@import/.test(css)) + throw new Error( + 'the package stylesheet has an @import the build does not resolve', + ); + if (/url\(['"]?\.\.?\//.test(css)) + throw new Error( + 'the package stylesheet has a relative url() the build does not publish', ); - const css = inline(entry).replaceAll("url('../assets/fonts/", `url('${ASSETS}/fonts/`); - if (/@import/.test(css)) throw new Error('the package stylesheet has an @import the build does not resolve'); - if (/url\(['"]?\.\.?\//.test(css)) throw new Error('the package stylesheet has a relative url() the build does not publish'); return css; } @@ -371,13 +508,20 @@ mkdirSync(OUT, { recursive: true }); cpSync(path.join(ROOT, 'assets'), path.join(OUT, 'assets'), { recursive: true, filter: (source) => { - const rel = path.relative(path.join(ROOT, 'assets'), source).split(path.sep).join('/'); - return !rel.startsWith('design-system') || PUBLISHED.some((pattern) => pattern.test(rel)); + const rel = path + .relative(path.join(ROOT, 'assets'), source) + .split(path.sep) + .join('/'); + return ( + !rel.startsWith('design-system') || + PUBLISHED.some((pattern) => pattern.test(rel)) + ); }, }); writeFileSync(path.join(OUT, 'assets/design-system.css'), packageStylesheet()); if (rumOn) { - if (!existsSync(RUM_SDK)) throw new Error('@datadog/browser-rum is not installed; run npm ci'); + if (!existsSync(RUM_SDK)) + throw new Error('@datadog/browser-rum is not installed; run npm ci'); mkdirSync(path.join(OUT, 'assets/vendor'), { recursive: true }); cpSync(RUM_SDK, path.join(OUT, 'assets/vendor/datadog-rum.js')); cpSync(path.join(ROOT, 'templates/rum.js'), path.join(OUT, 'assets/rum.js')); @@ -396,17 +540,27 @@ const rumTags = rumOn '', ].join('\n ') : ''; -if (existsSync(path.join(ROOT, 'CNAME'))) cpSync(path.join(ROOT, 'CNAME'), path.join(OUT, 'CNAME')); +if (existsSync(path.join(ROOT, 'CNAME'))) + cpSync(path.join(ROOT, 'CNAME'), path.join(OUT, 'CNAME')); if (existsSync(path.join(ROOT, 'llms.txt'))) { // The family's llms.txt is plain ASCII: marks spelled (TM). const plain = (text) => text.replaceAll('™', '(TM)'); - const listed = [...(pages.includes('') ? [''] : []), ...groups().flatMap(({ pages: members }) => members)]; + const listed = [ + ...(pages.includes('') ? [''] : []), + ...groups().flatMap(({ pages: members }) => members), + ]; const list = listed - .map((page) => `- [${plain(meta.get(page).title)}](${BASE}/${page ? `${page}/` : ''})`) + .map( + (page) => + `- [${plain(meta.get(page).title)}](${BASE}/${page ? `${page}/` : ''})`, + ) .join('\n'); writeFileSync( path.join(OUT, 'llms.txt'), - readFileSync(path.join(ROOT, 'llms.txt'), 'utf8').replace('{{pages}}', () => list), + readFileSync(path.join(ROOT, 'llms.txt'), 'utf8').replace( + '{{pages}}', + () => list, + ), ); } cpSync(path.join(ROOT, 'assets/favicon.ico'), path.join(OUT, 'favicon.ico')); @@ -420,31 +574,48 @@ const themeToggle = h(ThemeToggle, { 'span', { className: 'theme-marks' }, h('span', { 'data-theme-mark': 'moon' }, h(Icon, { name: 'moon' })), - h('span', { 'data-theme-mark': 'sun', hidden: true }, h(Icon, { name: 'sun' })), + h( + 'span', + { 'data-theme-mark': 'sun', hidden: true }, + h(Icon, { name: 'sun' }), + ), ), }); // React hoists an image preload for each lockup art ahead of the markup; both // arts are already in the page, so the preloads only repeat them. -const render = (element) => renderToStaticMarkup(element).replace(/^(?:]*\/>)+/, ''); +const render = (element) => + renderToStaticMarkup(element).replace( + /^(?:]*\/>)+/, + '', + ); const header = render( h(DocHeader, { brand: h(Lockup, { ...LOCKUP, height: 30 }), brandHref: 'https://cuesoft.io', - links: DOCUMENTS.map((document) => ({ ...document, current: document.href === BASE })), + links: DOCUMENTS.map((document) => ({ + ...document, + current: document.href === BASE, + })), tools: themeToggle, }), ); const skip = render(h(SkipLink, { href: '#main' })); const legal = render( - h(DocLegal, { copyright: { owner: 'Cuesoft Inc.', year: new Date().getFullYear() }, links: LEGAL_LINKS }), + h(DocLegal, { + copyright: { owner: 'Cuesoft Inc.', year: new Date().getFullYear() }, + links: LEGAL_LINKS, + }), ); /** The template with its {{slots}} filled; a slot the build does not fill is an error. */ const fill = (values) => template.replaceAll(/\{\{(\w+)\}\}/g, (_, key) => { - if (!(key in values)) throw new Error(`templates/page.html names {{${key}}}, which the build does not fill`); + if (!(key in values)) + throw new Error( + `templates/page.html names {{${key}}}, which the build does not fill`, + ); return values[key]; }); @@ -457,15 +628,22 @@ for (const page of pages) { // under it is the shell's date and the opening paragraph is its lede; // everything after is the document. const heading = parsed.match(/^

([\s\S]*?)<\/h1>\n/); - if (!heading) throw new Error(`${page || 'README.md'} does not open with a level-one heading`); + if (!heading) + throw new Error( + `${page || 'README.md'} does not open with a level-one heading`, + ); let rest = parsed.slice(heading[0].length); - const effective = rest.match(/^

(Effective date:[^<]*)<\/strong><\/p>\n/); + const effective = rest.match( + /^

(Effective date:[^<]*)<\/strong><\/p>\n/, + ); if (effective) rest = rest.slice(effective[0].length); // The opening paragraph is the shell's lede, set larger under the title. const opening = rest.match(/^

([\s\S]*?)<\/p>\n/); if (opening) rest = rest.slice(opening[0].length); // Tables scroll inside a wrapper instead of widening the page on phones. - const body = rest.replaceAll('', '
').replaceAll('
', ''); + const body = rest + .replaceAll('', '
') + .replaceAll('
', ''); // Crumbs carry the same names the sidebar shows: a page's H1 where the // segment is a page, the section label otherwise, and only pages link. @@ -485,7 +663,8 @@ for (const page of pages) { ? { label: text, href: '../'.repeat(parts.length - 1 - index) } : { label: text }, ); - if (isLast || meta.has(prefix)) crumbList.push({ name: text, item: `${BASE}/${prefix}/` }); + if (isLast || meta.has(prefix)) + crumbList.push({ name: text, item: `${BASE}/${prefix}/` }); }); } @@ -516,7 +695,11 @@ for (const page of pages) { name: title, url: canonical, isPartOf: { '@type': 'WebSite', name: SITE, url: `${BASE}/` }, - publisher: { '@type': 'Organization', name: 'Cuesoft', url: 'https://cuesoft.io' }, + publisher: { + '@type': 'Organization', + name: 'Cuesoft', + url: 'https://cuesoft.io', + }, }, { '@type': 'BreadcrumbList', @@ -534,7 +717,15 @@ for (const page of pages) { // When the page title and site name overlap, the longer one stands alone: // never "The Cuesoft Handbook | Cuesoft Handbook". Escaped once for every // context it lands in, including meta attributes. - doc_title: escapeHtml(titleCase(SITE.includes(title) ? SITE : title.includes(SITE) ? title : `${title} | ${SITE}`)), + doc_title: escapeHtml( + titleCase( + SITE.includes(title) + ? SITE + : title.includes(SITE) + ? title + : `${title} | ${SITE}`, + ), + ), site: SITE, og_alt: escapeHtml(CARD.alt), description: escapeHtml(descriptionOf(markdown)), @@ -559,7 +750,8 @@ for (const page of pages) { // site does not hold. It is the same page around the design system's not-found // band: no contents or rails, one way back to the root, kept out of the index. { - const home = DOCUMENTS.find((document) => document.href === BASE)?.label ?? SITE; + const home = + DOCUMENTS.find((document) => document.href === BASE)?.label ?? SITE; const title = 'Page Not Found'; const canonical = `${BASE}/404.html`; const description = `That page is not here. Go back to the ${SITE} home page.`; @@ -579,13 +771,21 @@ for (const page of pages) { name: title, url: canonical, isPartOf: { '@type': 'WebSite', name: SITE, url: `${BASE}/` }, - publisher: { '@type': 'Organization', name: 'Cuesoft', url: 'https://cuesoft.io' }, + publisher: { + '@type': 'Organization', + name: 'Cuesoft', + url: 'https://cuesoft.io', + }, }), robots: '\n ', skip, header, shell: renderToStaticMarkup( - h(NotFound, { eyebrow: 'Not found', title: 'That page is not here.', action: { label: `Back to ${home}`, href: '/' } }), + h(NotFound, { + eyebrow: 'Not found', + title: 'That page is not here.', + action: { label: `Back to ${home}`, href: '/' }, + }), ), legal, rum: rumTags, @@ -604,7 +804,10 @@ writeFileSync( }) .join('\n')}\n\n`, ); -writeFileSync(path.join(OUT, 'robots.txt'), `User-agent: *\nAllow: /\n\nSitemap: ${BASE}/sitemap.xml\n`); +writeFileSync( + path.join(OUT, 'robots.txt'), + `User-agent: *\nAllow: /\n\nSitemap: ${BASE}/sitemap.xml\n`, +); for (const [from, to] of Object.entries(REDIRECTS)) { const target = path.join(OUT, from, 'index.html'); @@ -616,4 +819,6 @@ for (const [from, to] of Object.entries(REDIRECTS)) { ); } -console.log(`built ${pages.length} page(s) and 404.html into _site/ (+${Object.keys(REDIRECTS).length} redirect stub(s))`); +console.log( + `built ${pages.length} page(s) and 404.html into _site/ (+${Object.keys(REDIRECTS).length} redirect stub(s))`, +); diff --git a/scripts/check-dashes.mjs b/scripts/check-dashes.mjs index e282ac0..02d7da0 100644 --- a/scripts/check-dashes.mjs +++ b/scripts/check-dashes.mjs @@ -18,23 +18,35 @@ const SKIPPED_DIRECTORIES = new Set(['.git', 'node_modules', '_site']); const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name); - if (entry.isDirectory()) return SKIPPED_DIRECTORIES.has(entry.name) ? [] : walk(full); + if (entry.isDirectory()) + return SKIPPED_DIRECTORIES.has(entry.name) ? [] : walk(full); return [full]; }); /** Tracked files plus new ones not yet ignored, so a file is checked before it is committed. */ function sourceFiles() { try { - const listed = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { - cwd: ROOT, - maxBuffer: 1 << 26, - }) + const listed = execFileSync( + 'git', + ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], + { + cwd: ROOT, + maxBuffer: 1 << 26, + }, + ) .toString() .split('\0') .filter(Boolean); - return listed.map((file) => path.join(ROOT, file)).filter((file) => existsSync(file)); + return listed + .map((file) => path.join(ROOT, file)) + .filter((file) => existsSync(file)); } catch { - return walk(ROOT).filter((file) => !path.relative(ROOT, file).startsWith(path.join('assets', 'design-system'))); + return walk(ROOT).filter( + (file) => + !path + .relative(ROOT, file) + .startsWith(path.join('assets', 'design-system')), + ); } } @@ -42,12 +54,19 @@ const problems = []; function scan(file, { skipVendor = false } = {}) { const relative = path.relative(ROOT, file); - if (skipVendor && relative.split(path.sep).slice(0, 3).join('/') === '_site/assets/vendor') return false; + if ( + skipVendor && + relative.split(path.sep).slice(0, 3).join('/') === '_site/assets/vendor' + ) + return false; const buffer = readFileSync(file); if (buffer.subarray(0, 8000).includes(0)) return false; let text = buffer.toString('utf8'); // Comments become blank lines, so the reported line numbers stay true. - if (file.endsWith('.css')) text = text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => comment.replaceAll(/[^\n]/g, '')); + if (file.endsWith('.css')) + text = text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => + comment.replaceAll(/[^\n]/g, ''), + ); text.split('\n').forEach((line, index) => { if (line.includes(EM_DASH) || line.includes(EN_DASH)) { problems.push(`${relative}:${index + 1}: ${line.trim().slice(0, 100)}`); @@ -57,10 +76,14 @@ function scan(file, { skipVendor = false } = {}) { } const source = sourceFiles().filter((file) => scan(file)); -const built = existsSync(SITE) ? walk(SITE).filter((file) => scan(file, { skipVendor: true })) : []; +const built = existsSync(SITE) + ? walk(SITE).filter((file) => scan(file, { skipVendor: true })) + : []; if (problems.length) { - console.error(`check-dashes: ${problems.length} dash(es); write a colon, a comma or parentheses, and "to" for a range`); + console.error( + `check-dashes: ${problems.length} dash(es); write a colon, a comma or parentheses, and "to" for a range`, + ); for (const problem of problems.slice(0, 40)) console.error(` ${problem}`); process.exit(1); } diff --git a/scripts/check-descriptions.mjs b/scripts/check-descriptions.mjs index 385513d..d420649 100644 --- a/scripts/check-descriptions.mjs +++ b/scripts/check-descriptions.mjs @@ -54,8 +54,10 @@ for (const file of pages(SITE)) { checked += 1; const text = decode(match[1]).trim(); if (!text) problems.push(`${where}: empty`); - else if (text.endsWith('…') || text.endsWith('...')) problems.push(`${where}: ends in an ellipsis: "${text}"`); - else if (!COMPLETE.test(text)) problems.push(`${where}: does not end at a sentence end: "${text}"`); + else if (text.endsWith('…') || text.endsWith('...')) + problems.push(`${where}: ends in an ellipsis: "${text}"`); + else if (!COMPLETE.test(text)) + problems.push(`${where}: does not end at a sentence end: "${text}"`); } } if (checked === 0) problems.push('no description found in _site'); diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs index 15c9059..6763df8 100644 --- a/scripts/check-links.mjs +++ b/scripts/check-links.mjs @@ -18,7 +18,9 @@ if (!existsSync(SITE)) { const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => - entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)], + entry.isDirectory() + ? walk(path.join(dir, entry.name)) + : [path.join(dir, entry.name)], ); const files = walk(SITE); @@ -37,7 +39,10 @@ const decode = (value) => { const idsIn = (file) => { if (!idsOf.has(file)) { const html = readFileSync(file, 'utf8'); - idsOf.set(file, new Set([...html.matchAll(/\sid="([^"]*)"/g)].map((match) => match[1]))); + idsOf.set( + file, + new Set([...html.matchAll(/\sid="([^"]*)"/g)].map((match) => match[1])), + ); } return idsOf.get(file); }; @@ -55,7 +60,8 @@ let checked = 0; for (const page of pages) { const rel = path.relative(SITE, page); const html = readFileSync(page, 'utf8'); - if (/\{\{\w+\}\}/.test(html)) problems.push(`${rel}: an unfilled {{placeholder}}`); + if (/\{\{\w+\}\}/.test(html)) + problems.push(`${rel}: an unfilled {{placeholder}}`); const origin = `https://${HOST || 'site.invalid'}/${path.dirname(rel) === '.' ? '' : `${path.dirname(rel)}/`}`; for (const match of html.matchAll(/\s(?:href|src)="([^"]*)"/g)) { const value = match[1].replaceAll('&', '&'); @@ -74,7 +80,11 @@ for (const page of pages) { problems.push(`${rel}: ${value} resolves to nothing in _site`); continue; } - if (url.hash && file.endsWith('.html') && !idsIn(file).has(decode(url.hash.slice(1)))) { + if ( + url.hash && + file.endsWith('.html') && + !idsIn(file).has(decode(url.hash.slice(1))) + ) { problems.push(`${rel}: ${value} has no id ${url.hash} on its target`); } } @@ -84,7 +94,10 @@ for (const sheet of files.filter((file) => file.endsWith('.css'))) { const css = readFileSync(sheet, 'utf8'); for (const match of css.matchAll(/url\(\s*['"]?(\/[^'")\s]+)['"]?\s*\)/g)) { checked += 1; - if (!resolveFile(match[1])) problems.push(`${path.relative(SITE, sheet)}: url(${match[1]}) resolves to nothing in _site`); + if (!resolveFile(match[1])) + problems.push( + `${path.relative(SITE, sheet)}: url(${match[1]}) resolves to nothing in _site`, + ); } } @@ -93,4 +106,6 @@ if (problems.length) { for (const problem of problems) console.error(` - ${problem}`); process.exit(1); } -console.log(`check-links: ${pages.length} page(s), ${checked} internal reference(s), all resolve`); +console.log( + `check-links: ${pages.length} page(s), ${checked} internal reference(s), all resolve`, +); diff --git a/scripts/check-not-found.mjs b/scripts/check-not-found.mjs index 2d28ced..9aaf149 100644 --- a/scripts/check-not-found.mjs +++ b/scripts/check-not-found.mjs @@ -12,23 +12,38 @@ const FILE = path.join(import.meta.dirname, '..', '_site', '404.html'); const EM_DASH = String.fromCharCode(0x2014); if (!existsSync(FILE)) { - console.error('check-not-found: _site/404.html is missing; GitHub Pages would show its own 404 page'); + console.error( + 'check-not-found: _site/404.html is missing; GitHub Pages would show its own 404 page', + ); process.exit(1); } const html = readFileSync(FILE, 'utf8'); const problems = []; -if (html.includes(EM_DASH) || /—|�*8212;|�*2014;/i.test(html)) problems.push('carries an em dash'); -if (!/Page Not Found \| [^<]+<\/title>/.test(html)) problems.push('title is not "Page Not Found | <site>"'); -if (!/<meta name="robots" content="[^"]*\bnoindex\b[^"]*"/.test(html)) problems.push('is not marked noindex'); -if (!/<h1\b[^>]*>That page is not here\.<\/h1>/.test(html)) problems.push('has no "That page is not here." heading'); -const actions = [...html.matchAll(/<a\b[^>]*\bclass="[^"]*\bds-notfound__action\b[^"]*"[^>]*>/g)]; -if (actions.length !== 1 || !/\shref="\/"/.test(actions[0][0])) problems.push('does not have exactly one action to "/"'); +if (html.includes(EM_DASH) || /—|�*8212;|�*2014;/i.test(html)) + problems.push('carries an em dash'); +if (!/<title>Page Not Found \| [^<]+<\/title>/.test(html)) + problems.push('title is not "Page Not Found | <site>"'); +if (!/<meta name="robots" content="[^"]*\bnoindex\b[^"]*"/.test(html)) + problems.push('is not marked noindex'); +if (!/<h1\b[^>]*>That page is not here\.<\/h1>/.test(html)) + problems.push('has no "That page is not here." heading'); +const actions = [ + ...html.matchAll( + /<a\b[^>]*\bclass="[^"]*\bds-notfound__action\b[^"]*"[^>]*>/g, + ), +]; +if (actions.length !== 1 || !/\shref="\/"/.test(actions[0][0])) + problems.push('does not have exactly one action to "/"'); if (problems.length) { - console.error(`check-not-found: _site/404.html ${problems.length} problem(s)`); + console.error( + `check-not-found: _site/404.html ${problems.length} problem(s)`, + ); for (const problem of problems) console.error(` - ${problem}`); process.exit(1); } -console.log('check-not-found: _site/404.html present, no em dash, noindex, one way back to /'); +console.log( + 'check-not-found: _site/404.html present, no em dash, noindex, one way back to /', +); diff --git a/scripts/check-titles.mjs b/scripts/check-titles.mjs index 83df028..eaa1a49 100644 --- a/scripts/check-titles.mjs +++ b/scripts/check-titles.mjs @@ -43,7 +43,10 @@ function titleCaseErrors(title) { const errors = []; words.forEach((word, index) => { const previous = words[index - 1] ?? ''; - const opensClause = index === 0 || /[:?!.]$/.test(previous) || !/[\p{L}\p{N}]/u.test(previous); + const opensClause = + index === 0 || + /[:?!.]$/.test(previous) || + !/[\p{L}\p{N}]/u.test(previous); const edge = opensClause || index === words.length - 1; const markLead = words[index + 1]?.includes('™') ?? false; const parts = word.split(/[-/]/); @@ -52,7 +55,8 @@ function titleCaseErrors(title) { if (!/^\p{L}/u.test(core) || /[.@]/.test(core)) return; const lower = core[0] === core[0].toLowerCase(); if (parts.length === 1 && MINOR.has(core.toLowerCase())) { - if (edge ? lower : core !== core.toLowerCase() && !markLead) errors.push(core); + if (edge ? lower : core !== core.toLowerCase() && !markLead) + errors.push(core); return; } if (lower && !/\p{Lu}/u.test(core.slice(1))) errors.push(core); @@ -90,8 +94,10 @@ for (const file of pages(SITE)) { ['twitter:title', /<meta name="twitter:title" content="([^"]*)"/], ]) { const match = html.match(pattern); - if (match) emitted.push({ where: `${rel} ${label}`, text: decode(match[1]) }); - else if (label === 'title') emitted.push({ where: `${rel} ${label}`, text: '' }); + if (match) + emitted.push({ where: `${rel} ${label}`, text: decode(match[1]) }); + else if (label === 'title') + emitted.push({ where: `${rel} ${label}`, text: '' }); } } @@ -99,23 +105,33 @@ const manifest = path.join(SITE, 'assets', 'site.webmanifest'); if (existsSync(manifest)) { const data = JSON.parse(readFileSync(manifest, 'utf8')); for (const key of ['name', 'short_name']) - if (key in data) emitted.push({ where: `assets/site.webmanifest ${key}`, text: String(data[key]) }); + if (key in data) + emitted.push({ + where: `assets/site.webmanifest ${key}`, + text: String(data[key]), + }); } const llms = path.join(SITE, 'llms.txt'); if (existsSync(llms)) { for (const line of readFileSync(llms, 'utf8').split('\n')) if (/^#{1,2} /.test(line)) - emitted.push({ where: `llms.txt ${line.startsWith('## ') ? 'H2' : 'H1'}`, text: line.replace(/^#+ /, '') }); + emitted.push({ + where: `llms.txt ${line.startsWith('## ') ? 'H2' : 'H1'}`, + text: line.replace(/^#+ /, ''), + }); } const problems = []; -if (!emitted.some((item) => item.where === 'index.html title')) problems.push('index.html: no home page in _site'); -if (!emitted.some((item) => item.where === '404.html title')) problems.push('404.html: no not-found page in _site'); +if (!emitted.some((item) => item.where === 'index.html title')) + problems.push('index.html: no home page in _site'); +if (!emitted.some((item) => item.where === '404.html title')) + problems.push('404.html: no not-found page in _site'); for (const { where, text } of emitted) { if (!text.trim()) problems.push(`${where}: empty`); const errors = titleCaseErrors(text); - if (errors.length) problems.push(`${where}: ${errors.join(', ')} in "${text}"`); + if (errors.length) + problems.push(`${where}: ${errors.join(', ')} in "${text}"`); } if (problems.length) { diff --git a/templates/page.html b/templates/page.html index 568ab46..82f6abb 100644 --- a/templates/page.html +++ b/templates/page.html @@ -4,7 +4,8 @@ <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{{doc_title}} - {{robots}} + + {{robots}} @@ -30,11 +31,27 @@ {{jsonld}} - - + + - + + - {{skip}} - {{header}} + {{skip}} {{header}}

{{shell}}
{{legal}}