@sonordev/contracts 1.0.0 → 2.0.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 +28 -0
- package/README.md +5 -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/package.json +17 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
A major for one entry, `slots`, plus three new entries; every other entry is unchanged.
|
|
6
|
+
|
|
7
|
+
- **Slots contract v2**: `rich`, `link` and `list` content types beside
|
|
8
|
+
`text`, with a structured `value` that the signature now covers.
|
|
9
|
+
`SLOTS_CONTRACT_VERSION` is 2 and `signSlotContent` / `verifySlotContent`
|
|
10
|
+
default to v2. Pass `1` as the version to keep signing and verifying the
|
|
11
|
+
pre-v2 text-only form, which older site-kit releases verify.
|
|
12
|
+
- **`normalizeSlotValue(type, { content, value })`**: the one rule for what
|
|
13
|
+
a valid slot of each type is, returning the canonical content and value to
|
|
14
|
+
store and sign.
|
|
15
|
+
- **`parseSlotRichText(markdown)`**: the rich-text subset (paragraphs, line
|
|
16
|
+
breaks, bold, italic, safe links, lists) as a small AST to render as
|
|
17
|
+
elements, never HTML.
|
|
18
|
+
- **New entry `@sonordev/contracts/slot-content`**: the pure half of the
|
|
19
|
+
slots contract (types, `normalizeSlotValue`, `isSafeSlotHref`,
|
|
20
|
+
`parseSlotRichText`) with no `crypto`, so a browser can import it.
|
|
21
|
+
`slots` re-exports all of it.
|
|
22
|
+
- **New entry `@sonordev/contracts/site-cache`**: how Sonor refreshes a
|
|
23
|
+
site's cached pages. `SITE_CACHE_TAGS` (the tag names site-kit's fetches
|
|
24
|
+
carry and sonor-api expires), `SITE_REVALIDATE_PATH`, the webhook payload
|
|
25
|
+
and its `ping`, and `isSiteRevalidatePong`.
|
|
26
|
+
- **New entry `@sonordev/contracts/site-edit`**: Sonor's Edit on page. The
|
|
27
|
+
messages between a framed site and the dashboard, which origins may drive
|
|
28
|
+
the overlay (`isSonorAppOrigin`), and `slotNeedles`, the visible text used
|
|
29
|
+
to find a slot on the page.
|
|
30
|
+
|
|
3
31
|
## 1.0.0
|
|
4
32
|
|
|
5
33
|
First release. The rules site-kit 6.x published as `@sonordev/site-kit/*/contract`
|
package/README.md
CHANGED
|
@@ -17,7 +17,10 @@ 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 |
|
|
22
25
|
|
|
23
26
|
## Why it exists
|
|
@@ -34,7 +37,7 @@ for everyone else.
|
|
|
34
37
|
CommonJS and ESM both. `require('@sonordev/contracts/website')` works in a
|
|
35
38
|
CommonJS Nest API (including `moduleResolution: "node"`, through
|
|
36
39
|
`typesVersions`); `import` works everywhere else. `slots` imports Node's
|
|
37
|
-
`crypto`, so keep it out of browser code
|
|
40
|
+
`crypto`, so keep it out of browser code; browser code reads `slot-content`.
|
|
38
41
|
|
|
39
42
|
## Changing a rule
|
|
40
43
|
|
|
@@ -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;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// src/contracts/slot-content.ts
|
|
2
|
+
var SLOT_HREF_MAX_LENGTH = 2048;
|
|
3
|
+
function isSafeSlotHref(href) {
|
|
4
|
+
if (typeof href !== "string") return false;
|
|
5
|
+
const h = href.trim();
|
|
6
|
+
if (!h || h.length > SLOT_HREF_MAX_LENGTH) return false;
|
|
7
|
+
if (h.startsWith("/")) return !h.startsWith("//") && !/\s/.test(h);
|
|
8
|
+
if (h.startsWith("#")) return !/\s/.test(h);
|
|
9
|
+
return /^(https?:\/\/[^\s]+|mailto:[^\s]+|tel:[+\d][\d\s().-]*)$/i.test(h);
|
|
10
|
+
}
|
|
11
|
+
var UL_ITEM = /^\s*[-*]\s+(.*)$/;
|
|
12
|
+
var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
|
|
13
|
+
var MAX_INLINE_DEPTH = 4;
|
|
14
|
+
function parseSlotRichText(markdown) {
|
|
15
|
+
const blocks = [];
|
|
16
|
+
const chunks = markdown.replace(/\r\n?/g, "\n").split(/\n\s*\n/);
|
|
17
|
+
for (const chunk of chunks) {
|
|
18
|
+
const lines = chunk.split("\n").filter((l) => l.trim() !== "");
|
|
19
|
+
if (lines.length === 0) continue;
|
|
20
|
+
if (lines.every((l) => UL_ITEM.test(l))) {
|
|
21
|
+
blocks.push({ t: "ul", items: lines.map((l) => parseInline(UL_ITEM.exec(l)[1])) });
|
|
22
|
+
} else if (lines.every((l) => OL_ITEM.test(l))) {
|
|
23
|
+
blocks.push({ t: "ol", items: lines.map((l) => parseInline(OL_ITEM.exec(l)[1])) });
|
|
24
|
+
} else {
|
|
25
|
+
const c = [];
|
|
26
|
+
lines.forEach((line, i) => {
|
|
27
|
+
if (i > 0) c.push({ t: "br" });
|
|
28
|
+
c.push(...parseInline(line.trim()));
|
|
29
|
+
});
|
|
30
|
+
blocks.push({ t: "p", c });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return blocks;
|
|
34
|
+
}
|
|
35
|
+
function parseInline(src, depth = 0) {
|
|
36
|
+
const out = [];
|
|
37
|
+
let text = "";
|
|
38
|
+
const flush = () => {
|
|
39
|
+
if (text) out.push({ t: "text", v: text });
|
|
40
|
+
text = "";
|
|
41
|
+
};
|
|
42
|
+
const nest = depth < MAX_INLINE_DEPTH;
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < src.length) {
|
|
45
|
+
const ch = src[i];
|
|
46
|
+
if (ch === "\\" && i + 1 < src.length && /[\\*_[\]()]/.test(src[i + 1])) {
|
|
47
|
+
text += src[i + 1];
|
|
48
|
+
i += 2;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (nest && src.startsWith("**", i)) {
|
|
52
|
+
const end = src.indexOf("**", i + 2);
|
|
53
|
+
if (end > i + 2) {
|
|
54
|
+
flush();
|
|
55
|
+
out.push({ t: "strong", c: parseInline(src.slice(i + 2, end), depth + 1) });
|
|
56
|
+
i = end + 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (nest && (ch === "*" || ch === "_")) {
|
|
61
|
+
const end = src.indexOf(ch, i + 1);
|
|
62
|
+
if (end > i + 1 && src[i + 1] !== " " && src[end - 1] !== " ") {
|
|
63
|
+
flush();
|
|
64
|
+
out.push({ t: "em", c: parseInline(src.slice(i + 1, end), depth + 1) });
|
|
65
|
+
i = end + 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (nest && ch === "[") {
|
|
70
|
+
const close = src.indexOf("](", i + 1);
|
|
71
|
+
const end = close === -1 ? -1 : src.indexOf(")", close + 2);
|
|
72
|
+
if (close > i + 1 && end > close + 2) {
|
|
73
|
+
const href = src.slice(close + 2, end).trim();
|
|
74
|
+
if (isSafeSlotHref(href)) {
|
|
75
|
+
flush();
|
|
76
|
+
out.push({ t: "link", href, c: parseInline(src.slice(i + 1, close), depth + 1) });
|
|
77
|
+
i = end + 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
text += ch;
|
|
83
|
+
i += 1;
|
|
84
|
+
}
|
|
85
|
+
flush();
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/contracts/site-edit-param.ts
|
|
90
|
+
var SITE_EDIT_PARAM = "sonor_edit";
|
|
91
|
+
|
|
92
|
+
// src/contracts/site-edit.ts
|
|
93
|
+
var SITE_EDIT_VERSION = 1;
|
|
94
|
+
var SONOR_APP_ORIGIN = "https://app.sonor.io";
|
|
95
|
+
function isSonorAppOrigin(origin) {
|
|
96
|
+
if (origin === SONOR_APP_ORIGIN) return true;
|
|
97
|
+
try {
|
|
98
|
+
const url = new URL(origin);
|
|
99
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
var SONOR_TO_SITE = /* @__PURE__ */ new Set(["sonor-edit:slots", "sonor-edit:select", "sonor-edit:preview", "sonor-edit:reload"]);
|
|
105
|
+
var SITE_TO_SONOR = /* @__PURE__ */ new Set(["sonor-edit:ready", "sonor-edit:found", "sonor-edit:select"]);
|
|
106
|
+
function isRecord(value) {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
109
|
+
function parseSonorToSiteMessage(data) {
|
|
110
|
+
if (!isRecord(data) || typeof data.type !== "string" || !SONOR_TO_SITE.has(data.type)) return null;
|
|
111
|
+
switch (data.type) {
|
|
112
|
+
case "sonor-edit:slots":
|
|
113
|
+
return Array.isArray(data.slots) ? { type: data.type, slots: data.slots.filter(isEditableSlot).slice(0, 500) } : null;
|
|
114
|
+
case "sonor-edit:select":
|
|
115
|
+
return data.key === null || typeof data.key === "string" ? { type: data.type, key: data.key } : null;
|
|
116
|
+
case "sonor-edit:preview":
|
|
117
|
+
return typeof data.key === "string" ? {
|
|
118
|
+
type: data.type,
|
|
119
|
+
key: data.key,
|
|
120
|
+
...typeof data.content === "string" || data.content === null ? { content: data.content } : {},
|
|
121
|
+
...isRecord(data.value) || data.value === null ? { value: data.value } : {}
|
|
122
|
+
} : null;
|
|
123
|
+
default:
|
|
124
|
+
return { type: "sonor-edit:reload" };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function parseSiteToSonorMessage(data) {
|
|
128
|
+
if (!isRecord(data) || typeof data.type !== "string" || !SITE_TO_SONOR.has(data.type)) return null;
|
|
129
|
+
switch (data.type) {
|
|
130
|
+
case "sonor-edit:ready":
|
|
131
|
+
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;
|
|
132
|
+
case "sonor-edit:found":
|
|
133
|
+
return typeof data.path === "string" && Array.isArray(data.found) ? { type: data.type, path: data.path, found: data.found.filter((k) => typeof k === "string") } : null;
|
|
134
|
+
default:
|
|
135
|
+
return typeof data.key === "string" ? { type: "sonor-edit:select", key: data.key } : null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function isEditableSlot(value) {
|
|
139
|
+
return isRecord(value) && typeof value.key === "string" && typeof value.id === "string" && typeof value.type === "string" && (typeof value.content === "string" || value.content === null);
|
|
140
|
+
}
|
|
141
|
+
function normalizeEditText(text) {
|
|
142
|
+
return text.replace(/\s+/g, " ").trim();
|
|
143
|
+
}
|
|
144
|
+
function slotNeedles(slot) {
|
|
145
|
+
const clean = (values) => values.map((v) => normalizeEditText(v ?? "")).filter((v) => v.length > 0);
|
|
146
|
+
switch (slot.type) {
|
|
147
|
+
case "link": {
|
|
148
|
+
const value = slot.value;
|
|
149
|
+
return clean([value?.label ?? slot.content]);
|
|
150
|
+
}
|
|
151
|
+
case "list": {
|
|
152
|
+
const value = slot.value;
|
|
153
|
+
const items = value?.items ?? [];
|
|
154
|
+
return items.length ? clean(items.map((item) => item.title || item.body)) : clean((slot.content ?? "").split("\n"));
|
|
155
|
+
}
|
|
156
|
+
case "rich": {
|
|
157
|
+
const first = parseSlotRichText(slot.content ?? "")[0];
|
|
158
|
+
if (!first) return [];
|
|
159
|
+
const inline = first.t === "p" ? first.c : first.items[0] ?? [];
|
|
160
|
+
return clean([inlineText(inline)]);
|
|
161
|
+
}
|
|
162
|
+
default:
|
|
163
|
+
return clean([slot.content]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function inlineText(nodes) {
|
|
167
|
+
return nodes.map((n) => n.t === "text" ? n.v : n.t === "br" ? " " : inlineText(n.c)).join("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { SITE_EDIT_PARAM, SITE_EDIT_VERSION, SONOR_APP_ORIGIN, isSonorAppOrigin, normalizeEditText, parseSiteToSonorMessage, parseSonorToSiteMessage, slotNeedles };
|