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 @@
+
+