@askeleven/sms-guard 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/src/phone.js ADDED
@@ -0,0 +1,213 @@
1
+ import { NANP_AREA_CODES } from './data/nanp.js'
2
+ import { COUNTRY_CODES } from './data/countries.js'
3
+
4
+ /**
5
+ * @typedef {object} ParsedNumber
6
+ * @property {string} e164 The number as +<digits>.
7
+ * @property {string} countryCode '1' for NANP, otherwise as matched in the country
8
+ * table, or an empty string when no row matches.
9
+ * @property {boolean} assumedNanp True when the number had no + and was read as a
10
+ * US or Canadian number because it had 10 digits (or 11 starting with 1).
11
+ *
12
+ * @typedef {object} ZoneLookup
13
+ * @property {string | null} region State, province or country, when known.
14
+ * @property {string[]} zones IANA zones. None means unknown; more than one, ambiguous.
15
+ * @property {string} basis What the zones were worked out from, in plain English.
16
+ */
17
+
18
+ /** Formatting people type around a phone number, stripped before parsing. */
19
+ const FORMATTING = /[\s().\-/\u00A0]/g
20
+
21
+ /**
22
+ * Reads a phone number. Accepts E.164 with or without formatting (+1 (305) 555-0100,
23
+ * +44 20 7946 0000). Without a leading +, only a 10-digit number, or an 11-digit
24
+ * number starting with 1, is accepted, and it is read as NANP (US, Canada and the
25
+ * rest of +1). Anything else without a + is refused rather than guessed at.
26
+ *
27
+ * @param {string} raw
28
+ * @returns {{ ok: true, number: ParsedNumber } | { ok: false, error: string }}
29
+ */
30
+ export function parseNumber(raw) {
31
+ const input = String(raw).trim()
32
+ const hasPlus = input.startsWith('+')
33
+ const rest = (hasPlus ? input.slice(1) : input).replace(FORMATTING, '')
34
+ if (!/^\d+$/.test(rest)) {
35
+ return { ok: false, error: `${input || 'An empty string'} is not a phone number.` }
36
+ }
37
+
38
+ let digits = rest
39
+ let assumedNanp = false
40
+ if (!hasPlus) {
41
+ if (digits.length === 10) {
42
+ digits = `1${digits}`
43
+ assumedNanp = true
44
+ } else if (digits.length === 11 && digits.startsWith('1')) {
45
+ assumedNanp = true
46
+ } else {
47
+ return {
48
+ ok: false,
49
+ error:
50
+ `${input} has no country code. Write it in E.164, starting with + and the ` +
51
+ 'country code, such as +44 20 7946 0000.',
52
+ }
53
+ }
54
+ }
55
+
56
+ if (digits.length < 7 || digits.length > 15) {
57
+ return { ok: false, error: `${input} is not a valid E.164 number (7 to 15 digits).` }
58
+ }
59
+ if (digits.startsWith('1')) {
60
+ if (!/^1[2-9]\d{2}[2-9]\d{6}$/.test(digits)) {
61
+ return {
62
+ ok: false,
63
+ error: `${input} is not a valid US or Canadian number: +1, then 10 digits.`,
64
+ }
65
+ }
66
+ return { ok: true, number: { e164: `+${digits}`, countryCode: '1', assumedNanp } }
67
+ }
68
+ const countryCode =
69
+ [3, 2, 1].map((n) => digits.slice(0, n)).find((prefix) => COUNTRY_CODES.has(prefix)) ?? null
70
+ return {
71
+ ok: true,
72
+ number: { e164: `+${digits}`, countryCode: countryCode ?? '', assumedNanp },
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The time zone, or zones, a number could be in.
78
+ *
79
+ * @param {ParsedNumber} number
80
+ * @returns {ZoneLookup}
81
+ */
82
+ export function zonesFor(number) {
83
+ if (number.countryCode === '1') {
84
+ const areaCode = number.e164.slice(2, 5)
85
+ const entry = NANP_AREA_CODES.get(areaCode)
86
+ if (!entry) {
87
+ return {
88
+ region: null,
89
+ zones: [],
90
+ basis: `Area code ${areaCode} is not a geographic area code sms-guard knows.`,
91
+ }
92
+ }
93
+ return { region: entry.region, zones: entry.zones, basis: `area code ${areaCode}` }
94
+ }
95
+ const entry = number.countryCode ? COUNTRY_CODES.get(number.countryCode) : undefined
96
+ if (!entry) {
97
+ return {
98
+ region: null,
99
+ zones: [],
100
+ basis: `The country for ${number.e164} is not in sms-guard's table.`,
101
+ }
102
+ }
103
+ return { region: entry.region, zones: entry.zones, basis: `country code +${number.countryCode}` }
104
+ }
105
+
106
+ /**
107
+ * @typedef {object} Window
108
+ * @property {number} start Minutes after local midnight, inclusive.
109
+ * @property {number} end Minutes after local midnight, exclusive.
110
+ * @property {string} label As given, HH:MM-HH:MM.
111
+ */
112
+
113
+ /**
114
+ * Parses HH:MM-HH:MM. The start is inclusive and the end exclusive, so 08:00-21:00
115
+ * allows 08:00 and 20:59 but not 21:00. A window that wraps midnight (22:00-06:00)
116
+ * is allowed.
117
+ *
118
+ * @param {string} window
119
+ * @returns {Window | null}
120
+ */
121
+ export function parseWindow(window) {
122
+ const m = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(String(window).trim())
123
+ if (!m) return null
124
+ const [sh, sm, eh, em] = m.slice(1).map(Number)
125
+ if (sm > 59 || em > 59 || sh > 23 || eh > 24 || (eh === 24 && em > 0)) return null
126
+ const start = sh * 60 + sm
127
+ const end = eh * 60 + em
128
+ if (start === end) return null
129
+ const pad = (/** @type {number} */ n) => String(n).padStart(2, '0')
130
+ return { start, end, label: `${pad(sh)}:${pad(sm)}-${pad(eh)}:${pad(em)}` }
131
+ }
132
+
133
+ /**
134
+ * @param {number} minutes Minutes after local midnight.
135
+ * @param {Window} window
136
+ * @returns {boolean}
137
+ */
138
+ export function inWindow(minutes, window) {
139
+ return window.start < window.end
140
+ ? minutes >= window.start && minutes < window.end
141
+ : minutes >= window.start || minutes < window.end
142
+ }
143
+
144
+ /**
145
+ * @typedef {object} LocalTime
146
+ * @property {string} timezone
147
+ * @property {string} time Local wall-clock time, YYYY-MM-DDTHH:MM.
148
+ * @property {string} label Such as "Sun 19:30 EDT".
149
+ * @property {number} minutes Minutes after local midnight.
150
+ */
151
+
152
+ /** @type {Map<string, Intl.DateTimeFormat>} */
153
+ const formatters = new Map()
154
+
155
+ /**
156
+ * The wall-clock time in a zone, from the runtime's own time zone data.
157
+ *
158
+ * @param {Date} date
159
+ * @param {string} timezone
160
+ * @returns {LocalTime}
161
+ */
162
+ export function localTime(date, timezone) {
163
+ let format = formatters.get(timezone)
164
+ if (!format) {
165
+ format = new Intl.DateTimeFormat('en-US', {
166
+ timeZone: timezone,
167
+ year: 'numeric',
168
+ month: '2-digit',
169
+ day: '2-digit',
170
+ hour: '2-digit',
171
+ minute: '2-digit',
172
+ hourCycle: 'h23',
173
+ weekday: 'short',
174
+ timeZoneName: 'short',
175
+ })
176
+ formatters.set(timezone, format)
177
+ }
178
+ /** @type {Record<string, string>} */
179
+ const p = {}
180
+ for (const part of format.formatToParts(date)) p[part.type] = part.value
181
+ const hour = p.hour === '24' ? '00' : p.hour
182
+ return {
183
+ timezone,
184
+ time: `${p.year}-${p.month}-${p.day}T${hour}:${p.minute}`,
185
+ label: `${p.weekday} ${hour}:${p.minute} ${p.timeZoneName}`,
186
+ minutes: Number(hour) * 60 + Number(p.minute),
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The first minute at or after `from` when it is inside the window in every one of
192
+ * the zones, or null if there is none in the next two days.
193
+ *
194
+ * @param {Date} from
195
+ * @param {string[]} zones
196
+ * @param {Window} window
197
+ * @returns {Date | null}
198
+ */
199
+ export function nextWindowStart(from, zones, window) {
200
+ const MINUTE = 60_000
201
+ const ok = (/** @type {number} */ t) =>
202
+ zones.every((z) => inWindow(localTime(new Date(t), z).minutes, window))
203
+ const start = Math.ceil(from.getTime() / MINUTE) * MINUTE
204
+ const limit = start + 2 * 24 * 60 * MINUTE
205
+ // Coarse steps to find the opening, then minute by minute back from it.
206
+ for (let t = start; t <= limit; t += 15 * MINUTE) {
207
+ if (!ok(t)) continue
208
+ let first = t
209
+ while (first - MINUTE >= start && ok(first - MINUTE)) first -= MINUTE
210
+ return new Date(first)
211
+ }
212
+ return null
213
+ }
package/src/report.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @typedef {import('./index.js').Result} Result
3
+ * @typedef {import('./index.js').ReasonCode} ReasonCode
4
+ * @typedef {import('./index.js').Verdict} Verdict
5
+ * @typedef {import('./split.js').Part} Part
6
+ */
7
+
8
+ const COLOURS = {
9
+ reset: '\x1b[0m',
10
+ dim: '\x1b[2m',
11
+ bold: '\x1b[1m',
12
+ red: '\x1b[31m',
13
+ yellow: '\x1b[33m',
14
+ green: '\x1b[32m',
15
+ }
16
+
17
+ /** Headings for each reason. */
18
+ const REASON_TITLES = /** @type {Record<ReasonCode, string>} */ ({
19
+ empty: 'Empty message',
20
+ too_many_segments: 'Too many segments',
21
+ invalid_number: 'Not a valid phone number',
22
+ quiet_hours: 'Outside allowed hours',
23
+ timezone_ambiguous: 'Time zone ambiguous',
24
+ timezone_unknown: 'Time zone unknown',
25
+ url_shortener: 'Public link shortener',
26
+ no_opt_out: 'No opt-out wording',
27
+ ucs2: 'Sent as UCS-2',
28
+ })
29
+
30
+ const HEADLINES = /** @type {Record<Verdict, string>} */ ({
31
+ keep: 'OK to send',
32
+ review: 'A person should look at this before it is sent',
33
+ fail: 'Do not send this as it stands',
34
+ })
35
+
36
+ /**
37
+ * @param {boolean} enabled
38
+ * @returns {(code: string, text: string) => string}
39
+ */
40
+ function painter(enabled) {
41
+ return (code, text) => (enabled ? `${code}${text}${COLOURS.reset}` : text)
42
+ }
43
+
44
+ /**
45
+ * @param {string} text
46
+ * @param {number} width
47
+ * @param {string} indent
48
+ * @returns {string}
49
+ */
50
+ function wrap(text, width, indent) {
51
+ /** @type {string[]} */
52
+ const lines = []
53
+ let line = ''
54
+ for (const word of text.split(/\s+/)) {
55
+ if (line && line.length + word.length + 1 > width) {
56
+ lines.push(line)
57
+ line = word
58
+ } else {
59
+ line = line ? `${line} ${word}` : word
60
+ }
61
+ }
62
+ if (line) lines.push(line)
63
+ return lines.map((l) => indent + l).join('\n')
64
+ }
65
+
66
+ /**
67
+ * @param {Result} result
68
+ * @param {{ colour?: boolean, width?: number }} [options]
69
+ * @returns {string}
70
+ */
71
+ export function formatText(result, options = {}) {
72
+ const { colour = true, width = 76 } = options
73
+ const paint = painter(colour)
74
+ const { facts } = result
75
+ const lines = ['']
76
+
77
+ const label = { keep: 'PASS', review: 'REVIEW', fail: 'FAIL' }[result.verdict]
78
+ const tint = { keep: COLOURS.green, review: COLOURS.yellow, fail: COLOURS.red }[result.verdict]
79
+ lines.push(`${paint(tint, label)} ${paint(COLOURS.bold, HEADLINES[result.verdict])}`)
80
+ lines.push('')
81
+
82
+ const row = (/** @type {string} */ name, /** @type {string} */ value) =>
83
+ lines.push(` ${paint(COLOURS.dim, name.padEnd(12))} ${value}`)
84
+ row('Encoding', facts.encoding)
85
+ row(
86
+ 'Length',
87
+ `${facts.characters} character${facts.characters === 1 ? '' : 's'}, ${facts.units} ` +
88
+ (facts.encoding === 'GSM-7' ? 'septets' : 'UTF-16 units'),
89
+ )
90
+ const over = facts.segments > facts.maxSegments
91
+ row(
92
+ 'Segments',
93
+ `${over ? paint(COLOURS.red, String(facts.segments)) : facts.segments} of up to ${facts.perSegment} ` +
94
+ `units each (limit ${facts.maxSegments})`,
95
+ )
96
+ const r = facts.recipient
97
+ if (r) {
98
+ const where = [r.number ?? r.input, r.region, r.timezone].filter(Boolean).join(', ')
99
+ row('Recipient', where)
100
+ if (r.local.length > 0) {
101
+ for (const [i, t] of r.local.entries()) {
102
+ const mark = t.inWindow ? 'inside' : paint(COLOURS.red, 'outside')
103
+ const zone = r.local.length > 1 ? ` in ${t.timezone}` : ''
104
+ row(i === 0 ? 'Local time' : '', `${t.label}${zone}, ${mark} ${facts.window}`)
105
+ }
106
+ }
107
+ } else {
108
+ row('Recipient', paint(COLOURS.dim, 'not given, send time not checked'))
109
+ }
110
+
111
+ for (const verdict of /** @type {const} */ (['fail', 'review', 'keep'])) {
112
+ const reasons = result.reasons.filter((x) => x.verdict === verdict)
113
+ if (reasons.length === 0) continue
114
+ lines.push('')
115
+ lines.push(
116
+ verdict === 'fail'
117
+ ? paint(COLOURS.red, 'FAIL')
118
+ : verdict === 'review'
119
+ ? paint(COLOURS.yellow, 'REVIEW')
120
+ : paint(COLOURS.dim, 'NOTE'),
121
+ )
122
+ for (const reason of reasons) {
123
+ lines.push(` ${paint(COLOURS.bold, REASON_TITLES[reason.code])}`)
124
+ lines.push(wrap(reason.detail, width - 6, ' '))
125
+ }
126
+ }
127
+
128
+ if (result.notes.length) {
129
+ lines.push('')
130
+ for (const note of result.notes) lines.push(paint(COLOURS.dim, wrap(note, width - 2, ' ')))
131
+ }
132
+
133
+ lines.push('')
134
+ return lines.join('\n')
135
+ }
136
+
137
+ /**
138
+ * @param {Part[]} parts
139
+ * @param {{ maxSegments: number, colour?: boolean }} options
140
+ * @returns {string}
141
+ */
142
+ export function formatSplit(parts, options) {
143
+ const { maxSegments, colour = true } = options
144
+ const paint = painter(colour)
145
+ const lines = ['']
146
+ const n = parts.length
147
+ lines.push(
148
+ `${paint(COLOURS.green, 'PASS')} ${paint(COLOURS.bold, `${n} message${n === 1 ? '' : 's'}`)}` +
149
+ paint(COLOURS.dim, `, each within ${maxSegments} segment${maxSegments === 1 ? '' : 's'}. Send them in order.`),
150
+ )
151
+ for (const [i, part] of parts.entries()) {
152
+ lines.push('')
153
+ lines.push(
154
+ ` ${paint(COLOURS.bold, `${i + 1}/${n}`)} ` +
155
+ paint(
156
+ COLOURS.dim,
157
+ `${part.segments} segment${part.segments === 1 ? '' : 's'}, ${part.encoding}, ` +
158
+ `${part.characters} characters`,
159
+ ),
160
+ )
161
+ for (const line of part.text.split('\n')) lines.push(` ${line}`)
162
+ }
163
+ lines.push('')
164
+ return lines.join('\n')
165
+ }
@@ -0,0 +1,139 @@
1
+ import { GSM_BASIC, GSM_EXTENSION, GSM_REPLACEMENTS } from './data/gsm.js'
2
+
3
+ /**
4
+ * @typedef {'GSM-7'|'UCS-2'} Encoding
5
+ *
6
+ * @typedef {object} SegmentCount
7
+ * @property {Encoding} encoding
8
+ * @property {number} characters Unicode code points, what a person would count.
9
+ * @property {number} units What the network counts: septets for GSM-7 (an extension
10
+ * character is two), UTF-16 code units for UCS-2 (an emoji is usually two).
11
+ * @property {number} segments Parts the message is sent as. 0 for an empty message.
12
+ * @property {number} perSegment Units that fit in each part: 160 or 70 alone, 153 or
13
+ * 67 once a message is split, since the rest of each part carries the header that
14
+ * joins them back together.
15
+ *
16
+ * @typedef {object} Ucs2Character
17
+ * @property {string} char
18
+ * @property {string} codePoint As U+XXXX.
19
+ * @property {number} count Times it appears.
20
+ * @property {string | null} replacement A GSM-7 equivalent, offered only for
21
+ * typographic characters such as curly quotes, dashes and odd spaces.
22
+ */
23
+
24
+ /** Units per message: [single, per part once concatenated]. */
25
+ export const LIMITS = {
26
+ 'GSM-7': { single: 160, part: 153 },
27
+ 'UCS-2': { single: 70, part: 67 },
28
+ }
29
+
30
+ /**
31
+ * GSM-7 when every character is in the default alphabet or its extension table,
32
+ * UCS-2 otherwise. One character outside both is enough to switch the whole message.
33
+ *
34
+ * @param {string} text
35
+ * @returns {Encoding}
36
+ */
37
+ export function encodingOf(text) {
38
+ for (const char of text) {
39
+ if (!GSM_BASIC.has(char) && !GSM_EXTENSION.has(char)) return 'UCS-2'
40
+ }
41
+ return 'GSM-7'
42
+ }
43
+
44
+ /**
45
+ * What one character costs in the given encoding.
46
+ *
47
+ * @param {string} char One code point.
48
+ * @param {Encoding} encoding
49
+ * @returns {number}
50
+ */
51
+ export function unitCost(char, encoding) {
52
+ if (encoding === 'GSM-7') return GSM_EXTENSION.has(char) ? 2 : 1
53
+ return char.length // 2 for anything outside the Basic Multilingual Plane
54
+ }
55
+
56
+ /**
57
+ * Counts segments the way the network does. A GSM-7 escape pair, or a UTF-16
58
+ * surrogate pair, is never cut across two parts: when it does not fit in what is left
59
+ * of a part, the part is sent short and the pair starts the next one.
60
+ *
61
+ * @param {string} text
62
+ * @returns {SegmentCount}
63
+ */
64
+ export function countSegments(text) {
65
+ const encoding = encodingOf(text)
66
+ const { single, part } = LIMITS[encoding]
67
+ let characters = 0
68
+ let units = 0
69
+ /** @type {number[]} */
70
+ const costs = []
71
+ for (const char of text) {
72
+ const cost = unitCost(char, encoding)
73
+ characters++
74
+ units += cost
75
+ costs.push(cost)
76
+ }
77
+
78
+ if (units === 0) return { encoding, characters, units, segments: 0, perSegment: single }
79
+ if (units <= single) return { encoding, characters, units, segments: 1, perSegment: single }
80
+
81
+ let segments = 1
82
+ let used = 0
83
+ for (const cost of costs) {
84
+ if (used + cost > part) {
85
+ segments++
86
+ used = cost
87
+ } else {
88
+ used += cost
89
+ }
90
+ }
91
+ return { encoding, characters, units, segments, perSegment: part }
92
+ }
93
+
94
+ /**
95
+ * The characters that force a message into UCS-2, each once, in order of first
96
+ * appearance, with a GSM-7 replacement where one reads the same.
97
+ *
98
+ * @param {string} text
99
+ * @returns {Ucs2Character[]}
100
+ */
101
+ export function ucs2Characters(text) {
102
+ /** @type {Map<string, Ucs2Character>} */
103
+ const found = new Map()
104
+ for (const char of text) {
105
+ if (GSM_BASIC.has(char) || GSM_EXTENSION.has(char)) continue
106
+ const entry = found.get(char)
107
+ if (entry) {
108
+ entry.count++
109
+ } else {
110
+ found.set(char, {
111
+ char,
112
+ codePoint: codePointOf(char),
113
+ count: 1,
114
+ replacement: GSM_REPLACEMENTS.get(char) ?? null,
115
+ })
116
+ }
117
+ }
118
+ return [...found.values()]
119
+ }
120
+
121
+ /**
122
+ * Applies the typographic replacements, and nothing else.
123
+ *
124
+ * @param {string} text
125
+ * @returns {string}
126
+ */
127
+ export function replaceTypographic(text) {
128
+ let out = ''
129
+ for (const char of text) out += GSM_REPLACEMENTS.get(char) ?? char
130
+ return out
131
+ }
132
+
133
+ /**
134
+ * @param {string} char
135
+ * @returns {string}
136
+ */
137
+ export function codePointOf(char) {
138
+ return `U+${(char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0')}`
139
+ }
package/src/split.js ADDED
@@ -0,0 +1,127 @@
1
+ import { countSegments } from './segments.js'
2
+
3
+ /**
4
+ * @typedef {import('./segments.js').Encoding} Encoding
5
+ *
6
+ * @typedef {object} Part
7
+ * @property {string} text
8
+ * @property {Encoding} encoding Worked out for this part alone: a part with no
9
+ * accented letters can go back to GSM-7.
10
+ * @property {number} characters
11
+ * @property {number} units
12
+ * @property {number} segments
13
+ *
14
+ * @typedef {object} SplitOptions
15
+ * @property {number} [maxSegments] Most segments any one part may use. Default 5.
16
+ */
17
+
18
+ export const DEFAULT_MAX_SEGMENTS = 5
19
+
20
+ /**
21
+ * Where a message may be broken, from the most natural to the last resort, and what
22
+ * goes back between two pieces when they are packed into the same part.
23
+ *
24
+ * @type {Array<{ split: (text: string) => string[], join: string }>}
25
+ */
26
+ const LEVELS = [
27
+ { split: (t) => t.split(/\n[ \t]*\n\s*/), join: '\n\n' }, // paragraphs
28
+ { split: (t) => t.split(/\n/), join: '\n' }, // lines
29
+ { split: (t) => t.split(/(?<=[.!?\u2026])\s+/), join: ' ' }, // sentences
30
+ { split: (t) => t.split(/\s+/), join: ' ' }, // words
31
+ ]
32
+
33
+ /**
34
+ * Splits a message into parts that each fit within maxSegments, to be sent in order
35
+ * as separate texts. Breaks at a blank line where it can, then a line, then a
36
+ * sentence, then a word, and only cuts inside a word when a single word is too long.
37
+ * Parts are packed greedily, so there are as few as the breaks allow. Whitespace at
38
+ * each break is dropped. A GSM-7 escape pair or a surrogate pair is never cut, since
39
+ * the text is only ever cut between whole characters.
40
+ *
41
+ * @param {string} text
42
+ * @param {SplitOptions} [options]
43
+ * @returns {Part[]} Empty for a message with nothing but whitespace.
44
+ */
45
+ export function splitMessage(text, options = {}) {
46
+ const { maxSegments = DEFAULT_MAX_SEGMENTS } = options
47
+ if (!Number.isInteger(maxSegments) || maxSegments < 1) {
48
+ throw new RangeError(`maxSegments must be a whole number of 1 or more, got ${maxSegments}`)
49
+ }
50
+ const fits = (/** @type {string} */ t) => countSegments(t).segments <= maxSegments
51
+ const body = String(text).trim()
52
+ if (!body) return []
53
+ return pack(body, 0, fits).map(describe)
54
+ }
55
+
56
+ /**
57
+ * @param {string} text Trimmed, not empty.
58
+ * @param {number} level
59
+ * @param {(text: string) => boolean} fits
60
+ * @returns {string[]}
61
+ */
62
+ function pack(text, level, fits) {
63
+ if (fits(text)) return [text]
64
+ if (level >= LEVELS.length) return hardSplit(text, fits)
65
+
66
+ const { split, join } = LEVELS[level]
67
+ const pieces = split(text)
68
+ .map((p) => p.trim())
69
+ .filter(Boolean)
70
+ if (pieces.length <= 1) return pack(text, level + 1, fits)
71
+
72
+ /** @type {string[]} */
73
+ const out = []
74
+ /** @type {string | null} */
75
+ let current = null
76
+ for (const piece of pieces) {
77
+ if (!fits(piece)) {
78
+ if (current !== null) out.push(current)
79
+ const sub = pack(piece, level + 1, fits)
80
+ out.push(...sub.slice(0, -1))
81
+ current = sub[sub.length - 1]
82
+ continue
83
+ }
84
+ if (current === null) {
85
+ current = piece
86
+ } else if (fits(current + join + piece)) {
87
+ current = current + join + piece
88
+ } else {
89
+ out.push(current)
90
+ current = piece
91
+ }
92
+ }
93
+ if (current !== null) out.push(current)
94
+ return out
95
+ }
96
+
97
+ /**
98
+ * Cuts between characters, filling each part as far as it goes.
99
+ *
100
+ * @param {string} text
101
+ * @param {(text: string) => boolean} fits
102
+ * @returns {string[]}
103
+ */
104
+ function hardSplit(text, fits) {
105
+ /** @type {string[]} */
106
+ const out = []
107
+ let current = ''
108
+ for (const char of text) {
109
+ if (current && !fits(current + char)) {
110
+ out.push(current)
111
+ current = char
112
+ } else {
113
+ current += char
114
+ }
115
+ }
116
+ if (current) out.push(current)
117
+ return out.map((p) => p.trim()).filter(Boolean)
118
+ }
119
+
120
+ /**
121
+ * @param {string} text
122
+ * @returns {Part}
123
+ */
124
+ function describe(text) {
125
+ const { encoding, characters, units, segments } = countSegments(text)
126
+ return { text, encoding, characters, units, segments }
127
+ }
package/src/version.js ADDED
@@ -0,0 +1,6 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ /** The published version, read from package.json so there is one place to bump it. */
4
+ export const VERSION = /** @type {{ version: string }} */ (
5
+ JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
6
+ ).version
@@ -0,0 +1,2 @@
1
+ /** @type {Map<string, import('./nanp.js').ZoneEntry>} */
2
+ export const COUNTRY_CODES: Map<string, import("./nanp.js").ZoneEntry>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The GSM 03.38 default alphabet (3GPP TS 23.038), in code order, without the escape
3
+ * at 0x1B. Non-ASCII characters are written as escapes so no lookalike can creep in.
4
+ * Note that it is not ASCII: 0x24 is the currency sign, and the backtick is missing.
5
+ */
6
+ export const GSM_BASIC: Set<string>;
7
+ /**
8
+ * The extension table: each is sent as the escape plus one septet, so it costs two.
9
+ */
10
+ export const GSM_EXTENSION: Set<string>;
11
+ /**
12
+ * Typographic characters with a GSM-7 equivalent that reads the same. Only these are
13
+ * ever offered as replacements. Accented letters are real text and are left alone.
14
+ *
15
+ * @type {Map<string, string>}
16
+ */
17
+ export const GSM_REPLACEMENTS: Map<string, string>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @typedef {object} ZoneEntry
3
+ * @property {string} region State, province, territory or country.
4
+ * @property {string[]} zones IANA zones. More than one means ambiguous.
5
+ */
6
+ /** @type {Map<string, ZoneEntry>} */
7
+ export const NANP_AREA_CODES: Map<string, ZoneEntry>;
8
+ export type ZoneEntry = {
9
+ /**
10
+ * State, province, territory or country.
11
+ */
12
+ region: string;
13
+ /**
14
+ * IANA zones. More than one means ambiguous.
15
+ */
16
+ zones: string[];
17
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Public URL shorteners. Carriers filter links on shared shortener domains, because
3
+ * anyone can hide any destination behind one. Matched exactly, and on subdomains.
4
+ */
5
+ export const SHORTENER_DOMAINS: Set<string>;