@sonordev/contracts 1.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 +76 -0
- package/README.md +117 -2
- package/dist/site-cache.d.ts +55 -0
- package/dist/site-cache.js +23 -0
- package/dist/site-cache.mjs +19 -0
- package/dist/site-edit-param.d.ts +7 -0
- package/dist/site-edit.d.ts +77 -0
- package/dist/site-edit.js +179 -0
- package/dist/site-edit.mjs +170 -0
- package/dist/slot-content.d.ts +111 -0
- package/dist/slot-content.js +184 -0
- package/dist/slot-content.mjs +169 -0
- package/dist/slots.d.ts +39 -25
- package/dist/slots.js +206 -10
- package/dist/slots.mjs +195 -11
- package/dist/voice.d.ts +56 -0
- package/dist/voice.js +200 -0
- package/dist/voice.mjs +195 -0
- package/package.json +22 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
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
|
+
|
|
51
|
+
## 2.0.0
|
|
52
|
+
|
|
53
|
+
A major for one entry, `slots`, plus three new entries; every other entry is unchanged.
|
|
54
|
+
|
|
55
|
+
- **Slots contract v2**: `rich`, `link` and `list` content types beside
|
|
56
|
+
`text`, with a structured `value` that the signature now covers.
|
|
57
|
+
`SLOTS_CONTRACT_VERSION` is 2 and `signSlotContent` / `verifySlotContent`
|
|
58
|
+
default to v2. Pass `1` as the version to keep signing and verifying the
|
|
59
|
+
pre-v2 text-only form, which older site-kit releases verify.
|
|
60
|
+
- **`normalizeSlotValue(type, { content, value })`**: the one rule for what
|
|
61
|
+
a valid slot of each type is, returning the canonical content and value to
|
|
62
|
+
store and sign.
|
|
63
|
+
- **`parseSlotRichText(markdown)`**: the rich-text subset (paragraphs, line
|
|
64
|
+
breaks, bold, italic, safe links, lists) as a small AST to render as
|
|
65
|
+
elements, never HTML.
|
|
66
|
+
- **New entry `@sonordev/contracts/slot-content`**: the pure half of the
|
|
67
|
+
slots contract (types, `normalizeSlotValue`, `isSafeSlotHref`,
|
|
68
|
+
`parseSlotRichText`) with no `crypto`, so a browser can import it.
|
|
69
|
+
`slots` re-exports all of it.
|
|
70
|
+
- **New entry `@sonordev/contracts/site-cache`**: how Sonor refreshes a
|
|
71
|
+
site's cached pages. `SITE_CACHE_TAGS` (the tag names site-kit's fetches
|
|
72
|
+
carry and sonor-api expires), `SITE_REVALIDATE_PATH`, the webhook payload
|
|
73
|
+
and its `ping`, and `isSiteRevalidatePong`.
|
|
74
|
+
- **New entry `@sonordev/contracts/site-edit`**: Sonor's Edit on page. The
|
|
75
|
+
messages between a framed site and the dashboard, which origins may drive
|
|
76
|
+
the overlay (`isSonorAppOrigin`), and `slotNeedles`, the visible text used
|
|
77
|
+
to find a slot on the page.
|
|
78
|
+
|
|
3
79
|
## 1.0.0
|
|
4
80
|
|
|
5
81
|
First release. The rules site-kit 6.x published as `@sonordev/site-kit/*/contract`
|
package/README.md
CHANGED
|
@@ -17,8 +17,123 @@ pnpm add @sonordev/contracts
|
|
|
17
17
|
| `@sonordev/contracts/llms` | llms.txt sanitizers and contract version | site-kit, sonor-api, Signal's schema validation |
|
|
18
18
|
| `@sonordev/contracts/forms` | The honeypot policy | site-kit forms, sonor-api intake |
|
|
19
19
|
| `@sonordev/contracts/fleet` | The fleet heartbeat's wire shape | site-kit, sonor-api |
|
|
20
|
-
| `@sonordev/contracts/slots` | Slot payload signing (HMAC; Node only) | sonor-api, site-kit on the server |
|
|
20
|
+
| `@sonordev/contracts/slots` | Slot payload signing, contract v2 (HMAC; Node only). Signs and verifies v1 for older site-kit releases | 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 | 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, sonor-api calls it |
|
|
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 |
|
|
21
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.
|
|
22
137
|
|
|
23
138
|
## Why it exists
|
|
24
139
|
|
|
@@ -34,7 +149,7 @@ for everyone else.
|
|
|
34
149
|
CommonJS and ESM both. `require('@sonordev/contracts/website')` works in a
|
|
35
150
|
CommonJS Nest API (including `moduleResolution: "node"`, through
|
|
36
151
|
`typesVersions`); `import` works everywhere else. `slots` imports Node's
|
|
37
|
-
`crypto`, so keep it out of browser code
|
|
152
|
+
`crypto`, so keep it out of browser code; browser code reads `slot-content`.
|
|
38
153
|
|
|
39
154
|
## Changing a rule
|
|
40
155
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How Sonor refreshes a site's cached pages when content changes.
|
|
3
|
+
*
|
|
4
|
+
* A site serves cached pages and caches every fetch from Sonor. When someone
|
|
5
|
+
* edits content in Sonor, sonor-api POSTs the paths and cache tags that
|
|
6
|
+
* changed to the site's revalidation route, authenticated with the project's
|
|
7
|
+
* API key, and the site regenerates just those. site-kit tags its fetches with
|
|
8
|
+
* these names and serves the route (`@sonordev/site-kit/revalidate`);
|
|
9
|
+
* sonor-api sends them. Single source of truth for the names on both sides:
|
|
10
|
+
* never spell a tag as a string literal in either.
|
|
11
|
+
*/
|
|
12
|
+
/** Where the revalidation route lives on every site. */
|
|
13
|
+
export declare const SITE_REVALIDATE_PATH = "/api/seo-revalidate";
|
|
14
|
+
/**
|
|
15
|
+
* One tag per kind of content Sonor serves, so an edit refreshes only the
|
|
16
|
+
* fetches it touched. The values predate this file (sites and
|
|
17
|
+
* agency-site-kit already expire them), so they never change.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SITE_CACHE_TAGS: {
|
|
20
|
+
/** Managed copy (slots). */
|
|
21
|
+
readonly slots: 'sonor-slots';
|
|
22
|
+
/** Managed metadata, schema and FAQs. */
|
|
23
|
+
readonly seo: 'seo';
|
|
24
|
+
/** The publication: articles, their lists and feeds. */
|
|
25
|
+
readonly articles: 'blog';
|
|
26
|
+
/** Article categories and tags. */
|
|
27
|
+
readonly taxonomy: 'editorial-taxonomy';
|
|
28
|
+
/** Portfolio items (agency-site-kit). */
|
|
29
|
+
readonly portfolio: 'portfolio';
|
|
30
|
+
};
|
|
31
|
+
export type SiteCacheTag = (typeof SITE_CACHE_TAGS)[keyof typeof SITE_CACHE_TAGS];
|
|
32
|
+
/** The body sonor-api POSTs to the revalidation route. */
|
|
33
|
+
export interface SiteRevalidatePayload {
|
|
34
|
+
/** Local paths to regenerate, e.g. `['/services/roofing']`. */
|
|
35
|
+
paths?: string[];
|
|
36
|
+
/** Cache tags to expire (see SITE_CACHE_TAGS). */
|
|
37
|
+
tags?: string[];
|
|
38
|
+
/** Regenerate every page. */
|
|
39
|
+
revalidateAll?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Only check that the route is installed and the key matches; nothing is
|
|
42
|
+
* regenerated. sonor-api pings after it registers a site's route, so it
|
|
43
|
+
* knows whether an edit goes live in seconds or waits for the cache to age out.
|
|
44
|
+
*/
|
|
45
|
+
ping?: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** What the route answers a ping with. */
|
|
48
|
+
export interface SiteRevalidatePong {
|
|
49
|
+
ok: true;
|
|
50
|
+
ping: true;
|
|
51
|
+
/** The site-kit version serving the route. */
|
|
52
|
+
version?: string;
|
|
53
|
+
}
|
|
54
|
+
/** True when a response body is a pong from the revalidation route. */
|
|
55
|
+
export declare function isSiteRevalidatePong(body: unknown): body is SiteRevalidatePong;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/site-cache.ts
|
|
4
|
+
var SITE_REVALIDATE_PATH = "/api/seo-revalidate";
|
|
5
|
+
var SITE_CACHE_TAGS = {
|
|
6
|
+
/** Managed copy (slots). */
|
|
7
|
+
slots: "sonor-slots",
|
|
8
|
+
/** Managed metadata, schema and FAQs. */
|
|
9
|
+
seo: "seo",
|
|
10
|
+
/** The publication: articles, their lists and feeds. */
|
|
11
|
+
articles: "blog",
|
|
12
|
+
/** Article categories and tags. */
|
|
13
|
+
taxonomy: "editorial-taxonomy",
|
|
14
|
+
/** Portfolio items (agency-site-kit). */
|
|
15
|
+
portfolio: "portfolio"
|
|
16
|
+
};
|
|
17
|
+
function isSiteRevalidatePong(body) {
|
|
18
|
+
return typeof body === "object" && body !== null && body.ok === true && body.ping === true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
exports.SITE_CACHE_TAGS = SITE_CACHE_TAGS;
|
|
22
|
+
exports.SITE_REVALIDATE_PATH = SITE_REVALIDATE_PATH;
|
|
23
|
+
exports.isSiteRevalidatePong = isSiteRevalidatePong;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/contracts/site-cache.ts
|
|
2
|
+
var SITE_REVALIDATE_PATH = "/api/seo-revalidate";
|
|
3
|
+
var SITE_CACHE_TAGS = {
|
|
4
|
+
/** Managed copy (slots). */
|
|
5
|
+
slots: "sonor-slots",
|
|
6
|
+
/** Managed metadata, schema and FAQs. */
|
|
7
|
+
seo: "seo",
|
|
8
|
+
/** The publication: articles, their lists and feeds. */
|
|
9
|
+
articles: "blog",
|
|
10
|
+
/** Article categories and tags. */
|
|
11
|
+
taxonomy: "editorial-taxonomy",
|
|
12
|
+
/** Portfolio items (agency-site-kit). */
|
|
13
|
+
portfolio: "portfolio"
|
|
14
|
+
};
|
|
15
|
+
function isSiteRevalidatePong(body) {
|
|
16
|
+
return typeof body === "object" && body !== null && body.ok === true && body.ping === true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { SITE_CACHE_TAGS, SITE_REVALIDATE_PATH, isSiteRevalidatePong };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query parameter that asks a framed page to start Sonor's edit overlay.
|
|
3
|
+
* Its own file so site-kit's bridge, which runs for every visitor, imports
|
|
4
|
+
* this string and not the rest of `site-edit` (and the rich-text parser it
|
|
5
|
+
* pulls in). Published through `site-edit`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SITE_EDIT_PARAM = "sonor_edit";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit on page: the messages between a site and the Sonor dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard frames the live site with `?sonor_edit=1`. site-kit sees that
|
|
5
|
+
* it's framed by Sonor, finds the managed copy on the page and outlines it;
|
|
6
|
+
* a click selects a slot, and the dashboard edits it with the person's own
|
|
7
|
+
* Sonor session. Nothing on the site writes anything: it reports what's where
|
|
8
|
+
* and shows a draft in place, and the dashboard does the rest. So the site
|
|
9
|
+
* needs no login, no secret and no cookie.
|
|
10
|
+
*
|
|
11
|
+
* site-kit's overlay (`slots/edit`) and the dashboard's visual editor both
|
|
12
|
+
* read this file. Pure: no DOM, no network.
|
|
13
|
+
*/
|
|
14
|
+
import { type SlotContentType, type SlotValue } from './slot-content';
|
|
15
|
+
export { SITE_EDIT_PARAM } from './site-edit-param';
|
|
16
|
+
/** Protocol version, sent in `ready` so the dashboard can tell an old overlay. */
|
|
17
|
+
export declare const SITE_EDIT_VERSION = 1;
|
|
18
|
+
/** The dashboard's production origin. */
|
|
19
|
+
export declare const SONOR_APP_ORIGIN = "https://app.sonor.io";
|
|
20
|
+
/**
|
|
21
|
+
* Origins allowed to drive the overlay: the dashboard, and localhost for
|
|
22
|
+
* development. A localhost frame is the person's own machine; the overlay
|
|
23
|
+
* never exposes anything the framed page doesn't already show.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isSonorAppOrigin(origin: string): boolean;
|
|
26
|
+
/** What the dashboard asks the overlay to find: a slot as it's live now. */
|
|
27
|
+
export interface EditableSlot {
|
|
28
|
+
/** The slot's row id in Sonor. */
|
|
29
|
+
key: string;
|
|
30
|
+
/** The slot id in code, e.g. `home-hero-headline`. */
|
|
31
|
+
id: string;
|
|
32
|
+
type: SlotContentType;
|
|
33
|
+
/** What the page shows now: the published content, else the site's fallback. */
|
|
34
|
+
content: string | null;
|
|
35
|
+
value?: SlotValue | null;
|
|
36
|
+
}
|
|
37
|
+
/** Dashboard → site. */
|
|
38
|
+
export type SonorToSiteMessage = {
|
|
39
|
+
type: 'sonor-edit:slots';
|
|
40
|
+
slots: EditableSlot[];
|
|
41
|
+
} | {
|
|
42
|
+
type: 'sonor-edit:select';
|
|
43
|
+
key: string | null;
|
|
44
|
+
} | {
|
|
45
|
+
type: 'sonor-edit:preview';
|
|
46
|
+
key: string;
|
|
47
|
+
content?: string | null;
|
|
48
|
+
value?: SlotValue | null;
|
|
49
|
+
} | {
|
|
50
|
+
type: 'sonor-edit:reload';
|
|
51
|
+
};
|
|
52
|
+
/** Site → dashboard. */
|
|
53
|
+
export type SiteToSonorMessage = {
|
|
54
|
+
type: 'sonor-edit:ready';
|
|
55
|
+
version: number;
|
|
56
|
+
path: string;
|
|
57
|
+
title: string;
|
|
58
|
+
} | {
|
|
59
|
+
type: 'sonor-edit:found';
|
|
60
|
+
path: string;
|
|
61
|
+
found: string[];
|
|
62
|
+
} | {
|
|
63
|
+
type: 'sonor-edit:select';
|
|
64
|
+
key: string;
|
|
65
|
+
};
|
|
66
|
+
/** A message from the dashboard, or null for anything else (other scripts post messages too). */
|
|
67
|
+
export declare function parseSonorToSiteMessage(data: unknown): SonorToSiteMessage | null;
|
|
68
|
+
/** A message from the site, or null for anything else. */
|
|
69
|
+
export declare function parseSiteToSonorMessage(data: unknown): SiteToSonorMessage | null;
|
|
70
|
+
/** Collapse whitespace the way a browser lays text out. */
|
|
71
|
+
export declare function normalizeEditText(text: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* The visible text the overlay looks for to find a slot on the page: the
|
|
74
|
+
* text itself, the first paragraph of rich text, a link's label, a list's
|
|
75
|
+
* item titles. Empty strings are dropped.
|
|
76
|
+
*/
|
|
77
|
+
export declare function slotNeedles(slot: EditableSlot): string[];
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/slot-content.ts
|
|
4
|
+
var SLOT_HREF_MAX_LENGTH = 2048;
|
|
5
|
+
function isSafeSlotHref(href) {
|
|
6
|
+
if (typeof href !== "string") return false;
|
|
7
|
+
const h = href.trim();
|
|
8
|
+
if (!h || h.length > SLOT_HREF_MAX_LENGTH) return false;
|
|
9
|
+
if (h.startsWith("/")) return !h.startsWith("//") && !/\s/.test(h);
|
|
10
|
+
if (h.startsWith("#")) return !/\s/.test(h);
|
|
11
|
+
return /^(https?:\/\/[^\s]+|mailto:[^\s]+|tel:[+\d][\d\s().-]*)$/i.test(h);
|
|
12
|
+
}
|
|
13
|
+
var UL_ITEM = /^\s*[-*]\s+(.*)$/;
|
|
14
|
+
var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
|
|
15
|
+
var MAX_INLINE_DEPTH = 4;
|
|
16
|
+
function parseSlotRichText(markdown) {
|
|
17
|
+
const blocks = [];
|
|
18
|
+
const chunks = markdown.replace(/\r\n?/g, "\n").split(/\n\s*\n/);
|
|
19
|
+
for (const chunk of chunks) {
|
|
20
|
+
const lines = chunk.split("\n").filter((l) => l.trim() !== "");
|
|
21
|
+
if (lines.length === 0) continue;
|
|
22
|
+
if (lines.every((l) => UL_ITEM.test(l))) {
|
|
23
|
+
blocks.push({ t: "ul", items: lines.map((l) => parseInline(UL_ITEM.exec(l)[1])) });
|
|
24
|
+
} else if (lines.every((l) => OL_ITEM.test(l))) {
|
|
25
|
+
blocks.push({ t: "ol", items: lines.map((l) => parseInline(OL_ITEM.exec(l)[1])) });
|
|
26
|
+
} else {
|
|
27
|
+
const c = [];
|
|
28
|
+
lines.forEach((line, i) => {
|
|
29
|
+
if (i > 0) c.push({ t: "br" });
|
|
30
|
+
c.push(...parseInline(line.trim()));
|
|
31
|
+
});
|
|
32
|
+
blocks.push({ t: "p", c });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return blocks;
|
|
36
|
+
}
|
|
37
|
+
function parseInline(src, depth = 0) {
|
|
38
|
+
const out = [];
|
|
39
|
+
let text = "";
|
|
40
|
+
const flush = () => {
|
|
41
|
+
if (text) out.push({ t: "text", v: text });
|
|
42
|
+
text = "";
|
|
43
|
+
};
|
|
44
|
+
const nest = depth < MAX_INLINE_DEPTH;
|
|
45
|
+
let i = 0;
|
|
46
|
+
while (i < src.length) {
|
|
47
|
+
const ch = src[i];
|
|
48
|
+
if (ch === "\\" && i + 1 < src.length && /[\\*_[\]()]/.test(src[i + 1])) {
|
|
49
|
+
text += src[i + 1];
|
|
50
|
+
i += 2;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (nest && src.startsWith("**", i)) {
|
|
54
|
+
const end = src.indexOf("**", i + 2);
|
|
55
|
+
if (end > i + 2) {
|
|
56
|
+
flush();
|
|
57
|
+
out.push({ t: "strong", c: parseInline(src.slice(i + 2, end), depth + 1) });
|
|
58
|
+
i = end + 2;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (nest && (ch === "*" || ch === "_")) {
|
|
63
|
+
const end = src.indexOf(ch, i + 1);
|
|
64
|
+
if (end > i + 1 && src[i + 1] !== " " && src[end - 1] !== " ") {
|
|
65
|
+
flush();
|
|
66
|
+
out.push({ t: "em", c: parseInline(src.slice(i + 1, end), depth + 1) });
|
|
67
|
+
i = end + 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (nest && ch === "[") {
|
|
72
|
+
const close = src.indexOf("](", i + 1);
|
|
73
|
+
const end = close === -1 ? -1 : src.indexOf(")", close + 2);
|
|
74
|
+
if (close > i + 1 && end > close + 2) {
|
|
75
|
+
const href = src.slice(close + 2, end).trim();
|
|
76
|
+
if (isSafeSlotHref(href)) {
|
|
77
|
+
flush();
|
|
78
|
+
out.push({ t: "link", href, c: parseInline(src.slice(i + 1, close), depth + 1) });
|
|
79
|
+
i = end + 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
text += ch;
|
|
85
|
+
i += 1;
|
|
86
|
+
}
|
|
87
|
+
flush();
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/contracts/site-edit-param.ts
|
|
92
|
+
var SITE_EDIT_PARAM = "sonor_edit";
|
|
93
|
+
|
|
94
|
+
// src/contracts/site-edit.ts
|
|
95
|
+
var SITE_EDIT_VERSION = 1;
|
|
96
|
+
var SONOR_APP_ORIGIN = "https://app.sonor.io";
|
|
97
|
+
function isSonorAppOrigin(origin) {
|
|
98
|
+
if (origin === SONOR_APP_ORIGIN) return true;
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(origin);
|
|
101
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
var SONOR_TO_SITE = /* @__PURE__ */ new Set(["sonor-edit:slots", "sonor-edit:select", "sonor-edit:preview", "sonor-edit:reload"]);
|
|
107
|
+
var SITE_TO_SONOR = /* @__PURE__ */ new Set(["sonor-edit:ready", "sonor-edit:found", "sonor-edit:select"]);
|
|
108
|
+
function isRecord(value) {
|
|
109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
function parseSonorToSiteMessage(data) {
|
|
112
|
+
if (!isRecord(data) || typeof data.type !== "string" || !SONOR_TO_SITE.has(data.type)) return null;
|
|
113
|
+
switch (data.type) {
|
|
114
|
+
case "sonor-edit:slots":
|
|
115
|
+
return Array.isArray(data.slots) ? { type: data.type, slots: data.slots.filter(isEditableSlot).slice(0, 500) } : null;
|
|
116
|
+
case "sonor-edit:select":
|
|
117
|
+
return data.key === null || typeof data.key === "string" ? { type: data.type, key: data.key } : null;
|
|
118
|
+
case "sonor-edit:preview":
|
|
119
|
+
return typeof data.key === "string" ? {
|
|
120
|
+
type: data.type,
|
|
121
|
+
key: data.key,
|
|
122
|
+
...typeof data.content === "string" || data.content === null ? { content: data.content } : {},
|
|
123
|
+
...isRecord(data.value) || data.value === null ? { value: data.value } : {}
|
|
124
|
+
} : null;
|
|
125
|
+
default:
|
|
126
|
+
return { type: "sonor-edit:reload" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function parseSiteToSonorMessage(data) {
|
|
130
|
+
if (!isRecord(data) || typeof data.type !== "string" || !SITE_TO_SONOR.has(data.type)) return null;
|
|
131
|
+
switch (data.type) {
|
|
132
|
+
case "sonor-edit:ready":
|
|
133
|
+
return typeof data.version === "number" && typeof data.path === "string" ? { type: data.type, version: data.version, path: data.path, title: typeof data.title === "string" ? data.title : "" } : null;
|
|
134
|
+
case "sonor-edit:found":
|
|
135
|
+
return typeof data.path === "string" && Array.isArray(data.found) ? { type: data.type, path: data.path, found: data.found.filter((k) => typeof k === "string") } : null;
|
|
136
|
+
default:
|
|
137
|
+
return typeof data.key === "string" ? { type: "sonor-edit:select", key: data.key } : null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function isEditableSlot(value) {
|
|
141
|
+
return isRecord(value) && typeof value.key === "string" && typeof value.id === "string" && typeof value.type === "string" && (typeof value.content === "string" || value.content === null);
|
|
142
|
+
}
|
|
143
|
+
function normalizeEditText(text) {
|
|
144
|
+
return text.replace(/\s+/g, " ").trim();
|
|
145
|
+
}
|
|
146
|
+
function slotNeedles(slot) {
|
|
147
|
+
const clean = (values) => values.map((v) => normalizeEditText(v ?? "")).filter((v) => v.length > 0);
|
|
148
|
+
switch (slot.type) {
|
|
149
|
+
case "link": {
|
|
150
|
+
const value = slot.value;
|
|
151
|
+
return clean([value?.label ?? slot.content]);
|
|
152
|
+
}
|
|
153
|
+
case "list": {
|
|
154
|
+
const value = slot.value;
|
|
155
|
+
const items = value?.items ?? [];
|
|
156
|
+
return items.length ? clean(items.map((item) => item.title || item.body)) : clean((slot.content ?? "").split("\n"));
|
|
157
|
+
}
|
|
158
|
+
case "rich": {
|
|
159
|
+
const first = parseSlotRichText(slot.content ?? "")[0];
|
|
160
|
+
if (!first) return [];
|
|
161
|
+
const inline = first.t === "p" ? first.c : first.items[0] ?? [];
|
|
162
|
+
return clean([inlineText(inline)]);
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
return clean([slot.content]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function inlineText(nodes) {
|
|
169
|
+
return nodes.map((n) => n.t === "text" ? n.v : n.t === "br" ? " " : inlineText(n.c)).join("");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
exports.SITE_EDIT_PARAM = SITE_EDIT_PARAM;
|
|
173
|
+
exports.SITE_EDIT_VERSION = SITE_EDIT_VERSION;
|
|
174
|
+
exports.SONOR_APP_ORIGIN = SONOR_APP_ORIGIN;
|
|
175
|
+
exports.isSonorAppOrigin = isSonorAppOrigin;
|
|
176
|
+
exports.normalizeEditText = normalizeEditText;
|
|
177
|
+
exports.parseSiteToSonorMessage = parseSiteToSonorMessage;
|
|
178
|
+
exports.parseSonorToSiteMessage = parseSonorToSiteMessage;
|
|
179
|
+
exports.slotNeedles = slotNeedles;
|