Skip to content

Instantly share code, notes, and snippets.

@kanzure
Created July 29, 2026 11:03
Show Gist options
  • Select an option

  • Save kanzure/436cf9c66fa43e0576a0a60aa14b2fba to your computer and use it in GitHub Desktop.

Select an option

Save kanzure/436cf9c66fa43e0576a0a60aa14b2fba to your computer and use it in GitHub Desktop.
slop-text.mjs - find excessive text in your UI project
#!/usr/bin/env node
/**
* slop-text — a census of long user-facing text in a React codebase.
*
* Long copy in a UI is usually a symptom rather than a feature: a helper
* paragraph under a field is explaining a control that should have explained
* itself, and a two-sentence tooltip is a design decision that was deferred
* into prose. This walks the JSX/TSX sources, reconstructs every string that
* reaches a user, and ranks the ones that are long *for their role* — a
* 12-word button label is worse than a 12-word paragraph.
*
* Why AST and not jsdom/React: rendering only reaches the branches a given set
* of props and API data happens to produce, so empty states, error states and
* anything behind a feature flag stay invisible; it also needs a server, a
* session, and seconds per page. Parsing sees every branch at once and the
* whole tree runs in about a second, which is what makes it cheap enough to
* run like a linter.
*
* Standalone and project-agnostic: one dependency (@babel/parser), no config
* file, no knowledge of any particular codebase. Anything site-specific — which
* generated trees to skip, which roots to scan — is passed in on the command
* line, so this file can be lifted into another repo unchanged. MIT.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import babelParser from '@babel/parser';
const { parse } = babelParser;
const SOURCE_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts', '.mjs']);
/** Generated, vendored, or non-shipping trees, in any JS project. */
const EXCLUDED_DIRECTORIES = new Set([
'node_modules',
'dist',
'build',
'coverage',
'.git',
'.next',
'storybook-static',
'playwright-report',
'test-results',
]);
/**
* Path fragments skipped everywhere. Generated code and per-project trees are
* *not* listed here — pass those with `--exclude` so this file stays portable.
*/
const EXCLUDED_PATH_FRAGMENTS = [`${path.sep}__mocks__${path.sep}`, `${path.sep}__fixtures__${path.sep}`];
const isTestFile = (filePath) => /\.(test|spec)\.[jt]sx?$/.test(filePath) || /\.d\.ts$/.test(filePath);
/**
* Roles, and the point at which text in each stops being a label and starts
* being an essay. The numbers are deliberately per-role: the same 15 words are
* unremarkable in a paragraph and a design failure on a button.
*
* `sentences` is the second trigger — anything that needs a full stop and then
* keeps going is explaining rather than naming, whatever its word count.
*/
const ROLES = {
action: { words: 6, sentences: 2, blurb: 'button / link' },
heading: { words: 10, sentences: 2, blurb: 'heading' },
label: { words: 8, sentences: 2, blurb: 'field label' },
placeholder: { words: 7, sentences: 2, blurb: 'input placeholder' },
tooltip: { words: 12, sentences: 2, blurb: 'title / aria-label' },
hint: { words: 14, sentences: 2, blurb: 'helper text' },
body: { words: 40, sentences: 4, blurb: 'body copy' },
};
/**
* Attributes that render as text. Anything not listed here is treated as
* plumbing (className, href, testids) even when its value is a long string.
*/
const ATTRIBUTE_ROLES = new Map([
['placeholder', 'placeholder'],
['title', 'tooltip'],
['aria-label', 'tooltip'],
['aria-description', 'tooltip'],
['alt', 'tooltip'],
['tooltip', 'tooltip'],
['label', 'label'],
['ariaLabel', 'tooltip'],
['legend', 'label'],
['heading', 'heading'],
['description', 'hint'],
['helpText', 'hint'],
['helperText', 'hint'],
['hint', 'hint'],
['subtitle', 'hint'],
['caption', 'hint'],
['note', 'hint'],
['blurb', 'hint'],
['message', 'body'],
['emptyMessage', 'body'],
['emptyState', 'body'],
['body', 'body'],
['confirmLabel', 'action'],
['cancelLabel', 'action'],
['ctaLabel', 'action'],
['buttonLabel', 'action'],
['submitLabel', 'action'],
['actionLabel', 'action'],
]);
/**
* Object keys that carry copy. Catches the message maps and config objects
* that hold text far away from the component that renders it.
*/
const OBJECT_KEY_ROLES = new Map([
['title', 'heading'],
['heading', 'heading'],
['label', 'label'],
['placeholder', 'placeholder'],
['tooltip', 'tooltip'],
['description', 'hint'],
['helpText', 'hint'],
['helperText', 'hint'],
['hint', 'hint'],
['subtitle', 'hint'],
['caption', 'hint'],
['note', 'hint'],
['blurb', 'hint'],
['summary', 'body'],
['message', 'body'],
['body', 'body'],
['text', 'body'],
['copy', 'body'],
['cta', 'action'],
]);
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
const ACTION_TAGS = new Set(['button', 'a', 'Link', 'NavLink']);
const LABEL_TAGS = new Set(['label', 'legend', 'th', 'caption', 'summary', 'figcaption']);
/** Form controls, matched by shape so a project's own wrappers count too. */
const CONTROL_TAG_PATTERN = /^(input|select|textarea)$|(Input|Select|Textarea|TextArea|Field|Picker|Combobox|SearchBox)$/;
/** Placeholder standing in for a `{value}` interpolation, counted as one word. */
const DYNAMIC = '{…}';
// ---------------------------------------------------------------------------
// Text measurement
// ---------------------------------------------------------------------------
const WORD_PATTERN = /[\p{L}\p{N}][\p{L}\p{N}'’./-]*|\{…\}/gu;
/** Abbreviations whose full stop is not the end of a sentence. */
const NON_TERMINAL = /\b(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|approx|no)\.$/i;
const countWords = (text) => (text.match(WORD_PATTERN) || []).length;
const countSentences = (text) => {
if (!countWords(text)) return 0;
let sentences = 0;
// A terminator followed by whitespace or end-of-string, minus the
// abbreviations that only look like one.
const pattern = /[.!?]+(?=\s|$)/g;
let match;
while ((match = pattern.exec(text)) !== null) {
const upTo = text.slice(0, match.index + match[0].length);
if (!NON_TERMINAL.test(upTo)) sentences += 1;
}
// Text with words but no terminator is still one thing being said.
return Math.max(sentences, 1);
};
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
/**
* True when a string looks like copy rather than like machinery. Filters out
* the class-name soup, URLs, keys, and single symbols that share a type with
* real text.
*/
const looksLikeCopy = (text) => {
const stripped = text.replace(/\{…\}/g, '').trim();
if (!stripped) return false;
if (!/\p{L}{2}/u.test(stripped)) return false;
if (/^(https?:\/\/|\/|#|mailto:)/.test(stripped)) return false;
if (/^[a-z0-9_.-]+$/i.test(stripped) && !/\s/.test(stripped)) return false;
return true;
};
// ---------------------------------------------------------------------------
// AST walking
// ---------------------------------------------------------------------------
const SKIP_NODE_KEYS = new Set(['loc', 'range', 'leadingComments', 'trailingComments', 'innerComments', 'extra', 'tokens', 'comments']);
const isNode = (value) => value && typeof value === 'object' && typeof value.type === 'string';
/** Depth-first walk over every node in the tree, in source order. */
function walk(node, visit) {
if (!isNode(node)) return;
visit(node);
for (const key of Object.keys(node)) {
if (SKIP_NODE_KEYS.has(key)) continue;
const value = node[key];
if (Array.isArray(value)) {
for (const child of value) walk(child, visit);
} else if (isNode(value)) {
walk(value, visit);
}
}
}
const jsxName = (node) => {
if (!node) return '';
if (node.type === 'JSXIdentifier') return node.name;
if (node.type === 'JSXNamespacedName') return `${node.namespace.name}:${node.name.name}`;
if (node.type === 'JSXMemberExpression') return `${jsxName(node.object)}.${jsxName(node.property)}`;
return '';
};
/** The literal text of a template with no substitutions, else null. */
const staticString = (node) => {
if (!node) return null;
if (node.type === 'StringLiteral') return node.value;
if (node.type === 'TemplateLiteral') {
return node.quasis.map((quasi, index) => quasi.value.cooked + (index < node.expressions.length ? DYNAMIC : '')).join('');
}
// Long copy is routinely spelled as 'one line ' + 'and the next' to stay
// inside the line length, and it is one sentence to whoever reads it.
if (node.type === 'BinaryExpression' && node.operator === '+') {
const left = staticString(node.left);
const right = staticString(node.right);
if (left === null && right === null) return null;
return (left ?? DYNAMIC) + (right ?? DYNAMIC);
}
return null;
};
/**
* Reconstruct what an element renders as text, following the same rules the
* JSX runtime does: adjacent text and expressions concatenate, whitespace
* collapses, and nested elements contribute their own text inline (a `<strong>`
* in the middle of a sentence is part of that sentence).
*
* Returns the flattened text plus the elements found inside it, so the caller
* can report the tightest container rather than every ancestor of a paragraph.
*/
function renderJsxText(node, nested) {
const parts = [];
const fromExpression = (expression) => {
if (!expression || expression.type === 'JSXEmptyExpression') return;
switch (expression.type) {
case 'StringLiteral':
case 'TemplateLiteral':
parts.push(staticString(expression));
return;
case 'JSXElement':
case 'JSXFragment':
nested.push(expression);
parts.push(renderJsxText(expression, nested).text);
return;
case 'LogicalExpression':
fromExpression(expression.right);
return;
case 'ConditionalExpression':
// Both branches are text a user can see; keep the longer one so
// one finding is not split into two half-sentences.
{
const left = collectBranch(expression.consequent, nested);
const right = collectBranch(expression.alternate, nested);
parts.push(left.length >= right.length ? left : right);
}
return;
default:
parts.push(DYNAMIC);
}
};
for (const child of node.children || []) {
if (child.type === 'JSXText') {
parts.push(child.value);
} else if (child.type === 'JSXExpressionContainer') {
fromExpression(child.expression);
} else if (child.type === 'JSXElement' || child.type === 'JSXFragment') {
nested.push(child);
parts.push(renderJsxText(child, nested).text);
}
}
// Joined with nothing, because JSXText carries its own spacing: the comma in
// `{name}, then` belongs to the text node and must not be pushed off the word.
return { text: normalize(parts.join('')), nested };
}
function collectBranch(expression, nested) {
const scratch = [];
const holder = { children: [{ type: 'JSXExpressionContainer', expression }] };
const rendered = renderJsxText(holder, scratch);
nested.push(...scratch);
return rendered.text;
}
const attributeValue = (attribute) => {
const { value } = attribute;
if (!value) return null;
if (value.type === 'StringLiteral') return value.value;
if (value.type === 'JSXExpressionContainer') return staticString(value.expression);
return null;
};
const classNameOf = (element) => {
const attributes = element.openingElement?.attributes || [];
for (const attribute of attributes) {
if (attribute.type !== 'JSXAttribute') continue;
if (jsxName(attribute.name) !== 'className') continue;
return attributeValue(attribute) || '';
}
return '';
};
/**
* Text that follows a form control inside the same parent is helper text, and
* is judged as such however it is styled. This catches the copy that is
* apologising for the control above it in the many places that do not reach for
* the `text-xs` idiom.
*/
function markHelperSiblings(parent, helpers) {
let seenControl = false;
for (const child of parent.children || []) {
if (child.type !== 'JSXElement') continue;
const tag = jsxName(child.openingElement.name);
if (CONTROL_TAG_PATTERN.test(tag)) {
seenControl = true;
} else if (seenControl) {
helpers.add(child);
}
}
}
/** Which of the ROLES this element's text is playing, from its tag and classes. */
function classifyElement(element, helpers) {
const tag = element.type === 'JSXFragment' ? '' : jsxName(element.openingElement.name);
const className = classNameOf(element);
if (HEADING_TAGS.has(tag)) return 'heading';
if (ACTION_TAGS.has(tag) || /\bbtn-/.test(className)) return 'action';
if (LABEL_TAGS.has(tag)) return 'label';
if (/\btext-(2xl|3xl|4xl|5xl)\b/.test(className)) return 'heading';
// Small muted type under a control is helper text — the single most common
// home for copy that is apologising for the control above it.
if (/\btext-xs\b/.test(className)) return 'hint';
if (/\btext-sm\b/.test(className) && /(muted|secondary|text-gray-[45]00)/.test(className)) return 'hint';
if (helpers.has(element)) return 'hint';
return 'body';
}
// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------
function collectFiles(target, out, excludes = []) {
const fragments = [...EXCLUDED_PATH_FRAGMENTS, ...excludes.map((exclude) => exclude.split('/').join(path.sep))];
const excluded = (filePath) => fragments.some((fragment) => filePath.includes(fragment));
const stat = fs.statSync(target);
if (stat.isFile()) {
if (SOURCE_EXTENSIONS.has(path.extname(target)) && !isTestFile(target)) out.push(target);
return;
}
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
const filePath = path.join(target, entry.name);
if (entry.isDirectory()) {
if (EXCLUDED_DIRECTORIES.has(entry.name)) continue;
if (excluded(filePath)) continue;
collectFiles(filePath, out, excludes);
} else if (entry.isFile()) {
if (!SOURCE_EXTENSIONS.has(path.extname(entry.name))) continue;
if (isTestFile(filePath)) continue;
if (excluded(filePath)) continue;
out.push(filePath);
}
}
}
const parseFile = (source, filePath) =>
parse(source, {
sourceType: 'module',
allowReturnOutsideFunction: true,
errorRecovery: true,
plugins: [
'jsx',
path.extname(filePath) === '.ts' || path.extname(filePath) === '.tsx' ? 'typescript' : 'flow',
'decorators-legacy',
'classProperties',
'topLevelAwait',
'importAssertions',
],
});
/** Lines carrying a `slop-text-ignore` comment; findings on the next line are muted. */
function ignoredLines(ast) {
const lines = new Set();
for (const comment of ast.comments || []) {
if (!comment.value.includes('slop-text-ignore')) continue;
lines.add(comment.loc.end.line);
lines.add(comment.loc.end.line + 1);
}
return lines;
}
function scanFile(filePath) {
const source = fs.readFileSync(filePath, 'utf8');
if (!source.includes('<') && !/['"`]/.test(source)) return [];
let ast;
try {
ast = parseFile(source, filePath);
} catch (error) {
process.stderr.write(`slop-text: could not parse ${path.relative(process.cwd(), filePath)}: ${error.message}\n`);
return [];
}
const findings = [];
const muted = ignoredLines(ast);
// Elements that are an ancestor of some other text-bearing element: the
// paragraph is the finding, not the six divs wrapped around it.
const hasTextChild = new Set();
// Elements sitting after a form control in their parent (see markHelperSiblings).
const helpers = new Set();
const add = (role, text, loc, context) => {
const normalized = normalize(text);
if (!looksLikeCopy(normalized)) return;
const words = countWords(normalized);
const sentences = countSentences(normalized);
const limits = ROLES[role];
if (words <= limits.words && sentences <= limits.sentences) return;
if (muted.has(loc.start.line)) return;
findings.push({
file: path.relative(process.cwd(), filePath),
line: loc.start.line,
role,
context,
words,
sentences,
limit: limits.words,
// How far past its role's budget this is; the ranking that puts a
// 20-word button above a 45-word paragraph.
ratio: Number((words / limits.words).toFixed(2)),
text: normalized,
});
};
walk(ast, (node) => {
if (node.type === 'JSXElement' || node.type === 'JSXFragment') {
// Parents are visited before their children, so the helper-sibling
// marks are in place by the time a child is classified.
markHelperSiblings(node, helpers);
const nested = [];
const { text } = renderJsxText(node, nested);
if (normalize(text)) {
for (const child of nested) {
const childText = renderJsxText(child, []).text;
if (countWords(childText) >= 3) hasTextChild.add(node);
}
}
if (!hasTextChild.has(node) && text) {
const tag = node.type === 'JSXFragment' ? 'fragment' : jsxName(node.openingElement.name);
add(classifyElement(node, helpers), text, node.loc, `<${tag}>`);
}
}
if (node.type === 'JSXAttribute') {
const name = jsxName(node.name);
const role = ATTRIBUTE_ROLES.get(name);
if (role) {
const value = attributeValue(node);
if (value) add(role, value, node.loc, name);
}
}
if (node.type === 'ObjectProperty' && !node.computed) {
const key = node.key.type === 'Identifier' ? node.key.name : node.key.type === 'StringLiteral' ? node.key.value : null;
const role = key && OBJECT_KEY_ROLES.get(key);
if (role) {
const value = staticString(node.value);
if (value) add(role, value, node.loc, `${key}:`);
}
}
});
// An outer element only learns it has a text-bearing child after the walk
// reaches that child, so drop the ancestors here rather than during it.
return findings.filter((finding, index) => {
if (!finding.context.startsWith('<')) return true;
return !findings.some(
(other, otherIndex) =>
otherIndex !== index &&
other.context.startsWith('<') &&
other.line >= finding.line &&
other.text !== finding.text &&
finding.text.includes(other.text),
);
});
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const USAGE = `Usage: slop-text [options] [paths...]
Ranks long user-facing text in JSX/TSX sources. Defaults to ./src.
--limit N show N findings (default 40, 0 for all)
--min-words N only report text of at least N words, whatever its role
--role NAME only this role (${Object.keys(ROLES).join(', ')})
--sort KEY ratio (default), words, sentences, file
--exclude PATH skip paths containing this fragment (repeatable)
--max N exit 1 when more than N findings survive the filters
--json machine-readable output
--full print the whole string instead of a 160-char excerpt
--help
`;
function parseArguments(argv) {
const options = { limit: 40, minWords: 0, role: null, sort: 'ratio', max: null, json: false, full: false, exclude: [], paths: [] };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
const next = () => argv[(index += 1)];
switch (argument) {
case '--help':
case '-h':
process.stdout.write(USAGE);
process.exit(0);
break;
case '--limit':
options.limit = Number(next());
break;
case '--min-words':
options.minWords = Number(next());
break;
case '--role':
options.role = next();
break;
case '--sort':
options.sort = next();
break;
case '--exclude':
options.exclude.push(next());
break;
case '--max':
options.max = Number(next());
break;
case '--json':
options.json = true;
break;
case '--full':
options.full = true;
break;
default:
if (argument.startsWith('-')) {
process.stderr.write(`slop-text: unknown option ${argument}\n\n${USAGE}`);
process.exit(2);
}
options.paths.push(argument);
}
}
if (options.role && !ROLES[options.role]) {
process.stderr.write(`slop-text: unknown role ${options.role}\n\n${USAGE}`);
process.exit(2);
}
return options;
}
const SORTS = {
ratio: (a, b) => b.ratio - a.ratio || b.words - a.words,
words: (a, b) => b.words - a.words,
sentences: (a, b) => b.sentences - a.sentences || b.words - a.words,
file: (a, b) => a.file.localeCompare(b.file) || a.line - b.line,
};
const excerpt = (text, full) => (full || text.length <= 160 ? text : `${text.slice(0, 157)}…`);
function main() {
const options = parseArguments(process.argv.slice(2));
// Default to ./src when there is one, else the working directory: no
// assumption about where this file sits relative to the code it reads.
const defaultRoot = fs.existsSync(path.join(process.cwd(), 'src')) ? path.join(process.cwd(), 'src') : process.cwd();
const roots = options.paths.length ? options.paths.map((p) => path.resolve(p)) : [defaultRoot];
const files = [];
for (const root of roots) {
if (!fs.existsSync(root)) {
process.stderr.write(`slop-text: no such path ${root}\n`);
process.exit(2);
}
collectFiles(root, files, options.exclude);
}
let findings = files.flatMap(scanFile);
if (options.role) findings = findings.filter((finding) => finding.role === options.role);
if (options.minWords) findings = findings.filter((finding) => finding.words >= options.minWords);
findings.sort(SORTS[options.sort] || SORTS.ratio);
if (options.json) {
process.stdout.write(`${JSON.stringify({ files: files.length, findings }, null, 2)}\n`);
} else {
const shown = options.limit > 0 ? findings.slice(0, options.limit) : findings;
for (const finding of shown) {
const budget = `${finding.words}w/${finding.sentences}s vs ${finding.limit}w`;
process.stdout.write(`${finding.file}:${finding.line} ${finding.role} ${finding.context} ${budget} ×${finding.ratio}\n`);
process.stdout.write(` ${excerpt(finding.text, options.full)}\n\n`);
}
const byRole = new Map();
for (const finding of findings) byRole.set(finding.role, (byRole.get(finding.role) || 0) + 1);
const roleSummary = [...byRole.entries()]
.sort((a, b) => b[1] - a[1])
.map(([role, count]) => `${role} ${count}`)
.join(', ');
process.stdout.write(`${findings.length} findings in ${files.length} files`);
if (shown.length < findings.length) process.stdout.write(` (showing ${shown.length}; --limit 0 for all)`);
process.stdout.write(`\n${roleSummary || 'nothing over budget'}\n`);
}
if (options.max !== null && findings.length > options.max) {
process.stderr.write(`\nslop-text: ${findings.length} findings exceeds --max ${options.max}\n`);
process.exit(1);
}
}
export { ROLES, countWords, countSentences, looksLikeCopy, scanFile, collectFiles };
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment