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([]); }); });