@mailwoman/normalize 7.2.0 → 7.3.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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Abbreviation expansion — a small bounded dictionary per locale. Initial dict covers en-US street
7
+ * suffixes + directional prefixes. fr-FR + others added as needed.
8
+ *
9
+ * This is the INVERSE of the corpus synthesis pass (which produces `Ave` from `Avenue` for
10
+ * augmentation). Both sides should eventually share dictionaries; for v1 this dict is duplicated
11
+ * intentionally — refactoring sharing is a separate task.
12
+ */
13
+
14
+ import type { SpanRange } from "./types.ts"
15
+
16
+ export interface AbbreviationEntry {
17
+ from: string // short form (case-insensitive match)
18
+ to: string // canonical long form
19
+ }
20
+
21
+ const EN_US_DICT: ReadonlyArray<AbbreviationEntry> = [
22
+ // Directional prefixes / suffixes
23
+ { from: "N", to: "North" },
24
+ { from: "S", to: "South" },
25
+ { from: "E", to: "East" },
26
+ { from: "W", to: "West" },
27
+ { from: "NE", to: "Northeast" },
28
+ { from: "NW", to: "Northwest" },
29
+ { from: "SE", to: "Southeast" },
30
+ { from: "SW", to: "Southwest" },
31
+ // Street suffixes
32
+ { from: "St", to: "Street" },
33
+ { from: "Ave", to: "Avenue" },
34
+ { from: "Blvd", to: "Boulevard" },
35
+ { from: "Rd", to: "Road" },
36
+ { from: "Dr", to: "Drive" },
37
+ { from: "Ct", to: "Court" },
38
+ { from: "Ln", to: "Lane" },
39
+ { from: "Pl", to: "Place" },
40
+ { from: "Pkwy", to: "Parkway" },
41
+ { from: "Hwy", to: "Highway" },
42
+ { from: "Sq", to: "Square" },
43
+ { from: "Ter", to: "Terrace" },
44
+ ]
45
+
46
+ const FR_FR_DICT: ReadonlyArray<AbbreviationEntry> = [
47
+ { from: "R", to: "Rue" },
48
+ { from: "Bd", to: "Boulevard" },
49
+ { from: "Av", to: "Avenue" },
50
+ { from: "Bvd", to: "Boulevard" },
51
+ { from: "Pl", to: "Place" },
52
+ { from: "Imp", to: "Impasse" },
53
+ { from: "Sq", to: "Square" },
54
+ ]
55
+
56
+ /**
57
+ * #1002: the locale-UNKNOWN expansion set — the entries safe to apply when the input's locale hasn't been established
58
+ * yet (the geocode path expands BEFORE the parse, which is what determines the locale). Safe = multi-char,
59
+ * collision-free across the locale dictionaries, and never a plausible standalone token in the other locale (FR
60
+ * `Bd`/`Bvd`/`Imp` have no EN reading; `Av` reads Avenue in both). Deliberately EXCLUDED: the FR single letters (`R` →
61
+ * Rue would fire on Washington DC's literal "R St") and the EN suffixes (`St`, `Ave`, `Dr`, … — the model is
62
+ * trained-robust on those, and `St`/`Dr` are ambiguous with Saint/Doctor).
63
+ */
64
+ const LOCALE_UNKNOWN_DICT: ReadonlyArray<AbbreviationEntry> = [
65
+ { from: "Bd", to: "Boulevard" },
66
+ { from: "Bvd", to: "Boulevard" },
67
+ { from: "Boul", to: "Boulevard" },
68
+ { from: "Av", to: "Avenue" },
69
+ { from: "Imp", to: "Impasse" },
70
+ ]
71
+
72
+ function getDictionary(locale: string | undefined): ReadonlyArray<AbbreviationEntry> {
73
+ const lc = (locale ?? "en-US").toLowerCase()
74
+
75
+ // BCP-47 "und" (undetermined) — the caller knows it does NOT know the locale yet (the geocode path
76
+ // expands before the parse). Only the collision-free multi-locale set applies; `undefined` keeps its
77
+ // historical en-US default.
78
+ if (lc === "und") return LOCALE_UNKNOWN_DICT
79
+
80
+ if (lc.startsWith("fr")) return FR_FR_DICT
81
+
82
+ return EN_US_DICT
83
+ }
84
+
85
+ /**
86
+ * The per-locale abbreviation table (short↔long), exposed so consumers can reuse the SAME data instead of duplicating
87
+ * it. The metamorphic gauntlet inverts this table to generate expanded→abbreviated perturbations (`Avenue`→`Ave`); the
88
+ * "no load-bearing trivia" rule means that data lives in exactly one place — here.
89
+ */
90
+ export function abbreviationDictionary(locale?: string): ReadonlyArray<AbbreviationEntry> {
91
+ return getDictionary(locale)
92
+ }
93
+
94
+ export interface AbbreviationResult {
95
+ text: string
96
+ map: number[]
97
+ expansions: Array<{ from: string; to: string; at: SpanRange }>
98
+ }
99
+
100
+ /**
101
+ * Expand known abbreviations. Walks the input token-by-token (whitespace-delimited) and rewrites matching tokens to
102
+ * their canonical long form. The output map points every char of the expanded form to its position in the original
103
+ * short form (first char of input token).
104
+ *
105
+ * Case rules: match case-insensitively. Output form preserves the dictionary's canonical casing (`St` → `Street`, `st`
106
+ * → `Street`, `ST` → `Street`).
107
+ */
108
+ export function expandAbbreviations(input: string, locale?: string): AbbreviationResult {
109
+ const dict = getDictionary(locale)
110
+ const lookup = new Map<string, string>()
111
+
112
+ for (const entry of dict) {
113
+ lookup.set(entry.from.toLowerCase(), entry.to)
114
+ }
115
+
116
+ const out: string[] = []
117
+ const map: number[] = []
118
+ const expansions: Array<{ from: string; to: string; at: SpanRange }> = []
119
+
120
+ let i = 0
121
+
122
+ while (i < input.length) {
123
+ const ch = input[i]!
124
+ // Walk to end of token (non-whitespace, non-punctuation). Unicode-letter-aware so
125
+ // "République" stays one token instead of fragmenting on 'é'.
126
+ const isTokenChar = (c: string) => /[\p{L}\p{N}'_-]/u.test(c)
127
+
128
+ if (!isTokenChar(ch)) {
129
+ out.push(ch)
130
+ map.push(i)
131
+ i += 1
132
+ continue
133
+ }
134
+ const start = i
135
+
136
+ while (i < input.length && isTokenChar(input[i]!)) {
137
+ i += 1
138
+ }
139
+ const token = input.slice(start, i)
140
+ const tokenWithTrailingDot = i < input.length && input[i] === "." ? `${token}.` : token
141
+ const lookupKey = token.replace(/\.$/, "").toLowerCase()
142
+ const expansion = lookup.get(lookupKey)
143
+
144
+ if (!expansion) {
145
+ for (let k = 0; k < token.length; k++) {
146
+ out.push(token[k]!)
147
+ map.push(start + k)
148
+ }
149
+ continue
150
+ }
151
+
152
+ // Emit expansion; map every char back to start of source token.
153
+ for (let k = 0; k < expansion.length; k++) {
154
+ out.push(expansion[k]!)
155
+ map.push(start + Math.min(k, token.length - 1))
156
+ }
157
+ expansions.push({
158
+ from: tokenWithTrailingDot,
159
+ to: expansion,
160
+ at: { start, end: i, body: token },
161
+ })
162
+
163
+ // Skip the trailing period if we consumed an abbreviation with one (e.g. "St." → "Street").
164
+ if (i < input.length && input[i] === ".") {
165
+ i += 1
166
+ }
167
+ }
168
+
169
+ return { text: out.join(""), map, expansions }
170
+ }
package/cjk.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * CJK input normalization (Direction E, #291) — a conservative, character-level pass that runs as
7
+ * part of `normalize()` so the parser sees a stable form of CJK addresses. It does only the
8
+ * transformations that are UNAMBIGUOUS in any context:
9
+ *
10
+ * - **Strip the postal mark 〒 (U+3012).** The JP cheap-probe found 〒 is byte-fallback OOV for the
11
+ * SentencePiece tokenizer — it fragments into raw UTF-8 byte pieces and poisons the parse of
12
+ * the digits right after it (the postcode gets mislabeled as a house number). It's a
13
+ * "postcode follows" marker with no addressing content of its own, so dropping it is safe and
14
+ * fixes the bug.
15
+ * - **Fold full-width ASCII (U+FF01–U+FF5E → U+0021–U+007E).** A full-width `1` is always the digit
16
+ * 1, a full-width `-` always a hyphen — keyboards and copy-paste produce these constantly.
17
+ * Folding them to ASCII makes `104−0061` and `104-0061` the same input.
18
+ * - **Fold the ideographic space (U+3000 → ' ').**
19
+ *
20
+ * It deliberately does NOT convert **kanji numerals** (一二三…): place names carry numeral kanji as
21
+ * ordinary characters (三田 _Mita_, 四谷 _Yotsuya_), so a blind 三→3 would corrupt them.
22
+ * Disambiguating "this 三 is a block number, that one is part of a name" is parsing, not
23
+ * normalization — deferred. Kana→kanji transliteration (ちょうめ→丁目) is dictionary work and likewise
24
+ * deferred.
25
+ *
26
+ * Self-gating: a string with none of these characters returns identity, so Latin input is
27
+ * untouched.
28
+ */
29
+
30
+ import { identityMap } from "./offset-map.ts"
31
+
32
+ export interface CjkResult {
33
+ text: string
34
+ map: number[]
35
+ /** Count of characters folded in place (full-width → ASCII, ideographic space → ' '). */
36
+ folded: number
37
+ /** Count of characters dropped (the postal mark). */
38
+ stripped: number
39
+ }
40
+
41
+ const FULLWIDTH_START = 0xff01 // !
42
+ const FULLWIDTH_END = 0xff5e // ~
43
+ const FULLWIDTH_TO_ASCII = 0xfee0 // U+FFxx − 0xFEE0 = U+00xx
44
+ const IDEOGRAPHIC_SPACE = 0x3000
45
+ const POSTAL_MARK = 0x3012 // 〒
46
+
47
+ export function applyCjkNormalization(input: string): CjkResult {
48
+ let folded = 0
49
+ let stripped = 0
50
+ const out: string[] = []
51
+ const map: number[] = []
52
+
53
+ // All transformed code points are in the BMP (single UTF-16 unit), and every other character is
54
+ // passed through verbatim, so a per-unit walk is safe for surrogate-pair input too.
55
+ for (let i = 0; i < input.length; i++) {
56
+ const code = input.charCodeAt(i)
57
+
58
+ if (code === POSTAL_MARK) {
59
+ stripped += 1
60
+ continue // drop — no addressing content; whitespace collapse later tidies any gap
61
+ }
62
+
63
+ if (code >= FULLWIDTH_START && code <= FULLWIDTH_END) {
64
+ out.push(String.fromCharCode(code - FULLWIDTH_TO_ASCII))
65
+ map.push(i)
66
+ folded += 1
67
+ continue
68
+ }
69
+
70
+ if (code === IDEOGRAPHIC_SPACE) {
71
+ out.push(" ")
72
+ map.push(i)
73
+ folded += 1
74
+ continue
75
+ }
76
+ out.push(input[i]!)
77
+ map.push(i)
78
+ }
79
+
80
+ if (folded === 0 && stripped === 0) {
81
+ return { text: input, map: identityMap(input.length), folded: 0, stripped: 0 }
82
+ }
83
+
84
+ return { text: out.join(""), map, folded, stripped }
85
+ }
package/compute.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `normalize(raw, opts)` — the Stage 1 entry point. Composes NFC + punctuation + whitespace
7
+ * (always) with case-fold + abbreviation expansion (opt-in).
8
+ */
9
+
10
+ import { expandAbbreviations } from "./abbreviations.ts"
11
+ import { applyCjkNormalization } from "./cjk.ts"
12
+ import { applyNFC } from "./nfc.ts"
13
+ import { composeMaps, identityMap } from "./offset-map.ts"
14
+ import { applyPunctuation } from "./punctuation.ts"
15
+ import type { NormalizationTransform, NormalizedInput, NormalizeOpts } from "./types.ts"
16
+ import { collapseWhitespace } from "./whitespace.ts"
17
+
18
+ export function normalize(raw: string, opts?: NormalizeOpts): NormalizedInput {
19
+ const transforms: NormalizationTransform[] = []
20
+ let text = raw
21
+ let map = identityMap(raw.length)
22
+
23
+ // 1. NFC
24
+ if (!opts?.skipNFC) {
25
+ const r = applyNFC(text)
26
+ text = r.text
27
+ map = composeMaps(map, r.map)
28
+ transforms.push({ kind: "nfc", changed: r.changed })
29
+ }
30
+
31
+ // 1.5 CJK normalization — strip the postal mark 〒 (byte-fallback OOV that poisons the postcode
32
+ // parse) and fold full-width ASCII + the ideographic space. Runs after NFC so it sees composed
33
+ // forms, before punctuation/whitespace so any gap left by 〒 is then collapsed. No-op off-script.
34
+ {
35
+ const r = applyCjkNormalization(text)
36
+
37
+ if (r.folded > 0 || r.stripped > 0) {
38
+ text = r.text
39
+ map = composeMaps(map, r.map)
40
+ transforms.push({ kind: "normalize_cjk", folded: r.folded, stripped: r.stripped })
41
+ }
42
+ }
43
+
44
+ // 2. Punctuation
45
+ {
46
+ const r = applyPunctuation(text)
47
+
48
+ if (r.replacements > 0) {
49
+ text = r.text
50
+ map = composeMaps(map, r.map)
51
+ transforms.push({ kind: "normalize_punctuation", replacements: r.replacements })
52
+ }
53
+ }
54
+
55
+ // 3. Whitespace
56
+ {
57
+ const r = collapseWhitespace(text)
58
+
59
+ if (r.runs > 0 || r.text.length !== text.length) {
60
+ text = r.text
61
+ map = composeMaps(map, r.map)
62
+ transforms.push({ kind: "collapse_whitespace", runs: r.runs })
63
+ }
64
+ }
65
+
66
+ // 4. Abbreviation expansion (opt-in) — runs BEFORE case-fold so case-folding the canonical
67
+ // expansion form (e.g. "Street") gives a consistent final case.
68
+ if (opts?.expandAbbreviations) {
69
+ const r = expandAbbreviations(text, opts.locale)
70
+
71
+ if (r.expansions.length > 0) {
72
+ text = r.text
73
+ map = composeMaps(map, r.map)
74
+
75
+ for (const e of r.expansions) {
76
+ transforms.push({ kind: "expand_abbreviation", from: e.from, to: e.to, at: e.at })
77
+ }
78
+ }
79
+ }
80
+
81
+ // 5. Case fold (opt-in)
82
+ if (opts?.caseFold) {
83
+ const lc = text.toLocaleLowerCase(opts.locale)
84
+
85
+ if (lc !== text) {
86
+ text = lc
87
+ // Case-fold is identity-length for ASCII + most Latin; map unchanged.
88
+ transforms.push({ kind: "case_fold", locale: opts.locale ?? "und" })
89
+ }
90
+ }
91
+
92
+ return Object.freeze({
93
+ raw,
94
+ normalized: text,
95
+ transforms: Object.freeze(transforms) as NormalizationTransform[],
96
+ offsetMap: map,
97
+ appliedLocale: opts?.locale,
98
+ }) satisfies NormalizedInput
99
+ }
package/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `@mailwoman/normalize` — Stage 1 of the runtime pipeline.
7
+ *
8
+ * Deterministic input preprocessing: NFC, punctuation, whitespace, optional case-fold +
9
+ * abbreviation expansion. Pure functions. Produces a `NormalizedInput` with a critical
10
+ * `offsetMap` so downstream stages can map normalized-string spans back to raw-string character
11
+ * offsets.
12
+ *
13
+ * See `docs/articles/plan/reference/STAGES.md` § Stage 1 for the contract.
14
+ */
15
+
16
+ export { type AbbreviationEntry, abbreviationDictionary, expandAbbreviations } from "./abbreviations.ts"
17
+ export { applyCjkNormalization, type CjkResult } from "./cjk.ts"
18
+ export { normalize } from "./compute.ts"
19
+ export { applyNFC } from "./nfc.ts"
20
+ export { composeMaps, identityMap } from "./offset-map.ts"
21
+ export { applyPunctuation } from "./punctuation.ts"
22
+ export type { NormalizationTransform, NormalizeOpts, NormalizedInput, SpanRange } from "./types.ts"
23
+ export { collapseWhitespace } from "./whitespace.ts"
package/nfc.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Unicode NFC normalization. For inputs already in NFC (the common case) this is a no-op. When the
7
+ * input has combining characters (`e` + `́` → `é`), NFC composes them — the normalized string can
8
+ * be shorter than the raw.
9
+ *
10
+ * Approximation: we walk the input grapheme-by-grapheme (best effort via codepoint stepping) and
11
+ * map each output index to the start of its source sequence. Rare CJK edge cases involving
12
+ * variant selectors may produce off-by-one offsets — acceptable for v1.
13
+ */
14
+
15
+ import { identityMap } from "./offset-map.ts"
16
+
17
+ export interface NFCResult {
18
+ text: string
19
+ /** `text[i]` came from `input[map[i]]`. */
20
+ map: number[]
21
+ changed: boolean
22
+ }
23
+
24
+ export function applyNFC(input: string): NFCResult {
25
+ const normalized = input.normalize("NFC")
26
+
27
+ if (normalized === input) {
28
+ return { text: input, map: identityMap(input.length), changed: false }
29
+ }
30
+
31
+ return { text: normalized, map: estimateNFCMap(input, normalized), changed: true }
32
+ }
33
+
34
+ /**
35
+ * Estimate per-output-codepoint offsets. Walks both strings in parallel; emits the next source index for each output
36
+ * position. Imprecise for combining sequences but correct for length-equal NFC outputs (the common length-changing case
37
+ * is when a sequence shortens).
38
+ */
39
+ function estimateNFCMap(input: string, output: string): number[] {
40
+ const map: number[] = []
41
+ let inIdx = 0
42
+
43
+ for (let outIdx = 0; outIdx < output.length; outIdx++) {
44
+ map.push(inIdx)
45
+ const outCp = output.codePointAt(outIdx)!
46
+ const outStep = outCp > 0xffff ? 2 : 1
47
+
48
+ // Walk the input forward by at least one codepoint; absorb any combining marks (0x0300–0x036f).
49
+ if (inIdx < input.length) {
50
+ const inCp = input.codePointAt(inIdx)!
51
+ inIdx += inCp > 0xffff ? 2 : 1
52
+
53
+ while (inIdx < input.length) {
54
+ const nextCp = input.codePointAt(inIdx)!
55
+
56
+ if (nextCp >= 0x0300 && nextCp <= 0x036f) {
57
+ inIdx += nextCp > 0xffff ? 2 : 1
58
+ } else {
59
+ break
60
+ }
61
+ }
62
+ }
63
+
64
+ if (outStep === 2) {
65
+ outIdx += 1
66
+ }
67
+ }
68
+
69
+ return map
70
+ }
package/offset-map.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Utilities for composing per-transform offset maps into the final `raw → normalized` map.
7
+ */
8
+
9
+ /** Identity map for an input of length `n`: `[0, 1, 2, ..., n-1]`. */
10
+ export function identityMap(n: number): number[] {
11
+ const m = new Array<number>(n)
12
+
13
+ for (let i = 0; i < n; i++) {
14
+ m[i] = i
15
+ }
16
+
17
+ return m
18
+ }
19
+
20
+ /**
21
+ * Compose `inputMap` (input → raw) with `transformMap` (output → input) to produce `outputMap` (output → raw).
22
+ *
23
+ * @example
24
+ * // raw = "350 5th" (chars 0..7, double space at 3-4) // input = "350 5th" (identity from
25
+ * raw, length 8) // output = "350 5th" (whitespace collapsed, length 7) // inputMap =
26
+ * [0,1,2,3,4,5,6,7] // transformMap = [0,1,2,3,5,6,7] (output[3]=' ' came from input[3];
27
+ * output[4]='5' from input[5]) // composed = [0,1,2,3,5,6,7]
28
+ */
29
+ export function composeMaps(inputMap: number[], transformMap: number[]): number[] {
30
+ const out = new Array<number>(transformMap.length)
31
+
32
+ for (let i = 0; i < transformMap.length; i++) {
33
+ const j = transformMap[i]!
34
+ out[i] = inputMap[j] ?? j
35
+ }
36
+
37
+ return out
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mailwoman/normalize",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "Stage 1 of the runtime pipeline — deterministic input preprocessing (Unicode NFC, punctuation, whitespace, abbreviation). Pure functions, no ML.",
5
5
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
6
6
  "repository": {
@@ -12,18 +12,32 @@
12
12
  "out/**/*.js",
13
13
  "out/**/*.js.map",
14
14
  "out/**/*.d.ts",
15
- "out/**/*.d.ts.map"
15
+ "out/**/*.d.ts.map",
16
+ "*.ts",
17
+ "*.tsx",
18
+ "**/*.ts",
19
+ "**/*.tsx",
20
+ "!*.test.ts",
21
+ "!*.test.tsx",
22
+ "!**/*.test.ts",
23
+ "!**/*.test.tsx"
16
24
  ],
17
25
  "type": "module",
18
26
  "exports": {
19
27
  "./package.json": "./package.json",
20
28
  ".": {
21
- "node": "./index.ts",
22
- "default": "./out/index.js",
23
- "types": "./out/index.d.ts"
29
+ "types": "./out/index.d.ts",
30
+ "default": "./out/index.js"
24
31
  }
25
32
  },
26
33
  "publishConfig": {
27
- "access": "public"
34
+ "access": "public",
35
+ "exports": {
36
+ "./package.json": "./package.json",
37
+ ".": {
38
+ "types": "./out/index.d.ts",
39
+ "default": "./out/index.js"
40
+ }
41
+ }
28
42
  }
29
43
  }
package/punctuation.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Punctuation normalization — fancy quotes / dashes to ASCII equivalents. Identity-length: every
7
+ * fancy character is a single codepoint that maps to a single ASCII char.
8
+ */
9
+
10
+ import { identityMap } from "./offset-map.ts"
11
+
12
+ const REPLACEMENTS = new Map<string, string>([
13
+ ["‘", "'"], // ‘
14
+ ["’", "'"], // ’
15
+ ["“", '"'], // “
16
+ ["”", '"'], // ”
17
+ ["–", "-"], // – en dash
18
+ ["—", "-"], // — em dash
19
+ ["−", "-"], // − U+2212 minus sign — Japanese IMEs emit this as the block separator (1−2−3)
20
+ ["―", "-"], // ― U+2015 horizontal bar — another common JP block separator
21
+ ["…", "..."], // … expands; tracked specially
22
+ [" ", " "], // non-breaking space
23
+ ])
24
+
25
+ export interface PunctuationResult {
26
+ text: string
27
+ map: number[]
28
+ replacements: number
29
+ }
30
+
31
+ export function applyPunctuation(input: string): PunctuationResult {
32
+ let changed = false
33
+ let replacements = 0
34
+ const out: string[] = []
35
+ const map: number[] = []
36
+
37
+ for (let i = 0; i < input.length; i++) {
38
+ const ch = input[i]!
39
+ const sub = REPLACEMENTS.get(ch)
40
+
41
+ if (sub === undefined) {
42
+ out.push(ch)
43
+ map.push(i)
44
+ } else {
45
+ changed = true
46
+ replacements += 1
47
+
48
+ for (let k = 0; k < sub.length; k++) {
49
+ out.push(sub[k]!)
50
+ map.push(i)
51
+ }
52
+ }
53
+ }
54
+
55
+ if (!changed) {
56
+ return { text: input, map: identityMap(input.length), replacements: 0 }
57
+ }
58
+
59
+ return { text: out.join(""), map, replacements }
60
+ }
package/types.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ export interface SpanRange {
8
+ start: number
9
+ end: number
10
+ body: string
11
+ }
12
+
13
+ /** A single normalization step, as recorded on `NormalizedInput.transforms`. */
14
+ export type NormalizationTransform =
15
+ | { kind: "nfc"; changed: boolean }
16
+ | { kind: "case_fold"; locale: string }
17
+ | { kind: "expand_abbreviation"; from: string; to: string; at: SpanRange }
18
+ | { kind: "collapse_whitespace"; runs: number }
19
+ | { kind: "normalize_punctuation"; replacements: number }
20
+ | { kind: "normalize_cjk"; folded: number; stripped: number }
21
+
22
+ /**
23
+ * Result of running `normalize()` on a raw input string.
24
+ *
25
+ * `offsetMap[i]` is the index in `raw` from which `normalized[i]` came. For multi-character source sequences (NFC
26
+ * composition, whitespace collapse, abbreviation expansion), each output char points to the FIRST source char by
27
+ * convention.
28
+ */
29
+ export interface NormalizedInput {
30
+ /** The input as the caller sent it. */
31
+ raw: string
32
+
33
+ /** Canonical form, all transforms applied. */
34
+ normalized: string
35
+
36
+ /** Ordered record of what was done. */
37
+ transforms: NormalizationTransform[]
38
+
39
+ /** `normalized[i]` came from `raw[offsetMap[i]]`. Length === normalized.length. */
40
+ offsetMap: number[]
41
+
42
+ /** The locale used for case-folding + abbreviation rules. */
43
+ appliedLocale?: string
44
+ }
45
+
46
+ export interface NormalizeOpts {
47
+ /** Locale hint for case-folding + abbreviation dictionaries. */
48
+ locale?: string
49
+
50
+ /** Apply locale-aware lowercasing. Default: false (preserve case for downstream consumers). */
51
+ caseFold?: boolean
52
+
53
+ /** Expand known abbreviations (`St` → `Street`, `NW` → `Northwest`, etc.). Default: false. */
54
+ expandAbbreviations?: boolean
55
+
56
+ /** Skip Unicode NFC. Only use for debugging — production callers should leave on. */
57
+ skipNFC?: boolean
58
+ }
package/whitespace.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Whitespace collapse — runs of whitespace become a single ASCII space. Newlines and tabs are
7
+ * preserved as-is (segmentation grammar in QueryShape uses them); inline runs of spaces
8
+ * collapse. The trailing trim also drops trailing sentence-punctuation NOISE (#829 tail): a
9
+ * trailing `.`/`,`/`;`/`:` (e.g. `…Washington DC.`) glues onto the last token and drops the street
10
+ * tier (`address_point`→`admin`). Trailing only + a conservative set — leading punctuation and
11
+ * quotes/brackets are never touched (they can be meaningful). Offset-map-correct via the same slice
12
+ * as the whitespace trim, so span alignment survives.
13
+ */
14
+
15
+ import { identityMap } from "./offset-map.ts"
16
+
17
+ const INLINE_SPACE = /[ \t]/
18
+ const ANY_SPACE = /[ \t\n\r]/
19
+ // Trailing NOISE trimmed off the END of the input: whitespace + the sentence-punctuation that a user
20
+ // commonly appends. NOT leading (a leading token is load-bearing) and NOT quotes/brackets/parens.
21
+ const TRAILING_NOISE = /[ \t\n\r.,;:]/
22
+
23
+ export interface WhitespaceResult {
24
+ text: string
25
+ map: number[]
26
+ runs: number
27
+ }
28
+
29
+ export function collapseWhitespace(input: string): WhitespaceResult {
30
+ let changed = false
31
+ let runs = 0
32
+ const out: string[] = []
33
+ const map: number[] = []
34
+ let i = 0
35
+
36
+ while (i < input.length) {
37
+ const ch = input[i]!
38
+
39
+ if (ch === "\n" || ch === "\r") {
40
+ // Preserve newlines as segment separators.
41
+ out.push(ch)
42
+ map.push(i)
43
+ i += 1
44
+ continue
45
+ }
46
+
47
+ if (INLINE_SPACE.test(ch)) {
48
+ out.push(" ")
49
+ map.push(i)
50
+ const start = i
51
+ i += 1
52
+
53
+ while (i < input.length && INLINE_SPACE.test(input[i]!)) {
54
+ i += 1
55
+ }
56
+
57
+ if (i - start > 1) {
58
+ changed = true
59
+ runs += 1
60
+ }
61
+ continue
62
+ }
63
+
64
+ // Collapse \r\n into one
65
+ if (ch === "\n" && out[out.length - 1] === "\r") {
66
+ // Already handled in CR branch above by emitting both; skip combiner check
67
+ }
68
+ out.push(ch)
69
+ map.push(i)
70
+ i += 1
71
+ }
72
+
73
+ // Trim leading whitespace, and trailing whitespace + sentence-punctuation noise (#829 tail).
74
+ let lead = 0
75
+
76
+ while (lead < out.length && ANY_SPACE.test(out[lead]!)) {
77
+ lead += 1
78
+ }
79
+ let trail = out.length
80
+
81
+ while (trail > lead && TRAILING_NOISE.test(out[trail - 1]!)) {
82
+ trail -= 1
83
+ }
84
+
85
+ if (lead > 0 || trail < out.length) {
86
+ changed = true
87
+ }
88
+ const trimmedOut = out.slice(lead, trail)
89
+ const trimmedMap = map.slice(lead, trail)
90
+
91
+ if (!changed && trimmedOut.length === input.length) {
92
+ return { text: input, map: identityMap(input.length), runs: 0 }
93
+ }
94
+
95
+ return { text: trimmedOut.join(""), map: trimmedMap, runs }
96
+ }