diff --git a/CHANGELOG.md b/CHANGELOG.md index b635088..3908089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases before 1.1.0 predate this file. +## [1.2.9] - 2026-10-03 + +### Added + +- Twitter and locale tags on every page. + +### Changed + +- Estate list includes Cueprise, Storefront and Cuelearn. + +### Fixed + +- Page descriptions end on a whole sentence. + ## [1.2.8] - 2026-10-03 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 446c060..1074890 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ from `node_modules` at build time. `scripts/build.mjs` (bar the knob block at its top), `scripts/check-links.mjs`, `scripts/check-dashes.mjs`, `scripts/generate-og-image.mjs`, `scripts/check-og-card.mjs`, -`scripts/check-not-found.mjs`, `scripts/check-titles.mjs`, +`scripts/check-not-found.mjs`, `scripts/check-titles.mjs`, `scripts/check-descriptions.mjs`, `templates/page.html`, `templates/rum.js`, `.github/workflows/pages.yml`, `.npmrc` and the browser icons under `assets/` are identical in handbook, terms and privacy-policy. Change one, change all diff --git a/llms.txt b/llms.txt index 68c5e52..4617ed9 100644 --- a/llms.txt +++ b/llms.txt @@ -26,9 +26,9 @@ CueLABS(TM) proves them in public on open-source products, and CueHIRE(TM), Cuesoft's talent and project management agency, delivers them to companies through end-to-end delivery, managed engineers and referral hiring. Three products: Cueprise(TM), a business management system licensed per business and -deployed on the client's own cloud, Storefront(TM), the website a business puts -in front of its customers, and Cuelearn(TM), a learning platform Cuesoft sells -to institutions. +deployed on the client's own cloud, Storefront(TM), the website a business +puts in front of its customers, and Cuelearn(TM), the learning platform Cuesoft +sells to institutions. ## Contact @@ -42,6 +42,9 @@ to institutions. - [CueTA(TM)](https://cueta.cuesoft.io): the talent academy - [CueLABS(TM)](https://cuelabs.cuesoft.io): open-source internships - [CueHIRE(TM)](https://cuehire.cuesoft.io): Cuesoft's talent and project management agency +- [Cueprise(TM)](https://cueprise.cuesoft.io): business management software on the client's own cloud +- [Storefront(TM)](https://storefront.cuesoft.io): e-commerce, booking and landing page sites, built free +- [Cuelearn(TM)](https://cuelearn.cuesoft.io): AI-powered learning for institutions - [Clients](https://clients.cuesoft.io): client case studies - [The CueShow(TM)](https://cueshow.cuesoft.io): the Cuesoft podcast - [The CueBlog(TM)](https://blog.cuesoft.io): the Cuesoft blog diff --git a/package-lock.json b/package-lock.json index d2b3ae4..a0e0d6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "terms", - "version": "1.2.8", + "version": "1.2.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "terms", - "version": "1.2.8", + "version": "1.2.9", "dependencies": { "@cuesoftinc/design-system": "1.46.9", "@datadog/browser-rum": "7.14.0", diff --git a/package.json b/package.json index 1335039..46664de 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "terms", - "version": "1.2.8", + "version": "1.2.9", "description": "Cuesoft Terms of Service: markdown source, built into the brand template.", "private": true, "type": "module", "scripts": { "build": "node scripts/build.mjs", "prebuild": "cuesoft-design-assets assets/design-system", - "postbuild": "node scripts/check-dashes.mjs && node scripts/check-og-card.mjs && node scripts/check-not-found.mjs && node scripts/check-titles.mjs", + "postbuild": "node scripts/check-dashes.mjs && node scripts/check-og-card.mjs && node scripts/check-not-found.mjs && node scripts/check-titles.mjs && node scripts/check-descriptions.mjs", "og": "node scripts/generate-og-image.mjs", "check:dashes": "node scripts/check-dashes.mjs", + "check:descriptions": "node scripts/check-descriptions.mjs", "check:design-system": "cuesoft-design-check corporate", "check:links": "node scripts/check-links.mjs", "check:og": "node scripts/check-og-card.mjs", diff --git a/scripts/build.mjs b/scripts/build.mjs index d03f924..044987a 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -107,8 +107,42 @@ const escapeHtml = (s) => .replaceAll('>', '>') .replaceAll('"', '"'); +const DESCRIPTION_LIMIT = 160; +// twitter:description holds 200 characters. +const DESCRIPTION_CEILING = 200; +// A boundary after an initial, a dotted abbreviation or one of these words is not a sentence end. +const ABBREVIATION = /^(?:\p{L}\.)*\p{L}$|^(?:etc|vs|cf|inc|ltd|art|approx)$/iu; + +// The text's sentences, each ending in . ! or ?; an unfinished tail is dropped. +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, ''); + if (ABBREVIATION.test(word)) continue; + const end = match.index + match[0].length; + sentences.push(text.slice(start, end).trim()); + start = end; + } + return sentences; +} + +// Whole sentences only, never a clipped one: the longest leading run within the limit, else the first sentence within the ceiling, else the site description. +function clipDescription(text) { + if (text.length <= DESCRIPTION_LIMIT) return text; + const sentences = sentencesOf(text); + let run = ''; + for (const sentence of sentences) { + const next = run ? `${run} ${sentence}` : sentence; + if (next.length > DESCRIPTION_LIMIT) break; + run = next; + } + if (run) return run; + return sentences.length > 0 && sentences[0].length <= DESCRIPTION_CEILING ? sentences[0] : DESCRIPTION; +} + // A page describes itself: its first body paragraph, stripped of markdown, -// clipped for the description and social-card tags. +// cut at a sentence end for the description and social-card tags. function descriptionOf(markdown) { const block = markdown .split(/\n\s*\n/) @@ -132,9 +166,7 @@ function descriptionOf(markdown) { .replaceAll(/[*_`]/g, '') .replaceAll(/\s+/g, ' ') .trim(); - if (text.length <= 160) return text; - const cut = text.slice(0, 157); - return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 120))}…`; + return clipDescription(text); } /** Every directory that carries a README.md is a page. */ diff --git a/scripts/check-descriptions.mjs b/scripts/check-descriptions.mjs new file mode 100644 index 0000000..385513d --- /dev/null +++ b/scripts/check-descriptions.mjs @@ -0,0 +1,68 @@ +// Fails when a built page's description, og:description or twitter:description is clipped: +// one that ends in an ellipsis, or anywhere but at the end of a sentence, so a mid-word +// cut cannot ship. Redirect stubs carry no description and are skipped. Runs after every +// build (postbuild) and in CI. +// +// node scripts/check-descriptions.mjs +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +const SITE = path.join(import.meta.dirname, '..', '_site'); + +const TAGS = [ + ['description', / + text + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); + +const pages = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) return entry.name === 'assets' ? [] : pages(file); + return entry.name.endsWith('.html') ? [file] : []; + }); + +if (!existsSync(SITE)) { + console.error('check-descriptions: _site is missing; run the build first'); + process.exit(1); +} + +const problems = []; +let checked = 0; +for (const file of pages(SITE)) { + const html = readFileSync(file, 'utf8'); + if (/ + @@ -17,6 +18,8 @@ + +