A strict TypeScript profanity filter that splits text into segments so your UI can render the redaction itself — the library never mutates or masks your string.
English and German ship pre-registered; any other language is a registerLanguage() call away. Leet spellings, lookalike letters, spaced-out words and repetition are matched; a cross-check keeps ordinary words like
Klassik
and classic
out of the results.Zero runtime dependencies. Optional adapters for React, Vue and Angular, an optional AI check, a PII detector for e-mail addresses, phone numbers, IBANs and cards, a streaming batch runner with a CLI for corpora that do not fit in memory, and an optional generator for the DSA Art. 17 statement of reasons you owe whoever you moderated. Each is its own subpath, so nothing you do not import reaches your bundle.
npm install ts-profanity-filter
Analysing one comment is a function call. Analysing two million is a different problem — and the difference is not speed. The obvious version holds the whole corpus in memory, dies at row 900 000 with nothing written, and makes one paid model call per row.
import { runBatch, formatSummary } from 'ts-profanity-filter/batch';
import { ndjsonFrom } from 'ts-profanity-filter/batch/node';
const summary = await runBatch(ndjsonFrom('comments.ndjson'), {
filter: { languages: ['en', 'de'] },
pii: true,
ai: { provider: 'gemini', when: 'matched', maxCalls: 500 },
onResult: (result) => { if (result.flagged) hold(result.id); },
});
console.log(formatSummary(summary));
Iterable in, results streamed out: peak memory is one record, whatever the
file size. Every stage is wrapped per record, so one hostile input costs a result
and not the run. And the model is gated rather than called per row — when: 'matched'
asks only about records a word list already hit, and maxCalls
is a hard ceiling, because a batch is exactly where one call per row becomes a bill.
There is a command now:
npx ts-profanity-filter scan comments.ndjson --pii --out flagged.ndjson --pdf report.pdf
It reads NDJSON, CSV, TSV or plain lines, guesses the text column from the CSV
header and says which one it picked, writes the flagged records back as NDJSON,
and exits 1 under --fail-on-findings
for CI. Progress goes to stderr and the summary to stdout, so it composes.
The full section → · The CLI guide → · Try it in the playground →
Also in this release:
A chat log to test against. is 25 messages with a documented expected verdict for every row — twelve of them deliberately clean, which is the half that catches a detector getting eager.examples/batch/chat-log.csv
The CSV reader no longer scans the wrong column quietly. It used to default to column 0, so pointing it at a file whose first column is an id reported0 flagged
— indistinguishable from a genuinely clean file. It now guesses from the header, announces the choice, and stops and asks when it cannot.PDF reports viarenderSummaryPdf()
, throughas anfast-pdf
optional peer dependency loaded by dynamic import in that one function.dependencies
stays empty.lists every feature with one line of what it is and one of why it works that way, in English and German.FEATURES.md
A moderation filter that cannot see an IBAN is half a filter. The same
comment box that collects insults collects phone numbers, bank details and card
numbers, and ts-profanity-filter/pii
reports those the way this library reports everything — as spans, so the redaction stays yours to render.
import { detectPii } from 'ts-profanity-filter/pii';
detectPii('IBAN DE44 5001 0517 5407 3249 31, Tel. 030 12345678');
// [
// { kind: 'iban', confidence: 0.99, evidence: ['structure', 'checksum', 'context'], … },
// { kind: 'phone', confidence: 0.99, evidence: ['structure', 'context'], … },
// ]
The admission criterion is that a finding can be verified. An IBAN passes mod-97 and its country's length, a card passes Luhn and owns its issuer prefix, a German tax id passes ISO 7064 and the repetition rule the BZSt guarantees. Names, postal addresses and dates of birth are missing on purpose: nothing inside the string can confirm them, and a detector that guesses at those turns every capitalised word into a finding.
It is one pass, not six regexes. The text is walked once for anchors, digit
clusters are built once and interpreted by three recognizers, every candidate is
scored rather than accepted, and overlaps are settled by weighted interval
scheduling — because ::ffff:192.168.1.1
is an IPv6 address containing an IPv4 one, and resolving greedily from the left picks the earliest candidate rather than the best one.
The full section → · Try it in the playground →
Also in 1.4.0, alongside this:
The playground shows what it suppresses. The panel has a switch that dropsminConfidence
to 0.2, so the findings that scored too low to be reported become visible in grey instead of being invisible.The demo build now catches a clash the old guard could not. Its modules share one script scope on the page, and two of them declaring the same top-levelconst
is aSyntaxError
that blanks the whole page. The check compared the bundle against the page script but never against itself — it now does, which is howSEPARATOR
andALNUM
were caught before shipping.
In the EU, deleting the comment is only half the obligation. Article 17 of the Digital Services Act requires that whoever is moderated gets a statement of reasons: what was done, on which ground, on which facts, whether an automated system was involved, how long it lasts, and where to contest it — in their language, on a durable medium they can keep.
A filter that returns flagged: true
gives you none of that. So the new
ts-profanity-filter/compliance
subpath builds the notice out of the moderation result you already have:
import { moderateText } from 'ts-profanity-filter/ai';
import {
generateJustification,
formatJustificationAsText,
} from 'ts-profanity-filter/compliance';
const result = await moderateText(comment, {
languages: ['de'],
ai: { provider: 'gemini', enabled: true }, // the graded verdict
});
const notice = await generateJustification(comment, result, {
action: 'CONTENT_REMOVED',
policyBases: [{ name: 'Community Guidelines', section: '§4.2' }],
appealUrl: 'https://example.com/appeal/8f21',
ai: { provider: 'gemini', enabled: true }, // wording only — optional
});
formatJustificationAsText(notice); // the text you send the user
exportJustification(notice); // the JSON you keep for your records
The facts are never the model's to decide. Action, policy basis, categories,
severity, confidence, the quoted excerpt and the timestamp are all fixed by the
code before any model is asked. What a model contributes is the two things a
template cannot write: a reason
that names the measure and the behaviour in one
breath, and an assessment
that weighs the case — and says so plainly when the
call is uncertain. Leave ai
out and the built-in German and English templates carry the notice on their own.
The full section → · See one generated →
Also in 1.3.0, alongside this:
A third AI provider that needs no network.ollama
runs the check on your own machine — no key, no third party, the same JSON Schema constraining the answer, so switching is a config change and not a second code path. SeeNothing leaves the building.Three false positives and a Cyrillic, found by pointing an adversarial benchmark of 81 attacks at this filter.к
fixedCockburn
,Lightwater
andMatsushita
are names, which is the most expensive kind of false positive;к
was never in the expandable set, so no pattern could reach it. English went from 82/83 to 94/100, German to 74/100, both with regression tests.
Optionally, a model reads the whole sentence — for what no word list can see. A message can be a threat without containing a single listed word, and it can be full of them and still be a quotation. Word lists cannot tell the difference; this can.
It reports hate, threats, harassment, racism, obscenity, sexual content involving minors and pressure toward self-harm — with a severity, a confidence, one sentence of reasoning in the language of the text, and the exact stretch it objected to, so you can highlight it.
import { moderateText } from 'ts-profanity-filter/ai';
const result = await moderateText(comment, {
languages: ['en', 'de'],
ai: { provider: 'gemini', enabled: true }, // key from GEMINI_API_KEY
});
result.matchedList // a word list matched
result.ai.flagged // the model flagged the sentence as a whole
result.flagged // either of the two
Google Gemini needs nothing installed — it is a plain fetch
, and the free
tier covers this. Anthropic Claude works through the optional SDK. Or bring
any model at all with ai.complete
.
Off unless you ask for it. No ai
option means no model is contacted and nothing leaves your machine — the word-list half never calls out at all.
The full section → · Try it with your own key →
Evasion is a Unicode problem, and the matching path treats it as one.
Compatibility spellings fold with NFKC to the letters the patterns are written
in, so Drecksau
, 𝐃𝐫𝐞𝐜𝐤𝐬𝐚𝐮
and Ⓓⓡⓔⓒⓚⓢⓐⓤ
stop walking past the
list. Whole-word anchors use Unicode boundaries instead of \b
— which is
defined in terms of \w
and stays ASCII even under the u
flag, so every
umlaut and every ß
read as a word boundary and Straußschwanz
came back flagged.
Iteration is by code point rather than code unit, and the offset map carries one entry per output character, so a folded character that expands still points back at the one it came from — and a segment boundary can no longer land inside a surrogate pair.
Seventeen cases assert the offsets, not the round trip. Rebuilding the string intact proves only that nothing was dropped; it says nothing about whether the flagged span still covers the right characters, which is exactly where a filter holding three representations of the input — original, folded haystack, segments — goes wrong.
import { filterFWordsToSegments } from 'ts-profanity-filter';
const output = filterFWordsToSegments('This is bullsh1t.', { languages: ['en'] });
// [
// { text: 'This is bull', isProfane: false },
// { text: 'sh1t', isProfane: true },
// { text: '.', isProfane: false },
// ]
Concatenating every segment.text
always reproduces the original input exactly, so rendering is lossless:
<p>
{filterFWordsToSegments(comment).map((seg, i) =>
seg.isProfane ? <span key={i} className="redacted">{seg.text}</span> : seg.text,
)}
</p>
Matching is substring-based. That is what catches asshole
from ass
and
survives obfuscation — but on its own it also flags class
, Klassik
and
Massage
.
So every hit is checked against an allowlist of ordinary words before it counts. The allowlist is anchored against the whole surrounding word, and an allowed word always beats a blocked pattern:
filterFWordsToSegments('Der Klassiker war klasse.', { languages: ['en', 'de'] });
// -> one clean segment; the two `ass` hits are dropped
filterFWordsToSegments('Der Klassiker war klasse.', {
languages: ['en', 'de'],
crossCheck: false, // raw substring matching
});
// -> `ass` flagged twice
Cross-checked out of the box, among others:
| Language | Blocked pattern | Ordinary words it would otherwise hit |
|---|---|---|
| en | ass |
|
| class, pass, assistant, embarrass, potassium | ||
| en | cunt |
|
| Scunthorpe | ||
| en | cock |
|
| cocktail, cockpit, peacock | ||
| en | spic |
|
| spicy, suspicious, conspicuous | ||
| de | ass |
|
| Klassik, klassisch, Massage, Sparkasse, Tasse | ||
| de | arsch |
|
| Marsch, marschieren, Barsch, harsch | ||
| de | anal |
|
| Analyse, Kanal, banal, Analphabet | ||
| de | cum (via k ) |
|
| Dokument, Kumpel, Publikum, Vakuum |
Add your own with allowList
— entries are regex sources matched against the whole word:
filterFWordsToSegments('Die Assmann GmbH', {
languages: ['en', 'de'],
allowList: ['assmann', 'meine-firma\\p{L}*'],
});
| Option | Type | Default | Description |
|---|---|---|---|
languages |
|||
| `string[] | '*'` | ||
['en'] |
|||
Registered languages to match against. '*' uses every registered one. |
|||
crossCheck |
|||
boolean |
|||
true |
|||
Drop a hit when the surrounding word is allowlisted. false = raw substrings. |
|||
allowList |
|||
string[] |
|||
| — | Extra allowed words, added on top of the built-in allowlist. Regex sources. | ||
customList |
|||
string[] |
|||
| — | Replaces the built-in patterns entirely. Regex sources. Empty array = fall back. | ||
aggressive |
|||
boolean |
|||
true |
|||
| Also match lookalike spellings — see below. |
Patterns are compiled with the u
flag, so case-insensitive matching uses full
Unicode case folding — SCHEIẞE
folds to scheiße
and is caught. It also means
your patterns must be valid in unicode mode: a stray identity escape like
\\-
is an error there. registerLanguage
reports that up front.
interface TextSegment {
text: string;
isProfane: boolean;
}
With aggressive
on (the default) every letter is expanded into the things people actually type to get past a filter:
| Kind | Example |
|---|---|
| leet | Dr3cks4u , a$$hole , $hit , fu(k |
| diacritics | DräckSAU , ärschloch |
| cross-script lookalikes | Аrschloch (Cyrillic А), Sсheiße (Cyrillic с) |
The diacritics are deliberately ambiguous: ä
counts as an a and as an
e, because Dräck
uses it as an e while ärsch
uses it as an a. A one-to-one normalisation would have to pick a side and get one of them wrong.
The allowlist gets the same expansion. That symmetry is the whole point —
without it ass
matches the 4ss
in Kl4ssik
while the allow entry still
only spells klass
, and an ordinary word comes back flagged. Kl4ssik
,
M4ssage
, Cl4ss
and Fässer
all stay clean.
Separators, repetition and invisible characters are handled differently, because they change the length of the text and no character class can reach them. The text is rewritten for matching, and every rewritten character remembers which slice of the original it came from — so matches are found in the rewritten copy and sliced out of the original, and the segments still add up to the input exactly.
| Kind | Example | Rule |
|---|---|---|
| spaced out | D r e c k s a u , D-r-e-c-k-s-a-u |
|
| three or more whole one-letter words in a row | ||
| repetition | Dreeecksau , fuuuuck |
|
| runs of three or more identical characters collapse | ||
| invisible | Dreck<ZWSP>sau |
|
| formatting characters are dropped | ||
| decomposed | a + combining diaeresis |
|
composed into ä first |
||
| compatibility forms | Drecksau , 𝐃𝐫𝐞𝐜𝐤𝐬𝐚𝐮 , Ⓓⓡⓔⓒⓚⓢⓐⓤ |
|
| folded with NFKC to the plain letters |
Doubles are left alone — Klasse
and Fässer
are ordinary spelling, and
collapsing them would break the allowlist. The spaced-out rule needs whole
one-letter words, which is what keeps next to a cockroach
from collapsing
into next toacockroach
and inventing a cock
.
en
and de
are simply the two that ship pre-registered. Nothing about the library is limited to them.
import { registerLanguage } from 'ts-profanity-filter';
registerLanguage('fr', {
profanity: ['merde', 'connard', 'salope', 'putain'],
allow: ['\\p{L}*connaiss\\p{L}*'], // connaissance, connaisseur
});
filterFWordsToSegments('Quelle merde', { languages: ['fr'] });
Regional variants inherit instead of duplicating. A parent's patterns come first, yours are added on top — including its allowlist, so the false positives it already solved stay solved:
registerLanguage('de-AT', { extends: 'de', profanity: ['oasch', 'gschissana'] });
filterFWordsToSegments('Du Oasch, du Trottel!', { languages: ['de-AT'] });
// both flagged: 'oasch' is the variant's, 'Trottel' is inherited from 'de'
Lookups fall back along BCP-47 subtags. de-AT-1996
tries de-at-1996
,
then de-at
, then de
— so an unregistered de-CH
still works, and codes are case-insensitive.
Use everything at once with '*'
:
filterFWordsToSegments(text, { languages: '*' });
Patterns are validated when you register them, not when text is filtered, so a typo fails at startup naming the offending entry rather than throwing inside a moderation request:
SyntaxError: registerLanguage('fr'): profanity[1] "(unclosed" is not a
valid regular expression — Invalid regular expression: /(unclosed/gi:
Unterminated group
An unknown language throws rather than being ignored — silently matching nothing is the worst way for a moderation filter to fail.
| Function | Purpose |
|---|---|
registerLanguage(code, def) |
|
Add or replace a language. def is { profanity?, allow?, extends? } . |
|
unregisterLanguage(code) |
|
| Remove one. Refuses while another language extends it. | |
resetLanguages() |
|
Back to just the built-in en and de . |
|
getLanguage(code) |
|
Resolved lists, extends flattened and subtags applied. |
|
hasLanguage(code) / resolveKey(code) |
|
| Existence check / which code it resolves to. | |
listLanguages() |
|
| Every registered code. |
The built-in packs are importable on their own, which is also the shape a third-party language pack should export:
import { en, EN_PROFANITY, EN_ALLOWLIST } from 'ts-profanity-filter/lang/en';
import { de } from 'ts-profanity-filter/lang/de';
registerLanguage('en-custom', { extends: 'en', profanity: ourExtraWords });
Both are registered by the main entry point, so they are in your bundle whether
or not you use them — unregisterLanguage
changes behaviour, not bundle size.
The package is plain ESM with no dependencies, so a module script and a CDN import are the whole setup — no build step, nothing to install.
Run this example on JSFiddle →
<textarea id="draft"></textarea>
<p id="output"></p>
<style>
.redacted { background: currentColor; border-radius: 1px; }
</style>
<script type="module">
import { filterFWordsToSegments } from 'https://cdn.jsdelivr.net/npm/ts-profanity-filter@1.5.0/+esm';
const draft = document.getElementById('draft');
const output = document.getElementById('output');
function render() {
const segments = filterFWordsToSegments(draft.value, { languages: ['en', 'de'] });
output.replaceChildren(
...segments.map((seg) => {
const node = document.createElement('span');
if (seg.isProfane) node.className = 'redacted';
node.textContent = seg.text; // textContent, never innerHTML
return node;
}),
);
}
draft.addEventListener('input', render);
render();
</script>
node.textContent = seg.text
is the line that matters. What you are rendering
is whatever a stranger typed, and pushing it through innerHTML
would hand them your page. Because the API returns segments rather than a marked-up string, building nodes is both the safe route and the obvious one.
Nothing to set up — every npm CDN serves the package automatically, subpath imports included. The jsDelivr package page lists every published file and version.
| CDN | URL |
|---|---|
| jsDelivr | https://cdn.jsdelivr.net/npm/ts-profanity-filter@1.5.0/+esm |
| esm.sh | https://esm.sh/ts-profanity-filter@1.5.0 |
| unpkg | https://unpkg.com/ts-profanity-filter@1.5.0/dist/index.js |
import { useProfanitySegments } from 'https://esm.sh/ts-profanity-filter@1.5.0/react';
import { de } from 'https://esm.sh/ts-profanity-filter@1.5.0/lang/de';
Pin the version. An unpinned URL like https://esm.sh/ts-profanity-filter
always resolves to the newest release, so your page starts running different code the next time this package is published — without you changing anything. Fine for a playground, not for production.
To use a local install instead of a CDN, point an import map at it:
<script type="importmap">
{
"imports": {
"ts-profanity-filter": "/node_modules/ts-profanity-filter/dist/index.js"
}
}
</script>
If all you need is the verdict or a masked string:
const segments = filterFWordsToSegments(text, { languages: ['en', 'de'] });
const isProfane = segments.some((seg) => seg.isProfane);
const masked = segments
.map((seg) => (seg.isProfane ? '*'.repeat(seg.text.length) : seg.text))
.join('');
Word lists catch words. They cannot tell that a sentence containing no listed word at all is a threat, or that one full of them is a quotation. That judgement is what a model adds.
Nothing to install for the Gemini path — it is a plain fetch
, and Google's free tier covers this use case:
import { moderateText } from 'ts-profanity-filter/ai';
const result = await moderateText(comment, {
languages: ['en', 'de'],
ai: { provider: 'gemini', enabled: true }, // key read from GEMINI_API_KEY
});
result.matchedList // a word list matched
result.ai.flagged // the model flagged the sentence as a whole
result.flagged // either of the two
It is off unless you ask for it. No ai
option means no model is contacted,
and moderateText
is then just the local filter in a wrapper. enabled: false
keeps the configuration around with the check switched off.
ai: { provider: 'gemini', enabled: true } // key from GEMINI_API_KEY
ai: { enabled: true } // anthropic, key from ANTHROPIC_API_KEY
ai: { provider: 'ollama', model: 'llama3.2' } // your machine, no key, no network
| Needs | Default model | Key from | |
|---|---|---|---|
gemini |
|||
nothing — plain fetch |
|||
gemini-flash-lite-latest |
|||
anthropic
@anthropic-ai/sdk
claude-opus-5
console.anthropic.com— paidollama
Ollamallama3.2
ollama
answers the objection that makes this whole feature a non-starter for some deployments: that moderating a message means handing it to a third party.
const result = await moderateText(comment, {
languages: ['de'],
ai: { provider: 'ollama', model: 'gemma3' },
});
Same prompt, same schema, same verdict shape as the hosted providers — Ollama
constrains decoding to the JSON Schema exactly as they do, so switching is a
config change rather than a different code path. Point ai.baseUrl
at another
host, or set OLLAMA_HOST
; a bearer token is sent only if you supply one, for the case where the server sits behind an authenticating proxy.
Two things to expect. It is slower — a first call also loads the weights, and a 9 GB model took 34 seconds on a warm laptop, which is why the default timeout for this provider is 120 s rather than 20 s. And it is only as good as the model you pulled: a small instruct model handles clear-cut cases well and gets vaguer at the edges, which is the trade you are making for the text never leaving the host.
For anything else — transformers.js, a hosted open-weights endpoint, your own
fine-tune — ai.complete
takes any function that returns JSON matching the schema. See Any model, not just Claude.
Gemini is the cheapest way to try this: its free tier covers the use case and it
adds no dependency at all. ai.model
takes any id the provider accepts;
AI_MODELS
lists a few per provider for populating a picker.
The Gemini list is the one verified against a fresh free-tier key, which is not
the same as the list the models endpoint returns: gemini-2.5-flash
is
advertised there but rejected for new accounts ("no longer available to new
users"), and gemini-2.0-flash
is out of free quota. The default is
gemini-flash-lite-latest
because an alias cannot go stale that way.
One thing worth knowing about the Gemini provider: it sends BLOCK_NONE
for Google's own safety categories. For a moderation classifier that default is backwards — the text you need it to read is exactly the text it otherwise refuses to look at. It is safe here because the output is a verdict rather than generated content: the model labels text you already have, it never produces any.
| Category | Covers |
|---|---|
racism |
|
| racial or ethnic slurs; dehumanising by origin or skin colour | |
hate |
|
| contempt toward a group — religion, ethnicity, nationality, sexuality, gender, disability | |
violence |
|
| threats, calls to harm, approval of harm; incitement against a whole group is the most severe form | |
harassment |
|
| insults and degradation aimed at a specific person | |
sexual |
|
| explicit or obscene sexual content | |
sexual_minors |
|
| sexualisation of a minor, grooming, predatory approaches | |
self_harm |
|
| encouraging suicide or self-injury |
Alongside them: a severity
(none
… critical
), a confidence
, one
sentence of reason
in the language of the text, and a quote
— the stretch of the input the model objected to, copied verbatim so you can locate and highlight it:
const at = result.ai.quote ? text.indexOf(result.ai.quote) : -1;
if (at !== -1) highlight(at, at + result.ai.quote.length);
Check it against the original before using it, as above. A model can paraphrase despite being told not to, and a span that does not match must not be invented.
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
|||
boolean |
|||
true when ai is present |
|||
The switch. No ai at all means no model is contacted. |
|||
provider |
|||
| `'anthropic' | 'gemini' | 'ollama'` | |
'anthropic' |
|||
gemini needs no SDK and has a free tier; ollama needs no key and no network. |
|||
apiKey |
|||
string |
|||
ANTHROPIC_API_KEY / GEMINI_API_KEY |
|||
| Keep it server-side. | |||
model |
|||
string |
|||
| per provider | Any id the provider accepts; AI_MODELS lists a few. |
||
categories |
|||
AiCategory[] |
|||
| all seven | Narrow what is checked. | ||
prompt |
|||
string |
|||
| built-in | Replaces the system prompt entirely. | ||
extraInstructions |
|||
string |
|||
| — | Appended to the built-in prompt. | ||
languageHint |
|||
string |
|||
| auto-detect | e.g. 'German' . |
||
effort |
|||
'low'…'max' |
|||
'low' |
|||
| Anthropic only. This is a classification, not an essay. | |||
maxTokens |
|||
number |
|||
4096 |
|||
timeoutMs |
|||
number |
|||
20000 |
|||
fallback |
|||
boolean |
|||
true |
|||
| Anthropic only: retry on another model if its safety layer declines. | |||
onError |
|||
| `'return' | 'throw'` | ||
'return' |
|||
| A failed check is a decision, not an exception. | |||
complete |
|||
AiCompletion |
|||
| — | Bring your own model; bypasses both built-in providers. |
Both functions are exported: moderateText(text, options)
runs the local
filter and the model, analyzeWithAi(text, aiOptions)
runs only the model.
The built-in prompt describes each category rather than listing example slurs — spelling them out would ship those words in every request and teach the filter one exact wording. Extend it, or replace it:
ai: {
enabled: true,
categories: ['racism', 'hate', 'violence'], // narrow the check
extraInstructions: 'Football banter is fine here.', // added to the default
prompt: myOwnSystemPrompt, // replaces it entirely
languageHint: 'German',
effort: 'low', // cheap by default
}
moderateText
never throws by default — a moderation call that fails should be something you decide about, not something that takes down the request:
switch (result.ai.status) {
case 'ok': break; // a verdict was produced
case 'disabled': break; // the check is switched off
case 'refused': hold(result.ai.error); break; // the provider's own safety layer declined
case 'error': hold(result.ai.error); break; // the call failed
}
A failed or refused check never reports flagged: true
— absence of a
verdict is not a clean bill of health. Set onError: 'throw'
if a missing verdict should stop the request instead. The API key is stripped from error messages before they are returned.
ai.complete
replaces the built-in provider with anything that takes a system prompt plus text and returns JSON matching the schema. It is also how the test suite runs without a network:
import type { AiCompletion } from 'ts-profanity-filter/ai';
const myModel: AiCompletion = async ({ system, text, schema }) => ({
json: await callWhateverYouLike(system, text, schema),
});
This package sends nothing on its own. It ships no key and has no account.
Without an ai
option it makes no network call at all, and the word-list half never does under any circumstances. When you switch the check on, the call goes out under your key, to your account, on your decision.
That is worth being precise about, because it decides who answers for it: you are the controller of that processing, and the provider is your processor. Under the GDPR that means you need a lawful basis for sending user-submitted text to them, a data processing agreement with them, and a privacy notice that says so. None of that is something a library can do for you.
The free Gemini tier is not suitable for production data. From Google's Gemini API terms:
To help with quality and improve our products, human reviewers may read, annotate, and process your API input and output.
On the paid tier the same terms say the opposite — "Google doesn't use your prompts … or responses to improve our products". The free tier is the right way to try this feature; it is the wrong way to run it on real user messages. Check Anthropic's current terms the same way before relying on either.
The moderation case makes this sharper than usual: the text you send is, by definition, the text somebody wrote when they were at their worst. Three ways to keep that proportionate:
Filter first, ask second. Run the local lists on everything and reach for the model only on what they cannot settle. Most messages never leave.Send the message, not the person. No names, no ids, no metadata — the check takes a string and nothing else.Or keep it in-house.ai.complete
takes any model, including one you run yourself, and nothing about the rest of the feature changes.
examples/server
is the whole shape in one file: a browser page, an endpoint, and the key staying in the server process.
cd examples/server
cp .env.example .env # put your key in it — .env is gitignored
npm install && npm start # http://localhost:8787
It reads the key from the environment rather than the request body, returns a verdict rather than the machinery behind it, caps the body size, and rate-limits per IP — an endpoint that spends money per call needs all four. See its README for what it deliberately leaves out (auth, above all).
An API key shipped to a browser is readable by everyone who loads the page and spendable by all of them — no amount of obfuscation changes that. Run the check on your server and send the verdict to the client, never the key. The word-list half of this library runs happily in the browser; the model half does not belong there.
Defaults worth knowing: effort: 'low'
(this is a
classification, not an essay), a 20-second timeout, and provider-side retry on
another model if Anthropic's own safety layer declines the request — moderation
text is exactly the kind of input that trips those classifiers, and a refusal is
not a verdict. Turn that off with fallback: false
.
Article 17 of the Digital Services Act applies to any hosting provider that restricts something a user posted — removal, demotion, a feature lock, a suspension. The user is owed a statement of reasons, and the article lists what has to be in it: the measure and its scope and duration, the facts the decision rests on, the ground relied on, whether automated means were used, and how to contest it.
ts-profanity-filter/compliance
assembles that from a ModerationResult
.
import {
generateJustification,
exportJustification,
formatJustificationAsText,
InMemoryJustificationStore,
} from 'ts-profanity-filter/compliance';
| Field | Comes from | Art. 17 point it answers |
|---|---|---|
action |
||
| you | which measure — CONTENT_REMOVED , CONTENT_DEMOTION , ACCOUNT_SUSPENSION , ACCOUNT_TERMINATION , FEATURE_RESTRICTION |
|
duration |
||
| you | its scope in time — '7d' , 'permanent' , … |
|
policyBases |
||
| you | the ground: a name, an optional section , an optional url to the rule |
|
facts.quote |
||
| the model's excerpt, else the words the lists matched | which content, verbatim | |
facts.categories |
||
| the AI check | what it was classified as | |
facts.severity |
||
| the AI check | how heavily it weighs | |
facts.confidence |
||
| the AI check | how sure the classification was | |
facts.automatedDetection |
||
| computed | whether automation was involved — Art. 17(3)(c) | |
facts.humanReview |
||
always false |
||
| see below | ||
reason |
||
| template or model | one sentence, in the user's language | |
assessment |
||
| template or model | two or three sentences weighing the case | |
appealUrl |
||
| you | the redress route | |
timestamp , language |
||
| computed | when, and in which language |
language
is auto-detected between de
and en
from the text unless you pass one. Pass it explicitly if you know the user's language — which you usually do, and it is their language the notice owes, not the language they happened to write that sentence in.
A model may word the notice. It may not decide anything in it. The facts are handed to it as data, together with an instruction that quoted text is material under judgement and not instructions to follow. It writes two fields and nothing else; a reply that fails to parse leaves the templates in place.
That split is what makes the optional model safe here. An invented category or a made-up date in a notice like this is exactly the error that loses an appeal.
ai: {
enabled: true,
provider: 'gemini',
extraInstructions: 'Sign off as the Beispiel.de moderation team.',
}
Same rule as the AI check: no ai option means no model is contacted. The templates then write both fields, in German or English, and that is a complete notice — blunter, not incomplete.
When only a word list matched, no model graded anything. Writing "severity: none" into the notice would read as a considered finding that the content was fine, which is not what happened — so the severity line is simply absent from the rendered text, and the template assessment says what was actually established:
Die zitierte Stelle entspricht einem Begriff aus der Wortliste der geprüften Sprachen. Damit steht fest, welche Wörter gefallen sind — nicht, was der Satz mit ihnen tut. Eine solche Feststellung wird hier auch nicht behauptet.
Overstating there would mean inventing grounds. The same reticence applies to
categories and confidence: those lines are omitted rather than printed as
(none)
and 0%
.
Art. 17 wants the statement on a durable medium — retrievable next month, not a
toast that disappears. JustificationStore
is a two-method interface for that,
and InMemoryJustificationStore
implements it for demos:
interface JustificationStore {
save(id: string, justification: ComplianceJustification): Promise<void>;
get(id: string): Promise<ComplianceJustification | null>;
list?(): Promise<string[]>;
}
The in-memory one is not a production store — it is a Map
, and a restart
erases every notice you owe. Implement the interface against your database.
exportJustification
gives you the JSON to put in a column.
examples/server
shows the round trip: POST /api/justifications
moderates,
generates and stores, returning an id; GET /api/justifications/:id
is the durable link you put in the notice.
A failed model call must not stop a legal notification from going out.
generateJustification
catches everything: on a refusal, a timeout or unparseable JSON it returns the template wording. There is no code path where you get no notice at all.
Not legal advice, and not compliance in a box. It produces the artefact; whether your process around it satisfies the DSA is your assessment to make, with your own counsel.The library cannot know whether a person looked at the case. If one did, set it on the object before you store it — and note that Art. 17 ishumanReview
is hardcodedfalse
.whyyou would want to: a purely automated restriction has to say so.Nothing distinguishes "illegal content" from "incompatible with your terms". Art. 17(3) treats those as different grounds with different consequences. The module has onepolicyBases
list, so that distinction is yours to encode —{ name: 'Legal', section: '§ 130 StGB' }
versus your house rules.No complaint handling (Art. 20), no transparency database submission (Art. 24(5)). Those are systems, not strings. This module writes the notice that both of them start from.
ts-profanity-filter/pii
finds e-mail addresses, phone numbers, IBANs, payment cards, IP addresses and German tax ids — as spans, like everything else here, so the redaction stays yours to render.
import { detectPii, piiToSegments } from 'ts-profanity-filter/pii';
detectPii('Meine IBAN ist DE44 5001 0517 5407 3249 31, Tel. 030 12345678');
// [
// { kind: 'iban', text: 'DE44 5001 0517 5407 3249 31', start: 15, end: 42,
// confidence: 0.99, evidence: ['structure', 'checksum', 'context'] },
// { kind: 'phone', text: '030 12345678', start: 49, end: 61,
// confidence: 0.99, evidence: ['structure', 'context'] },
// ]
| Kind | What confirms it |
|---|---|
email |
|
| structure — local part, labels, TLD, all length-checked | |
iban |
|
| ISO 13616 mod-97, plus the country's published length | |
card |
|
| Luhn, plus an issuer prefix that owns that length | |
taxid-de |
|
| ISO 7064 MOD 11,10, plus the BZSt repetition rule | |
ip |
|
| octet ranges; IPv6 group counting, compression and embedded IPv4 | |
phone |
|
| E.164 length, a trunk zero, or a word nearby that says so |
Names, postal addresses and dates of birth are missing on purpose. They are personal data too, but nothing inside the string can confirm them, and a detector that guesses turns every capitalised word into a finding. Everything on that list either verifies arithmetically or is honest about leaning on context.
Four stages, and each exists because the obvious alternative is worse:
One O(n) scan records the only three things worth anchoring on:@
positions, digit runs, colons. Six regexes over the text would walk it six times and still miss the grouped spellings.Digit clusters are built once.030 12 34 56
becomes one object with its groups and separators intact. A phone number, a card and a tax id are the same object at this stage; telling them apart is not the scanner's job.Recognizers score, they do not vote. A checksum is a fact, a shape is an argument, punctuation like+
is a hint, and a nearby word only adjusts what the string already said.Overlaps are resolved optimally by weighted interval scheduling — confidence × length,O(m log m)
. This is not decoration:::ffff:192.168.1.1
is an IPv6 address containing an IPv4 one, and a grouped IBAN contains digit runs that pass Luhn. Resolving left to right greedily picks the earliest candidate, which is not the same as the best one.
A verified IBAN sits at 0.98
because the arithmetic says so. A bare
ten-digit number sits at 0.3
until a word next to it agrees, and then at
0.65
. The threshold is 0.6
, so:
detectPii('Die Zahl 1701234567 steht hier'); // []
detectPii('Telefon 1701234567'); // phone, 0.65
detectPii('1.2.3.4'); // [] — that is a version number
detectPii('8.8.8.8'); // ip, 0.8 — no version repeats one component
Lower minConfidence
to audit what is being suppressed rather than wondering.
What a nearby word is worth also differs per kind: Telefon
in front of ten
digits is nearly the whole case, while IBAN:
in front of a string that already passes mod-97 adds almost nothing.
| Function | Returns |
|---|---|
detectPii(text, options?) |
|
PiiMatch[] — non-overlapping, in reading order |
|
hasPii(text, options?) |
|
boolean |
|
piiToSegments(text, options?) |
|
PiiSegment[] , same shape as the filter's — concatenates back to the input exactly |
|
isValidIban · isValidLuhn · isValidGermanTaxId · iso7064Mod1110 |
|
| the checksums on their own |
| Option | Type | Default | Description |
|---|---|---|---|
kinds |
|||
| `PiiKind[] | '*'` | ||
'*' |
|||
| Narrow the search. An unknown kind throws. | |||
minConfidence |
|||
number |
|||
0.6 |
|||
| Findings below this are dropped. | |||
contextWindow |
|||
number |
|||
48 |
|||
| How many characters either side count as context. |
Because segments come back in the same shape as the profanity filter's, one renderer handles both:
{piiToSegments(comment).map((seg, i) =>
seg.isPii ? <span key={i} className="redacted" title={seg.kind}>{seg.text}</span> : seg.text,
)}
The cross-check keeps class
, Klassik
and Scunthorpe
out of the results, and the built-in allowlist was written against English and German as they are generally used — not against your domain. A medical corpus, a Dutch user base, a supplier called Assmann: each finds holes the shipped lists never saw.
ts-profanity-filter/allowlist
finds them in your text and proposes entries to close them.
import { tuneAllowlist, formatAllowEntries } from 'ts-profanity-filter/allowlist';
const report = await tuneAllowlist(() => ndjsonTexts('comments.ndjson'), {
languages: ['en', 'de'],
ai: { provider: 'gemini' }, // optional — see below
});
report.before; // 412 records were flagged
report.after; // 37 still are
report.entries; // ['assmann\\p{L}*', 'diagnos\\p{L}*', …]
console.log(formatAllowEntries(report, 'en-house'));
Scan— read the corpus, collect thewhole wordsthe lists flagged, count them. Entirely local. Frequency is the first signal: an ordinary word appears everywhere, a slur appears rarely.Judge— ordinary word or genuine insult? A model can answer, or you can throughverdicts
, or both. Without anai
optionno model is contacted and only your own verdicts are used.Verify— compile each proposal andtestit.
Stage 3 is the whole point. A model asked to fix Klassiker
may answer
\p{L}*ass\p{L}*
, which technically clears it and destroys the filter for the entire language. No amount of prompting reliably prevents that; compiling the suggestion and measuring it does. Every proposed entry must
- clear the word it was written for — otherwise
why: 'no-effect'
; not clear any word judged offensive — otherwisewhy: 'too-broad'
, naming the word it would have cleared;- be a valid pattern under the
u
flag — otherwisewhy: 'invalid'
.
Nothing is dropped silently: rejected proposals come back in report.rejected
with the reason.
Once to find what gets flagged, once to prove the accepted entries helped. So pass an array or a factory, not a spent iterator:
tuneAllowlist(() => linesFrom('comments.txt'), { … }); // before/after measured
tuneAllowlist(oneShotGenerator, { … }); // rerun: false, after unmeasured
The model may say it cannot tell, and that verdict is never acted on. The same goes for a word you did not judge at all: no verdict, no entry.
| Option | Type | Default | Description |
|---|---|---|---|
languages |
|||
| `Language[] | '*'` | ||
['en'] |
|||
| Which lists to tune against. | |||
minCount |
|||
number |
|||
1 |
|||
| Ignore words appearing fewer times than this. | |||
limit |
|||
number |
|||
50 |
|||
| Judge at most this many distinct words, most frequent first. | |||
sampleLimit |
|||
number |
|||
3 |
|||
| Records kept per word as evidence. | |||
verdicts |
|||
Record<string, WordVerdict> |
|||
| — | Judgements by hand. Never sent to a model. | ||
ai |
|||
AiOptions |
|||
| absent | Absent means no model is contacted. |
findFlaggedWords()
is exported on its own if all you want is the scan.
A generated entry is a proposal, not a policy. It has been proved not to break the corpus you gave it, which is a real guarantee and a narrow one — a native speaker should still read the list before it ships.
Analysing one comment is a function call. Analysing two million is a different problem, and the difference is not speed — it is that the obvious version holds the whole corpus in memory, dies at row 900 000 with nothing written, and makes one paid model call per row.
import { runBatch, formatSummary } from 'ts-profanity-filter/batch';
import { ndjsonFrom } from 'ts-profanity-filter/batch/node';
const summary = await runBatch(ndjsonFrom('comments.ndjson'), {
filter: { languages: ['en', 'de'] },
pii: true,
onResult: (result) => { if (result.flagged) hold(result.id); },
});
console.log(formatSummary(summary));
Nothing is buffered: the file is read a chunk at a time, results are handed to
onResult
and dropped, and peak memory is one record regardless of file size.
runBatch(source, options) |
runs to completion, returns the BatchSummary , hands each result to onResult . For large input. |
streamBatch(source, options) |
an AsyncGenerator yielding each result. Its return value is the summary, which for await discards — drive .next() yourself if you want both. |
The source is anything iterable: an array, a generator, a database cursor, one of
the Node readers below. A record is a string
or { text, id }
, and the id
comes back on the result so you can join to your own data.
ai: {
provider: 'gemini',
when: 'matched', // only records a word list already hit — the default
maxCalls: 500, // a hard ceiling for the whole run
retries: 2, // exponential backoff on failure
}
when: 'matched'
is the cheap and usually correct reading: it is the quotation check on the records the lists flagged. ** when: 'unmatched' is the expensive one** — most records in any real corpus are clean, so it sends nearly all of them.
'all'
sends everything, and a predicate lets you decide per record.maxCalls
exists because a batch is exactly where one call per row becomes a
bill. Once it is reached the run continues locally and the summary says
aiBudgetExhausted: true
, so a truncated run can never read as a complete one.
A refusal is not retried — the provider's safety layer declining is a decision, not a transient fault, and asking again in 500 ms will not change its mind.
Every stage is wrapped per record. A pattern that throws, a hostile input, a
provider that is down: the result carries error: { stage, message }
, the other
stages still ran, and the run continues. signal
stops it early and the summary
comes back with aborted: true
rather than throwing away the work already done.
ts-profanity-filter/batch/node
is a separate subpath so that importing the runner itself never drags a Node API into a browser bundle.
| Function | For |
|---|---|
ndjsonFrom(path, { textField, idField, onBadLine }) |
|
| one JSON object per line | |
csvFrom(path, { column, idColumn, delimiter, header }) |
|
| one column of a CSV | |
csvRowsFrom(path, delimiter) |
|
| raw rows | |
linesFrom(path) |
|
| one text per line | |
recordsFrom(path) |
|
| picks the reader from the extension | |
createNdjsonWriter(path) |
|
| append results, respecting backpressure |
The CSV reader is a character-level state machine, not split('\n')
— a quoted
field may contain the delimiter, a newline or an escaped ""
, and splitting on lines first makes the embedded-newline case unrecoverable rather than merely wrong.
npx ts-profanity-filter scan comments.ndjson --languages en,de --pii \
--out flagged.ndjson --pdf report.pdf
Records processed 5
Flagged 3 (60.0%)
Matched a word list 1
Records with personal data 2 (3 findings)
Duration 50 ms · 100/s
Progress goes to stderr and the summary to stdout, so the command composes.
--fail-on-findings
exits 1 for CI, --json
prints the summary as JSON, and
--max-calls
defaults to 100 when --ai
is used — a command that can spend money should not spend an unbounded amount of it by default.
** The full CLI guide →** — every format, every flag, exit codes, recipes for CI and nightly runs, and what to check when a scan comes back empty.
examples/batch/chat-log.csv
renderSummaryPdf(summary)
returns the bytes of a report — the same rows as the text version, from the same function, so the two can never disagree.
import { renderSummaryPdf } from 'ts-profanity-filter/batch';
await writeFile('report.pdf', await renderSummaryPdf(summary, { title: 'Nightly scan' }));
It needs fast-pdf, and
this does not change the dependency count. It is an
optionalpeer dependency reached through a dynamic import in that one function, exactly as the Anthropic SDK is:
dependencies
stays empty, an install that never renders a PDF pulls nothing,
and the call throws with an install hint if the package is absent. Pass
deterministic: true
for byte-identical output — useful for hashing or
archiving a report.| Option | Type | Default | Description |
|---|---|---|---|
filter |
FilterOptions | false |
{} |
Word lists, or false to skip them. |
pii |
PiiOptions | true | false |
off | Off unless asked, like the AI check. |
ai |
BatchAiOptions |
absent | Absent means no model is contacted. |
segments |
boolean |
false |
Include the segments per record — the one part of a result whose size grows with the text. |
concurrency |
number |
8 |
In-flight records. Only matters with a model. |
ordered |
boolean |
true |
false is faster when durations vary: nothing waits behind a slow neighbour. |
onProgress |
(p) => void |
— | Called every progressEvery records, and once at the end. |
progressEvery |
number |
500 |
Per-record callbacks are their own cost at this scale. |
signal |
AbortSignal |
— | Stops pulling; the summary reports aborted . |
sampleLimit |
number |
20 |
Flagged records kept in the summary. A summary must not grow with the input. |
Each adapter is a separate subpath import, so nothing you do not use reaches
your bundle. react
and vue
are optional peer dependencies.
import { useProfanitySegments, useIsProfane } from 'ts-profanity-filter/react';
function Comment({ body }: { body: string }) {
const segments = useProfanitySegments(body, { languages: ['en', 'de'] });
return (
<p>
{segments.map((seg, i) =>
seg.isProfane ? <mark key={i}>{seg.text}</mark> : <span key={i}>{seg.text}</span>,
)}
</p>
);
}
Memoised by the value of the options, not their identity — an inline object literal will not re-run the filter on every render.
<script setup lang="ts">
import { ref } from 'vue';
import { useProfanitySegments } from 'ts-profanity-filter/vue';
const body = ref('');
const segments = useProfanitySegments(body, { languages: ['en', 'de'] });
</script>
Both arguments accept a plain value, a ref, or a getter; you get back a computed
ref. Needs Vue 3.3+ (for toValue
).
A class carrying a real @Pipe()
decorator has to be compiled by Angular's own
compiler, and a plain tsc
build cannot produce that — an AOT build in your app
would reject it. So this package ships the logic without decorators, and you
add the decorator in your app where ngtsc
compiles it properly:
// profanity-segments.pipe.ts — the whole file
import { Pipe } from '@angular/core';
import { ProfanitySegmentsPipeBase } from 'ts-profanity-filter/angular';
@Pipe({ name: 'profanitySegments', standalone: true })
export class ProfanitySegmentsPipe extends ProfanitySegmentsPipeBase {}
js
<span *ngFor="let seg of body | profanitySegments:{ languages: ['en','de'] }"
[class.redacted]="seg.isProfane">{{ seg.text }}</span>
The base class caches by value, which matters because a pure pipe re-runs whenever a template's object literal gets a new identity.
There is also a plain service class (no @Injectable()
, so provide it explicitly):
import { ProfanityFilter } from 'ts-profanity-filter/angular';
providers: [
{ provide: ProfanityFilter, useFactory: () => new ProfanityFilter({ languages: ['en', 'de'] }) },
]
filter.mask('Du Trottel!'); // 'Du *******!'
Because it imports nothing from @angular/core
, Angular is not a peer dependency of this package at all.
https://kevinci.github.io/ts-profanity-filter/
Bilingual UI (English/German), live segmentation, three render modes, and a table showing exactly which false positives the cross-check suppressed and which allowlist rule cleared them.
The AI panel runs the check from the page itself: pick a provider and model, paste your own key, and see both signals for the same sentence — what the word lists caught, and what the model made of it. The key is never stored and goes only to the provider. That is a playground pattern, not a production one; see Keep the key server-side above.
The page is a single self-contained file generated from the compiled dist/
, so it always runs the same code npm ships. To work on it locally:
npm run demo # tsc, then regenerate docs/index.html
open docs/index.html
Edit demo/template.html
, never docs/index.html
— the latter is generated.
GitHub Pages serves it straight from main
, so a push updates the live page.
The core needs Unicode property escapes (\p{L}
) — Chrome 64, Firefox 78, Safari 11.1, Node 10. That is a hard floor: they are used throughout, and nothing can degrade without them.
Lookbehind is the one construct beyond that, and it is optional. It powers the
spaced-out detection (D r e c k s a u
) and the whole-word anchors — \b
is
useless for those, because it is defined in terms of \w
and stays ASCII even
under the u
flag, so every umlaut and every ß
reads as a word boundary and
Straußschwanz
comes back flagged. Engines shipped lookbehind late — Safari
only from 16.4 — so it is compiled with new RegExp
inside a try
, never written as a literal:
| Safari 16.4+, Chrome 62+, Firefox 78+, Node 18+ | older engines | |
|---|---|---|
| word lists, cross-check, leet, lookalikes, repetition, zero-width, NFKC folding | ✅ | ✅ |
spaced-out words (D r e c k s a u ) |
||
| ✅ | not detected | |
| whole-word anchors | Unicode boundaries | fall back to ASCII \b |
The distinction matters more than it looks. A regex literal is compiled when the script is parsed, so one the engine cannot handle is a syntax error for the whole module — importing the package would fail outright rather than losing one feature. A test asserts that no shipped file contains such a literal.
React Native / Hermes is untested. Hermes has had gaps in Unicode regex support; verify before shipping.
ESM only. require()
works on Node 20.19+ / 22.12+; on Node 18 a CommonJS caller cannot load it.
The AI check belongs on a server. A key shipped to a browser is readable and spendable by every visitor.
German compounding forces permissive allow entries like\p{L}*klass\p{L}*
. A contrived word containing both a slur and an allowed stem comes out clean. Allowed always beats blocked.With the German list active it is allowlisted, which necessarily clears the English word too. Usedick
is German for "thick".languages: ['en']
for English-only text.A customcustomList
replaces the patterns but not the allowlist.ass
pattern still loses against an allowlistedKlassik
. Combine withcrossCheck: false
if you want nothing suppressed., letters and all, so a pattern carrying its own syntax breaks:aggressive
rewrites the regex source[abc]
becomes[[a@4]b[c(k<]]
and(?<word>…)
becomes(?<w[o0]rd>…)
. Both are rejected at registration. Write patterns as plain words, or turnaggressive
off for hand-written regexes.Word lists are a starting point, not a policy. Extend them for your domain.The AI check costs money or quota, and adds latency. It is one network round trip per call. Run the local filter first and reach for the model only when it matters — or useai: false
on the cheap path.A model verdict is a second opinion, not ground truth. It has aconfidence
for a reason. Treat a flag as a reason to hold a message for review, not as a conviction, and keep a human in the loop for anything consequential.A failed or refused check reports Absence of a verdict is not a clean bill of health — branch onflagged: false
.ai.status
, do not readai.flagged
alone.The Gemini provider disables Google's own safety filtering. It has to: the text a moderation classifier must read is the text those filters refuse to look at. Safe here because the output is a verdict, never generated content.A batch is one thread with bounded concurrency, not a thread pool. That is the right answer for the model path, where the network waits rather than the CPU. For millions of records through the word lists alone, the ceiling is one core — shard the input across processes if that is not enough.and to 100 in the CLI. A batch with a model and no ceiling is the easiest way to spend real money by accident; set it explicitly.maxCalls
defaults to unlimited in the libraryPII detection finds only what a string can prove. No names, no postal addresses, no dates of birth — seeOnly what can be verified. A phone number without a+
, a trunk zero or a word next to it stays below the threshold, which means bare digit runs in prose are missed on purpose.It is equally a version number. A bare one appears once1.2.3.4
is not reported as an IP address.minConfidence
drops below 0.5 — but with the wordversionin front of it the candidate isdiscarded, not scored low, so no threshold will surface that one.** The Art. 17 module writes the notice, not the process.**It does not store anything durably on its own, does not know whether a human reviewed the case, and draws no line between illegal content and a breach of your terms. SeeWhat this is not.
npm run build # tsc -> dist/ (.js, .d.ts, .d.ts.map, .js.map)
npm test # builds, then runs node --test
npm run typecheck # type-checks src + test
npm run lint # eslint, type-aware
npm run lint:fix # the same, applying what it can fix
npm run format # prettier --write
npm run format:check # prettier --check, for CI
npm run demo # builds and regenerates docs/index.html
prepublishOnly
runs all of them, so a release cannot ship code that fails its own checks.
Prettier leaves three things alone, on purpose: the Markdown, because the
prose is hand-wrapped and it would rewrite every *word*
to _word_
for no
gain; the playground template, because it is one hand-built page rather than a
module; and a handful of // prettier-ignore
tables — the IBAN lengths, the PII context words, the 81-attack corpus — which are data laid out to be read across, not down. The corpus alone would go from 226 lines to 711.
The warranty disclaimer and the limitation of liability are in there, in the last two paragraphs: the software is provided as is, without warranty of any kind, and the authors are not liable for claims or damages arising from its use. That is the whole of what is offered and the whole of what is disclaimed — there is no separate agreement anywhere.
Two things it does not do, because no wording can. It cannot exclude liability for intent or gross negligence, or for injury to life, body or health, where mandatory law says otherwise. And it does not decide anything about your obligations to your users — see What leaves your machine, and whose problem it is above.