Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
40 changes: 36 additions & 4 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand All @@ -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. */
Expand Down
68 changes: 68 additions & 0 deletions scripts/check-descriptions.mjs
Original file line number Diff line number Diff line change
@@ -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', /<meta name="description" content="([^"]*)"/],
['og:description', /<meta property="og:description" content="([^"]*)"/],
['twitter:description', /<meta name="twitter:description" content="([^"]*)"/],
];

// A description ends on . ! ? or : (the lead-in to a list), closed by at most a quote or bracket.
const COMPLETE = /[.!?:]["')\]’”]*$/;

const decode = (text) =>
text
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&');

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 (/<meta http-equiv="refresh"/i.test(html)) continue;
const rel = path.relative(SITE, file);
for (const [label, pattern] of TAGS) {
const match = html.match(pattern);
const where = `${rel} ${label}`;
if (!match) {
problems.push(`${where}: missing`);
continue;
}
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}"`);
}
}
if (checked === 0) problems.push('no description found in _site');

if (problems.length) {
console.error(`check-descriptions: ${problems.length} problem(s)`);
for (const problem of problems) console.error(` - ${problem}`);
process.exit(1);
}
console.log(`check-descriptions: ${checked} description(s), none clipped`);
3 changes: 3 additions & 0 deletions templates/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<link rel="canonical" href="{{canonical}}" />
<meta name="theme-color" content="#325EA6" />
<meta property="og:site_name" content="{{site}}" />
<meta property="og:locale" content="en_GB" />
<meta property="og:type" content="{{og_type}}" />
<meta property="og:title" content="{{doc_title}}" />
<meta property="og:description" content="{{description}}" />
Expand All @@ -17,6 +18,8 @@
<meta property="og:image:height" content="628" />
<meta property="og:image:alt" content="{{og_alt}}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@cuesoftinc" />
<meta name="twitter:creator" content="@cuesoftinc" />
<meta name="twitter:title" content="{{doc_title}}" />
<meta name="twitter:description" content="{{description}}" />
<meta name="twitter:image" content="{{base}}/assets/og-card.png" />
Expand Down
Loading