72 lines
2.6 KiB
TypeScript
72 lines
2.6 KiB
TypeScript
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([]);
|
||
|
|
});
|
||
|
|
});
|