@sonordev/contracts 2.6.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,10 @@
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
+
3
8
  ## 2.6.0
4
9
 
5
10
  A minor release: one new entry, `@sonordev/contracts/knowledge`. Every other entry is unchanged.
package/README.md CHANGED
@@ -434,3 +434,8 @@ Edit `src/contracts/<entry>.ts` in the site-kit repo, run
434
434
  `pnpm test:contracts`, and bump this package's version. A breaking change to
435
435
  a shape bumps the major and that contract's own `*_VERSION` constant.
436
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonordev/contracts",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
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": {
@@ -110,7 +110,12 @@
110
110
  "import": "./dist/knowledge.mjs",
111
111
  "require": "./dist/knowledge.js"
112
112
  },
113
- "./package.json": "./package.json"
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
+ }
114
119
  },
115
120
  "typesVersions": {
116
121
  "*": {