copy(web): replace em dashes in public copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
271ffe92d4
commit
d1688ac150
39 files changed
+183
-111
No files matched your search
@@ -104,7 +104,7 @@ describe('CompareAdmissions', () => {
|
||||
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Entry is by entrance test — the school is selective/),
|
||||
screen.getByText(/Entry is by entrance test\. The school is selective/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('CutoffMapPanel', () => {
|
||||
// Explanation is supporting text, not part of the bold verdict line.
|
||||
expect(result.querySelector('[class*="cutoffCheckHeadline"]')!.textContent)
|
||||
.not.toMatch(/measurement error/);
|
||||
expect(result).not.toHaveTextContent(/^\S+ away — inside/);
|
||||
expect(result).not.toHaveTextContent(/^\S+ away, inside/);
|
||||
});
|
||||
|
||||
it('surfaces a postcode the geocoder cannot find', async () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import ts from 'typescript';
|
||||
|
||||
/**
|
||||
* Keeps the em dash out of public copy.
|
||||
*
|
||||
* Every visitor-facing sentence was rewritten to use a colon, comma, full stop
|
||||
* or parentheses instead. A new one slips back in unnoticed, because it reads
|
||||
* fine to whoever wrote it, so this walks the source with the TypeScript parser
|
||||
* and checks every string literal, template chunk and JSX text node. Comments
|
||||
* are not nodes the walk visits, so they stay free to use it.
|
||||
*
|
||||
* Two uses are allowed:
|
||||
* - a lone dash standing in for a missing value in a table cell or stat slot,
|
||||
* which is a data convention rather than prose;
|
||||
* - the message of a thrown Error, which only a developer reads.
|
||||
*/
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const SCANNED = ['app', 'components', 'lib'];
|
||||
const SKIPPED = [path.join(ROOT, 'app', '(payload)')];
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
if (SKIPPED.includes(dir)) return [];
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return sources(full);
|
||||
return /\.tsx?$/.test(entry.name) ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function isErrorMessage(node: ts.Node): boolean {
|
||||
for (let p = node.parent; p; p = p.parent) {
|
||||
if (ts.isNewExpression(p) && /Error$/.test(p.expression.getText())) return true;
|
||||
if (ts.isStatement(p)) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const EMPTY_VALUE = /^(—|—)$/;
|
||||
|
||||
function dashedCopy(file: string): string[] {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
||||
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind);
|
||||
const found: string[] = [];
|
||||
const visit = (node: ts.Node) => {
|
||||
if (
|
||||
ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isJsxText(node)
|
||||
|| ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)
|
||||
) {
|
||||
const copy = node.getText().replace(/\s+/g, ' ').trim();
|
||||
const bare = node.text.trim();
|
||||
if (/—|—/.test(copy) && !EMPTY_VALUE.test(bare) && !isErrorMessage(node)) {
|
||||
const { line } = sf.getLineAndCharacterOfPosition(node.getStart());
|
||||
found.push(`${path.relative(ROOT, file)}:${line + 1} ${copy}`);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('public copy', () => {
|
||||
it('uses no em dashes', () => {
|
||||
const files = SCANNED.flatMap((dir) => sources(path.join(ROOT, dir)));
|
||||
expect(files.length).toBeGreaterThan(50);
|
||||
expect(files.flatMap(dashedCopy)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,7 @@ describe('all-through schools', () => {
|
||||
|
||||
it('labels the KS2 block explicitly', () => {
|
||||
renderSchoolDetail(allThroughFixture);
|
||||
expect(screen.getAllByText(/Primary — KS2 SATs/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Primary: KS2 SATs/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('compareToCutoff', () => {
|
||||
|
||||
it('reads back the reported case in one unit', () => {
|
||||
expect(compareToCutoff(69, 270, 2026).headline)
|
||||
.toBe('0.04 miles away — inside the September 2026 cut-off of 0.17 miles.');
|
||||
.toBe('0.04 miles away, inside the September 2026 cut-off of 0.17 miles.');
|
||||
});
|
||||
|
||||
it('calls a clearly nearer home inside, and names the year', () => {
|
||||
|
||||
Reference in new issue
Block a user