@tornstudio/voice-gate
v0.1.0
Published
Lints marketing copy for the patterns that read as machine-written: first-person drift, negative definitions, filler words. English and Swedish.
Maintainers
Readme
voice-gate
Lints marketing copy for the patterns that read as machine-written: first-person drift, negative definitions, filler words. English and Swedish, zero runtime dependencies.
npx @tornstudio/voice-gateThe line it rejects
Product management that decides, not meets.voice-gate - 1 violation
content/services.md:14
Product management that decides, not meets.
-> comma-not - "X, not Y" manufactures drama - make the positive claim aloneThat sentence carries one piece of information. decides is the claim. The
second half invents an opponent who does the opposite, and the reader has to
read both halves to come away with the one thing the first half already said.
It reads as generated because generation reaches for contrast when it has nothing specific to say. A person who knows the work writes the specific thing.
The rewrite:
Product management that ships a decision a week.Same length. One claim, and the claim now carries a number.
That is the whole tool: seven constructions and a word list that survive a spellcheck, a grammar checker and a human read, and give the copy away anyway.
Install
npm install --save-dev @tornstudio/voice-gateNode 18.17 or newer. No runtime dependencies.
The package is scoped; the command it installs is not. Inside a project that
has it, the command is voice-gate. To run it once with no install, name the
scope: npx @tornstudio/voice-gate.
Use
# Read the config, or default to every Markdown file in the project
npx voice-gate
# Check the paths given, whatever the config says
npx voice-gate content/en/pricing.md
# Write a starter config
npx voice-gate --initThe exit code is 0 when the copy passes, 1 when a rule fires, 2 when the
config or the arguments could not be read. That makes it a build step:
{
"scripts": {
"check:voice": "voice-gate"
}
}What it checks
Two families of rule, and both catch the same habit.
| Rule | Rejects |
| ------------------------ | --------------------------------------------------- |
| comma-not | Copy that ships, not copy that talks |
| and-not | We build the thing and not the deck |
| sentence-opening-not | Not a deck. A working site. |
| negative-definition | rather than, instead of, in place of |
| without-construction | Content at volume without the filler |
| first-person-singular | I build websites for founders |
| first-person-possessive| My studio ships in three weeks |
| banned-word | unlock, seamless, game-changer, synergy |
The Swedish preset covers the same ground in Swedish, written for the language:
snarare än, utan, , inte, Inte …, jag/mig/mitt/mina, sömlös,
vi brinner för.
Swedish min is left out on purpose. It collides with the abbreviation in
30 min, which is ordinary copy, and a rule that fires on a booking time is a
rule a writer learns to skip.
One violation is reported per line. A rewritten line gets rewritten once.
Configure
Drop a voice-gate.config.json at the root. Every field is optional, and
voice-gate.config.js, .voice-gaterc.json and a voiceGate key in
package.json work the same way.
{
"presets": ["en", "sv"],
"include": ["content/**/*.{md,mdx}", "src/i18n/dictionaries/**/*.ts"],
"exclude": ["content/drafts/**"],
"readerVoice": ["^\\s*question:"],
"allowPhrases": ["without JavaScript"],
"bannedWords": ["bespoke"],
"allowWords": ["leverage"],
"disable": ["en/first-person-possessive"],
"rules": [
{
"name": "no-hedging",
"pattern": "\\b(?:basically|essentially)\\b",
"flags": "i",
"hint": "cut it"
}
]
}| Field | Does |
| ------------------ | ---------------------------------------------------------------------- |
| presets | Built-in rule sets to start from: en, sv. Defaults to ["en"]. |
| include | Globs, directories or files. Defaults to every .md and .mdx. |
| exclude | Globs never read, added to the built-in list. |
| rules | Rules of your own, appended after the presets. |
| disable | Rule names switched off, from a preset or from rules. |
| bannedWords | Words added to the banned-word rule. |
| allowWords | Words taken back out of it. |
| allowPhrases | Literal phrases dropped from a line before any rule reads it. |
| ignore | Regexes. A line that matches is skipped whole. |
| readerVoice | Regexes. A line that matches keeps every rule except the voice ones. |
| exemptMarker | The inline escape hatch. Defaults to voice-exempt:. |
| extractors | Extension to mode: prose, strings or json. |
Load two presets and the rule names carry theirs, as sv/comma-not, so one
language never shadows the other.
Copy, and the code around it
A Markdown file is copy from the first character. A TypeScript file holds copy inside string literals and code everywhere else, so a rule name, an import path and a comment all have to stay out of the check. Read either kind of file the other way and you get an empty run or a page of noise.
voice-gate picks the reader by extension:
| Mode | Reads | Default for |
| --------- | ------------------------------ | ------------------------------------ |
| prose | Every line | .md .mdx .txt .yml .html |
| strings | Quoted string literals | .ts .tsx .js .vue .svelte |
| json | Quoted values, never the keys | .json |
prose skips fenced code, HTML comments, tags, link destinations and inline
code spans, which is why every example on this page sits in a fence or in
backticks, and the gate this README runs on itself stays green.
strings carries block comments and template literals across lines, drops
${…} interpolations, and skips module specifiers, so an import of
./rather-than.js stays quiet.
Override it per extension:
{ "extractors": { ".svelte": "prose", ".po": "prose" } }The reader's half of a Q&A
An FAQ question is written in the reader's voice, where first person is exactly
right. readerVoice names those lines by pattern, and they keep every other
rule:
{ "readerVoice": ["^\\s*question:", "^\\s*> "] }The escape hatch
Some contrasts carry meaning. A technical predicate is one. An honest limitation stated as plain fact is another. Put the marker in a comment on the line or on the line above it, with the reason after it:
<!-- voice-exempt: a technical predicate, not a rhetorical contrast -->
The site is readable without JavaScript.// voice-exempt: the reader's own situation, in their words
const painPoint = 'Traffic that arrives and leaves without converting';The reason is the point. An exemption with a reason shows up in review; a rule switched off in the config disappears.
In CI
- run: npx @tornstudio/voice-gate --format github--format github writes annotations against the offending lines, so a failure
lands on the diff. --format json gives the whole report to a script.
As a library
import { runVoiceGate, checkSource, resolveConfig } from '@tornstudio/voice-gate';
const report = await runVoiceGate({ cwd: process.cwd() });
console.log(report.violations, report.filesScanned);
// Or check a buffer that is not on disk yet, from an editor plugin or a hook
const config = resolveConfig({ presets: ['en'] });
const violations = checkSource('draft.md', text, config, 'prose');checkSource, extractCopy, collectFiles, resolveConfig and loadConfig
are all exported, with types.
Where it came from
voice-gate started as scripts/check-voice.ts in the Torn Studio site, where it
runs on every commit across five locales. It stayed useful long enough to be
worth handing over, so the four things that tied it to one repository — the
directories, the rules, the reader-voice test and the copy extractor — became
configuration.
It has an opinion, and the opinion is editable. Every default here is a line in a config file away from being someone else's.
Licence
MIT © Torn Studio. Built at torn.studio.
