@sonordev/contracts 2.0.0 → 2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0
4
+
5
+ A minor release: one new entry, `@sonordev/contracts/voice`. Every other entry is unchanged.
6
+
7
+ - **New entry `@sonordev/contracts/voice`**: the house voice's contraction rule, so every app that checks or rewrites copy agrees on it.
8
+ - `findUncontracted(text, options?)` finds full forms that should be
9
+ contractions ("do not" to "don't", "it is" to "it's", "let us look" to
10
+ "let's look") and returns each one with its position, the matched text and
11
+ the suggestion, first-letter case kept.
12
+ - It leaves alone what can't contract where it stands: a clause-final "it
13
+ is" or "we are" (negations skip that guard, so "We do not." still becomes
14
+ "We don't."), "what it is and why", "can not only", a "what is" question, a
15
+ heading that opens with "What Is" (when `titleLike` is set), and "let us
16
+ know" and "let us help". "let us" only contracts at the start of a sentence and
17
+ before look, start, begin, consider, turn, see, review, compare, break,
18
+ dig, recap, unpack, explore, examine, revisit, focus, go, talk, move, "get
19
+ started", "step back" or "zoom out". "have" and "has" on their own are
20
+ never touched, though "have not" and "has not" still contract.
21
+ - `contractText(text, options?)` applies those findings and returns the new
22
+ text.
23
+ - `findContracted(text, options?)` is the reverse rule for question-form
24
+ headings and FAQ questions: it finds contractions ("What's") and suggests
25
+ the full form ("What is").
26
+ - `VOICE_MASK` is the one character that stands in for text the rule must
27
+ not touch.
28
+ - **Options**: `titleLike` marks a heading or title. `protectedRanges` takes
29
+ half-open `[start, end)` ranges (code, quotations, link destinations) that
30
+ are never rewritten and never read as a clause end, except a protected line
31
+ break, which still ends its line. An infinite bound means
32
+ "from the start" or "to the end". A malformed range (NaN, not a number,
33
+ start after end), or a truthy value that isn't an array, protects the whole
34
+ text, so a bad range can never cause code to be rewritten.
35
+ - **Lines**: multi-line text is read one line at a time. Nothing matches
36
+ across a line break, and the end of a line ends a clause. A "\r" before a
37
+ "\n" or at the very end of the text is part of the line ending. Hits carry
38
+ offsets into the whole string.
39
+ - **Known limits**: the rule is deliberately plain. "must not only" and "will
40
+ not only" contract, "need not" contracts even where "need" is a noun, "let
41
+ us" is read by its position and next word rather than its meaning, a
42
+ decimal, a domain or an initial hides a "what is" question, a straight
43
+ apostrophe doesn't end a clause, only spaces and tabs join the words of a
44
+ pair, all-caps input gets a mixed-case suggestion, and `findContracted`
45
+ always expands "'s" to "is" and "'d" to "would" and expands "ain't" like any
46
+ other "n't" word ("ai not"). The README lists each one with an example.
47
+ - Anything that isn't a string is read as no text, so a stray number or object
48
+ can't throw.
49
+ - No new dependencies, and CommonJS and ESM builds like every other entry.
50
+
3
51
  ## 2.0.0
4
52
 
5
53
  A major for one entry, `slots`, plus three new entries; every other entry is unchanged.
package/README.md CHANGED
@@ -22,6 +22,118 @@ pnpm add @sonordev/contracts
22
22
  | `@sonordev/contracts/site-cache` | The cache tags a site's Sonor fetches carry and the revalidation route's path, payload and ping | site-kit tags and serves the route, sonor-api calls it |
23
23
  | `@sonordev/contracts/site-edit` | Edit on page: the messages between a framed site and the dashboard, trusted origins, how a slot is found on the page | site-kit's overlay, the dashboard's visual editor |
24
24
  | `@sonordev/contracts/portfolio` | Where a portfolio number comes from and which may headline; the live-frame message | sonor-api, Signal, agency-site-kit |
25
+ | `@sonordev/contracts/voice` | The house voice's contraction rule. `findUncontracted` and `contractText` find and apply full forms that should be contractions; `findContracted` finds the contractions a question-form heading should expand | anything that checks or rewrites copy: Sonor's APIs and dashboard |
26
+
27
+ ## The voice contract
28
+
29
+ `voice` is the contraction rule for written copy, so every app that checks or
30
+ rewrites it agrees. It's the rule Sonor's article checker has always applied,
31
+ moved here so it exists once.
32
+
33
+ - **What it finds.** Full forms that should be contractions: "do not" becomes
34
+ "don't", "it is" becomes "it's", "let us look" becomes "let's look". The
35
+ first letter's case is kept ("DO NOT" gives "Don't"), and the suggestion
36
+ always uses a straight apostrophe. There are 36 pairs, and a negation wins
37
+ an overlap ("we would not" gives "we wouldn't", not "we'd not").
38
+ - **What it leaves alone.** A pronoun and verb ("it is", "we are", "you will")
39
+ at the end of a clause ("until it is."), or before "and", "or", "but" or
40
+ "nor" ("what it is and why"). Negations skip that guard: "We do not." still
41
+ becomes "We don't.". "can not" before "only" ("it can not only save time").
42
+ "what is" when the next ".", "!" or "?" after it turns out to be a "?", and
43
+ "What Is" at the start of a line when you mark the text as a heading.
44
+ "let us" unless it starts a sentence and the next word is one of
45
+ look, start, begin, consider, turn, see, review, compare, break, dig, recap,
46
+ unpack, explore, examine, revisit, focus, go, talk, move, "get started",
47
+ "step back" or "zoom out" ("let us know" and "let us help" mean "allow us").
48
+ "have" and "has" on their own ("we have a team") are never touched, though
49
+ "have not" and "has not" still contract ("haven't", "hasn't").
50
+ - **Lines.** Text is read one line at a time: split on "\n". A "\r" right
51
+ before a "\n", or at the very end of the text, is part of the line ending;
52
+ any other "\r" is ordinary text. If you've already split the text into lines
53
+ yourself and a line keeps a final "\r" you want read as text, replace it with
54
+ `VOICE_MASK` first.
55
+ Nothing matches across a line break, and the end of a line ends a clause.
56
+ Every hit's `index` is an offset into the whole string you passed in.
57
+ - **The other direction.** `findContracted` finds the contractions a
58
+ question-form heading or FAQ question should spell out ("What's" becomes
59
+ "What is"). It only reports; whether a text is a
60
+ question is the caller's call, and a caller shouldn't apply these blindly.
61
+
62
+ What the caller does:
63
+
64
+ - **Mask what isn't prose.** Code, quotations and link destinations shouldn't
65
+ be rewritten. Pass their `[start, end)` positions as `protectedRanges`, or
66
+ replace them with `VOICE_MASK` yourself. Every protected character is masked
67
+ except "\n" and "\r", which stay as they are, so a protected line break still
68
+ ends its line. A masked span separates
69
+ words, can't sit inside a phrase, and counts as text that follows. Ranges
70
+ can arrive in any order, and overlap or touch. An infinite bound clamps to
71
+ the start or end of the text, and an empty or out-of-text range is ignored.
72
+ A malformed range (NaN, not a number, start after end), or a truthy value
73
+ that isn't an array, protects the whole text, so a bad range never causes
74
+ code to be rewritten. A falsy value means no ranges.
75
+ - **Pass a string.** Anything else (a number, an object, `null`) is read as no
76
+ text: nothing is found and `contractText` hands it back unchanged.
77
+ - **Pass a bare heading.** Set `titleLike` for a heading or title, with the `#`
78
+ and list markers already blanked to spaces.
79
+
80
+ ```ts
81
+ import { contractText, findUncontracted } from '@sonordev/contracts/voice'
82
+
83
+ contractText('We do not ship on Sundays.')
84
+ // "We don't ship on Sundays."
85
+
86
+ findUncontracted('We do not ship on Sundays.')
87
+ // [{ index: 3, length: 6, match: 'do not', suggestion: "don't" }]
88
+ ```
89
+
90
+ ### Known limits
91
+
92
+ The rule is plain on purpose, and these are what it does today. Each one is
93
+ pinned by a test, so changing it later is a visible change.
94
+
95
+ - **"not" before "only".** Only "can not only" stays full. "You must not only
96
+ wait" becomes "You mustn't only wait", "will not only rise" becomes "won't
97
+ only rise", and "cannot only" becomes "can't only", which reads differently.
98
+ - **"need" as a noun.** "The need not being met" becomes "The needn't being
99
+ met", and "must not" and "need not" contract wherever they appear.
100
+ - **"let us" by position, not meaning.** "Let us look at your account, please"
101
+ becomes "Let's look at your account, please". "Let us try again", "let us
102
+ walk through the plan" and any sentence that opens with a bracket, a quote
103
+ or a dash ("(Let us look at it.)") stay full.
104
+ - **Question detection is the first "." "!" or "?" on the line.** A decimal, a
105
+ domain or an initial hides the "?": "what is 3.5 percent?" becomes "what's
106
+ 3.5 percent?". Only "what is" has a question guard: "Who is on the team?"
107
+ becomes "Who's on the team?". A question that wraps onto the next line isn't
108
+ seen.
109
+ - **A line break ends a clause.** In a hard-wrapped paragraph, "we think it is"
110
+ at the end of a line stays full.
111
+ - **A clause end is an inventory of characters.** ".", ",", ";", ":", "!", "?",
112
+ ")", "]", "}", a straight double quote, a curly closing double or single
113
+ quote, an ellipsis, an em dash, an en dash and "-" end a clause; a straight
114
+ apostrophe doesn't, so "Say 'it is' now." becomes "Say 'it's' now.", and a
115
+ hyphen inside a word counts ("it is-fine" stays full).
116
+ - **A masked "]" reads as text that follows.** A link whose anchor text ends a
117
+ clause is contracted when you protect the whole "](destination)": "Meet [who
118
+ we are](/about)." becomes "Meet [who we're](/about).".
119
+ - **Only spaces and tabs join the words of a pair.** A non-breaking space
120
+ between "do" and "not", or a line break, keeps the pair from matching.
121
+ - **The other direction is plain suffix rules.** `findContracted` always
122
+ expands "'s" to "is" ("It's been" suggests "It is", not "It has"), "'d" to
123
+ "would" ("She'd left" suggests "She would", not "She had"), and "ain't"
124
+ like any other "n't" word ("ai not"). Only the first letter's case carries
125
+ over ("WHY IT'S" suggests "IT is"). Treat its suggestions as prompts for a
126
+ person, not as edits to apply.
127
+ - **Case.** Only the first letter's case carries over: "IT IS FINE." becomes
128
+ "It's FINE.".
129
+ - **The heading rule is first-on-the-line.** "What Is" keeps its form only
130
+ when nothing but spaces, "*", "_" and masked text comes before it on its
131
+ line, and only when `titleLike` is set and the markers are already blanked.
132
+ The check is on the "what is" pair in any case, so "what is local seo" is
133
+ kept too.
134
+ - **"let us" opens a sentence after a full stop, "!", "?" or ":".** Spaces,
135
+ "*", "_" and masked text before it are skipped, so "Now: let us look at it."
136
+ contracts.
25
137
 
26
138
  ## Why it exists
27
139
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Stands in for every character that isn't prose (code, quotations, link
3
+ * destinations). It's neither whitespace nor a word character, so a masked
4
+ * span still separates words and no pattern can match it.
5
+ */
6
+ export declare const VOICE_MASK = "\u2800";
7
+ export interface VoiceHit {
8
+ /** UTF-16 offset into the whole string you passed in, never line-relative. */
9
+ readonly index: number;
10
+ /** Length of the span to replace. May include a run of spaces or tabs. */
11
+ readonly length: number;
12
+ /** The text at [index, index + length) of the string you passed in. */
13
+ readonly match: string;
14
+ /** What to put there, first-letter case preserved. Always a straight apostrophe. */
15
+ readonly suggestion: string;
16
+ }
17
+ export interface VoiceOptions {
18
+ /**
19
+ * The text is a heading or title, so one that opens with "What Is" keeps it.
20
+ * Applies to every line. Pass the heading text, not the Markdown line: `#`
21
+ * and list markers must already be blanked.
22
+ */
23
+ readonly titleLike?: boolean | undefined;
24
+ /**
25
+ * Half-open [start, end) UTF-16 ranges to leave alone, computed from the
26
+ * exact string passed in. Any order, overlaps and touching ranges allowed.
27
+ * Start is floored and clamped to 0 (so -Infinity means "from the start"),
28
+ * end is ceiled and clamped to the text length (so Infinity means "to the
29
+ * end"), and an empty or out-of-text range is ignored. Every protected
30
+ * character is masked except "\n" and "\r", which stay as they are (a
31
+ * protected line break still ends its line). A malformed range (NaN, a
32
+ * non-number, or start after end) or a truthy value that isn't an array
33
+ * protects the whole text: protection never fails open. A falsy value means
34
+ * no ranges.
35
+ */
36
+ readonly protectedRanges?: ReadonlyArray<readonly [number, number]> | undefined;
37
+ }
38
+ /**
39
+ * Full forms the house voice contracts ("do not" -> "don't"). Read one line at
40
+ * a time: split on "\n"; a "\r" right before it, or at the very end of the
41
+ * text, ends the line with it; any other "\r" is text.
42
+ * Sorted by index, never overlapping, [] for ''.
43
+ */
44
+ export declare function findUncontracted(text: string, options?: VoiceOptions): VoiceHit[];
45
+ /**
46
+ * `text` with every findUncontracted hit applied. Returns `text` itself when
47
+ * nothing applies, and for anything that isn't a string. Protected characters
48
+ * are never rewritten.
49
+ */
50
+ export declare function contractText(text: string, options?: VoiceOptions): string;
51
+ /**
52
+ * Contractions a question-form heading or FAQ question must expand
53
+ * ("What's" -> "What is"). Sorted by index, never overlapping. `titleLike` is
54
+ * not part of this direction.
55
+ */
56
+ export declare function findContracted(text: string, options?: Pick<VoiceOptions, 'protectedRanges'>): VoiceHit[];
package/dist/voice.js ADDED
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/voice.ts
4
+ var VOICE_MASK = "\u2800";
5
+ var TABLE = [
6
+ ["do not", "don't"],
7
+ ["does not", "doesn't"],
8
+ ["did not", "didn't"],
9
+ ["is not", "isn't"],
10
+ ["are not", "aren't"],
11
+ ["was not", "wasn't"],
12
+ ["were not", "weren't"],
13
+ ["cannot", "can't"],
14
+ ["can not", "can't"],
15
+ ["will not", "won't"],
16
+ ["would not", "wouldn't"],
17
+ ["should not", "shouldn't"],
18
+ ["could not", "couldn't"],
19
+ ["has not", "hasn't"],
20
+ ["have not", "haven't"],
21
+ ["had not", "hadn't"],
22
+ ["must not", "mustn't"],
23
+ ["need not", "needn't"],
24
+ ["it is", "it's"],
25
+ ["that is", "that's"],
26
+ ["there is", "there's"],
27
+ ["here is", "here's"],
28
+ ["who is", "who's"],
29
+ ["what is", "what's"],
30
+ ["you are", "you're"],
31
+ ["we are", "we're"],
32
+ ["they are", "they're"],
33
+ ["I am", "I'm"],
34
+ ["you will", "you'll"],
35
+ ["we will", "we'll"],
36
+ ["they will", "they'll"],
37
+ ["it will", "it'll"],
38
+ ["we would", "we'd"],
39
+ ["you would", "you'd"],
40
+ ["they would", "they'd"],
41
+ ["let us", "let's"]
42
+ ];
43
+ var PAIRS = TABLE.map(([full, contracted]) => ({
44
+ full,
45
+ contracted,
46
+ negation: / not$|^cannot$/.test(full),
47
+ pattern: new RegExp(`\\b${full.replace(/ /g, "[ \\t]+")}\\b`, "gi")
48
+ }));
49
+ var CAN_NOT_ONLY = /[ \t]+only\b/iy;
50
+ var CLAUSE_END = /[ \t]*(?:[.,;:!?)\]}"\u201d\u2019\u2026\u2014\u2013-]|\r?\n|\r?$)/y;
51
+ var CONJUNCTION = /[ \t]+(?:and|or|but|nor)\b/iy;
52
+ var LET_US_SUGGESTIONS = /[ \t]+(?:look|start|begin|consider|turn|see|review|compare|break|dig|recap|unpack|explore|examine|revisit|focus|go|talk|move|get started|step back|zoom out)\b/iy;
53
+ var PROSE_GAP = /[\s*_\u2800]/;
54
+ var preserveCase = (match, replacement) => match[0] === match[0].toUpperCase() && match[0] !== match[0].toLowerCase() ? replacement[0].toUpperCase() + replacement.slice(1) : replacement;
55
+ var maskAll = (text) => text.replace(/[^\r\n]/g, VOICE_MASK);
56
+ function maskProtected(text, ranges) {
57
+ if (!ranges) return text;
58
+ if (!Array.isArray(ranges)) return maskAll(text);
59
+ if (ranges.length === 0) return text;
60
+ const length = text.length;
61
+ const edges = new Int32Array(length + 1);
62
+ let any = false;
63
+ for (const range of ranges) {
64
+ const from = range?.[0];
65
+ const to = range?.[1];
66
+ if (typeof from !== "number" || typeof to !== "number" || Number.isNaN(from) || Number.isNaN(to) || from > to) return maskAll(text);
67
+ const start = Math.max(0, Math.floor(from));
68
+ const end = Math.min(length, Math.ceil(to));
69
+ if (end <= start) continue;
70
+ edges[start] += 1;
71
+ edges[end] -= 1;
72
+ any = true;
73
+ }
74
+ if (!any) return text;
75
+ const parts = [];
76
+ let depth = 0;
77
+ let runStart = 0;
78
+ let runProtected = false;
79
+ for (let i = 0; i <= length; i++) {
80
+ const isProtected = i < length && (depth += edges[i]) > 0;
81
+ if (i === length || isProtected !== runProtected) {
82
+ if (i > runStart) parts.push(runProtected ? maskAll(text.slice(runStart, i)) : text.slice(runStart, i));
83
+ runStart = i;
84
+ runProtected = isProtected;
85
+ }
86
+ }
87
+ return parts.join("");
88
+ }
89
+ function walkBackOverGap(text, start) {
90
+ let at = start;
91
+ while (at > 0 && text[at - 1] !== "\n" && PROSE_GAP.test(text[at - 1])) at--;
92
+ return at;
93
+ }
94
+ function firstStop(text, from) {
95
+ for (let i = from; i < text.length; i++) {
96
+ const code = text.charCodeAt(i);
97
+ if (code === 46 || code === 33 || code === 63 || code === 10) return i;
98
+ }
99
+ return text.length;
100
+ }
101
+ function mustStayFull(pair, text, titleLike, start, end, look) {
102
+ if (pair.full === "can not") {
103
+ CAN_NOT_ONLY.lastIndex = end;
104
+ return CAN_NOT_ONLY.test(text);
105
+ }
106
+ if (pair.negation) return false;
107
+ CLAUSE_END.lastIndex = end;
108
+ if (CLAUSE_END.test(text)) return true;
109
+ CONJUNCTION.lastIndex = end;
110
+ if (CONJUNCTION.test(text)) return true;
111
+ if (pair.full === "what is") {
112
+ if (look.stop < end) look.stop = firstStop(text, end);
113
+ if (text.charCodeAt(look.stop) === 63) return true;
114
+ if (titleLike) {
115
+ const at = walkBackOverGap(text, start);
116
+ if (at === 0 || text[at - 1] === "\n") return true;
117
+ }
118
+ }
119
+ if (pair.full === "let us") {
120
+ const at = walkBackOverGap(text, start);
121
+ if (at > 0 && !".!?:\n".includes(text[at - 1])) return true;
122
+ LET_US_SUGGESTIONS.lastIndex = end;
123
+ return !LET_US_SUGGESTIONS.test(text);
124
+ }
125
+ return false;
126
+ }
127
+ function scanUncontracted(text, masked, titleLike) {
128
+ if (masked.length < 4) return [];
129
+ const hits = [];
130
+ const claimed = new Uint8Array(masked.length);
131
+ for (const pair of PAIRS) {
132
+ const look = { stop: -1 };
133
+ for (const found of masked.matchAll(pair.pattern)) {
134
+ const start = found.index ?? 0;
135
+ const end = start + found[0].length;
136
+ let overlaps = false;
137
+ for (let i = start; i < end; i++) {
138
+ if (claimed[i] === 1) {
139
+ overlaps = true;
140
+ break;
141
+ }
142
+ }
143
+ if (overlaps || mustStayFull(pair, masked, titleLike, start, end, look)) continue;
144
+ claimed.fill(1, start, end);
145
+ const match = text.slice(start, end);
146
+ hits.push({ index: start, length: end - start, match, suggestion: preserveCase(match, pair.contracted) });
147
+ }
148
+ }
149
+ return hits.sort((a, b) => a.index - b.index);
150
+ }
151
+ function findUncontracted(text, options) {
152
+ if (typeof text !== "string" || !text) return [];
153
+ return scanUncontracted(text, maskProtected(text, options?.protectedRanges), options?.titleLike === true);
154
+ }
155
+ function contractText(text, options) {
156
+ const hits = findUncontracted(text, options);
157
+ if (hits.length === 0) return text;
158
+ let out = "";
159
+ let cursor = 0;
160
+ for (const hit of hits) {
161
+ out += text.slice(cursor, hit.index) + hit.suggestion;
162
+ cursor = hit.index + hit.length;
163
+ }
164
+ return out + text.slice(cursor);
165
+ }
166
+ var CONTRACTION = /\b(?:[A-Za-z]+n['\u2019]t|(?:it|that|what|there|here|who|where|how|when|why|let|he|she)['\u2019]s|(?:you|we|they|who|what)['\u2019]re|(?:i|you|we|they|it|that|who|there|what)['\u2019]ll|(?:i|you|we|they|who|could|would|should|might|must)['\u2019]ve|(?:i|you|we|they|he|she|it|that|who|there)['\u2019]d|i['\u2019]m)\b/gi;
167
+ var IRREGULAR_EXPANSIONS = {
168
+ "can't": "cannot",
169
+ "won't": "will not",
170
+ "shan't": "shall not",
171
+ "let's": "let us",
172
+ "i'm": "I am"
173
+ };
174
+ var SUFFIX_EXPANSIONS = { s: "is", re: "are", ll: "will", ve: "have", d: "would", m: "am" };
175
+ function expandContraction(contraction) {
176
+ const plain = contraction.replace(/\u2019/g, "'");
177
+ const lower = plain.toLowerCase();
178
+ let expanded = IRREGULAR_EXPANSIONS[lower];
179
+ if (!expanded && lower.endsWith("n't")) expanded = `${plain.slice(0, -3)} not`;
180
+ if (!expanded) {
181
+ const [word, suffix] = plain.split("'");
182
+ expanded = `${word} ${SUFFIX_EXPANSIONS[suffix.toLowerCase()] ?? suffix}`;
183
+ }
184
+ return preserveCase(plain, expanded);
185
+ }
186
+ function findContracted(text, options) {
187
+ if (typeof text !== "string" || !text) return [];
188
+ const hits = [];
189
+ for (const found of maskProtected(text, options?.protectedRanges).matchAll(CONTRACTION)) {
190
+ const index = found.index ?? 0;
191
+ const match = text.slice(index, index + found[0].length);
192
+ hits.push({ index, length: match.length, match, suggestion: expandContraction(match) });
193
+ }
194
+ return hits;
195
+ }
196
+
197
+ exports.VOICE_MASK = VOICE_MASK;
198
+ exports.contractText = contractText;
199
+ exports.findContracted = findContracted;
200
+ exports.findUncontracted = findUncontracted;
package/dist/voice.mjs ADDED
@@ -0,0 +1,195 @@
1
+ // src/contracts/voice.ts
2
+ var VOICE_MASK = "\u2800";
3
+ var TABLE = [
4
+ ["do not", "don't"],
5
+ ["does not", "doesn't"],
6
+ ["did not", "didn't"],
7
+ ["is not", "isn't"],
8
+ ["are not", "aren't"],
9
+ ["was not", "wasn't"],
10
+ ["were not", "weren't"],
11
+ ["cannot", "can't"],
12
+ ["can not", "can't"],
13
+ ["will not", "won't"],
14
+ ["would not", "wouldn't"],
15
+ ["should not", "shouldn't"],
16
+ ["could not", "couldn't"],
17
+ ["has not", "hasn't"],
18
+ ["have not", "haven't"],
19
+ ["had not", "hadn't"],
20
+ ["must not", "mustn't"],
21
+ ["need not", "needn't"],
22
+ ["it is", "it's"],
23
+ ["that is", "that's"],
24
+ ["there is", "there's"],
25
+ ["here is", "here's"],
26
+ ["who is", "who's"],
27
+ ["what is", "what's"],
28
+ ["you are", "you're"],
29
+ ["we are", "we're"],
30
+ ["they are", "they're"],
31
+ ["I am", "I'm"],
32
+ ["you will", "you'll"],
33
+ ["we will", "we'll"],
34
+ ["they will", "they'll"],
35
+ ["it will", "it'll"],
36
+ ["we would", "we'd"],
37
+ ["you would", "you'd"],
38
+ ["they would", "they'd"],
39
+ ["let us", "let's"]
40
+ ];
41
+ var PAIRS = TABLE.map(([full, contracted]) => ({
42
+ full,
43
+ contracted,
44
+ negation: / not$|^cannot$/.test(full),
45
+ pattern: new RegExp(`\\b${full.replace(/ /g, "[ \\t]+")}\\b`, "gi")
46
+ }));
47
+ var CAN_NOT_ONLY = /[ \t]+only\b/iy;
48
+ var CLAUSE_END = /[ \t]*(?:[.,;:!?)\]}"\u201d\u2019\u2026\u2014\u2013-]|\r?\n|\r?$)/y;
49
+ var CONJUNCTION = /[ \t]+(?:and|or|but|nor)\b/iy;
50
+ var LET_US_SUGGESTIONS = /[ \t]+(?:look|start|begin|consider|turn|see|review|compare|break|dig|recap|unpack|explore|examine|revisit|focus|go|talk|move|get started|step back|zoom out)\b/iy;
51
+ var PROSE_GAP = /[\s*_\u2800]/;
52
+ var preserveCase = (match, replacement) => match[0] === match[0].toUpperCase() && match[0] !== match[0].toLowerCase() ? replacement[0].toUpperCase() + replacement.slice(1) : replacement;
53
+ var maskAll = (text) => text.replace(/[^\r\n]/g, VOICE_MASK);
54
+ function maskProtected(text, ranges) {
55
+ if (!ranges) return text;
56
+ if (!Array.isArray(ranges)) return maskAll(text);
57
+ if (ranges.length === 0) return text;
58
+ const length = text.length;
59
+ const edges = new Int32Array(length + 1);
60
+ let any = false;
61
+ for (const range of ranges) {
62
+ const from = range?.[0];
63
+ const to = range?.[1];
64
+ if (typeof from !== "number" || typeof to !== "number" || Number.isNaN(from) || Number.isNaN(to) || from > to) return maskAll(text);
65
+ const start = Math.max(0, Math.floor(from));
66
+ const end = Math.min(length, Math.ceil(to));
67
+ if (end <= start) continue;
68
+ edges[start] += 1;
69
+ edges[end] -= 1;
70
+ any = true;
71
+ }
72
+ if (!any) return text;
73
+ const parts = [];
74
+ let depth = 0;
75
+ let runStart = 0;
76
+ let runProtected = false;
77
+ for (let i = 0; i <= length; i++) {
78
+ const isProtected = i < length && (depth += edges[i]) > 0;
79
+ if (i === length || isProtected !== runProtected) {
80
+ if (i > runStart) parts.push(runProtected ? maskAll(text.slice(runStart, i)) : text.slice(runStart, i));
81
+ runStart = i;
82
+ runProtected = isProtected;
83
+ }
84
+ }
85
+ return parts.join("");
86
+ }
87
+ function walkBackOverGap(text, start) {
88
+ let at = start;
89
+ while (at > 0 && text[at - 1] !== "\n" && PROSE_GAP.test(text[at - 1])) at--;
90
+ return at;
91
+ }
92
+ function firstStop(text, from) {
93
+ for (let i = from; i < text.length; i++) {
94
+ const code = text.charCodeAt(i);
95
+ if (code === 46 || code === 33 || code === 63 || code === 10) return i;
96
+ }
97
+ return text.length;
98
+ }
99
+ function mustStayFull(pair, text, titleLike, start, end, look) {
100
+ if (pair.full === "can not") {
101
+ CAN_NOT_ONLY.lastIndex = end;
102
+ return CAN_NOT_ONLY.test(text);
103
+ }
104
+ if (pair.negation) return false;
105
+ CLAUSE_END.lastIndex = end;
106
+ if (CLAUSE_END.test(text)) return true;
107
+ CONJUNCTION.lastIndex = end;
108
+ if (CONJUNCTION.test(text)) return true;
109
+ if (pair.full === "what is") {
110
+ if (look.stop < end) look.stop = firstStop(text, end);
111
+ if (text.charCodeAt(look.stop) === 63) return true;
112
+ if (titleLike) {
113
+ const at = walkBackOverGap(text, start);
114
+ if (at === 0 || text[at - 1] === "\n") return true;
115
+ }
116
+ }
117
+ if (pair.full === "let us") {
118
+ const at = walkBackOverGap(text, start);
119
+ if (at > 0 && !".!?:\n".includes(text[at - 1])) return true;
120
+ LET_US_SUGGESTIONS.lastIndex = end;
121
+ return !LET_US_SUGGESTIONS.test(text);
122
+ }
123
+ return false;
124
+ }
125
+ function scanUncontracted(text, masked, titleLike) {
126
+ if (masked.length < 4) return [];
127
+ const hits = [];
128
+ const claimed = new Uint8Array(masked.length);
129
+ for (const pair of PAIRS) {
130
+ const look = { stop: -1 };
131
+ for (const found of masked.matchAll(pair.pattern)) {
132
+ const start = found.index ?? 0;
133
+ const end = start + found[0].length;
134
+ let overlaps = false;
135
+ for (let i = start; i < end; i++) {
136
+ if (claimed[i] === 1) {
137
+ overlaps = true;
138
+ break;
139
+ }
140
+ }
141
+ if (overlaps || mustStayFull(pair, masked, titleLike, start, end, look)) continue;
142
+ claimed.fill(1, start, end);
143
+ const match = text.slice(start, end);
144
+ hits.push({ index: start, length: end - start, match, suggestion: preserveCase(match, pair.contracted) });
145
+ }
146
+ }
147
+ return hits.sort((a, b) => a.index - b.index);
148
+ }
149
+ function findUncontracted(text, options) {
150
+ if (typeof text !== "string" || !text) return [];
151
+ return scanUncontracted(text, maskProtected(text, options?.protectedRanges), options?.titleLike === true);
152
+ }
153
+ function contractText(text, options) {
154
+ const hits = findUncontracted(text, options);
155
+ if (hits.length === 0) return text;
156
+ let out = "";
157
+ let cursor = 0;
158
+ for (const hit of hits) {
159
+ out += text.slice(cursor, hit.index) + hit.suggestion;
160
+ cursor = hit.index + hit.length;
161
+ }
162
+ return out + text.slice(cursor);
163
+ }
164
+ var CONTRACTION = /\b(?:[A-Za-z]+n['\u2019]t|(?:it|that|what|there|here|who|where|how|when|why|let|he|she)['\u2019]s|(?:you|we|they|who|what)['\u2019]re|(?:i|you|we|they|it|that|who|there|what)['\u2019]ll|(?:i|you|we|they|who|could|would|should|might|must)['\u2019]ve|(?:i|you|we|they|he|she|it|that|who|there)['\u2019]d|i['\u2019]m)\b/gi;
165
+ var IRREGULAR_EXPANSIONS = {
166
+ "can't": "cannot",
167
+ "won't": "will not",
168
+ "shan't": "shall not",
169
+ "let's": "let us",
170
+ "i'm": "I am"
171
+ };
172
+ var SUFFIX_EXPANSIONS = { s: "is", re: "are", ll: "will", ve: "have", d: "would", m: "am" };
173
+ function expandContraction(contraction) {
174
+ const plain = contraction.replace(/\u2019/g, "'");
175
+ const lower = plain.toLowerCase();
176
+ let expanded = IRREGULAR_EXPANSIONS[lower];
177
+ if (!expanded && lower.endsWith("n't")) expanded = `${plain.slice(0, -3)} not`;
178
+ if (!expanded) {
179
+ const [word, suffix] = plain.split("'");
180
+ expanded = `${word} ${SUFFIX_EXPANSIONS[suffix.toLowerCase()] ?? suffix}`;
181
+ }
182
+ return preserveCase(plain, expanded);
183
+ }
184
+ function findContracted(text, options) {
185
+ if (typeof text !== "string" || !text) return [];
186
+ const hits = [];
187
+ for (const found of maskProtected(text, options?.protectedRanges).matchAll(CONTRACTION)) {
188
+ const index = found.index ?? 0;
189
+ const match = text.slice(index, index + found[0].length);
190
+ hits.push({ index, length: match.length, match, suggestion: expandContraction(match) });
191
+ }
192
+ return hits;
193
+ }
194
+
195
+ export { VOICE_MASK, contractText, findContracted, findUncontracted };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sonordev/contracts",
3
- "version": "2.0.0",
4
- "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags and portfolio metrics. Pure functions, no dependencies.",
3
+ "version": "2.1.0",
4
+ "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics and the house voice's contraction rule. Pure functions, no dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -80,6 +80,11 @@
80
80
  "import": "./dist/portfolio.mjs",
81
81
  "require": "./dist/portfolio.js"
82
82
  },
83
+ "./voice": {
84
+ "types": "./dist/voice.d.ts",
85
+ "import": "./dist/voice.mjs",
86
+ "require": "./dist/voice.js"
87
+ },
83
88
  "./package.json": "./package.json"
84
89
  },
85
90
  "typesVersions": {