@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 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;