@sonordev/contracts 2.5.0 → 2.7.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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.0
4
+
5
+ - Add `editorial-media`: shared section/takeaway text identities, article photo placement diagnostics, and source-image eligibility constants for APIs and site renderers.
6
+
7
+
8
+ ## 2.6.0
9
+
10
+ A minor release: one new entry, `@sonordev/contracts/knowledge`. Every other entry is unchanged.
11
+
12
+ - **New entry `@sonordev/contracts/knowledge`**: which Sonor records Signal reads into a project's knowledge, where each one's knowledge is stored, and how big a page sync may be, so the API that announces a change and Signal, which indexes it, agree.
13
+ - `KNOWLEDGE_RECORD_KINDS` lists the record kinds (`portfolio`, `proof`, `faq`, `review`, `article`, `offering`), and `isKnowledgeRecordKind(value)` checks one.
14
+ - `knowledgeRecordSourceUrl(kind, id, anchor?)` builds a record's address (`faq://<id>`, `article://<id>#pricing`) and `knowledgeRecordPrefix(kind)` the `<kind>://` all of a kind's addresses start with.
15
+ - `knowledgeRecordKindOf(url)` tells a record's row from a page passage by its scheme, and `knowledgeRecordRefOf(url)` returns the `{ kind, id }` an address names, the legacy `commerce://offering/<id>` (`legacyOfferingSourceUrl`) included.
16
+ - `KNOWLEDGE_SYNC_MAX_PAGES` (500), `KNOWLEDGE_SYNC_PATH_MAX_LENGTH` (2048) and `KNOWLEDGE_SYNC_SITE_MAX_LENGTH` (253) are the limits of one page sync, and `KnowledgePageChange` is the shape of each page in it. Signal applies every removal in a call and reads the changed pages in the order given, so a sender puts removals first.
17
+
3
18
  ## 2.5.0
4
19
 
5
20
  A minor release: one new entry, `@sonordev/contracts/sentences`, and a cache tag for commerce. Every other entry is unchanged.
package/README.md CHANGED
@@ -27,6 +27,7 @@ pnpm add @sonordev/contracts
27
27
  | `@sonordev/contracts/error-page` | Whether stored text is a rendered error page rather than a page's content, and whether generated copy says the page itself is missing | anything that stores page text or writes page copy from it: the Sonor API's page-view and sitemap writers, Signal's metadata generators |
28
28
  | `@sonordev/contracts/schema-placeholders` | Whether stored JSON-LD carries a template's unfilled slots (`example.com` URLs, an `Organization` named "Example", a placeholder phone, `[Resident Name]`, `{plan.name}`), and the value without them | site-kit's schema output, the Sonor API's schema reads and writes |
29
29
  | `@sonordev/contracts/sentences` | Where prose splits into sentences, without dropping a word or ending one at "p.m." (`splitSentences`) | anything that cuts an excerpt, a social slide or a summary out of an article: the Sonor API's carousels, Signal's article pipeline |
30
+ | `@sonordev/contracts/knowledge` | Which Sonor records Signal reads into a project's knowledge, the address each one's knowledge is stored under (`portfolio://<id>`), and the limits of the call that tells Signal a site's pages changed | the Sonor API sends those calls and Signal answers them |
30
31
 
31
32
  ## Schema placeholders
32
33
 
@@ -350,6 +351,69 @@ short forms (a period after one it doesn't know ends a sentence, so "Sra.
350
351
  García" splits after "Sra."), and the next sentence has to start with `A` to
351
352
  `Z` or a digit.
352
353
 
354
+ ## The knowledge contract
355
+
356
+ Signal answers questions about a business from what it has read: the
357
+ passages of the site's live pages, and the business's own records in Sonor.
358
+ The Sonor API tells Signal when either changes, and Signal indexes it. Both
359
+ have to agree on which records count, how a stored row says which record it
360
+ came from, and how big a page sync may be. If they don't, a record the API
361
+ announces is refused, or a row Signal wrote reads as a page. Anything that
362
+ shows what Signal has read, like the dashboard's view of it, reads stored
363
+ addresses by the same rules.
364
+
365
+ - **Record kinds.** `KNOWLEDGE_RECORD_KINDS` is `portfolio` (case studies),
366
+ `proof` (proof records), `faq` (managed FAQs), `review`, `article` and
367
+ `offering` (commerce offerings), in the order the dashboard lists them.
368
+ `isKnowledgeRecordKind(value)` is true only for one of them, spelled exactly.
369
+ - **Addresses.** A record's knowledge is stored under `<kind>://<id>`, and
370
+ any further chunk under `<kind>://<id>#<anchor>` (an article indexed in full
371
+ is split by heading). `knowledgeRecordSourceUrl(kind, id, anchor?)` builds
372
+ one and `knowledgeRecordPrefix(kind)` gives the `<kind>://` every address of
373
+ that kind starts with. The id is the record's UUID, and the caller checks
374
+ it: one holding `#`, `?`, `/` or whitespace can't be read back. A page's
375
+ passages are stored under the page's https URL instead, so the scheme alone
376
+ tells a record's rows apart. The content type can't: an article's record
377
+ has the same type as the page passages beside it, because a live article is
378
+ a page on the site.
379
+ - **Reading an address.** `knowledgeRecordKindOf(url)` is the kind by scheme
380
+ alone, or `null` for a page passage, an older page summary or a note. It's
381
+ the test for whether a row belongs to a record. `knowledgeRecordRefOf(url)`
382
+ is `{ kind, id }` for the record an address names, or `null` when it names
383
+ none; the id runs to the first `#`, `?`, `/` or whitespace. Both ignore the
384
+ scheme's case and surrounding whitespace.
385
+ - **The legacy offering address.** Offerings were once stored under
386
+ `commerce://offering/<id>` (`legacyOfferingSourceUrl(id)`). Nothing writes
387
+ it now, and Signal switches each such row off the next time its offering
388
+ syncs. Until then `knowledgeRecordRefOf` names the offering, while
389
+ `knowledgeRecordKindOf` doesn't count the row as a record's.
390
+ - **A page sync.** One call names at most `KNOWLEDGE_SYNC_MAX_PAGES` (500)
391
+ pages, each a `KnowledgePageChange` (`{ site?, path, removed? }`) with a
392
+ path of at most `KNOWLEDGE_SYNC_PATH_MAX_LENGTH` (2048) characters and a
393
+ host of at most `KNOWLEDGE_SYNC_SITE_MAX_LENGTH` (253). Signal refuses a
394
+ longer list, path or host outright, so a sender keeps within them before it
395
+ calls. Signal applies every removal in the call, then reads the changed
396
+ pages in the order given, as many as it reads in one call; the rest wait for
397
+ its nightly sweep. So put removals first, then the pages that matter most,
398
+ and cut the list after ordering it.
399
+
400
+ ```ts
401
+ import {
402
+ KNOWLEDGE_SYNC_MAX_PAGES,
403
+ knowledgeRecordRefOf,
404
+ knowledgeRecordSourceUrl,
405
+ } from '@sonordev/contracts/knowledge'
406
+
407
+ knowledgeRecordSourceUrl('faq', id)
408
+ // 'faq://<id>'
409
+
410
+ knowledgeRecordRefOf('article://<id>#part-2')
411
+ // { kind: 'article', id: '<id>' }
412
+
413
+ const batch = [...removed, ...changed].slice(0, KNOWLEDGE_SYNC_MAX_PAGES)
414
+ // removals first, so the cut drops changed pages before any removal
415
+ ```
416
+
353
417
  ## Why it exists
354
418
 
355
419
  A rule that lives in several copies is how two of them end up disagreeing.
@@ -370,3 +434,8 @@ Edit `src/contracts/<entry>.ts` in the site-kit repo, run
370
434
  `pnpm test:contracts`, and bump this package's version. A breaking change to
371
435
  a shape bumps the major and that contract's own `*_VERSION` constant.
372
436
  `pnpm build:contracts` builds it and checks every entry loads both ways.
437
+
438
+
439
+ ### Editorial media
440
+
441
+ `@sonordev/contracts/editorial-media` exports `resolveArticlePhotoPlacements(html, photos)`, returning section targets and a status/offset for each explicit placement. Both the editor and article renderer use this contract; a heading with no eligible prose is reported as `no_paragraph`, and missing/ambiguous anchors never drift to another section. `editorialTextId` creates content identities for explicit takeaway assignments. Source-image format, size and completed-art exclusions are shared by the picker and resolver.
@@ -0,0 +1,39 @@
1
+ /** Shared article placement and source eligibility rules. Pure, server/client safe. */
2
+ export type EditorialPhotoPlacement = {
3
+ kind: 'after_intro';
4
+ } | {
5
+ kind: 'after_heading';
6
+ heading: string;
7
+ headingId?: string;
8
+ };
9
+ export type ArticlePhotoPlacementStatus = 'placed' | 'missing_heading' | 'ambiguous_heading' | 'no_paragraph' | 'already_present' | 'unassigned';
10
+ export interface EditorialHeadingTarget {
11
+ id: string;
12
+ text: string;
13
+ level: number;
14
+ eligible: boolean;
15
+ }
16
+ export declare function editorialPlainText(value: string): string;
17
+ /** A text identity, never a positional index. Editing the meaning requires a new assignment. */
18
+ export declare function editorialTextId(kind: 'section' | 'takeaway', text: string): string;
19
+ export declare function resolveArticlePhotoPlacements(html: string, photos?: Array<{
20
+ placement?: EditorialPhotoPlacement;
21
+ url?: string;
22
+ }>): {
23
+ headings: {
24
+ id: string;
25
+ text: string;
26
+ level: number;
27
+ eligible: boolean;
28
+ }[];
29
+ placements: {
30
+ index: number;
31
+ status: ArticlePhotoPlacementStatus;
32
+ offset?: number;
33
+ }[];
34
+ };
35
+ export declare const EDITORIAL_PHOTO_MIME_TYPES: readonly ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/avif'];
36
+ export declare const EDITORIAL_PHOTO_MAX_BYTES: number;
37
+ /** The same generated-art path exclusion is used by the picker and source resolver. */
38
+ export declare const FINISHED_EDITORIAL_ART_PATHS: readonly ['Blog/Cards', 'Blog/Carousel', 'Broadcast/Cards', 'Broadcast/StylePreview', 'Reviews/Broadcast', 'Reviews/Photo', 'Reviews/StylePreview'];
39
+ export declare function isFinishedEditorialArtwork(path: string): boolean;
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/editorial-media.ts
4
+ var voidElements = /* @__PURE__ */ new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
5
+ var rawElements = /* @__PURE__ */ new Set(["pre", "code", "script", "style", "textarea", "xmp", "template"]);
6
+ var excludedAncestors = /* @__PURE__ */ new Set(["figure", "table", "ul", "ol", "blockquote", "aside", "nav", "header", "footer"]);
7
+ var tags = () => /<!--[\s\S]*?(?:-->|$)|<\/?[a-z][\w:-]*(?=[\s/>])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
8
+ function editorialPlainText(value) {
9
+ const entities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", ndash: "\u2013", mdash: "\u2014" };
10
+ return value.replace(/<!--[\s\S]*?-->/g, "").replace(tags(), "").replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (full, entity) => {
11
+ if (!entity.startsWith("#")) return entities[entity.toLowerCase()] ?? full;
12
+ const number = entity[1].toLowerCase() === "x" ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
13
+ return number > 0 && number <= 1114111 ? String.fromCodePoint(number) : full;
14
+ }).replace(/\s+/g, " ").trim();
15
+ }
16
+ function editorialTextId(kind, text) {
17
+ const normalized = editorialPlainText(text);
18
+ let hash = 2166136261;
19
+ for (let i = 0; i < normalized.length; i++) hash = Math.imul(hash ^ normalized.charCodeAt(i), 16777619);
20
+ return `${kind}-${(hash >>> 0).toString(16).padStart(8, "0")}-${normalized.length}`;
21
+ }
22
+ function attributes(tag) {
23
+ const out = /* @__PURE__ */ new Map();
24
+ const body = tag.replace(/^<[^\s>]+/, "").replace(/\/?\s*>$/, "");
25
+ for (const match of body.matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) out.set(match[1].toLowerCase(), editorialPlainText(match[2] ?? match[3] ?? match[4] ?? ""));
26
+ return out;
27
+ }
28
+ function resolveArticlePhotoPlacements(html, photos = []) {
29
+ const pattern = tags();
30
+ const stack = [];
31
+ const headings = [];
32
+ const paragraphs = [];
33
+ const existing = /* @__PURE__ */ new Set(), existingSlots = /* @__PURE__ */ new Set();
34
+ let token;
35
+ while (token = pattern.exec(html)) {
36
+ const tag = token[0];
37
+ if (tag.startsWith("<!--")) continue;
38
+ const name = /^<\/?([\w:-]+)/.exec(tag)[1].toLowerCase();
39
+ if (tag.startsWith("</")) {
40
+ const at = stack.map((frame2) => frame2.name).lastIndexOf(name);
41
+ if (at < 0) continue;
42
+ const frame = stack[at];
43
+ if (frame.eligible) {
44
+ const text = editorialPlainText(html.slice(frame.inner, token.index));
45
+ if (/^h[1-6]$/.test(name)) headings.push({ id: frame.id || editorialTextId("section", text), text, level: Number(name[1]), start: frame.start, end: pattern.lastIndex, eligible: false });
46
+ if (name === "p" && text) paragraphs.push({ start: frame.start, end: pattern.lastIndex });
47
+ }
48
+ stack.length = at;
49
+ continue;
50
+ }
51
+ if (rawElements.has(name)) {
52
+ const closing = new RegExp(`</${name}\\s*>`, "gi");
53
+ closing.lastIndex = pattern.lastIndex;
54
+ if (!closing.exec(html)) break;
55
+ pattern.lastIndex = closing.lastIndex;
56
+ continue;
57
+ }
58
+ const attrs = attributes(tag);
59
+ if (name === "img" && attrs.has("src")) existing.add(attrs.get("src"));
60
+ if (name === "figure" && /^\d+$/.test(attrs.get("data-sk-editorial-photo") ?? "")) existingSlots.add(Number(attrs.get("data-sk-editorial-photo")));
61
+ if (voidElements.has(name) || /\/\s*>$/.test(tag)) continue;
62
+ stack.push({ name, start: token.index, inner: pattern.lastIndex, id: attrs.get("id"), eligible: !stack.some((frame) => excludedAncestors.has(frame.name)) });
63
+ }
64
+ const paragraphFor = (heading) => {
65
+ const next = headings.find((item) => item.start > heading.start);
66
+ return paragraphs.find((p) => p.start >= heading.end && (!next || p.end < next.start));
67
+ };
68
+ for (const heading of headings) heading.eligible = !!paragraphFor(heading);
69
+ const placements = photos.map((photo, index) => {
70
+ if (existingSlots.has(index) || photo.url && existing.has(photo.url)) return { index, status: "already_present" };
71
+ let paragraph;
72
+ if (photo.placement?.kind === "after_intro") {
73
+ const firstSection = headings.find((h) => h.level > 1);
74
+ paragraph = paragraphs.find((p) => !firstSection || p.start < firstSection.start);
75
+ } else if (photo.placement?.kind === "after_heading") {
76
+ const placement = photo.placement;
77
+ const matches = headings.filter((h) => placement.headingId ? h.id === placement.headingId : h.text === editorialPlainText(placement.heading));
78
+ if (matches.length !== 1) return { index, status: matches.length ? "ambiguous_heading" : "missing_heading" };
79
+ paragraph = paragraphFor(matches[0]);
80
+ } else return { index, status: "unassigned" };
81
+ if (!paragraph) return { index, status: "no_paragraph" };
82
+ if (photo.url) existing.add(photo.url);
83
+ return { index, status: "placed", offset: paragraph.end };
84
+ });
85
+ return { headings: headings.map(({ start: _start, end: _end, ...heading }) => heading), placements };
86
+ }
87
+ var EDITORIAL_PHOTO_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/avif"];
88
+ var EDITORIAL_PHOTO_MAX_BYTES = 10 * 1024 * 1024;
89
+ var FINISHED_EDITORIAL_ART_PATHS = ["Blog/Cards", "Blog/Carousel", "Broadcast/Cards", "Broadcast/StylePreview", "Reviews/Broadcast", "Reviews/Photo", "Reviews/StylePreview"];
90
+ function isFinishedEditorialArtwork(path) {
91
+ const normalized = `/${path}`.toLowerCase();
92
+ return FINISHED_EDITORIAL_ART_PATHS.some((part) => normalized.includes(`/${part.toLowerCase()}/`));
93
+ }
94
+
95
+ exports.EDITORIAL_PHOTO_MAX_BYTES = EDITORIAL_PHOTO_MAX_BYTES;
96
+ exports.EDITORIAL_PHOTO_MIME_TYPES = EDITORIAL_PHOTO_MIME_TYPES;
97
+ exports.FINISHED_EDITORIAL_ART_PATHS = FINISHED_EDITORIAL_ART_PATHS;
98
+ exports.editorialPlainText = editorialPlainText;
99
+ exports.editorialTextId = editorialTextId;
100
+ exports.isFinishedEditorialArtwork = isFinishedEditorialArtwork;
101
+ exports.resolveArticlePhotoPlacements = resolveArticlePhotoPlacements;
@@ -0,0 +1,93 @@
1
+ // src/contracts/editorial-media.ts
2
+ var voidElements = /* @__PURE__ */ new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
3
+ var rawElements = /* @__PURE__ */ new Set(["pre", "code", "script", "style", "textarea", "xmp", "template"]);
4
+ var excludedAncestors = /* @__PURE__ */ new Set(["figure", "table", "ul", "ol", "blockquote", "aside", "nav", "header", "footer"]);
5
+ var tags = () => /<!--[\s\S]*?(?:-->|$)|<\/?[a-z][\w:-]*(?=[\s/>])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
6
+ function editorialPlainText(value) {
7
+ const entities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", ndash: "\u2013", mdash: "\u2014" };
8
+ return value.replace(/<!--[\s\S]*?-->/g, "").replace(tags(), "").replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (full, entity) => {
9
+ if (!entity.startsWith("#")) return entities[entity.toLowerCase()] ?? full;
10
+ const number = entity[1].toLowerCase() === "x" ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
11
+ return number > 0 && number <= 1114111 ? String.fromCodePoint(number) : full;
12
+ }).replace(/\s+/g, " ").trim();
13
+ }
14
+ function editorialTextId(kind, text) {
15
+ const normalized = editorialPlainText(text);
16
+ let hash = 2166136261;
17
+ for (let i = 0; i < normalized.length; i++) hash = Math.imul(hash ^ normalized.charCodeAt(i), 16777619);
18
+ return `${kind}-${(hash >>> 0).toString(16).padStart(8, "0")}-${normalized.length}`;
19
+ }
20
+ function attributes(tag) {
21
+ const out = /* @__PURE__ */ new Map();
22
+ const body = tag.replace(/^<[^\s>]+/, "").replace(/\/?\s*>$/, "");
23
+ for (const match of body.matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) out.set(match[1].toLowerCase(), editorialPlainText(match[2] ?? match[3] ?? match[4] ?? ""));
24
+ return out;
25
+ }
26
+ function resolveArticlePhotoPlacements(html, photos = []) {
27
+ const pattern = tags();
28
+ const stack = [];
29
+ const headings = [];
30
+ const paragraphs = [];
31
+ const existing = /* @__PURE__ */ new Set(), existingSlots = /* @__PURE__ */ new Set();
32
+ let token;
33
+ while (token = pattern.exec(html)) {
34
+ const tag = token[0];
35
+ if (tag.startsWith("<!--")) continue;
36
+ const name = /^<\/?([\w:-]+)/.exec(tag)[1].toLowerCase();
37
+ if (tag.startsWith("</")) {
38
+ const at = stack.map((frame2) => frame2.name).lastIndexOf(name);
39
+ if (at < 0) continue;
40
+ const frame = stack[at];
41
+ if (frame.eligible) {
42
+ const text = editorialPlainText(html.slice(frame.inner, token.index));
43
+ if (/^h[1-6]$/.test(name)) headings.push({ id: frame.id || editorialTextId("section", text), text, level: Number(name[1]), start: frame.start, end: pattern.lastIndex, eligible: false });
44
+ if (name === "p" && text) paragraphs.push({ start: frame.start, end: pattern.lastIndex });
45
+ }
46
+ stack.length = at;
47
+ continue;
48
+ }
49
+ if (rawElements.has(name)) {
50
+ const closing = new RegExp(`</${name}\\s*>`, "gi");
51
+ closing.lastIndex = pattern.lastIndex;
52
+ if (!closing.exec(html)) break;
53
+ pattern.lastIndex = closing.lastIndex;
54
+ continue;
55
+ }
56
+ const attrs = attributes(tag);
57
+ if (name === "img" && attrs.has("src")) existing.add(attrs.get("src"));
58
+ if (name === "figure" && /^\d+$/.test(attrs.get("data-sk-editorial-photo") ?? "")) existingSlots.add(Number(attrs.get("data-sk-editorial-photo")));
59
+ if (voidElements.has(name) || /\/\s*>$/.test(tag)) continue;
60
+ stack.push({ name, start: token.index, inner: pattern.lastIndex, id: attrs.get("id"), eligible: !stack.some((frame) => excludedAncestors.has(frame.name)) });
61
+ }
62
+ const paragraphFor = (heading) => {
63
+ const next = headings.find((item) => item.start > heading.start);
64
+ return paragraphs.find((p) => p.start >= heading.end && (!next || p.end < next.start));
65
+ };
66
+ for (const heading of headings) heading.eligible = !!paragraphFor(heading);
67
+ const placements = photos.map((photo, index) => {
68
+ if (existingSlots.has(index) || photo.url && existing.has(photo.url)) return { index, status: "already_present" };
69
+ let paragraph;
70
+ if (photo.placement?.kind === "after_intro") {
71
+ const firstSection = headings.find((h) => h.level > 1);
72
+ paragraph = paragraphs.find((p) => !firstSection || p.start < firstSection.start);
73
+ } else if (photo.placement?.kind === "after_heading") {
74
+ const placement = photo.placement;
75
+ const matches = headings.filter((h) => placement.headingId ? h.id === placement.headingId : h.text === editorialPlainText(placement.heading));
76
+ if (matches.length !== 1) return { index, status: matches.length ? "ambiguous_heading" : "missing_heading" };
77
+ paragraph = paragraphFor(matches[0]);
78
+ } else return { index, status: "unassigned" };
79
+ if (!paragraph) return { index, status: "no_paragraph" };
80
+ if (photo.url) existing.add(photo.url);
81
+ return { index, status: "placed", offset: paragraph.end };
82
+ });
83
+ return { headings: headings.map(({ start: _start, end: _end, ...heading }) => heading), placements };
84
+ }
85
+ var EDITORIAL_PHOTO_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/avif"];
86
+ var EDITORIAL_PHOTO_MAX_BYTES = 10 * 1024 * 1024;
87
+ var FINISHED_EDITORIAL_ART_PATHS = ["Blog/Cards", "Blog/Carousel", "Broadcast/Cards", "Broadcast/StylePreview", "Reviews/Broadcast", "Reviews/Photo", "Reviews/StylePreview"];
88
+ function isFinishedEditorialArtwork(path) {
89
+ const normalized = `/${path}`.toLowerCase();
90
+ return FINISHED_EDITORIAL_ART_PATHS.some((part) => normalized.includes(`/${part.toLowerCase()}/`));
91
+ }
92
+
93
+ export { EDITORIAL_PHOTO_MAX_BYTES, EDITORIAL_PHOTO_MIME_TYPES, FINISHED_EDITORIAL_ART_PATHS, editorialPlainText, editorialTextId, isFinishedEditorialArtwork, resolveArticlePhotoPlacements };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Knowledge contract: which Sonor records Signal reads into a project's
3
+ * knowledge, the address each record's knowledge is stored under, and the
4
+ * limits of the call that tells Signal a site's pages changed. The Sonor API
5
+ * makes those calls and Signal answers them, so both import this as
6
+ * `@sonordev/contracts/knowledge` instead of keeping lists that drift apart.
7
+ * The dashboard, which shows the owner what Signal read, reads stored
8
+ * addresses by the same rules.
9
+ *
10
+ * A record's knowledge is stored under a URI built from its kind and id:
11
+ * `<kind>://<id>` for its first chunk and `<kind>://<id>#<anchor>` for any
12
+ * more (an article indexed in full, split by heading). The scheme is what
13
+ * tells a record's rows apart from a page's passages, which are stored under
14
+ * the page's https URL. The content type can't: an article's record has the
15
+ * same content type as the page passages beside it, because a live article is
16
+ * a page on the site.
17
+ */
18
+ /**
19
+ * The kinds of Sonor record Signal indexes, in the order the dashboard lists
20
+ * them: case studies, proof records, managed FAQs, reviews, articles and
21
+ * commerce offerings. Each kind is also its URI scheme.
22
+ */
23
+ export declare const KNOWLEDGE_RECORD_KINDS: readonly ["portfolio", "proof", "faq", "review", "article", "offering"];
24
+ export type KnowledgeRecordKind = (typeof KNOWLEDGE_RECORD_KINDS)[number];
25
+ /** True for one of KNOWLEDGE_RECORD_KINDS, spelled exactly (lowercase). */
26
+ export declare function isKnowledgeRecordKind(value: unknown): value is KnowledgeRecordKind;
27
+ /** `<kind>://`, the start every address of that kind shares, for prefix queries. */
28
+ export declare function knowledgeRecordPrefix(kind: KnowledgeRecordKind): string;
29
+ /**
30
+ * Where a record's knowledge is stored: `<kind>://<id>` for its first chunk,
31
+ * `<kind>://<id>#<anchor>` for a further one. `id` is the record's UUID, and
32
+ * checking it is the caller's job: an empty id, or one holding `#`, `?`, `/`
33
+ * or whitespace, builds an address knowledgeRecordRefOf can't read back.
34
+ */
35
+ export declare function knowledgeRecordSourceUrl(kind: KnowledgeRecordKind, id: string, anchor?: string | null): string;
36
+ /**
37
+ * The address an offering had before offerings were records of their own:
38
+ * `commerce://offering/<id>`. Nothing writes it any more. Signal switches the
39
+ * row off the next time that offering syncs, and a reader counts it as the
40
+ * offering until then (knowledgeRecordRefOf).
41
+ */
42
+ export declare function legacyOfferingSourceUrl(id: string): string;
43
+ /**
44
+ * The kind of record a stored address belongs to, by its scheme
45
+ * (`article://<id>#part-2` is 'article'), or null for anything else: a page
46
+ * passage, an older page summary, a note. Case doesn't matter and surrounding
47
+ * whitespace is ignored. This is the test for "is this row a record's": it
48
+ * reads only the scheme, so it doesn't need an id, and an offering's legacy
49
+ * address isn't a record row by it.
50
+ */
51
+ export declare function knowledgeRecordKindOf(sourceUrl: string | null | undefined): KnowledgeRecordKind | null;
52
+ /** The record a stored address names. */
53
+ export interface KnowledgeRecordRef {
54
+ kind: KnowledgeRecordKind;
55
+ id: string;
56
+ }
57
+ /**
58
+ * The record a stored address names (`portfolio://<id>#part-2` is
59
+ * `{ kind: 'portfolio', id }`), or null when it names none. An offering's
60
+ * legacy address (`commerce://offering/<id>`) names that offering. Case
61
+ * doesn't matter in the scheme, surrounding whitespace is ignored, and the id
62
+ * keeps its own case.
63
+ */
64
+ export declare function knowledgeRecordRefOf(sourceUrl: string | null | undefined): KnowledgeRecordRef | null;
65
+ /**
66
+ * One page of a site in a page sync: it changed (Signal reads it live) or
67
+ * left the site (`removed`: Signal switches its passages off). Signal applies
68
+ * every removal in a call, then reads the changed pages in the order given,
69
+ * as many as it reads in one call; the rest wait for its nightly sweep. So a
70
+ * sender puts removals first, then the pages that matter most.
71
+ */
72
+ export interface KnowledgePageChange {
73
+ /** The host as the project's pages store it; null or absent is the project's primary domain. */
74
+ site?: string | null;
75
+ /** The page's path, from "/". */
76
+ path: string;
77
+ removed?: boolean;
78
+ }
79
+ /** The most pages one page sync may name. Signal refuses a longer list outright, so cut the list after ordering it. */
80
+ export declare const KNOWLEDGE_SYNC_MAX_PAGES = 500;
81
+ /** The longest path a page sync may name, in characters. */
82
+ export declare const KNOWLEDGE_SYNC_PATH_MAX_LENGTH = 2048;
83
+ /** The longest host a page sync may name, in characters (a DNS name's limit). */
84
+ export declare const KNOWLEDGE_SYNC_SITE_MAX_LENGTH = 253;
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/knowledge.ts
4
+ var KNOWLEDGE_RECORD_KINDS = Object.freeze(["portfolio", "proof", "faq", "review", "article", "offering"]);
5
+ function isKnowledgeRecordKind(value) {
6
+ return typeof value === "string" && KNOWLEDGE_RECORD_KINDS.includes(value);
7
+ }
8
+ function knowledgeRecordPrefix(kind) {
9
+ return `${kind}://`;
10
+ }
11
+ function knowledgeRecordSourceUrl(kind, id, anchor) {
12
+ const base = `${knowledgeRecordPrefix(kind)}${id}`;
13
+ return anchor ? `${base}#${anchor}` : base;
14
+ }
15
+ function legacyOfferingSourceUrl(id) {
16
+ return `${LEGACY_OFFERING_PREFIX}${id}`;
17
+ }
18
+ var LEGACY_OFFERING_PREFIX = "commerce://offering/";
19
+ var RECORD_SCHEME = new RegExp(`^(${KNOWLEDGE_RECORD_KINDS.join("|")})://`, "i");
20
+ var RECORD_REF = new RegExp(`^(${KNOWLEDGE_RECORD_KINDS.join("|")})://([^#?/\\s]+)`, "i");
21
+ var LEGACY_OFFERING_REF = /^commerce:\/\/offering\/([^#?/\s]+)/i;
22
+ function knowledgeRecordKindOf(sourceUrl) {
23
+ if (typeof sourceUrl !== "string") return null;
24
+ const match = RECORD_SCHEME.exec(sourceUrl.trim());
25
+ return match ? match[1].toLowerCase() : null;
26
+ }
27
+ function knowledgeRecordRefOf(sourceUrl) {
28
+ if (typeof sourceUrl !== "string") return null;
29
+ const value = sourceUrl.trim();
30
+ const record = RECORD_REF.exec(value);
31
+ if (record) return { kind: record[1].toLowerCase(), id: record[2] };
32
+ const legacy = LEGACY_OFFERING_REF.exec(value);
33
+ return legacy ? { kind: "offering", id: legacy[1] } : null;
34
+ }
35
+ var KNOWLEDGE_SYNC_MAX_PAGES = 500;
36
+ var KNOWLEDGE_SYNC_PATH_MAX_LENGTH = 2048;
37
+ var KNOWLEDGE_SYNC_SITE_MAX_LENGTH = 253;
38
+
39
+ exports.KNOWLEDGE_RECORD_KINDS = KNOWLEDGE_RECORD_KINDS;
40
+ exports.KNOWLEDGE_SYNC_MAX_PAGES = KNOWLEDGE_SYNC_MAX_PAGES;
41
+ exports.KNOWLEDGE_SYNC_PATH_MAX_LENGTH = KNOWLEDGE_SYNC_PATH_MAX_LENGTH;
42
+ exports.KNOWLEDGE_SYNC_SITE_MAX_LENGTH = KNOWLEDGE_SYNC_SITE_MAX_LENGTH;
43
+ exports.isKnowledgeRecordKind = isKnowledgeRecordKind;
44
+ exports.knowledgeRecordKindOf = knowledgeRecordKindOf;
45
+ exports.knowledgeRecordPrefix = knowledgeRecordPrefix;
46
+ exports.knowledgeRecordRefOf = knowledgeRecordRefOf;
47
+ exports.knowledgeRecordSourceUrl = knowledgeRecordSourceUrl;
48
+ exports.legacyOfferingSourceUrl = legacyOfferingSourceUrl;
@@ -0,0 +1,37 @@
1
+ // src/contracts/knowledge.ts
2
+ var KNOWLEDGE_RECORD_KINDS = Object.freeze(["portfolio", "proof", "faq", "review", "article", "offering"]);
3
+ function isKnowledgeRecordKind(value) {
4
+ return typeof value === "string" && KNOWLEDGE_RECORD_KINDS.includes(value);
5
+ }
6
+ function knowledgeRecordPrefix(kind) {
7
+ return `${kind}://`;
8
+ }
9
+ function knowledgeRecordSourceUrl(kind, id, anchor) {
10
+ const base = `${knowledgeRecordPrefix(kind)}${id}`;
11
+ return anchor ? `${base}#${anchor}` : base;
12
+ }
13
+ function legacyOfferingSourceUrl(id) {
14
+ return `${LEGACY_OFFERING_PREFIX}${id}`;
15
+ }
16
+ var LEGACY_OFFERING_PREFIX = "commerce://offering/";
17
+ var RECORD_SCHEME = new RegExp(`^(${KNOWLEDGE_RECORD_KINDS.join("|")})://`, "i");
18
+ var RECORD_REF = new RegExp(`^(${KNOWLEDGE_RECORD_KINDS.join("|")})://([^#?/\\s]+)`, "i");
19
+ var LEGACY_OFFERING_REF = /^commerce:\/\/offering\/([^#?/\s]+)/i;
20
+ function knowledgeRecordKindOf(sourceUrl) {
21
+ if (typeof sourceUrl !== "string") return null;
22
+ const match = RECORD_SCHEME.exec(sourceUrl.trim());
23
+ return match ? match[1].toLowerCase() : null;
24
+ }
25
+ function knowledgeRecordRefOf(sourceUrl) {
26
+ if (typeof sourceUrl !== "string") return null;
27
+ const value = sourceUrl.trim();
28
+ const record = RECORD_REF.exec(value);
29
+ if (record) return { kind: record[1].toLowerCase(), id: record[2] };
30
+ const legacy = LEGACY_OFFERING_REF.exec(value);
31
+ return legacy ? { kind: "offering", id: legacy[1] } : null;
32
+ }
33
+ var KNOWLEDGE_SYNC_MAX_PAGES = 500;
34
+ var KNOWLEDGE_SYNC_PATH_MAX_LENGTH = 2048;
35
+ var KNOWLEDGE_SYNC_SITE_MAX_LENGTH = 253;
36
+
37
+ export { KNOWLEDGE_RECORD_KINDS, KNOWLEDGE_SYNC_MAX_PAGES, KNOWLEDGE_SYNC_PATH_MAX_LENGTH, KNOWLEDGE_SYNC_SITE_MAX_LENGTH, isKnowledgeRecordKind, knowledgeRecordKindOf, knowledgeRecordPrefix, knowledgeRecordRefOf, knowledgeRecordSourceUrl, legacyOfferingSourceUrl };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sonordev/contracts",
3
- "version": "2.5.0",
4
- "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, and where prose splits into sentences. Pure functions, no dependencies.",
3
+ "version": "2.7.0",
4
+ "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, and which Sonor records Signal reads into a project's knowledge and the address each one is stored under. Pure functions, no dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -105,7 +105,17 @@
105
105
  "import": "./dist/sentences.mjs",
106
106
  "require": "./dist/sentences.js"
107
107
  },
108
- "./package.json": "./package.json"
108
+ "./knowledge": {
109
+ "types": "./dist/knowledge.d.ts",
110
+ "import": "./dist/knowledge.mjs",
111
+ "require": "./dist/knowledge.js"
112
+ },
113
+ "./package.json": "./package.json",
114
+ "./editorial-media": {
115
+ "types": "./dist/editorial-media.d.ts",
116
+ "import": "./dist/editorial-media.mjs",
117
+ "require": "./dist/editorial-media.js"
118
+ }
109
119
  },
110
120
  "typesVersions": {
111
121
  "*": {