@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 +48 -0
- package/README.md +112 -0
- package/dist/voice.d.ts +56 -0
- package/dist/voice.js +200 -0
- package/dist/voice.mjs +195 -0
- package/package.json +7 -2
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
|
|
package/dist/voice.d.ts
ADDED
|
@@ -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.
|
|
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
|
|
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": {
|