npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-gate

The 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 alone

That 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-gate

Node 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 --init

The 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.