@scalemule/news-publication 0.0.43 → 0.0.44

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Canonical structured article document model and block parser.
3
+ *
4
+ * Provides shared representation for visual journalism, photo essays,
5
+ * community features, and art reviews. Consumed by both the public article
6
+ * renderer and the private review renderer for absolute layout parity.
7
+ */
8
+ interface StructuredMedia {
9
+ kind?: "image" | "video";
10
+ contribution_id: string;
11
+ file_id: string;
12
+ caption: string;
13
+ credit: string;
14
+ alt: string;
15
+ position: "hero" | "inline" | "gallery" | "attachment" | string;
16
+ artist_name?: string;
17
+ artwork_title?: string;
18
+ artwork_date?: string;
19
+ medium?: string;
20
+ photographer?: string;
21
+ source?: string;
22
+ rights_status?: string;
23
+ }
24
+ type ContentBlock = {
25
+ type: "paragraph";
26
+ html: string;
27
+ } | {
28
+ type: "heading";
29
+ level: number;
30
+ html: string;
31
+ } | {
32
+ type: "inline_media";
33
+ media_id: string;
34
+ placement: "inline";
35
+ } | {
36
+ type: "quote";
37
+ html: string;
38
+ attribution?: string;
39
+ } | {
40
+ type: "list";
41
+ ordered: boolean;
42
+ items: string[];
43
+ } | {
44
+ type: "divider";
45
+ } | {
46
+ type: "embed";
47
+ kind: string;
48
+ id: string;
49
+ };
50
+ interface StructuredDocument {
51
+ title: string;
52
+ subtitle?: string;
53
+ content_markdown: string;
54
+ section: string;
55
+ community?: string;
56
+ media: StructuredMedia[];
57
+ blocks?: ContentBlock[];
58
+ }
59
+ /**
60
+ * Converts basic inline markdown emphasis and links to sanitized HTML without allowing script/image injection.
61
+ */
62
+ declare function sanitizeInlineMarkdown(raw: string): string;
63
+ /**
64
+ * Parses a structured document or markdown content into an ordered list of content blocks.
65
+ * Inline media placement is explicit and deterministic, resolving against known media items.
66
+ */
67
+ declare function parseDocumentBlocks(doc: {
68
+ content_markdown?: string;
69
+ media?: StructuredMedia[];
70
+ blocks?: ContentBlock[];
71
+ }): ContentBlock[];
72
+ /**
73
+ * Formats structured caption and credit information into clean editorial strings.
74
+ */
75
+ declare function formatArtworkCaption(media: StructuredMedia): {
76
+ title?: string;
77
+ artist?: string;
78
+ mediumAndDate?: string;
79
+ credit?: string;
80
+ source?: string;
81
+ captionText?: string;
82
+ };
83
+ /**
84
+ * Renders structured content blocks to public HTML, matching the reading sequence
85
+ * and visual presentation of the review page.
86
+ */
87
+ declare function renderArticleBlocksToHtml(blocks: ContentBlock[], mediaMap: Map<string, StructuredMedia>, options?: {
88
+ publicMediaUrlPrefix?: string;
89
+ }): string;
90
+
91
+ export { type ContentBlock as C, type StructuredDocument as S, type StructuredMedia as a, formatArtworkCaption as f, parseDocumentBlocks as p, renderArticleBlocksToHtml as r, sanitizeInlineMarkdown as s };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Canonical structured article document model and block parser.
3
+ *
4
+ * Provides shared representation for visual journalism, photo essays,
5
+ * community features, and art reviews. Consumed by both the public article
6
+ * renderer and the private review renderer for absolute layout parity.
7
+ */
8
+ interface StructuredMedia {
9
+ kind?: "image" | "video";
10
+ contribution_id: string;
11
+ file_id: string;
12
+ caption: string;
13
+ credit: string;
14
+ alt: string;
15
+ position: "hero" | "inline" | "gallery" | "attachment" | string;
16
+ artist_name?: string;
17
+ artwork_title?: string;
18
+ artwork_date?: string;
19
+ medium?: string;
20
+ photographer?: string;
21
+ source?: string;
22
+ rights_status?: string;
23
+ }
24
+ type ContentBlock = {
25
+ type: "paragraph";
26
+ html: string;
27
+ } | {
28
+ type: "heading";
29
+ level: number;
30
+ html: string;
31
+ } | {
32
+ type: "inline_media";
33
+ media_id: string;
34
+ placement: "inline";
35
+ } | {
36
+ type: "quote";
37
+ html: string;
38
+ attribution?: string;
39
+ } | {
40
+ type: "list";
41
+ ordered: boolean;
42
+ items: string[];
43
+ } | {
44
+ type: "divider";
45
+ } | {
46
+ type: "embed";
47
+ kind: string;
48
+ id: string;
49
+ };
50
+ interface StructuredDocument {
51
+ title: string;
52
+ subtitle?: string;
53
+ content_markdown: string;
54
+ section: string;
55
+ community?: string;
56
+ media: StructuredMedia[];
57
+ blocks?: ContentBlock[];
58
+ }
59
+ /**
60
+ * Converts basic inline markdown emphasis and links to sanitized HTML without allowing script/image injection.
61
+ */
62
+ declare function sanitizeInlineMarkdown(raw: string): string;
63
+ /**
64
+ * Parses a structured document or markdown content into an ordered list of content blocks.
65
+ * Inline media placement is explicit and deterministic, resolving against known media items.
66
+ */
67
+ declare function parseDocumentBlocks(doc: {
68
+ content_markdown?: string;
69
+ media?: StructuredMedia[];
70
+ blocks?: ContentBlock[];
71
+ }): ContentBlock[];
72
+ /**
73
+ * Formats structured caption and credit information into clean editorial strings.
74
+ */
75
+ declare function formatArtworkCaption(media: StructuredMedia): {
76
+ title?: string;
77
+ artist?: string;
78
+ mediumAndDate?: string;
79
+ credit?: string;
80
+ source?: string;
81
+ captionText?: string;
82
+ };
83
+ /**
84
+ * Renders structured content blocks to public HTML, matching the reading sequence
85
+ * and visual presentation of the review page.
86
+ */
87
+ declare function renderArticleBlocksToHtml(blocks: ContentBlock[], mediaMap: Map<string, StructuredMedia>, options?: {
88
+ publicMediaUrlPrefix?: string;
89
+ }): string;
90
+
91
+ export { type ContentBlock as C, type StructuredDocument as S, type StructuredMedia as a, formatArtworkCaption as f, parseDocumentBlocks as p, renderArticleBlocksToHtml as r, sanitizeInlineMarkdown as s };
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { setupNetworkConsentDelegation, checkUrlConsentBridge, hasConsented, isGeoConsentRequired, queryNetworkConsentHub, DEFAULT_CONSENT_HUB_URL, acceptAllCookies, rejectOptionalCookies, COOKIE_CONSENT_EVENT, recordConsentToCompliance, setNetworkConsentHub } from './chunk-56TU57OO.js';
2
+ import { setupNetworkConsentDelegation, checkUrlConsentBridge, hasConsented, isGeoConsentRequired, queryNetworkConsentHub, DEFAULT_CONSENT_HUB_URL, acceptAllCookies, rejectOptionalCookies, COOKIE_CONSENT_EVENT, recordConsentToCompliance, setNetworkConsentHub } from './chunk-UTBV6JJX.js';
3
3
  import { useState, useEffect } from 'react';
4
4
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
5
 
@@ -47,7 +47,7 @@ function definePublication(input) {
47
47
  nearbyTowns: input.nearbyTowns ?? [],
48
48
  sections: input.sections ?? [],
49
49
  description: input.description,
50
- tagline: input.tagline ?? "Local stories. Close to home.",
50
+ tagline: input.tagline ?? "Local stories. Stronger communities.",
51
51
  briefTitle: input.briefTitle ?? `${input.regionLabel} in Brief`,
52
52
  parent: input.parent === true,
53
53
  layout,
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { getCookieConsent, DEFAULT_PREFERENCES_ACCEPTED, COOKIE_CATEGORIES, setCookieConsent, recordConsentToCompliance, DEFAULT_PREFERENCES_REJECTED } from './chunk-56TU57OO.js';
2
+ import { getCookieConsent, DEFAULT_PREFERENCES_ACCEPTED, COOKIE_CATEGORIES, setCookieConsent, recordConsentToCompliance, DEFAULT_PREFERENCES_REJECTED } from './chunk-UTBV6JJX.js';
3
3
  import { useState, useEffect } from 'react';
4
4
  import { jsxs, jsx } from 'react/jsx-runtime';
5
5
 
@@ -1,4 +1,4 @@
1
- import { NETWORK_PARENT } from './chunk-56TU57OO.js';
1
+ import { NETWORK_PARENT } from './chunk-UTBV6JJX.js';
2
2
  import { jsxs, jsx } from 'react/jsx-runtime';
3
3
 
4
4
  function NetworkCopyrightNotice({
@@ -26,7 +26,9 @@ function NetworkCopyrightNotice({
26
26
  children: NETWORK_PARENT.name
27
27
  }
28
28
  ),
29
- "."
29
+ " ",
30
+ "\u2014 ",
31
+ NETWORK_PARENT.brandSlogan
30
32
  ] }),
31
33
  /* @__PURE__ */ jsxs("p", { className: "sm-network-legal", children: [
32
34
  prefix,
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { setupNetworkConsentDelegation, COOKIE_CONSENT_EVENT, listedPublications, decorateNetworkUrl } from './chunk-56TU57OO.js';
2
+ import { setupNetworkConsentDelegation, COOKIE_CONSENT_EVENT, listedPublications, decorateNetworkUrl } from './chunk-UTBV6JJX.js';
3
3
  import { useState, useId, useRef, useEffect } from 'react';
4
4
  import { jsx, jsxs } from 'react/jsx-runtime';
5
5
 
@@ -0,0 +1,235 @@
1
+ // src/article-blocks.ts
2
+ function normalizeUuid(id) {
3
+ const clean = id.trim().toLowerCase().replace(/[^0-9a-f]/g, "");
4
+ if (clean.length === 32) {
5
+ return `${clean.slice(0, 8)}-${clean.slice(8, 12)}-${clean.slice(12, 16)}-${clean.slice(16, 20)}-${clean.slice(20)}`;
6
+ }
7
+ return id.trim().toLowerCase();
8
+ }
9
+ function escapeHtml(text) {
10
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
11
+ }
12
+ function sanitizeInlineMarkdown(raw) {
13
+ let text = raw.trim();
14
+ text = text.replace(/on\w+\s*=\s*(?:["'][^"']*["']|[^\s>]+)/gi, "");
15
+ text = text.replace(/javascript\s*:/gi, "");
16
+ text = escapeHtml(text);
17
+ text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
18
+ text = text.replace(/__(.+?)__/g, "<strong>$1</strong>");
19
+ text = text.replace(/\*([^*]+?)\*/g, "<em>$1</em>");
20
+ text = text.replace(/_([^_]+?)_/g, "<em>$1</em>");
21
+ text = text.replace(/`([^`]+?)`/g, "<code>$1</code>");
22
+ text = text.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^)]+)\)/g, (_m, label, url) => {
23
+ return `<a href="${escapeHtml(url)}" rel="noopener noreferrer nofollow">${label}</a>`;
24
+ });
25
+ return text;
26
+ }
27
+ function parseDocumentBlocks(doc) {
28
+ const mediaList = doc.media || [];
29
+ const mediaMap = /* @__PURE__ */ new Map();
30
+ for (const m of mediaList) {
31
+ mediaMap.set(normalizeUuid(m.file_id), m);
32
+ if (m.contribution_id) {
33
+ mediaMap.set(normalizeUuid(m.contribution_id), m);
34
+ }
35
+ }
36
+ if (Array.isArray(doc.blocks) && doc.blocks.length > 0) {
37
+ const validated = [];
38
+ for (const b of doc.blocks) {
39
+ if (b.type === "inline_media") {
40
+ const norm = normalizeUuid(b.media_id);
41
+ const resolved = mediaMap.get(norm);
42
+ if (resolved) {
43
+ validated.push({
44
+ type: "inline_media",
45
+ media_id: resolved.file_id,
46
+ placement: "inline"
47
+ });
48
+ }
49
+ } else {
50
+ validated.push(b);
51
+ }
52
+ }
53
+ return validated;
54
+ }
55
+ const md = (doc.content_markdown || "").trim();
56
+ if (!md) return [];
57
+ const rawParagraphs = md.split(/\n{2,}/);
58
+ const blocks = [];
59
+ for (let i = 0; i < rawParagraphs.length; i++) {
60
+ const p = rawParagraphs[i].trim();
61
+ if (!p) continue;
62
+ let matchedMediaId = null;
63
+ const directiveMatch = p.match(/^::inline_media\{id=["']?([0-9a-f-]{32,36})["']?\}/i) || p.match(/^<!--\s*inline_media:([0-9a-f-]{32,36})\s*-->/i);
64
+ if (directiveMatch) {
65
+ matchedMediaId = normalizeUuid(directiveMatch[1]);
66
+ if (!mediaMap.has(matchedMediaId)) {
67
+ continue;
68
+ }
69
+ } else {
70
+ const imgMatch = p.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
71
+ if (imgMatch) {
72
+ const src = imgMatch[2];
73
+ const uuidMatch = src.match(/(?:media:|\/)([0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}|[0-9a-f]{32})(?:\/|$|\?)/i);
74
+ if (uuidMatch) {
75
+ const norm = normalizeUuid(uuidMatch[1]);
76
+ if (mediaMap.has(norm)) {
77
+ matchedMediaId = norm;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ if (matchedMediaId && mediaMap.has(matchedMediaId)) {
83
+ const mediaItem = mediaMap.get(matchedMediaId);
84
+ blocks.push({
85
+ type: "inline_media",
86
+ media_id: mediaItem.file_id,
87
+ placement: "inline"
88
+ });
89
+ const lines2 = p.split("\n").filter((l) => l.trim().length > 0);
90
+ if (lines2.length > 1 && !lines2[0].startsWith("::") && !lines2[0].startsWith("<!--")) {
91
+ const remaining = lines2.slice(1).join("\n").trim();
92
+ const isEcho = mediaItem.artwork_title && remaining.toLowerCase().includes(mediaItem.artwork_title.toLowerCase());
93
+ if (!isEcho && remaining) {
94
+ blocks.push({
95
+ type: "paragraph",
96
+ html: sanitizeInlineMarkdown(remaining)
97
+ });
98
+ }
99
+ }
100
+ continue;
101
+ }
102
+ const headingMatch = p.match(/^(#{1,6})\s+(.+)$/);
103
+ if (headingMatch) {
104
+ blocks.push({
105
+ type: "heading",
106
+ level: headingMatch[1].length,
107
+ html: sanitizeInlineMarkdown(headingMatch[2])
108
+ });
109
+ continue;
110
+ }
111
+ if (/^(\*{3,}|-{3,})$/.test(p)) {
112
+ blocks.push({ type: "divider" });
113
+ continue;
114
+ }
115
+ if (p.startsWith(">")) {
116
+ const quoteText = p.split("\n").map((l) => l.replace(/^>\s?/, "")).join(" ");
117
+ blocks.push({
118
+ type: "quote",
119
+ html: sanitizeInlineMarkdown(quoteText)
120
+ });
121
+ continue;
122
+ }
123
+ const lines = p.split("\n");
124
+ const isUnordered = lines.every((l) => /^\s*[-*]\s+/.test(l));
125
+ const isOrdered = lines.every((l) => /^\s*\d+\.\s+/.test(l));
126
+ if ((isUnordered || isOrdered) && lines.length > 0) {
127
+ const items = lines.map(
128
+ (l) => sanitizeInlineMarkdown(l.replace(/^\s*(?:[-*]|\d+\.)\s+/, ""))
129
+ );
130
+ blocks.push({
131
+ type: "list",
132
+ ordered: isOrdered,
133
+ items
134
+ });
135
+ continue;
136
+ }
137
+ blocks.push({
138
+ type: "paragraph",
139
+ html: sanitizeInlineMarkdown(p)
140
+ });
141
+ }
142
+ return blocks;
143
+ }
144
+ function formatArtworkCaption(media) {
145
+ let title = media.artwork_title ? media.artwork_title.trim() : void 0;
146
+ let artist = media.artist_name ? media.artist_name.trim() : void 0;
147
+ let mediumAndDate;
148
+ if (media.medium && media.artwork_date) {
149
+ mediumAndDate = `${media.medium} (${media.artwork_date})`;
150
+ } else if (media.medium) {
151
+ mediumAndDate = media.medium;
152
+ } else if (media.artwork_date) {
153
+ mediumAndDate = media.artwork_date;
154
+ }
155
+ let credit;
156
+ if (media.photographer) {
157
+ credit = `Photo: ${media.photographer}`;
158
+ } else if (media.credit) {
159
+ credit = media.credit.startsWith("Photo:") ? media.credit : `Photo: ${media.credit}`;
160
+ }
161
+ let source = media.source ? `Source: ${media.source}` : void 0;
162
+ let captionText = !title && media.caption ? media.caption : void 0;
163
+ if (!title && media.caption) {
164
+ const lines = media.caption.split("\n").map((l) => l.trim()).filter(Boolean);
165
+ if (lines.length >= 2 && (lines[0].startsWith("\u201C") || lines[0].startsWith('"'))) {
166
+ title = lines[0].replace(/^[“"']|[”"']$/g, "").trim();
167
+ artist = lines[1];
168
+ if (lines[2] && !lines[2].startsWith("Source:")) {
169
+ mediumAndDate = lines[2];
170
+ }
171
+ for (const line of lines) {
172
+ if (line.startsWith("Source:")) {
173
+ source = line;
174
+ }
175
+ }
176
+ captionText = void 0;
177
+ }
178
+ }
179
+ return {
180
+ title,
181
+ artist,
182
+ mediumAndDate,
183
+ credit,
184
+ source,
185
+ captionText
186
+ };
187
+ }
188
+ function renderArticleBlocksToHtml(blocks, mediaMap, options) {
189
+ const parts = [];
190
+ for (const block of blocks) {
191
+ switch (block.type) {
192
+ case "paragraph":
193
+ parts.push(`<p>${block.html}</p>`);
194
+ break;
195
+ case "heading":
196
+ parts.push(`<h${block.level}>${block.html}</h${block.level}>`);
197
+ break;
198
+ case "quote":
199
+ parts.push(`<blockquote><p>${block.html}</p>${block.attribution ? `<cite>${escapeHtml(block.attribution)}</cite>` : ""}</blockquote>`);
200
+ break;
201
+ case "list":
202
+ if (block.ordered) {
203
+ parts.push(`<ol>${block.items.map((it) => `<li>${it}</li>`).join("")}</ol>`);
204
+ } else {
205
+ parts.push(`<ul>${block.items.map((it) => `<li>${it}</li>`).join("")}</ul>`);
206
+ }
207
+ break;
208
+ case "divider":
209
+ parts.push("<hr />");
210
+ break;
211
+ case "inline_media": {
212
+ const media = mediaMap.get(normalizeUuid(block.media_id));
213
+ if (!media) break;
214
+ const info = formatArtworkCaption(media);
215
+ const prefix = options?.publicMediaUrlPrefix || "";
216
+ const cleanFileId = normalizeUuid(media.file_id).replace(/-/g, "");
217
+ const src = prefix ? `${prefix}/${cleanFileId}/full_display.webp` : `/api/media/${media.file_id}`;
218
+ let captionHtml = '<figcaption class="sm-article-caption">';
219
+ if (info.title) captionHtml += `<strong class="sm-article-artwork-title">\u201C${escapeHtml(info.title)}\u201D</strong> `;
220
+ if (info.artist) captionHtml += `<span class="sm-article-artist-name">${escapeHtml(info.artist)}</span> `;
221
+ if (info.mediumAndDate) captionHtml += `<span class="sm-article-artwork-medium">${escapeHtml(info.mediumAndDate)}</span> `;
222
+ if (info.captionText) captionHtml += `<span class="sm-article-caption-text">${escapeHtml(info.captionText)}</span> `;
223
+ if (info.credit) captionHtml += `<span class="sm-article-photo-credit">${escapeHtml(info.credit)}</span>`;
224
+ captionHtml += "</figcaption>";
225
+ parts.push(
226
+ `<figure class="sm-article-figure sm-inline-media" data-media-id="${escapeHtml(media.file_id)}"><img src="${escapeHtml(src)}" alt="${escapeHtml(media.alt || info.title || "Article artwork")}" loading="lazy" decoding="async" />` + captionHtml + `</figure>`
227
+ );
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ return parts.join("\n");
233
+ }
234
+
235
+ export { formatArtworkCaption, parseDocumentBlocks, renderArticleBlocksToHtml, sanitizeInlineMarkdown };