@sonordev/contracts 2.0.0 → 2.1.1
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 +55 -3
- package/README.md +123 -13
- package/dist/fleet.d.ts +9 -9
- package/dist/forms.d.ts +4 -10
- package/dist/llms.d.ts +1 -1
- package/dist/portfolio.d.ts +11 -15
- package/dist/seo-meta.d.ts +11 -12
- package/dist/seo-pages.d.ts +12 -18
- package/dist/site-cache.d.ts +4 -4
- package/dist/sites-normalize.d.ts +2 -4
- package/dist/sites.d.ts +3 -5
- package/dist/slot-content.d.ts +2 -2
- package/dist/slots.d.ts +6 -6
- package/dist/voice.d.ts +56 -0
- package/dist/voice.js +200 -0
- package/dist/voice.mjs +195 -0
- package/dist/website.d.ts +2 -2
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.1
|
|
4
|
+
|
|
5
|
+
- Docs only: the README names the Sonor API and the dashboard where it named internal repositories. No code changes.
|
|
6
|
+
|
|
7
|
+
## 2.1.0
|
|
8
|
+
|
|
9
|
+
A minor release: one new entry, `@sonordev/contracts/voice`. Every other entry is unchanged.
|
|
10
|
+
|
|
11
|
+
- **New entry `@sonordev/contracts/voice`**: the house voice's contraction rule, so every app that checks or rewrites copy agrees on it.
|
|
12
|
+
- `findUncontracted(text, options?)` finds full forms that should be
|
|
13
|
+
contractions ("do not" to "don't", "it is" to "it's", "let us look" to
|
|
14
|
+
"let's look") and returns each one with its position, the matched text and
|
|
15
|
+
the suggestion, first-letter case kept.
|
|
16
|
+
- It leaves alone what can't contract where it stands: a clause-final "it
|
|
17
|
+
is" or "we are" (negations skip that guard, so "We do not." still becomes
|
|
18
|
+
"We don't."), "what it is and why", "can not only", a "what is" question, a
|
|
19
|
+
heading that opens with "What Is" (when `titleLike` is set), and "let us
|
|
20
|
+
know" and "let us help". "let us" only contracts at the start of a sentence and
|
|
21
|
+
before look, start, begin, consider, turn, see, review, compare, break,
|
|
22
|
+
dig, recap, unpack, explore, examine, revisit, focus, go, talk, move, "get
|
|
23
|
+
started", "step back" or "zoom out". "have" and "has" on their own are
|
|
24
|
+
never touched, though "have not" and "has not" still contract.
|
|
25
|
+
- `contractText(text, options?)` applies those findings and returns the new
|
|
26
|
+
text.
|
|
27
|
+
- `findContracted(text, options?)` is the reverse rule for question-form
|
|
28
|
+
headings and FAQ questions: it finds contractions ("What's") and suggests
|
|
29
|
+
the full form ("What is").
|
|
30
|
+
- `VOICE_MASK` is the one character that stands in for text the rule must
|
|
31
|
+
not touch.
|
|
32
|
+
- **Options**: `titleLike` marks a heading or title. `protectedRanges` takes
|
|
33
|
+
half-open `[start, end)` ranges (code, quotations, link destinations) that
|
|
34
|
+
are never rewritten and never read as a clause end, except a protected line
|
|
35
|
+
break, which still ends its line. An infinite bound means
|
|
36
|
+
"from the start" or "to the end". A malformed range (NaN, not a number,
|
|
37
|
+
start after end), or a truthy value that isn't an array, protects the whole
|
|
38
|
+
text, so a bad range can never cause code to be rewritten.
|
|
39
|
+
- **Lines**: multi-line text is read one line at a time. Nothing matches
|
|
40
|
+
across a line break, and the end of a line ends a clause. A "\r" before a
|
|
41
|
+
"\n" or at the very end of the text is part of the line ending. Hits carry
|
|
42
|
+
offsets into the whole string.
|
|
43
|
+
- **Known limits**: the rule is deliberately plain. "must not only" and "will
|
|
44
|
+
not only" contract, "need not" contracts even where "need" is a noun, "let
|
|
45
|
+
us" is read by its position and next word rather than its meaning, a
|
|
46
|
+
decimal, a domain or an initial hides a "what is" question, a straight
|
|
47
|
+
apostrophe doesn't end a clause, only spaces and tabs join the words of a
|
|
48
|
+
pair, all-caps input gets a mixed-case suggestion, and `findContracted`
|
|
49
|
+
always expands "'s" to "is" and "'d" to "would" and expands "ain't" like any
|
|
50
|
+
other "n't" word ("ai not"). The README lists each one with an example.
|
|
51
|
+
- Anything that isn't a string is read as no text, so a stray number or object
|
|
52
|
+
can't throw.
|
|
53
|
+
- No new dependencies, and CommonJS and ESM builds like every other entry.
|
|
54
|
+
|
|
3
55
|
## 2.0.0
|
|
4
56
|
|
|
5
57
|
A major for one entry, `slots`, plus three new entries; every other entry is unchanged.
|
|
@@ -21,7 +73,7 @@ A major for one entry, `slots`, plus three new entries; every other entry is unc
|
|
|
21
73
|
`slots` re-exports all of it.
|
|
22
74
|
- **New entry `@sonordev/contracts/site-cache`**: how Sonor refreshes a
|
|
23
75
|
site's cached pages. `SITE_CACHE_TAGS` (the tag names site-kit's fetches
|
|
24
|
-
carry and
|
|
76
|
+
carry and the Sonor API expires), `SITE_REVALIDATE_PATH`, the webhook payload
|
|
25
77
|
and its `ping`, and `isSiteRevalidatePong`.
|
|
26
78
|
- **New entry `@sonordev/contracts/site-edit`**: Sonor's Edit on page. The
|
|
27
79
|
messages between a framed site and the dashboard, which origins may drive
|
|
@@ -32,5 +84,5 @@ A major for one entry, `slots`, plus three new entries; every other entry is unc
|
|
|
32
84
|
|
|
33
85
|
First release. The rules site-kit 6.x published as `@sonordev/site-kit/*/contract`
|
|
34
86
|
(website, color, sites, seo-pages, seo-meta, llms, forms, fleet, slots,
|
|
35
|
-
portfolio), as a dependency-free package, so
|
|
36
|
-
dashboard import them instead of keeping
|
|
87
|
+
portfolio), as a dependency-free package, so the Sonor APIs and the
|
|
88
|
+
dashboard import them instead of keeping copies.
|
package/README.md
CHANGED
|
@@ -9,26 +9,136 @@ pnpm add @sonordev/contracts
|
|
|
9
9
|
|
|
10
10
|
| Entry | What it decides | Who reads it |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| `@sonordev/contracts/website` | A site's design tokens and what a popup is made of (blocks, limits, the text sanitizer) | site-kit renders them,
|
|
12
|
+
| `@sonordev/contracts/website` | A site's design tokens and what a popup is made of (blocks, limits, the text sanitizer) | site-kit renders them, the Sonor API validates every write, the dashboard previews them, Signal's agents send them |
|
|
13
13
|
| `@sonordev/contracts/color` | Reading a CSS color, WCAG luminance and contrast | `website`, site-kit's brand extraction |
|
|
14
14
|
| `@sonordev/contracts/sites` | The canonical shape of a site host (the multi-site `site` dimension) | every ingest and read that filters by site |
|
|
15
|
-
| `@sonordev/contracts/seo-pages` | Which `seo_pages` row a path resolves to on a multi-site project |
|
|
15
|
+
| `@sonordev/contracts/seo-pages` | Which `seo_pages` row a path resolves to on a multi-site project | the Sonor API, Signal, site-kit's sitemap sync |
|
|
16
16
|
| `@sonordev/contracts/seo-meta` | Whether a title or description is fit to ship | site-kit, Signal's SEO gate |
|
|
17
|
-
| `@sonordev/contracts/llms` | llms.txt sanitizers and contract version | site-kit,
|
|
18
|
-
| `@sonordev/contracts/forms` | The honeypot policy | site-kit forms,
|
|
19
|
-
| `@sonordev/contracts/fleet` | The fleet heartbeat's wire shape | site-kit,
|
|
20
|
-
| `@sonordev/contracts/slots` | Slot payload signing, contract v2 (HMAC; Node only). Signs and verifies v1 for older site-kit releases |
|
|
21
|
-
| `@sonordev/contracts/slot-content` | What a slot of each kind may hold (`normalizeSlotValue`), safe links, the rich-text parser. No `crypto`, so browsers can import it |
|
|
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,
|
|
17
|
+
| `@sonordev/contracts/llms` | llms.txt sanitizers and contract version | site-kit, the Sonor API, Signal's schema validation |
|
|
18
|
+
| `@sonordev/contracts/forms` | The honeypot policy | site-kit forms, the Sonor API's form intake |
|
|
19
|
+
| `@sonordev/contracts/fleet` | The fleet heartbeat's wire shape | site-kit, the Sonor API |
|
|
20
|
+
| `@sonordev/contracts/slots` | Slot payload signing, contract v2 (HMAC; Node only). Signs and verifies v1 for older site-kit releases | the Sonor API, site-kit on the server |
|
|
21
|
+
| `@sonordev/contracts/slot-content` | What a slot of each kind may hold (`normalizeSlotValue`), safe links, the rich-text parser. No `crypto`, so browsers can import it | the Sonor API, site-kit, the dashboard editor |
|
|
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, the 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
|
-
| `@sonordev/contracts/portfolio` | Where a portfolio number comes from and which may headline; the live-frame message |
|
|
24
|
+
| `@sonordev/contracts/portfolio` | Where a portfolio number comes from and which may headline; the live-frame message | the 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
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
parallel copies of a rule is how two of them end up disagreeing. Now there's
|
|
31
|
-
one: the source is `src/contracts/` in the site-kit repo, site-kit bundles it
|
|
140
|
+
A rule that lives in several copies is how two of them end up disagreeing.
|
|
141
|
+
This package means there's one: the source is `src/contracts/` in the site-kit repo, site-kit bundles it
|
|
32
142
|
into its own `*/contract` entries, and this package publishes the same files
|
|
33
143
|
for everyone else.
|
|
34
144
|
|
package/dist/fleet.d.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* @sonordev/contracts/fleet — Fleet heartbeat wire contract (v1)
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for the heartbeat payload shape, shared by site-kit
|
|
5
|
-
* (send side, `@sonordev/site-kit/fleet/contract`) and
|
|
5
|
+
* (send side, `@sonordev/site-kit/fleet/contract`) and the Sonor API (ingest
|
|
6
6
|
* side: its DTO is type-checked against FleetHeartbeatPayload).
|
|
7
7
|
*
|
|
8
|
-
* Why it exists:
|
|
9
|
-
*
|
|
10
|
-
* site running" without
|
|
11
|
-
*
|
|
8
|
+
* Why it exists: sites run whatever site-kit version they last deployed, and
|
|
9
|
+
* Sonor should be able to answer "which version / modules / key state is each
|
|
10
|
+
* site running" without anyone opening each repo. The heartbeat answers that
|
|
11
|
+
* from live traffic. It is fire-and-forget, deferred to idle (never in the
|
|
12
12
|
* hydration path), and carries NO visitor data — only the site's own build
|
|
13
13
|
* fingerprint. Project identity is resolved server-side from the x-api-key
|
|
14
14
|
* header, exactly like every other /api/public/* endpoint; sites never send
|
|
@@ -56,7 +56,7 @@ export interface FleetHeartbeatPayload {
|
|
|
56
56
|
next_version?: string | null;
|
|
57
57
|
/** Client-observable active modules — deduped, validated, canonical order. */
|
|
58
58
|
modules: FleetModule[];
|
|
59
|
-
/** Normalized lowercase host (multi-site sub-dimension
|
|
59
|
+
/** Normalized lowercase host (multi-site sub-dimension). */
|
|
60
60
|
site?: string | null;
|
|
61
61
|
/** 'client' (runtime boot) or 'build' (CI/build step). Default 'client'. */
|
|
62
62
|
source?: FleetHeartbeatSource;
|
|
@@ -77,9 +77,9 @@ export declare function normalizeFleetModules(input: unknown): FleetModule[];
|
|
|
77
77
|
export declare function sanitizeFleetVersion(raw: unknown): string | null;
|
|
78
78
|
/**
|
|
79
79
|
* Light client-side site normalization to the canonical host shape (lowercase,
|
|
80
|
-
* no protocol/path/port).
|
|
81
|
-
*
|
|
82
|
-
*
|
|
80
|
+
* no protocol/path/port). The Sonor API's normalizer is AUTHORITATIVE and
|
|
81
|
+
* re-runs on ingest; this just avoids sending obvious noise. Keep the two
|
|
82
|
+
* shapes aligned.
|
|
83
83
|
*/
|
|
84
84
|
export declare function normalizeFleetSite(raw: unknown): string | null;
|
|
85
85
|
/**
|
package/dist/forms.d.ts
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @sonordev/contracts/forms — managed-form honeypot policy (v1)
|
|
3
3
|
*
|
|
4
|
-
* Single source of truth for the honeypot
|
|
5
|
-
*
|
|
6
|
-
* the server (sonor-api names the trap in the form config it returns AND
|
|
7
|
-
* detects hits on intake). Both import this: site-kit as
|
|
8
|
-
* `@sonordev/site-kit/forms/contract`, sonor-api through
|
|
9
|
-
* `src/modules/forms/honeypot-contract.ts`.
|
|
4
|
+
* Single source of truth for the honeypot field's naming, shared by site-kit
|
|
5
|
+
* (which renders the field, as `@sonordev/site-kit/forms/contract`) and Sonor.
|
|
10
6
|
*
|
|
11
|
-
* The default field name
|
|
12
|
-
*
|
|
13
|
-
* as spam, so generic names are only trusted when they DON'T match a real
|
|
14
|
-
* field of the form (see `resolveHoneypotFieldNames`).
|
|
7
|
+
* The default field name is deliberately unlike a real field's, so it never
|
|
8
|
+
* collides with a form's own fields (see `resolveHoneypotFieldNames`).
|
|
15
9
|
*/
|
|
16
10
|
/** Bump on breaking changes to the honeypot policy. */
|
|
17
11
|
export declare const FORMS_HONEYPOT_CONTRACT_VERSION: 1;
|
package/dist/llms.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare const LLMS_PUBLIC_SUMMARY_MAX_LENGTH = 400;
|
|
|
14
14
|
/** Max length for optional llms.txt blockquote disclaimer line. */
|
|
15
15
|
export declare const LLMS_DISCLAIMER_MAX_LENGTH = 280;
|
|
16
16
|
/**
|
|
17
|
-
* Optional keys we allow inside managed_llm_schema JSON from
|
|
17
|
+
* Optional keys we allow inside managed_llm_schema JSON from Sonor.
|
|
18
18
|
* Values outside this set should not be relied on by consumers; strip unknown keys when emitting JSON-LD.
|
|
19
19
|
*/
|
|
20
20
|
export declare const MANAGED_LLM_SCHEMA_KNOWN_KEYS: Set<string>;
|
package/dist/portfolio.d.ts
CHANGED
|
@@ -2,13 +2,9 @@
|
|
|
2
2
|
* @sonordev/contracts/portfolio — portfolio proof display policy (v1)
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for the Lighthouse-KPI collapse policy shared by the
|
|
5
|
-
* content side (
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* from `src/portfolio/curate-proof.ts`, which keeps the render-only
|
|
9
|
-
* primitives that bind to its section types.
|
|
10
|
-
* - signal-api imports it through
|
|
11
|
-
* `src/modules/skills/portfolio/portfolio-kpi-policy.util.ts`.
|
|
5
|
+
* content side (Signal's case-study generation) and the render side
|
|
6
|
+
* (agency-site-kit, which imports it as `@sonordev/site-kit/portfolio/contract`
|
|
7
|
+
* and keeps the render-only primitives that bind to its section types).
|
|
12
8
|
*
|
|
13
9
|
* Why it exists: generated case studies arrived with four "100/100 Lighthouse X"
|
|
14
10
|
* KPIs repeated across hero, results cards, and a Performance section. One
|
|
@@ -17,8 +13,8 @@
|
|
|
17
13
|
*
|
|
18
14
|
* It is also the single source of truth for METRIC PROVENANCE (below): the
|
|
19
15
|
* three sources a portfolio number can have, what a client-reported number
|
|
20
|
-
* must carry, and which numbers may headline.
|
|
21
|
-
*
|
|
16
|
+
* must carry, and which numbers may headline. The Sonor API applies the same
|
|
17
|
+
* rules.
|
|
22
18
|
*/
|
|
23
19
|
/** The message a cross-origin-framed site-kit page posts to its embedder once it has painted. */
|
|
24
20
|
export declare const FRAME_READY_MESSAGE: 'sonor:frame-ready';
|
|
@@ -109,11 +105,11 @@ export declare const METRIC_SOURCES: readonly ['measured', 'reported', 'estimate
|
|
|
109
105
|
export type MetricSource = (typeof METRIC_SOURCES)[number];
|
|
110
106
|
/** Who stands behind a client-reported number. */
|
|
111
107
|
export interface MetricAttribution {
|
|
112
|
-
/** The person, e.g. "
|
|
108
|
+
/** The person, e.g. "Dana Reyes". */
|
|
113
109
|
name: string;
|
|
114
|
-
/** Their title, e.g. "Director of
|
|
110
|
+
/** Their title, e.g. "Director of Operations". */
|
|
115
111
|
role?: string;
|
|
116
|
-
/** Where they work: almost always the client, e.g. "
|
|
112
|
+
/** Where they work: almost always the client, e.g. "Northwind Engineering". */
|
|
117
113
|
organization: string;
|
|
118
114
|
/** The day they reported it, YYYY-MM-DD. */
|
|
119
115
|
date: string;
|
|
@@ -140,12 +136,12 @@ export declare function isHeadlineMetric(metric: SourcedMetric): boolean;
|
|
|
140
136
|
/** The badge a display shows, or null for a missing or unknown source (show nothing rather than guess). */
|
|
141
137
|
export declare function metricSourceLabel(source: unknown): string | null;
|
|
142
138
|
/**
|
|
143
|
-
* The full credit: "Reported by
|
|
144
|
-
*
|
|
139
|
+
* The full credit: "Reported by Dana Reyes, Director of Operations, Northwind
|
|
140
|
+
* Engineering, September 25, 2026". The date is read as a plain day,
|
|
145
141
|
* never through a time zone, so it can't slip to the day before.
|
|
146
142
|
*/
|
|
147
143
|
export declare function formatAttribution(by: MetricAttribution): string;
|
|
148
|
-
/** The credit for a tight space (a card, a carousel tile): "per
|
|
144
|
+
/** The credit for a tight space (a card, a carousel tile): "per Northwind Engineering". */
|
|
149
145
|
export declare function shortAttribution(by: Pick<MetricAttribution, 'organization'>): string;
|
|
150
146
|
/**
|
|
151
147
|
* Carry a person's reported numbers through a regeneration. The generator can
|
package/dist/seo-meta.d.ts
CHANGED
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
* Single source of truth for "is this machine-generated seo_pages.managed_title
|
|
5
5
|
* / managed_meta_description fit to ship?". site-kit applies it at render time
|
|
6
6
|
* (getManagedMetadata falls back to the page's own title/description), and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Signal applies it to every generated title before it's written, so the two
|
|
8
|
+
* can't disagree.
|
|
9
9
|
*
|
|
10
|
-
* Why it exists
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* rendered them verbatim.
|
|
10
|
+
* Why it exists: a meta generator that uses the raw domain as the brand when it
|
|
11
|
+
* can't resolve one ships titles like "Apartments for Rent |
|
|
12
|
+
* maplecourtapts.com", hard-cuts them at 60 chars ("… |
|
|
13
|
+
* brightsideheatingandcooli"), loses the suffix to a word-boundary cut
|
|
14
|
+
* ("Contact Brightside Heating and Cooling |"), or rebuilds a brand out of the
|
|
15
|
+
* domain ("About BBE Llis Apartments | BBE Llis" for bbellisapts.com, when the
|
|
16
|
+
* brand is BB Ellis Apartments). Sites render them verbatim.
|
|
18
17
|
*
|
|
19
18
|
* Every rule derives from the site's own identity (its hosts and brand), so
|
|
20
19
|
* there is no list of bad strings to maintain. Pure string logic — no I/O, no
|
|
@@ -24,7 +23,7 @@
|
|
|
24
23
|
export declare const SEO_META_CONTRACT_VERSION: 1;
|
|
25
24
|
/** Who the site is. Hosts come from the project/site config, the brand from the resolved business name — never from the domain. */
|
|
26
25
|
export interface MetaIdentity {
|
|
27
|
-
/** The site's brand, e.g. "
|
|
26
|
+
/** The site's brand, e.g. "BB Ellis Apartments". */
|
|
28
27
|
brand?: string | null;
|
|
29
28
|
/** Other correct spellings of the brand. */
|
|
30
29
|
brandAliases?: Array<string | null | undefined>;
|
|
@@ -34,7 +33,7 @@ export interface MetaIdentity {
|
|
|
34
33
|
export type TitleProblem = 'empty' | 'placeholder' | 'dangling-separator' | 'hostname' | 'mangled-brand';
|
|
35
34
|
export type DescriptionProblem = 'empty' | 'hostname' | 'mangled-brand' | 'truncated';
|
|
36
35
|
export type SuffixProblem = 'hostname' | 'mangled-brand';
|
|
37
|
-
/** "https://www.
|
|
36
|
+
/** "https://www.MapleCourtApts.com:443/x" → "maplecourtapts.com". Null for anything that isn't a host (no dot, or words). */
|
|
38
37
|
export declare function metaHost(value: string | null | undefined): string | null;
|
|
39
38
|
/** True when the text names the brand (an alias, or either one's core) word for word. */
|
|
40
39
|
export declare function namesBrand(text: string, id: MetaIdentity): boolean;
|
package/dist/seo-pages.d.ts
CHANGED
|
@@ -3,13 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for choosing ONE seo_pages row when a (project_id,
|
|
5
5
|
* path/url) lookup matches several, and for scoping a project's rows to one
|
|
6
|
-
* host. site-kit (`@sonordev/site-kit/seo/pages/contract`)
|
|
7
|
-
*
|
|
8
|
-
* (`src/shared/utils/seo-page-pick.util.ts`) all import this; none keeps a
|
|
9
|
-
* copy.
|
|
6
|
+
* host. site-kit (`@sonordev/site-kit/seo/pages/contract`) and both Sonor
|
|
7
|
+
* APIs import this; none keeps a copy.
|
|
10
8
|
*
|
|
11
|
-
* seo_pages identity
|
|
12
|
-
* 20260702150000_seo_pages_per_site_identity.sql: a multi-site project (one
|
|
9
|
+
* seo_pages identity is (project_id, site, url): a multi-site project (one
|
|
13
10
|
* Sonor project hosting many domains) keeps one row PER SUB-SITE HOST for
|
|
14
11
|
* shared paths like `/` or `/contact`. Any path/url lookup can therefore match
|
|
15
12
|
* several rows — `.single()`/`.maybeSingle()` error on them (PGRST116) — so
|
|
@@ -26,11 +23,9 @@ export declare const SEO_PAGES_CONTRACT_VERSION: 1;
|
|
|
26
23
|
*
|
|
27
24
|
* Retired pages are kept as tombstones (status='removed') so their history and
|
|
28
25
|
* redirects survive, which means every public read has to opt out of them
|
|
29
|
-
* explicitly.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* was 32% dead URLs, advertising service areas in Atlanta, Detroit and Tampa
|
|
33
|
-
* that the business has never served.
|
|
26
|
+
* explicitly. A read that forgets serves dead URLs: an llms.txt, the file
|
|
27
|
+
* handed straight to AI crawlers, ends up advertising pages the business
|
|
28
|
+
* retired long ago.
|
|
34
29
|
*
|
|
35
30
|
* Null-safe by design: `status` is nullable with an 'active' default, so a row
|
|
36
31
|
* whose status was explicitly nulled stays live rather than vanishing from the
|
|
@@ -80,13 +75,12 @@ export declare function matchSitemapEntryRow<T extends SeoPageRowLike>(candidate
|
|
|
80
75
|
* that belongs to `site`, keeping at most one row per path.
|
|
81
76
|
*
|
|
82
77
|
* Why this exists: a multi-site project (one Sonor project hosting many
|
|
83
|
-
* domains
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* among same-path rows is broken by whatever order Postgres returns.
|
|
78
|
+
* domains, e.g. example.com plus dozens of city microsites) holds one row PER
|
|
79
|
+
* HOST for shared paths like `/` and `/terms`. Any project-wide read that
|
|
80
|
+
* dedupes by path alone therefore keeps an ARBITRARY host's row: example.com's
|
|
81
|
+
* llms.txt can describe `/` with a sibling's homepage copy, and which sibling
|
|
82
|
+
* wins changes every time a neighbour rebuilds, because the tie among
|
|
83
|
+
* same-path rows is broken by whatever order Postgres returns.
|
|
90
84
|
*
|
|
91
85
|
* Rules, per path, via `matchSitemapEntryRow`:
|
|
92
86
|
* - this host's row wins outright
|
package/dist/site-cache.d.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* How Sonor refreshes a site's cached pages when content changes.
|
|
3
3
|
*
|
|
4
4
|
* A site serves cached pages and caches every fetch from Sonor. When someone
|
|
5
|
-
* edits content in Sonor,
|
|
5
|
+
* edits content in Sonor, Sonor POSTs the paths and cache tags that
|
|
6
6
|
* changed to the site's revalidation route, authenticated with the project's
|
|
7
7
|
* API key, and the site regenerates just those. site-kit tags its fetches with
|
|
8
8
|
* these names and serves the route (`@sonordev/site-kit/revalidate`);
|
|
9
|
-
*
|
|
9
|
+
* Sonor sends them. Single source of truth for the names on both sides:
|
|
10
10
|
* never spell a tag as a string literal in either.
|
|
11
11
|
*/
|
|
12
12
|
/** Where the revalidation route lives on every site. */
|
|
@@ -29,7 +29,7 @@ export declare const SITE_CACHE_TAGS: {
|
|
|
29
29
|
readonly portfolio: 'portfolio';
|
|
30
30
|
};
|
|
31
31
|
export type SiteCacheTag = (typeof SITE_CACHE_TAGS)[keyof typeof SITE_CACHE_TAGS];
|
|
32
|
-
/** The body
|
|
32
|
+
/** The body Sonor POSTs to the revalidation route. */
|
|
33
33
|
export interface SiteRevalidatePayload {
|
|
34
34
|
/** Local paths to regenerate, e.g. `['/services/roofing']`. */
|
|
35
35
|
paths?: string[];
|
|
@@ -39,7 +39,7 @@ export interface SiteRevalidatePayload {
|
|
|
39
39
|
revalidateAll?: boolean;
|
|
40
40
|
/**
|
|
41
41
|
* Only check that the route is installed and the key matches; nothing is
|
|
42
|
-
* regenerated.
|
|
42
|
+
* regenerated. Sonor pings after it registers a site's route, so it
|
|
43
43
|
* knows whether an edit goes live in seconds or waits for the cache to age out.
|
|
44
44
|
*/
|
|
45
45
|
ping?: boolean;
|
|
@@ -14,9 +14,7 @@
|
|
|
14
14
|
* pushed those entries over their bundle budgets.
|
|
15
15
|
*
|
|
16
16
|
* `./sites` re-exports this, so `@sonordev/contracts/sites` (and site-kit's
|
|
17
|
-
* `sites/contract`) carry it too. The
|
|
18
|
-
* (sonor-api `src/common/utils/site-host.ts`, signal-api
|
|
19
|
-
* `src/shared/utils/seo-page-pick.util.ts`).
|
|
17
|
+
* `sites/contract`) carry it too. The Sonor APIs import it from there.
|
|
20
18
|
*
|
|
21
19
|
* Pure string logic only (no Node built-ins) so it is safe in the browser.
|
|
22
20
|
*/
|
|
@@ -24,7 +22,7 @@
|
|
|
24
22
|
* normalizeSiteHost — canonical normalizer for a sub-site host value before it
|
|
25
23
|
* is persisted or used as a filter.
|
|
26
24
|
*
|
|
27
|
-
* `https://
|
|
25
|
+
* `https://Ohio.Example.com:3000/path` → `ohio.example.com`
|
|
28
26
|
*
|
|
29
27
|
* Lowercases, strips protocol, keeps host only (drops path + query), strips
|
|
30
28
|
* port (rollups span dev/prod) and the FQDN root dot. Returns null for
|
package/dist/sites.d.ts
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
* @sonordev/contracts/sites — Multi-site "site" dimension host contract (v1)
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for normalizing and comparing sub-site host values
|
|
5
|
-
* (the multi-site `site` dimension
|
|
6
|
-
*
|
|
7
|
-
* (`src/shared/utils/seo-page-pick.util.ts`) all import it, so there's no
|
|
8
|
-
* copy to drift.
|
|
5
|
+
* (the multi-site `site` dimension). site-kit and both Sonor APIs import it,
|
|
6
|
+
* so there's no copy to drift.
|
|
9
7
|
*
|
|
10
8
|
* Pure string logic only (no Node built-ins) so it is safe in the browser:
|
|
11
9
|
* site-kit itself normalizes `__SITE_KIT_SITE__` with `normalizeSiteHost`.
|
|
@@ -28,7 +26,7 @@ export declare function stripWww(host: string): string;
|
|
|
28
26
|
*/
|
|
29
27
|
export declare function sameSite(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
30
28
|
/**
|
|
31
|
-
* Host of a full URL ("https://
|
|
29
|
+
* Host of a full URL ("https://ohio.example.com/x" → "ohio.example.com"),
|
|
32
30
|
* null for site-relative paths ("/services") and unparseable input.
|
|
33
31
|
*/
|
|
34
32
|
export declare function hostFromUrl(value: string | null | undefined): string | null;
|
package/dist/slot-content.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The pure half of the slots contract: content types, value validation
|
|
5
5
|
* (normalizeSlotValue), safe links, and the rich-text subset parser. No
|
|
6
6
|
* crypto, so the dashboard can import it in the browser to validate and
|
|
7
|
-
* preview exactly what
|
|
7
|
+
* preview exactly what the Sonor API will accept and site-kit will render.
|
|
8
8
|
* `@sonordev/contracts/slots` re-exports all of it next to the signing.
|
|
9
9
|
*/
|
|
10
10
|
/**
|
|
@@ -66,7 +66,7 @@ export type SlotValueResult = {
|
|
|
66
66
|
/**
|
|
67
67
|
* Validate and normalize what an editor (or Signal) submits for a slot of
|
|
68
68
|
* `type`, returning the canonical `content` + `value` to store and sign.
|
|
69
|
-
* The single rule for "what a valid slot of this type is":
|
|
69
|
+
* The single rule for "what a valid slot of this type is": the Sonor API runs it
|
|
70
70
|
* on every write and verifySlotContent re-runs it on every read.
|
|
71
71
|
*/
|
|
72
72
|
export declare function normalizeSlotValue(type: SlotContentType, input: {
|
package/dist/slots.d.ts
CHANGED
|
@@ -3,23 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for the slot payload shape, value validation, the
|
|
5
5
|
* rich-text subset, and content signing, shared by site-kit (verify + render),
|
|
6
|
-
*
|
|
6
|
+
* the Sonor API (validate + sign) and the dashboard (preview). Node only: it signs
|
|
7
7
|
* with `crypto`.
|
|
8
8
|
*
|
|
9
9
|
* Security model: every SlotContent is HMAC-signed with the project API key.
|
|
10
10
|
* site-kit verifies before rendering and falls back to the developer's static
|
|
11
11
|
* content on any mismatch, so platform-originated content carries provenance
|
|
12
|
-
* end to end.
|
|
12
|
+
* end to end.
|
|
13
13
|
*
|
|
14
14
|
* v1 (site-kit ≤ 7.0) is plain text only. v2 adds rich text, links and lists,
|
|
15
|
-
* and signs the structured value too.
|
|
15
|
+
* and signs the structured value too. Sonor serves both: a v1 request gets
|
|
16
16
|
* text slots signed the v1 way and null for everything else.
|
|
17
17
|
*/
|
|
18
18
|
import { type SlotContentType, type SlotValue } from './slot-content';
|
|
19
19
|
export * from './slot-content';
|
|
20
20
|
/** The version this package speaks. Bump on breaking payload changes. */
|
|
21
21
|
export declare const SLOTS_CONTRACT_VERSION = 2;
|
|
22
|
-
/** Versions
|
|
22
|
+
/** Versions Sonor still serves. */
|
|
23
23
|
export declare const SUPPORTED_SLOTS_CONTRACT_VERSIONS: readonly number[];
|
|
24
24
|
export interface SlotContent {
|
|
25
25
|
slot_id: string;
|
|
@@ -63,7 +63,7 @@ export interface SlotResolveRequestEntry {
|
|
|
63
63
|
export interface SlotsResolveRequest {
|
|
64
64
|
contract_version: number;
|
|
65
65
|
slots: SlotResolveRequestEntry[];
|
|
66
|
-
/** Multi-site sub-dimension: normalized lowercase host
|
|
66
|
+
/** Multi-site sub-dimension: normalized lowercase host. */
|
|
67
67
|
site?: string;
|
|
68
68
|
}
|
|
69
69
|
export interface SlotsResolveResponse {
|
|
@@ -83,7 +83,7 @@ type Signable = Pick<SlotContent, 'slot_id' | 'type' | 'content'> & {
|
|
|
83
83
|
*/
|
|
84
84
|
export declare function slotSigningPayload(content: Signable, version?: number): string;
|
|
85
85
|
/**
|
|
86
|
-
* Sign slot content with the project API key (
|
|
86
|
+
* Sign slot content with the project API key (Sonor's side).
|
|
87
87
|
* Returns the hex HMAC-SHA256 signature.
|
|
88
88
|
*/
|
|
89
89
|
export declare function signSlotContent(content: Signable, apiKey: string, version?: number): string;
|
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/dist/website.d.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* SINGLE SOURCE OF TRUTH shared by every side of the Website module's popups:
|
|
6
6
|
* - the site (site-kit measures its own rendered design and reports it,
|
|
7
7
|
* publishes it as `--sk-site-*` CSS variables, and renders popup blocks),
|
|
8
|
-
* -
|
|
8
|
+
* - the Sonor API (validates the design report and every popup write with these
|
|
9
9
|
* normalizers, so a popup can't be saved in a shape the site can't draw),
|
|
10
10
|
* - the dashboard (the builder's preview reads the same tokens through the
|
|
11
11
|
* same CSS-variable mapping, so it looks like the site),
|
|
12
12
|
* - Signal's agents (the block shapes an MCP `propose_site_popup` sends).
|
|
13
13
|
*
|
|
14
14
|
* All four import it: site-kit as `@sonordev/site-kit/website/contract`,
|
|
15
|
-
*
|
|
15
|
+
* the API and the dashboard as `@sonordev/contracts/website`.
|
|
16
16
|
*
|
|
17
17
|
* Why popups carry blocks and the site carries tokens: a popup should look
|
|
18
18
|
* like the site it's on. It says WHAT (a heading, some formatted text, an
|
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.1",
|
|
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": {
|