@namemasker/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Bell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @namemasker/core
2
+
3
+ The engine behind [NameMasker](https://namemasker.com): local PII detection,
4
+ masking, and restore for student documents. Plain TypeScript, zero runtime
5
+ dependencies, no network access of any kind. Runs in the browser and in Node.
6
+
7
+ **The design stance:** the tool stages, the professional approves. Detection
8
+ is deliberately layered and each layer is exported on its own, so callers
9
+ decide what to trust and what to review.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install @namemasker/core
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```ts
20
+ import {
21
+ scanDocument,
22
+ createStudentMap,
23
+ addToMap,
24
+ applyMap,
25
+ unmaskText,
26
+ } from '@namemasker/core';
27
+
28
+ const text = 'It is my pleasure to recommend Maya Chen of Lakeside Prep.';
29
+
30
+ // 1. Scan: returns flags (direct / name / contextual), each with a reason.
31
+ const { flags, accumulation } = scanDocument(text);
32
+
33
+ // 2. The caller (a human, in our UI) approves flags into a map.
34
+ const map = createStudentMap();
35
+ for (const flag of flags) {
36
+ addToMap(map, { text: flag.text, placeholderType: flag.placeholderType });
37
+ }
38
+
39
+ // 3. Mask: every occurrence, longest match first.
40
+ const masked = applyMap(text, map);
41
+ // -> 'It is my pleasure to recommend Student A of School 1.'
42
+
43
+ // 4. Unmask AI output: placeholders restore to canonical names only.
44
+ unmaskText('In short, Student A excels.', map.mapping);
45
+ // -> 'In short, Maya Chen excels.'
46
+ ```
47
+
48
+ ## Detection layers
49
+
50
+ 1. **Direct** (`detectDirect`) — deterministic patterns: emails, phones,
51
+ SSN/ID numbers, dates, street addresses. No ML.
52
+ 2. **Names** — bring your own: pass `nameFlags` from any NER (the NameMasker
53
+ site runs a vendored distilbert-NER in-browser), or fall back to the
54
+ built-in, honestly-labeled naive capitalized-pair pattern
55
+ (`detectNamesNaive`).
56
+ 3. **Contextual** (`detectContextual`) — weighted heuristics with proximity
57
+ scoring for passages that may identify a student without naming them
58
+ (uniqueness claims, rare activities, school patterns, geographic
59
+ narrowing). These are flags for human judgment; the engine never masks
60
+ them on its own.
61
+ 4. **Accumulation** — six or more contextual flags in one document raises a
62
+ document-level warning that the combination may identify even after
63
+ masking.
64
+
65
+ `scanDocument(text, options)` composes all layers. `options.knownTerms`
66
+ stages caller-supplied terms (a watchlist, or names from a student record)
67
+ with top priority; `options.nameFlags` replaces the naive name layer.
68
+
69
+ ## The map (format v2)
70
+
71
+ A `StudentMap` is `{ mapping, aliases, watchlist }`:
72
+
73
+ - `mapping` — canonical real → placeholder (`Student A`, `School 1`), unique
74
+ placeholders; the only section Unmask reads, so restoration is
75
+ deterministic.
76
+ - `aliases` — alternate spellings that mask to an existing placeholder
77
+ (bare "Maya" → `Student A`) but never win at unmask.
78
+ - `watchlist` — terms to always stage on future scans.
79
+
80
+ `parseStudentMap` accepts v2 files and lifts v1 flat `{real: placeholder}`
81
+ files forever. `serializeStudentMap` writes v2.
82
+
83
+ ## Honest scope
84
+
85
+ This library does not guarantee anonymity, and neither does anything else.
86
+ It catches what software can catch, flags what software can only suspect,
87
+ and is built to keep a human in the approval seat. See the
88
+ [threat model](https://namemasker.com/security.html).
89
+
90
+ MIT © Chris Bell
@@ -0,0 +1,10 @@
1
+ import type { ContextSignal, Flag, ScanOptions } from './types.js';
2
+ /** Gather raw rule hits, deduplicating overlapping hits of the same category. */
3
+ export declare function collectSignals(text: string): ContextSignal[];
4
+ export interface ContextualResult {
5
+ /** All raw signals, including those below the flag threshold. */
6
+ signals: ContextSignal[];
7
+ flags: Flag[];
8
+ }
9
+ /** Score signals against their neighbors and return flags for those crossing the threshold. */
10
+ export declare function detectContextual(text: string, options?: ScanOptions): ContextualResult;
@@ -0,0 +1,246 @@
1
+ import { DEFAULT_OPTIONS } from './types.js';
2
+ const RULES = [
3
+ {
4
+ category: 'uniqueness-claim',
5
+ weight: 2,
6
+ reason: 'Uniqueness claim (first/only/sole + a role) can identify a student on its own',
7
+ placeholderType: 'other',
8
+ patterns: [
9
+ /\b(?:first|only|sole)\b(?:\s+\w+){0,4}?\s+(?:captain|co-captain|president|chair|chairperson|founder|editor|student|member|winner|recipient|player|performer|graduate|girl|boy|woman|man|person)\b/gi,
10
+ ],
11
+ },
12
+ {
13
+ category: 'rare-role',
14
+ weight: 3,
15
+ reason: 'Rare role held by very few students in any school',
16
+ placeholderType: 'other',
17
+ patterns: [
18
+ /\b(?:valedictorian|salutatorian|concertmaster|concertmistress|drum\s+major|student\s+body\s+president)\b/gi,
19
+ ],
20
+ },
21
+ {
22
+ category: 'award-reference',
23
+ weight: 2,
24
+ reason: 'Award reference narrows the field of possible students',
25
+ placeholderType: 'other',
26
+ patterns: [
27
+ /\baward[\s-]winning\b/gi,
28
+ /\b(?:won|received|earned)\s+(?:an?|the|several|multiple)\s+(?:\w+\s+){0,3}?awards?\b/gi,
29
+ /\brecipient\s+of\b/gi,
30
+ ],
31
+ },
32
+ {
33
+ category: 'named-award',
34
+ weight: 3,
35
+ reason: 'Named award is searchable and may identify the student directly',
36
+ placeholderType: 'other',
37
+ patterns: [/\b(?:[A-Z][\w'&.]*\s+){1,4}(?:Award|Prize|Scholarship|Fellowship|Medal|Cup|Trophy)\b/g],
38
+ },
39
+ {
40
+ category: 'rare-instrument',
41
+ weight: 2,
42
+ reason: 'Rare instrument or soloist role narrows the field sharply',
43
+ placeholderType: 'other',
44
+ patterns: [
45
+ /\b(?:tuba|oboe|bassoon|harp|french\s+horn|euphonium|piccolo|marimba|harpsichord|bagpipes)(?:\s+soloist)?\b/gi,
46
+ /\bsoloist\b/gi,
47
+ ],
48
+ },
49
+ {
50
+ category: 'named-ensemble',
51
+ weight: 3,
52
+ reason: 'Named ensemble ties the student to a specific school or region',
53
+ placeholderType: 'org',
54
+ patterns: [
55
+ /\b(?:[A-Z][\w']+\s+){1,4}(?:Band|Orchestra|Choir|Chorale|Ensemble|Symphony|Philharmonic|Quartet|Quintet)\b/g,
56
+ ],
57
+ },
58
+ {
59
+ category: 'school-name',
60
+ weight: 3,
61
+ reason: 'School-name pattern (capitalized words + High/Prep/Academy)',
62
+ placeholderType: 'school',
63
+ patterns: [
64
+ /\b(?:[A-Z][\w'.]+\s+){1,3}(?:High(?:\s+School)?|Prep(?:aratory)?(?:\s+School)?|Academy|Middle\s+School|Elementary(?:\s+School)?)\b/g,
65
+ ],
66
+ },
67
+ {
68
+ category: 'narrow-school-type',
69
+ weight: 2,
70
+ reason: 'Narrow school type sharply limits which schools this could be',
71
+ placeholderType: 'other',
72
+ patterns: [
73
+ /\b(?:Quaker|Jesuit|Montessori|Waldorf|charter|boarding|magnet|parochial|all-girls|all-boys|single-sex)\s+(?:\w+\s+){0,2}?school\b/gi,
74
+ ],
75
+ },
76
+ {
77
+ category: 'school-size',
78
+ weight: 1,
79
+ reason: 'School-size mention narrows the school',
80
+ placeholderType: 'other',
81
+ patterns: [
82
+ /\b(?:small|tiny|little)\s+(?:\w+\s+){0,2}?school\b/gi,
83
+ /\bschool\s+of\s+(?:about\s+|around\s+|only\s+)?\d+\b/gi,
84
+ /\bclass\s+of\s+(?:about\s+|around\s+|only\s+)?\d+\s+students\b/gi,
85
+ ],
86
+ },
87
+ {
88
+ category: 'geographic-narrowing',
89
+ weight: 2,
90
+ reason: 'Geographic narrowing (outside/near + a place) localizes the student',
91
+ placeholderType: 'place',
92
+ patterns: [
93
+ /\b(?:just\s+outside|on\s+the\s+outskirts\s+of|outside|near)\s+(?:of\s+)?[A-Z][\w']+(?:\s+[A-Z][\w']+){0,2}/g,
94
+ ],
95
+ },
96
+ {
97
+ category: 'specific-achievement',
98
+ weight: 2,
99
+ reason: 'Specific achievement (state/national title) is searchable',
100
+ placeholderType: 'other',
101
+ patterns: [
102
+ /\bstate\s+(?:\w+\s+){0,3}?(?:title|champion(?:ship)?s?|finalist)\b/gi,
103
+ /\bnational\s+(?:\w+\s+){0,2}?(?:champion|title|finalist)\b/gi,
104
+ /\ball-state\b/gi,
105
+ /\ball-american\b/gi,
106
+ ],
107
+ },
108
+ {
109
+ category: 'uncommon-sport',
110
+ weight: 2,
111
+ reason: 'Less common sport narrows the field of students',
112
+ placeholderType: 'other',
113
+ patterns: [
114
+ /\b(?:wrestling|fencing|alpine\s+skiing|nordic\s+skiing|water\s+polo|squash|rowing|crew\s+team|rugby|badminton|archery|equestrian|diving|sailing|curling|ultimate\s+frisbee|gymnastics)\b/gi,
115
+ ],
116
+ },
117
+ {
118
+ category: 'leadership-role',
119
+ weight: 1,
120
+ reason: 'Leadership role; identifying only when stacked with other signals',
121
+ placeholderType: 'other',
122
+ patterns: [
123
+ /\b(?:captain|co-captain|president|vice\s+president|treasurer|secretary|editor-in-chief|section\s+leader|founder)\b/gi,
124
+ ],
125
+ },
126
+ {
127
+ category: 'anchoring-year',
128
+ weight: 1,
129
+ reason: 'Anchoring year; identifying only when stacked with other signals',
130
+ placeholderType: 'other',
131
+ patterns: [/\b(?:19|20)\d{2}\b/g, /\b(?:freshman|sophomore|junior|senior)\s+year\b/gi],
132
+ },
133
+ ];
134
+ const LIST_CONTINUATION = {
135
+ category: 'school-list-continuation',
136
+ weight: 2,
137
+ reason: 'Listed alongside a detected school name; likely another school (list continuation)',
138
+ placeholderType: 'school',
139
+ };
140
+ const REASON_BY_CATEGORY = new Map([
141
+ ...RULES.map((r) => [r.category, r.reason]),
142
+ [LIST_CONTINUATION.category, LIST_CONTINUATION.reason],
143
+ ]);
144
+ const PLACEHOLDER_BY_CATEGORY = new Map([
145
+ ...RULES.map((r) => [r.category, r.placeholderType]),
146
+ [LIST_CONTINUATION.category, LIST_CONTINUATION.placeholderType],
147
+ ]);
148
+ function collect(re, text) {
149
+ const out = [];
150
+ re.lastIndex = 0;
151
+ let m;
152
+ while ((m = re.exec(text)) !== null) {
153
+ out.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
154
+ if (m.index === re.lastIndex)
155
+ re.lastIndex++;
156
+ }
157
+ return out;
158
+ }
159
+ function spansOverlap(a, b) {
160
+ return a.start < b.end && b.start < a.end;
161
+ }
162
+ /**
163
+ * The Fairview rule: after a school-name match, capitalized tokens that
164
+ * continue the same comma/and list are probable schools too. The chain breaks
165
+ * at the first lowercase word, so it cannot wander into unrelated text.
166
+ */
167
+ function findListContinuations(text, schoolSpans) {
168
+ const out = [];
169
+ const CHAIN = /^(?:\s*,\s*(?:and\s+|or\s+)?|\s+(?:and|or)\s+)([A-Z][\w']+(?:\s+[A-Z][\w']+)?)/;
170
+ for (const school of schoolSpans) {
171
+ let pos = school.end;
172
+ for (;;) {
173
+ const m = CHAIN.exec(text.slice(pos));
174
+ if (m === null || m[1] === undefined)
175
+ break;
176
+ const start = pos + m[0].length - m[1].length;
177
+ const span = { start, end: start + m[1].length, text: m[1] };
178
+ pos = span.end;
179
+ // Skip anything the school-name rule already caught.
180
+ if (!schoolSpans.some((s) => spansOverlap(s, span)))
181
+ out.push(span);
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+ /** Gather raw rule hits, deduplicating overlapping hits of the same category. */
187
+ export function collectSignals(text) {
188
+ const signals = [];
189
+ const push = (category, weight, span) => {
190
+ if (signals.some((s) => s.category === category && spansOverlap(s, span)))
191
+ return;
192
+ signals.push({ category, weight, start: span.start, end: span.end, text: span.text });
193
+ };
194
+ let schoolSpans = [];
195
+ for (const rule of RULES) {
196
+ const spans = rule.patterns.flatMap((p) => collect(p, text));
197
+ if (rule.category === 'school-name')
198
+ schoolSpans = spans;
199
+ for (const span of spans)
200
+ push(rule.category, rule.weight, span);
201
+ }
202
+ for (const span of findListContinuations(text, schoolSpans)) {
203
+ push(LIST_CONTINUATION.category, LIST_CONTINUATION.weight, span);
204
+ }
205
+ return signals.sort((a, b) => a.start - b.start);
206
+ }
207
+ function gap(a, b) {
208
+ if (spansOverlap(a, b))
209
+ return 0;
210
+ return a.start >= b.end ? a.start - b.end : b.start - a.end;
211
+ }
212
+ /** Score signals against their neighbors and return flags for those crossing the threshold. */
213
+ export function detectContextual(text, options = {}) {
214
+ const { flagThreshold, proximityWindow } = { ...DEFAULT_OPTIONS, ...options };
215
+ const signals = collectSignals(text);
216
+ const flags = [];
217
+ for (const signal of signals) {
218
+ let score = signal.weight;
219
+ const supporters = [];
220
+ for (const other of signals) {
221
+ if (other === signal)
222
+ continue;
223
+ const d = gap(signal, other);
224
+ if (d > proximityWindow)
225
+ continue;
226
+ score += other.weight * (1 - d / proximityWindow);
227
+ supporters.push(other.category);
228
+ }
229
+ if (score < flagThreshold)
230
+ continue;
231
+ const support = supporters.length > 0
232
+ ? `; stacked with ${supporters.length} nearby signal${supporters.length === 1 ? '' : 's'}`
233
+ : '';
234
+ flags.push({
235
+ kind: 'contextual',
236
+ category: signal.category,
237
+ start: signal.start,
238
+ end: signal.end,
239
+ text: signal.text,
240
+ reason: `${REASON_BY_CATEGORY.get(signal.category) ?? signal.category} (score ${score.toFixed(1)}, threshold ${flagThreshold}${support})`,
241
+ placeholderType: PLACEHOLDER_BY_CATEGORY.get(signal.category) ?? 'other',
242
+ score,
243
+ });
244
+ }
245
+ return { signals, flags };
246
+ }
@@ -0,0 +1,9 @@
1
+ import type { Flag } from './types.js';
2
+ interface Span {
3
+ start: number;
4
+ end: number;
5
+ }
6
+ export declare function overlaps(a: Span, b: Span): boolean;
7
+ /** Detect direct identifiers. Returned flags never overlap each other. */
8
+ export declare function detectDirect(text: string): Flag[];
9
+ export {};
package/dist/direct.js ADDED
@@ -0,0 +1,84 @@
1
+ // Order matters: earlier categories win when spans overlap.
2
+ const RULES = [
3
+ {
4
+ category: 'email',
5
+ placeholderType: 'email',
6
+ reason: 'Email address (deterministic pattern)',
7
+ patterns: [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g],
8
+ },
9
+ {
10
+ category: 'phone',
11
+ placeholderType: 'phone',
12
+ reason: 'Phone number (deterministic pattern)',
13
+ patterns: [/(?:\+1[\s.-]?)?(?:\(\d{3}\)[\s.-]?|\b\d{3}[\s.-])\d{3}[\s.-]\d{4}\b/g],
14
+ },
15
+ {
16
+ category: 'ssn',
17
+ placeholderType: 'id',
18
+ reason: 'SSN-format number (deterministic pattern)',
19
+ patterns: [/\b\d{3}-\d{2}-\d{4}\b/g],
20
+ },
21
+ {
22
+ category: 'id',
23
+ placeholderType: 'id',
24
+ reason: 'Labeled ID number (deterministic pattern)',
25
+ patterns: [
26
+ /\b(?:student\s+(?:id|number|no)|id(?:\s+(?:number|no))?|case\s+(?:number|no))\s*[#:.]?\s*\d{4,12}\b/gi,
27
+ ],
28
+ },
29
+ {
30
+ category: 'date',
31
+ placeholderType: 'date',
32
+ reason: 'Date (deterministic pattern)',
33
+ patterns: [
34
+ /\b\d{4}-\d{2}-\d{2}\b/g,
35
+ /\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4}\b/gi,
36
+ /\b\d{1,2}\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\b/gi,
37
+ /\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/g,
38
+ ],
39
+ },
40
+ {
41
+ category: 'address',
42
+ placeholderType: 'address',
43
+ reason: 'Street address (deterministic pattern)',
44
+ patterns: [
45
+ /\b\d{1,5}\s+(?:[A-Z][\w']+\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Place|Pl|Way|Terrace|Ter|Circle|Cir)\b\.?/g,
46
+ ],
47
+ },
48
+ ];
49
+ export function overlaps(a, b) {
50
+ return a.start < b.end && b.start < a.end;
51
+ }
52
+ function collect(re, text) {
53
+ const out = [];
54
+ re.lastIndex = 0;
55
+ let m;
56
+ while ((m = re.exec(text)) !== null) {
57
+ out.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
58
+ if (m.index === re.lastIndex)
59
+ re.lastIndex++;
60
+ }
61
+ return out;
62
+ }
63
+ /** Detect direct identifiers. Returned flags never overlap each other. */
64
+ export function detectDirect(text) {
65
+ const flags = [];
66
+ for (const rule of RULES) {
67
+ for (const pattern of rule.patterns) {
68
+ for (const hit of collect(pattern, text)) {
69
+ if (flags.some((f) => overlaps(f, hit)))
70
+ continue;
71
+ flags.push({
72
+ kind: 'direct',
73
+ category: rule.category,
74
+ start: hit.start,
75
+ end: hit.end,
76
+ text: hit.text,
77
+ reason: rule.reason,
78
+ placeholderType: rule.placeholderType,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ return flags.sort((a, b) => a.start - b.start);
84
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @namemasker/core — local PII detection, masking, and restore.
3
+ *
4
+ * Everything here runs entirely on the caller's device, browser or Node.
5
+ * No function in this library performs any network request, ever.
6
+ *
7
+ * Detection layers are exported independently; scanDocument composes them.
8
+ * The tool stages, the professional approves: nothing in this library
9
+ * auto-redacts a contextual flag or claims a document is "safe."
10
+ */
11
+ export type { FlagKind, PlaceholderType, Flag, ContextSignal, ScanOptions, AccumulationResult, ScanResult, Mapping, StudentMap, KnownTerm, ApprovedItem, } from './types.js';
12
+ export { DEFAULT_OPTIONS } from './types.js';
13
+ export { detectDirect } from './direct.js';
14
+ export { detectNamesNaive } from './names.js';
15
+ export { detectContextual, collectSignals } from './contextual.js';
16
+ export type { ContextualResult } from './contextual.js';
17
+ export { scanDocument } from './scan.js';
18
+ export { detectKnownTerms } from './scan.js';
19
+ export { nextPlaceholder, maskText, unmaskText, serializeMapping, parseMapping, MAP_FORMAT, createStudentMap, liftV1, addToMap, applyMap, serializeStudentMap, parseStudentMap, } from './mapping.js';
20
+ export type { MaskResult } from './mapping.js';
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @namemasker/core — local PII detection, masking, and restore.
3
+ *
4
+ * Everything here runs entirely on the caller's device, browser or Node.
5
+ * No function in this library performs any network request, ever.
6
+ *
7
+ * Detection layers are exported independently; scanDocument composes them.
8
+ * The tool stages, the professional approves: nothing in this library
9
+ * auto-redacts a contextual flag or claims a document is "safe."
10
+ */
11
+ export { DEFAULT_OPTIONS } from './types.js';
12
+ export { detectDirect } from './direct.js';
13
+ export { detectNamesNaive } from './names.js';
14
+ export { detectContextual, collectSignals } from './contextual.js';
15
+ export { scanDocument } from './scan.js';
16
+ export { detectKnownTerms } from './scan.js';
17
+ export { nextPlaceholder, maskText, unmaskText, serializeMapping, parseMapping, MAP_FORMAT, createStudentMap, liftV1, addToMap, applyMap, serializeStudentMap, parseStudentMap, } from './mapping.js';
@@ -0,0 +1,41 @@
1
+ import type { ApprovedItem, Mapping, PlaceholderType, StudentMap } from './types.js';
2
+ /** Next unused placeholder in the sequence for a type: Student A/B, School 1/2, ... */
3
+ export declare function nextPlaceholder(type: PlaceholderType, mapping: Mapping): string;
4
+ export interface MaskResult {
5
+ masked: string;
6
+ mapping: Mapping;
7
+ }
8
+ /**
9
+ * Mask a document. Pass order per spec: exact-match replacement from the
10
+ * loaded mapping first (a loaded mapping beats model misses), then approved
11
+ * new detections get the next placeholder in their type's sequence, assigned
12
+ * in detection order (position in the document).
13
+ */
14
+ export declare function maskText(text: string, approved: ApprovedItem[], existing?: Mapping): MaskResult;
15
+ /** Unmask: the mapping applied in reverse. Pure substitution, nothing else. */
16
+ export declare function unmaskText(text: string, mapping: Mapping): string;
17
+ export declare const MAP_FORMAT = "namemasker-map@2";
18
+ export declare function createStudentMap(): StudentMap;
19
+ /** Lift a v1 flat mapping into the v2 shape. v1 files import forever. */
20
+ export declare function liftV1(mapping: Mapping): StudentMap;
21
+ /**
22
+ * Add an approved item to the map. If the item is a person whose text is a
23
+ * word of an already-mapped person (bare "Maya" after "Maya Chen"), it
24
+ * becomes an alias of that placeholder instead of getting its own.
25
+ * Mutates the map; returns the placeholder used.
26
+ */
27
+ export declare function addToMap(map: StudentMap, item: ApprovedItem): string;
28
+ /**
29
+ * Apply the map to a document: canonical reals and aliases both mask to
30
+ * their placeholder. `exclude` skips specific reals for this document only
31
+ * (a dismissed watchlist hit) without touching the map.
32
+ */
33
+ export declare function applyMap(text: string, map: StudentMap, exclude?: ReadonlySet<string>): string;
34
+ /** Serialize the full v2 map for export as {student}.map.json. */
35
+ export declare function serializeStudentMap(map: StudentMap): string;
36
+ /** Parse an imported map file: v2, or a v1 flat mapping (lifted). */
37
+ export declare function parseStudentMap(json: string): StudentMap;
38
+ /** Serialize for export as {student}.map.json. (v1 shape; superseded by serializeStudentMap.) */
39
+ export declare function serializeMapping(mapping: Mapping): string;
40
+ /** Parse an imported mapping file, validating shape and placeholder uniqueness. */
41
+ export declare function parseMapping(json: string): Mapping;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Mapping and restore. The mapping is a flat JSON object, real string ->
3
+ * placeholder. It is the only sensitive artifact the tool creates, it makes
4
+ * Unmask possible, and it never leaves the user's device.
5
+ */
6
+ const TYPE_LABEL = {
7
+ student: 'Student',
8
+ parent: 'Parent',
9
+ school: 'School',
10
+ coach: 'Coach',
11
+ org: 'Organization',
12
+ place: 'Place',
13
+ email: 'Email',
14
+ phone: 'Phone',
15
+ id: 'ID',
16
+ date: 'Date',
17
+ address: 'Address',
18
+ other: 'Detail',
19
+ };
20
+ /** 0 -> A, 25 -> Z, 26 -> AA (bijective base 26). Students get letters. */
21
+ function indexToLetters(index) {
22
+ let n = index + 1;
23
+ let s = '';
24
+ while (n > 0) {
25
+ const r = (n - 1) % 26;
26
+ s = String.fromCharCode(65 + r) + s;
27
+ n = Math.floor((n - 1) / 26);
28
+ }
29
+ return s;
30
+ }
31
+ /** Next unused placeholder in the sequence for a type: Student A/B, School 1/2, ... */
32
+ export function nextPlaceholder(type, mapping) {
33
+ const label = TYPE_LABEL[type];
34
+ const used = new Set(Object.values(mapping));
35
+ for (let i = 0;; i++) {
36
+ const candidate = type === 'student' ? `${label} ${indexToLetters(i)}` : `${label} ${i + 1}`;
37
+ if (!used.has(candidate))
38
+ return candidate;
39
+ }
40
+ }
41
+ function escapeRegExp(s) {
42
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
+ }
44
+ /** Build one alternation regex for exact, whole-token replacement. Longest first. */
45
+ function buildPattern(keys) {
46
+ const sorted = [...keys].sort((a, b) => b.length - a.length).map(escapeRegExp);
47
+ return new RegExp(`(?<!\\w)(?:${sorted.join('|')})(?!\\w)`, 'g');
48
+ }
49
+ /**
50
+ * Mask a document. Pass order per spec: exact-match replacement from the
51
+ * loaded mapping first (a loaded mapping beats model misses), then approved
52
+ * new detections get the next placeholder in their type's sequence, assigned
53
+ * in detection order (position in the document).
54
+ */
55
+ export function maskText(text, approved, existing = {}) {
56
+ const mapping = { ...existing };
57
+ const inOrder = [...approved].sort((a, b) => text.indexOf(a.text) - text.indexOf(b.text));
58
+ for (const item of inOrder) {
59
+ if (item.text.length === 0 || mapping[item.text] !== undefined)
60
+ continue;
61
+ mapping[item.text] = nextPlaceholder(item.placeholderType, mapping);
62
+ }
63
+ const keys = Object.keys(mapping);
64
+ if (keys.length === 0)
65
+ return { masked: text, mapping };
66
+ const masked = text.replace(buildPattern(keys), (m) => mapping[m] ?? m);
67
+ return { masked, mapping };
68
+ }
69
+ /** Unmask: the mapping applied in reverse. Pure substitution, nothing else. */
70
+ export function unmaskText(text, mapping) {
71
+ const byPlaceholder = new Map();
72
+ for (const [real, placeholder] of Object.entries(mapping)) {
73
+ byPlaceholder.set(placeholder, real);
74
+ }
75
+ if (byPlaceholder.size === 0)
76
+ return text;
77
+ const pattern = buildPattern([...byPlaceholder.keys()]);
78
+ return text.replace(pattern, (m) => byPlaceholder.get(m) ?? m);
79
+ }
80
+ // ---------- map format v2 ----------
81
+ export const MAP_FORMAT = 'namemasker-map@2';
82
+ export function createStudentMap() {
83
+ return { mapping: {}, aliases: {}, watchlist: [] };
84
+ }
85
+ /** Lift a v1 flat mapping into the v2 shape. v1 files import forever. */
86
+ export function liftV1(mapping) {
87
+ return { mapping: { ...mapping }, aliases: {}, watchlist: [] };
88
+ }
89
+ /**
90
+ * Add an approved item to the map. If the item is a person whose text is a
91
+ * word of an already-mapped person (bare "Maya" after "Maya Chen"), it
92
+ * becomes an alias of that placeholder instead of getting its own.
93
+ * Mutates the map; returns the placeholder used.
94
+ */
95
+ export function addToMap(map, item) {
96
+ const existing = map.mapping[item.text] ?? map.aliases[item.text];
97
+ if (existing !== undefined)
98
+ return existing;
99
+ if (item.placeholderType === 'student') {
100
+ for (const [real, placeholder] of Object.entries(map.mapping)) {
101
+ if (placeholder.startsWith('Student ') && real.split(/\s+/).includes(item.text)) {
102
+ map.aliases[item.text] = placeholder;
103
+ return placeholder;
104
+ }
105
+ }
106
+ }
107
+ const placeholder = nextPlaceholder(item.placeholderType, map.mapping);
108
+ map.mapping[item.text] = placeholder;
109
+ return placeholder;
110
+ }
111
+ /**
112
+ * Apply the map to a document: canonical reals and aliases both mask to
113
+ * their placeholder. `exclude` skips specific reals for this document only
114
+ * (a dismissed watchlist hit) without touching the map.
115
+ */
116
+ export function applyMap(text, map, exclude) {
117
+ const pairs = {};
118
+ for (const [real, ph] of Object.entries(map.mapping)) {
119
+ if (!exclude?.has(real))
120
+ pairs[real] = ph;
121
+ }
122
+ for (const [real, ph] of Object.entries(map.aliases)) {
123
+ if (!exclude?.has(real))
124
+ pairs[real] = ph;
125
+ }
126
+ const keys = Object.keys(pairs);
127
+ if (keys.length === 0)
128
+ return text;
129
+ return text.replace(buildPattern(keys), (m) => pairs[m] ?? m);
130
+ }
131
+ /** Serialize the full v2 map for export as {student}.map.json. */
132
+ export function serializeStudentMap(map) {
133
+ return `${JSON.stringify({ format: MAP_FORMAT, mapping: map.mapping, aliases: map.aliases, watchlist: map.watchlist }, null, 2)}\n`;
134
+ }
135
+ function validateEntries(obj, what) {
136
+ for (const [real, ph] of Object.entries(obj)) {
137
+ if (typeof ph !== 'string' || ph.length === 0 || real.length === 0) {
138
+ throw new Error(`Map file ${what} must map non-empty strings to non-empty strings.`);
139
+ }
140
+ }
141
+ }
142
+ /** Parse an imported map file: v2, or a v1 flat mapping (lifted). */
143
+ export function parseStudentMap(json) {
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(json);
147
+ }
148
+ catch {
149
+ throw new Error('Map file is not valid JSON.');
150
+ }
151
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
152
+ throw new Error('Map file must be a JSON object.');
153
+ }
154
+ const obj = parsed;
155
+ if (obj['format'] === undefined) {
156
+ // v1: flat real -> placeholder
157
+ return liftV1(parseMapping(json));
158
+ }
159
+ if (obj['format'] !== MAP_FORMAT) {
160
+ throw new Error(`Unrecognized map format "${String(obj['format'])}".`);
161
+ }
162
+ const mapping = (obj['mapping'] ?? {});
163
+ const aliases = (obj['aliases'] ?? {});
164
+ const watchlist = obj['watchlist'] ?? [];
165
+ if (typeof mapping !== 'object' || mapping === null || Array.isArray(mapping)) {
166
+ throw new Error('Map file "mapping" must be an object.');
167
+ }
168
+ if (typeof aliases !== 'object' || aliases === null || Array.isArray(aliases)) {
169
+ throw new Error('Map file "aliases" must be an object.');
170
+ }
171
+ if (!Array.isArray(watchlist) || watchlist.some((w) => typeof w !== 'string' || w.length === 0)) {
172
+ throw new Error('Map file "watchlist" must be an array of non-empty strings.');
173
+ }
174
+ validateEntries(mapping, '"mapping"');
175
+ validateEntries(aliases, '"aliases"');
176
+ const seen = new Set();
177
+ for (const ph of Object.values(mapping)) {
178
+ if (seen.has(ph))
179
+ throw new Error(`Map file reuses the placeholder "${ph}"; Unmask would be ambiguous.`);
180
+ seen.add(ph);
181
+ }
182
+ for (const [real, ph] of Object.entries(aliases)) {
183
+ if (!seen.has(ph)) {
184
+ throw new Error(`Alias "${real}" points at "${ph}", which is not in the mapping.`);
185
+ }
186
+ }
187
+ return { mapping, aliases, watchlist: [...new Set(watchlist)] };
188
+ }
189
+ /** Serialize for export as {student}.map.json. (v1 shape; superseded by serializeStudentMap.) */
190
+ export function serializeMapping(mapping) {
191
+ return `${JSON.stringify(mapping, null, 2)}\n`;
192
+ }
193
+ /** Parse an imported mapping file, validating shape and placeholder uniqueness. */
194
+ export function parseMapping(json) {
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(json);
198
+ }
199
+ catch {
200
+ throw new Error('Mapping file is not valid JSON.');
201
+ }
202
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
203
+ throw new Error('Mapping file must be a JSON object of real string -> placeholder.');
204
+ }
205
+ const entries = Object.entries(parsed);
206
+ const seen = new Set();
207
+ for (const [real, placeholder] of entries) {
208
+ if (typeof placeholder !== 'string' || placeholder.length === 0 || real.length === 0) {
209
+ throw new Error('Mapping file must map non-empty strings to non-empty strings.');
210
+ }
211
+ if (seen.has(placeholder)) {
212
+ throw new Error(`Mapping file reuses the placeholder "${placeholder}"; Unmask would be ambiguous.`);
213
+ }
214
+ seen.add(placeholder);
215
+ }
216
+ return parsed;
217
+ }
@@ -0,0 +1,3 @@
1
+ import type { Flag } from './types.js';
2
+ /** Detect probable personal names with the naive capitalized-pair pattern. */
3
+ export declare function detectNamesNaive(text: string): Flag[];
package/dist/names.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Layer 2, Phase 2 form: a deliberately naive capitalized-pair pattern.
3
+ * This is a stopgap with known false positives and misses; Phase 3 replaces
4
+ * it with a small on-device NER model. The reason string on every flag says
5
+ * so, honestly, and the UI surfaces that reason.
6
+ */
7
+ const NAIVE_REASON = 'Looks like a personal name: two capitalized words (naive pattern; an on-device model replaces this in a later phase)';
8
+ // Second words that indicate an institution or thing, not a person.
9
+ // Schools and ensembles are handled by the contextual layer instead.
10
+ const SECOND_WORD_STOPLIST = new Set([
11
+ 'High', 'Prep', 'Academy', 'Preparatory', 'School', 'Elementary', 'Middle',
12
+ 'Band', 'Orchestra', 'Choir', 'Chorale', 'Ensemble', 'Symphony', 'Philharmonic',
13
+ 'Honors', 'University', 'College', 'Institute', 'Award', 'Prize', 'Scholarship',
14
+ 'Fellowship', 'Medal', 'Committee', 'Department', 'Office', 'Center', 'Foundation',
15
+ 'Association', 'Club', 'Team', 'League', 'County', 'City', 'State', 'Hall',
16
+ 'Park', 'Library', 'Hospital', 'Church', 'Program', 'Project', 'Street',
17
+ 'Avenue', 'Road', 'Boulevard', 'Lane', 'Drive', 'Court',
18
+ ]);
19
+ // Common sentence-openers and function words that begin false pairs.
20
+ const FIRST_WORD_STOPLIST = new Set([
21
+ 'The', 'A', 'An', 'As', 'At', 'In', 'On', 'Of', 'My', 'Our', 'Your', 'His',
22
+ 'Her', 'Their', 'This', 'That', 'These', 'Those', 'Dear', 'If', 'When',
23
+ 'While', 'After', 'Before', 'She', 'He', 'They', 'It', 'We', 'You', 'But',
24
+ 'And', 'Or', 'So', 'To', 'For', 'From', 'With', 'By', 'Please',
25
+ ]);
26
+ const PAIR = /\b([A-Z][a-z]+)\s+(?:[A-Z]\.\s+)?([A-Z][a-z]+)\b/g;
27
+ /** Detect probable personal names with the naive capitalized-pair pattern. */
28
+ export function detectNamesNaive(text) {
29
+ const flags = [];
30
+ PAIR.lastIndex = 0;
31
+ let m;
32
+ while ((m = PAIR.exec(text)) !== null) {
33
+ const [whole, first, second] = m;
34
+ if (first === undefined || second === undefined)
35
+ continue;
36
+ if (FIRST_WORD_STOPLIST.has(first) || SECOND_WORD_STOPLIST.has(second)) {
37
+ // Rescan from the second word so "The Maya Chen" still finds "Maya Chen".
38
+ PAIR.lastIndex = m.index + first.length;
39
+ continue;
40
+ }
41
+ flags.push({
42
+ kind: 'name',
43
+ category: 'name-pair',
44
+ start: m.index,
45
+ end: m.index + whole.length,
46
+ text: whole,
47
+ reason: NAIVE_REASON,
48
+ placeholderType: 'student',
49
+ });
50
+ }
51
+ return flags;
52
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { Flag, KnownTerm, ScanOptions, ScanResult } from './types.js';
2
+ /** Stage every whole-token occurrence of the caller's known terms. */
3
+ export declare function detectKnownTerms(text: string, terms: KnownTerm[]): Flag[];
4
+ /**
5
+ * Run all detection layers over a document and apply layer 4: document-level
6
+ * accumulation. The banner is a flag for the professional, never a redaction,
7
+ * and no result from this function ever means "this document is safe."
8
+ */
9
+ export declare function scanDocument(text: string, options?: ScanOptions): ScanResult;
package/dist/scan.js ADDED
@@ -0,0 +1,62 @@
1
+ import { DEFAULT_OPTIONS } from './types.js';
2
+ import { detectDirect, overlaps } from './direct.js';
3
+ import { detectNamesNaive } from './names.js';
4
+ import { detectContextual } from './contextual.js';
5
+ function escapeRegExp(s) {
6
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ }
8
+ /** Stage every whole-token occurrence of the caller's known terms. */
9
+ export function detectKnownTerms(text, terms) {
10
+ const flags = [];
11
+ for (const t of terms) {
12
+ if (t.term.trim().length === 0)
13
+ continue;
14
+ const re = new RegExp(`(?<!\\w)${escapeRegExp(t.term)}(?!\\w)`, 'g');
15
+ let m;
16
+ while ((m = re.exec(text)) !== null) {
17
+ flags.push({
18
+ kind: 'name',
19
+ category: 'known-term',
20
+ start: m.index,
21
+ end: m.index + m[0].length,
22
+ text: m[0],
23
+ reason: t.label ?? 'On your always-flag list',
24
+ placeholderType: t.placeholderType ?? 'student',
25
+ });
26
+ if (m.index === re.lastIndex)
27
+ re.lastIndex++;
28
+ }
29
+ }
30
+ return flags;
31
+ }
32
+ /**
33
+ * Run all detection layers over a document and apply layer 4: document-level
34
+ * accumulation. The banner is a flag for the professional, never a redaction,
35
+ * and no result from this function ever means "this document is safe."
36
+ */
37
+ export function scanDocument(text, options = {}) {
38
+ const { accumulationThreshold } = { ...DEFAULT_OPTIONS, ...options };
39
+ // Known terms are caller-supplied truth; they win every overlap.
40
+ const known = detectKnownTerms(text, options.knownTerms ?? []);
41
+ const direct = detectDirect(text).filter((d) => !known.some((k) => overlaps(k, d)));
42
+ // Direct hits win over the name layer and over contextual signals that
43
+ // fall entirely inside them (e.g. a year inside a full date).
44
+ const names = (options.nameFlags ?? detectNamesNaive(text)).filter((n) => !direct.some((d) => overlaps(d, n)) && !known.some((k) => overlaps(k, n)));
45
+ const contextual = detectContextual(text, options).flags.filter((c) => !direct.some((d) => d.start <= c.start && c.end <= d.end) &&
46
+ !known.some((k) => k.start <= c.start && c.end <= k.end));
47
+ const flags = [...known, ...direct, ...names, ...contextual].sort((a, b) => a.start - b.start || a.end - b.end);
48
+ const contextualCount = contextual.length;
49
+ const triggered = contextualCount >= accumulationThreshold;
50
+ return {
51
+ flags,
52
+ accumulation: {
53
+ triggered,
54
+ contextualCount,
55
+ ...(triggered
56
+ ? {
57
+ message: `${contextualCount} contextual details flagged in this document. Even with each one masked, the combination may still identify the student. Review whether the pieces together narrow to one person.`,
58
+ }
59
+ : {}),
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,84 @@
1
+ /** The three flag kinds shown in the UI: red / name-colored / yellow. */
2
+ export type FlagKind = 'direct' | 'name' | 'contextual';
3
+ /** Placeholder sequences are assigned per type, in detection order. */
4
+ export type PlaceholderType = 'student' | 'parent' | 'school' | 'coach' | 'org' | 'place' | 'email' | 'phone' | 'id' | 'date' | 'address' | 'other';
5
+ /** A single staged detection. Every flag carries a human-readable reason. */
6
+ export interface Flag {
7
+ kind: FlagKind;
8
+ /** Rule identifier, e.g. 'email', 'name-pair', 'school-list-continuation'. */
9
+ category: string;
10
+ start: number;
11
+ end: number;
12
+ text: string;
13
+ reason: string;
14
+ /** Suggested placeholder sequence if the professional approves this flag. */
15
+ placeholderType: PlaceholderType;
16
+ /** Contextual flags only: the score that crossed the threshold. */
17
+ score?: number;
18
+ }
19
+ /** A raw contextual rule hit, before proximity scoring. */
20
+ export interface ContextSignal {
21
+ category: string;
22
+ weight: number;
23
+ start: number;
24
+ end: number;
25
+ text: string;
26
+ }
27
+ export interface ScanOptions {
28
+ /** Contextual score a signal must reach to become a flag. Default 3. */
29
+ flagThreshold?: number;
30
+ /** Contextual flag count that triggers the document-level banner. Default 6. */
31
+ accumulationThreshold?: number;
32
+ /** Distance (chars) within which neighboring signals contribute. Default 200. */
33
+ proximityWindow?: number;
34
+ /**
35
+ * Name-kind flags from an external detector (e.g. an on-device NER model).
36
+ * When provided, these replace the built-in naive capitalized-pair layer.
37
+ */
38
+ nameFlags?: Flag[];
39
+ /**
40
+ * Terms the caller already knows are identifying (the user's watchlist, or
41
+ * a student record on the caller's side). Staged with top priority; they
42
+ * win every overlap with detected flags.
43
+ */
44
+ knownTerms?: KnownTerm[];
45
+ }
46
+ export interface AccumulationResult {
47
+ triggered: boolean;
48
+ contextualCount: number;
49
+ /** Present when triggered. A flag for the professional, never a redaction. */
50
+ message?: string;
51
+ }
52
+ export interface ScanResult {
53
+ flags: Flag[];
54
+ accumulation: AccumulationResult;
55
+ }
56
+ /** Flat mapping: real string -> placeholder. The only sensitive artifact. */
57
+ export type Mapping = Record<string, string>;
58
+ /**
59
+ * Map format v2 (namemasker-map@2): the full sensitive artifact.
60
+ * - mapping: canonical real -> placeholder, placeholders unique. Unmask
61
+ * restores ONLY these, so restoration is always deterministic.
62
+ * - aliases: alternate spellings -> an existing placeholder. They mask but
63
+ * never win at unmask ("Maya" -> "Student A" alongside "Maya Chen").
64
+ * - watchlist: terms to always stage on scan. Deleting one only affects
65
+ * future scans; deleting a mapping entry is the only destructive act.
66
+ */
67
+ export interface StudentMap {
68
+ mapping: Mapping;
69
+ aliases: Mapping;
70
+ watchlist: string[];
71
+ }
72
+ /** A term the caller already knows is identifying (watchlist, student record). */
73
+ export interface KnownTerm {
74
+ term: string;
75
+ placeholderType?: PlaceholderType;
76
+ /** Optional reason override shown on the flag. */
77
+ label?: string;
78
+ }
79
+ /** A flag the professional approved, possibly with an edited replacement type. */
80
+ export interface ApprovedItem {
81
+ text: string;
82
+ placeholderType: PlaceholderType;
83
+ }
84
+ export declare const DEFAULT_OPTIONS: Required<Omit<ScanOptions, 'nameFlags' | 'knownTerms'>>;
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ export const DEFAULT_OPTIONS = {
2
+ flagThreshold: 3,
3
+ accumulationThreshold: 6,
4
+ proximityWindow: 200,
5
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@namemasker/core",
3
+ "version": "0.1.0",
4
+ "description": "Local PII detection, masking, and restore engine for NameMasker. Runs in browser and Node; no network, no telemetry.",
5
+ "license": "MIT",
6
+ "author": "Chris Bell",
7
+ "homepage": "https://namemasker.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/chrisbellco2/namemasker.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "bugs": "https://github.com/chrisbellco2/namemasker/issues",
14
+ "keywords": [
15
+ "pii",
16
+ "redaction",
17
+ "masking",
18
+ "privacy",
19
+ "student-privacy",
20
+ "local-first",
21
+ "ner"
22
+ ],
23
+ "type": "module",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "test": "vitest run",
40
+ "test:watch": "vitest",
41
+ "check": "tsc --noEmit",
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "prepublishOnly": "npm run check && npm test && npm run build"
44
+ },
45
+ "devDependencies": {
46
+ "typescript": "7.0.2",
47
+ "vitest": "4.1.10"
48
+ }
49
+ }