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

saniprompt

v0.3.0

Published

A multilingual NSFW prompt sanitizer for Node.js and browsers, with tiered detection and obfuscation resistance.

Readme

SaniPrompt

A multilingual NSFW prompt sanitizer that survives the obfuscation people actually use.

npm CI license dependencies types

3,417 terms · 21 languages · linear-time matching · no network calls


import { inspect } from "saniprompt";

await inspect("Create an explicit porn image");
// { isSafe: false, risk: "high", sanitizedText: "Create an image" }

Catches what filters usually miss:

await inspect("Make p.o.r.n art");   // separator insertion
await inspect("Make p0rn art");      // leetspeak
await inspect("Make pоrn art");      // Cyrillic homoglyph
await inspect("Make p​o​r​n art");      // zero-width spaces
await inspect("Make poooorn art");   // repeated letters
await inspect("pornografía" /* NFD */);  // decomposed Unicode
// all flagged

Without breaking what filters usually break:

await inspect("Type p or n to continue");     // prose is not obfuscation
await inspect("view it with the naked eye");
await inspect("grilled chicken breast");
await inspect("Nude Descending a Staircase");
await inspect("bok choy salad");
// all clean

Why this one

| | | |---|---| | Obfuscation-resistant | Zero-width characters, decomposed Unicode, leetspeak, homoglyphs, separator insertion and repeated letters all fold to one skeleton before matching. | | Precision-first | Ambiguous words are omitted, idioms are carved out, and words that collide across languages are gated. Measured at 100% on a 145-entry benign corpus, and on all 234k words of the system dictionary. | | Graded, not binary | Four tiers for NSFW and four for profanity, so you can block hardcore content while allowing artistic nudity, or allow swearing while catching slurs. | | Measured, not guessed | npm run eval reports recall, precision and evasion resistance. CI fails on regression, so vocabulary changes cannot quietly trade one for the other. | | Fast | Every term compiles into a single automaton. Cost is linear in input length and independent of lexicon size. |

[!IMPORTANT] Dictionary matching catches words, not intent. "two people in a bedroom, no clothes, photorealistic" contains nothing to match. Treat the local engine as a fast first pass and pair it with a contextual moderation provider for anything high-stakes.

Install

npm install saniprompt

Quick start

import { inspect } from "saniprompt";

const result = await inspect("Create an explicit porn image");

result.isSafe;        // false
result.risk;          // "high"
result.sanitizedText; // "Create an image"

The local engine never touches the network, so there is a synchronous API too:

import { sanitizeSync } from "saniprompt";

sanitizeSync("Create an explicit porn image"); // "Create an image"

Gate, don't only scrub

[!WARNING] Deleting a word does not make a prompt safe. Removing porn from "a photorealistic porn scene of two people in a bedroom" leaves a prompt that still produces the same image.

For anything that matters, gate on isSafe and reject rather than passing scrubbed text downstream:

const { isSafe, risk } = await inspect(prompt, { strategy: "flag" });
if (!isSafe) return reject(risk);

strategy: "flag" returns the text byte-for-byte unchanged and reports only.

Contents

Tiers · Strategies · Obfuscation · False positives · Inspection · Categories · Languages · Custom rules · Providers · Options · Quality gates · Limitations

Tiers

NSFW and profanity each carry a tier, so you decide how aggressive to be.

threshold sets the lowest tier acted on. Both default to the bottom of their scale — "contextual" for NSFW, "mild" for profanity — so the package is strict out of the box and you raise the floor to relax it. The two scales are independent, so a single tier adjusts only its own.

// Block hardcore content, allow artistic nudity, swimwear and lingerie.
await inspect(prompt, { threshold: "explicit" });

// Let everyday swearing through, catch only slurs.
await inspect(prompt, { categories: ["profanity"], threshold: "slur" });

// Set both at once.
await inspect(prompt, { threshold: { nsfw: "explicit", profanity: "mild" } });

A contextual term is promoted to suggestive when a supporting word sits nearby, so "generate adult content" is flagged while "adult education classes" is not.

[!NOTE] Strict by default. Both thresholds start at the bottom of their scale, so ambiguous words (adult, exposed, intimate) and mild profanity (damn, hell, idiot) are acted on. Raise threshold to relax.

[!NOTE] Swimwear is filtered unconditionally. bikini, swimsuit, swimwear and bathing suit sit at suggestive with no carve-outs, so Bikini Atoll, Bikini Bottom, bikini wax, racing swimsuit and swimwear retailer all flag. That is deliberate. Set threshold: "explicit" to allow swimwear through.

[!NOTE] minor findings ignore threshold entirely. They cannot be tuned away and are always critical. Categories without tiers (hate, violence, self-harm, illegal activity, prompt injection, PII, secrets) are unaffected by threshold.

Strategies

await sanitize(prompt, { strategy: "remove" });      // default
await sanitize(prompt, { strategy: "mask" });
await sanitize(prompt, { strategy: "placeholder" });
await sanitize(prompt, { strategy: "flag" });

Given "beautiful woman, nude, oil painting":

| Strategy | Result | |---|---| | remove | beautiful woman, oil painting | | mask | beautiful woman, ****, oil painting | | placeholder | beautiful woman, [redacted], oil painting | | flag | beautiful woman, nude, oil painting |

remove repairs only the seam it leaves, a doubled comma or a doubled space. Text outside a finding is never reformatted, so code indentation, blank lines and markdown structure survive intact. Customise the rest with maskCharacter and placeholder (a string, or a function of the finding).

Obfuscation handling

Detection runs on a folded skeleton of the input while edits apply to the original string.

| Evasion | Example | |---|---| | Separator insertion | p.o.r.n · p o r n · p-o-r-n | | Zero-width and format characters | zero-width spaces · soft hyphens · bidi marks | | Decomposed Unicode (NFD) | pornografía with a combining acute | | Compatibility forms | porn · 🅾ral | | Leetspeak | p0rn · sh1t · a$$hole | | Cyrillic and Greek homoglyphs | pоrn with a Cyrillic о | | Elongation | nuuude · pooorn (three or more repeats) | | Compound words | Pornoseite · pornofilm · pornocu (German, Dutch, Turkish) | | Regular inflections | swimsuits · orgies · masturbated · wanking · sexier (generated from the base form) |

Crucially it does not treat ordinary prose as obfuscation. "Type p or n to continue" spells porn only if you accept inconsistent separators; real obfuscation uses a consistent one, prose does not.

For the same reason a single doubled letter is not elongation. Accepting it would collapse rapping onto raping, Shiite onto shite, assess onto asses and pollack onto polack. Three or more repeats is deliberate; two is usually just a word.

Homoglyph folding is confined to mixed-script words, so genuine Russian and Greek text is untouched. Disable it with foldConfusables: false.

False positives

The Scunthorpe problem is solved three ways, none of which weaken word boundaries.

1. Genuinely ambiguous words are left out. hoe, knob, bloody, queer, cracker, dwarf and nip are absent by design. Their innocent uses are too common to be worth the recall.

2. Idioms are carved out with an exception list matched using the same folding and boundary rules, so the underlying term keeps its recall:

naked eye · naked truth · nude color · Nude Descending a Staircase · life drawing
breast cancer · chicken breast · breaststroke · sex education · sexual harassment
adult education · content moderation · underage drinking · child protection
chink in the armor · Maine Coon · homo sapiens · flame retardant · Van Dyke
prick your finger · bastard file · Dick Tracy · Hell's Kitchen · Wankel engine
magna cum laude · Moby Dick · pussy willow · missionary work · strip mall

3. Words that collide across languages are gated behind an explicit language. French con, flûte, mince and viol; Polish cholera; Portuguese piranha and burro; Turkish bok; Malay sial; German mist; Dutch lul; English negro; and French négro (which folds to the Spanish word for black) all need their language named:

await inspect("bok choy salad", { categories: ["profanity"] });            // clean
await inspect("bu bok", { categories: ["profanity"], languages: ["tr"] }); // flagged
await inspect("vestido negro elegante", { categories: "all" });            // clean

Tiers stay consistent across languages: a generic insult is mild whether written idiot, imbécile or Trottel, so a threshold behaves the same regardless of the input language.

Inspection

inspect() returns the sanitized text plus metadata. Findings deliberately omit the matched value, so logs do not make extra copies of what you were containing.

const result = await inspect(prompt, { categories: "all" });

result.sanitizedText;
result.isSafe;   // boolean
result.risk;     // "none" | "low" | "medium" | "high" | "critical"
result.findings; // category, start, end, severity, confidence, language, source, ruleId, tier

Offsets are UTF-16 indices into the original string. Overlapping findings within a category collapse to the most severe, so a social security number is not also reported as a phone number.

Categories

| Category | Covers | |---|---| | nsfw | The primary category. Sexual acts, anatomy, nudity, pornography, fetish, and minor-safety terms | | profanity | Profanity graded from mild to slurs, kept separate from NSFW | | hate | Supremacist, ethnic-cleansing and dehumanizing phrases | | violence | Graphic violence, torture and execution phrases | | selfHarm | Self-harm, suicide-method and suicidal-intent phrases | | illegalActivity | Criminal, fraud, phishing, malware and drug phrases | | promptInjection | Instruction overrides, prompt exfiltration and jailbreaks | | pii | Email, phone, Luhn-valid cards, IBANs, US SSNs, IP addresses | | secrets | JWTs, private keys, bearer tokens, credential-bearing URLs, vendor API keys |

await inspect(prompt, { categories: ["nsfw", "promptInjection"] });
await inspect(prompt, { categories: "all" });

Languages

Arabic · Bengali · Chinese · Dutch · English · French · German · Hindi (and Roman Hindi) · Indonesian · Italian · Japanese · Korean · Malay · Persian · Polish · Portuguese · Russian · Spanish · Turkish · Ukrainian · Urdu (and Roman Urdu)

All are checked by default. Naming languages narrows matching and enables the language-specific short terms:

await inspect(prompt, { languages: ["en", "ur"] });

Custom rules

await inspect(prompt, {
  categories: ["secrets"],
  customRules: [
    { id: "company-codename", category: "secrets", kind: "literal", pattern: "blue banana", severity: "high" },
    { id: "internal-ticket", category: "secrets", kind: "regex", pattern: /TICKET-\d+/i }
  ]
});

Literal rules get the same folding and obfuscation handling as bundled terms, so b.l.u.e b.a.n.a.n.a matches too.

Regular expressions run against the original text with their own flags preserved, including v. A bad rule throws InvalidRuleError naming the offending id.

createSanitizer compiles custom rules once instead of on every call. Prefer it on a hot path.

import { createSanitizer } from "saniprompt";

const sanitizer = createSanitizer({ categories: "all", threshold: "explicit" });

sanitizer.inspectSync(prompt);
sanitizer.inspectSync(prompt, { threshold: "suggestive" }); // per-call overrides

It exposes inspect, sanitize, inspectSync and sanitizeSync.

Moderation providers

Providers add the contextual judgement no dictionary can supply. This is the recommended path for high-stakes filtering.

import type { ModerationProvider } from "saniprompt";

const provider: ModerationProvider = {
  name: "my-moderator",
  async analyze(text, context) {
    const response = await callYourService(text, context.categories);
    return response.findings;
  }
};

await inspect(prompt, { categories: "all", provider });

Invalid offsets and findings for disabled categories are ignored. Provider errors fall back to local results; set providerFailure: "throw" to propagate them. inspectSync throws rather than silently skipping a configured provider.

Options

| Option | Default | Purpose | |---|---|---| | categories | ["nsfw"] | Which checks to run, or "all" | | languages | "auto" | Which dictionaries to run | | threshold | nsfw "contextual", profanity "mild" | Lowest tier acted on; a tier or a per-category object | | strategy | "remove" | remove · mask · placeholder · flag | | minConfidence | 0 | Drop findings below this confidence | | maxInputLength | 100000 | Throws InputTooLargeError beyond this | | foldConfusables | true | Fold Cyrillic and Greek look-alikes | | maskCharacter | "*" | Fill character for mask | | placeholder | "[redacted]" | Replacement for placeholder | | customRules | [] | Additional literal or regex rules | | provider | — | Contextual moderation provider | | providerFailure | "fallback" | fallback or throw |

All errors extend SanipromptError, so one catch covers the package:

import { SanipromptError, InputTooLargeError, InvalidRuleError } from "saniprompt";

try {
  await inspect(prompt, options);
} catch (error) {
  if (error instanceof InputTooLargeError) return tooLong(error.length, error.limit);
  if (error instanceof InvalidRuleError) return badConfig(error.ruleId);
  if (error instanceof SanipromptError) return unexpected(error);
  throw error;
}

InvalidRuleError carries the offending ruleId; InputTooLargeError carries length and limit.

import { CATEGORIES, lexiconSize } from "saniprompt";

CATEGORIES;     // readonly tuple of the nine category names
lexiconSize();  // number of bundled dictionary terms, for diagnostics

Types exported: Category, CustomRule, Finding, InspectionResult, LanguageCode, ModerationProvider, NsfwTier, ProfanityTier, ProviderContext, ProviderFinding, Risk, Sanitizer, SanitizeOptions, Severity, Strategy, Tier, TierFamily.

NsfwTier and ProfanityTier are the two halves of Tier, useful when writing a typed threshold:

const threshold: { nsfw: NsfwTier; profanity: ProfanityTier } = {
  nsfw: "explicit",
  profanity: "slur"
};

Quality gates

Lexicon changes are measured, not guessed. npm run eval runs the corpora in eval/ and CI fails on regression.

nsfw recall                     171/171   100.0%
minor-safety recall              46/46    100.0%   all critical
nsfw evasion resistance         110/110   100.0%
profanity recall                126/126   100.0%
slur recall                      64/64    100.0%   all critical
profanity evasion resistance     88/88    100.0%
mild caught at default           55/55    100.0%
precision (default thresholds)  145/145   100.0%
precision (raised thresholds)   145/145   100.0%

lexicon terms                     3417

Precision is measured twice, at both ends of the threshold range, and separately against all 234k words of the system dictionary. Contributing vocabulary means adding to a corpus as well as to the lexicon — see CONTRIBUTING.md.

Performance

Every term compiles into a single Aho-Corasick automaton, so cost is linear in input length and independent of lexicon size.

| Input | All categories | |---|---| | Typical image prompt | well under 1 ms | | 90 KB document | ~110 ms |

Limitations and privacy

[!CAUTION] This is a dictionary-based filter, not a complete safety system.

  • Dictionaries cannot understand intent, quotation, education, reclamation or paraphrase. False positives and false negatives are both possible.
  • Removing a phrase does not make the remaining prompt semantically safe. Gate on isSafe.
  • Semantic evasion ("no clothes", "wearing nothing") and reversed text are explicit non-goals for the local engine. Use a provider.
  • The local engine performs no network requests. Passing a provider sends data according to that provider's implementation and privacy policy.
  • The package does not store prompts. Applications remain responsible for logs, telemetry, retention, consent and human escalation.

AI-generated code

[!NOTE] The 0.2.0 detection engine, lexicons and test suite were generated with AI assistance and reviewed by the maintainer.

What that means in practice:

  • Behaviour is measured, not asserted. Every claim in this README is backed by the corpora in eval/, and npm run eval reproduces the numbers above. CI fails on regression.
  • The lexicon is the part to scrutinise. Vocabulary judgements — which terms belong in which tier, which words are too ambiguous to include — are opinions encoded as data. The English sets have had the most attention; the other 20 languages would benefit from native-speaker review, and corrections are welcome.
  • Tier assignments are product decisions. Whether erotic is suggestive rather than explicit, or idiot is mild rather than moderate, is a judgement call you may want to revisit for your use case. threshold and customRules exist so you do not have to fork to disagree.

If you find a miss or a false positive, the most useful bug report is a line added to the relevant corpus in eval/. See CONTRIBUTING.md.

Development

npm install
npm run check      # typecheck, lint, test, build
npm run eval       # recall / precision / evasion report
npm run bench      # timing report
npm run coverage

Releasing

Publishing uses npm trusted publishing through .github/workflows/publish.yml; no long-lived npm token is stored in GitHub.

For a brand-new npm package, publish the first version once from a maintainer machine with a current npm OTP and --provenance=false. Then configure the package's npm trusted publisher. This bootstrap is necessary because the trusted-publisher settings page exists only after the package does.

  1. Update version in package.json and package-lock.json, update CHANGELOG.md, and merge the tested release commit into main.
  2. Create a non-prerelease GitHub release whose tag exactly matches the package version, such as v0.2.0.
  3. GitHub Actions verifies the package and evaluation suite, then publishes it to npm with provenance.

The npm trusted publisher must match user mujtabachang, repository saniprompt, and workflow filename publish.yml.

License

MIT © Ahmed Mujtaba Chang