@scalemule/news-publication 0.0.20 → 0.0.22

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,232 @@
1
+ 'use strict';
2
+
3
+ var jsxRuntime = require('react/jsx-runtime');
4
+
5
+ // src/verified-card.tsx
6
+ var ALLOWED_CARD_TYPES = /* @__PURE__ */ new Set([
7
+ "PLACE",
8
+ "ACTIVITY",
9
+ "EVENT",
10
+ "ORGANIZATION",
11
+ "PRODUCT"
12
+ ]);
13
+ function clip(val, maxChars) {
14
+ if (typeof val !== "string") return void 0;
15
+ const trimmed = val.trim();
16
+ if (!trimmed) return void 0;
17
+ return Array.from(trimmed).slice(0, maxChars).join("");
18
+ }
19
+ function isSafeActionUrl(raw) {
20
+ if (typeof raw !== "string") return false;
21
+ const trimmed = raw.trim();
22
+ if (!trimmed || trimmed.length > 2048) return false;
23
+ if (trimmed.startsWith("tel:") || trimmed.startsWith("mailto:")) {
24
+ return /^tel:\+?[\d\-().\s]+$/.test(trimmed) || /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
25
+ }
26
+ try {
27
+ const url = new URL(trimmed);
28
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
29
+ if (url.username || url.password) return false;
30
+ const hostname = url.hostname.toLowerCase();
31
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".internal") || hostname.endsWith(".local") || hostname === "127.0.0.1" || hostname === "169.254.169.254") {
32
+ return false;
33
+ }
34
+ for (const [key] of url.searchParams.entries()) {
35
+ const cleanKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
36
+ if (cleanKey === "token" || cleanKey === "apikey" || cleanKey === "accesstoken" || cleanKey === "secret" || cleanKey === "clientsecret" || cleanKey === "authorization" || cleanKey === "signature" || cleanKey === "password" || cleanKey === "xamzsignature" || cleanKey === "xamzsecuritytoken") {
37
+ return false;
38
+ }
39
+ }
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ function getArticleCards(rawCards) {
46
+ if (!Array.isArray(rawCards) || rawCards.length === 0) return [];
47
+ const cards = [];
48
+ for (const item of rawCards) {
49
+ if (!item || typeof item !== "object") continue;
50
+ const c = item;
51
+ const id = clip(c.id, 64);
52
+ const title = clip(c.title, 300);
53
+ if (!id || !title) continue;
54
+ const rawType = typeof c.card_type === "string" ? c.card_type : c.cardType;
55
+ const cardType = ALLOWED_CARD_TYPES.has(rawType) ? rawType : "PLACE";
56
+ const kicker = clip(c.kicker, 120) || (cardType === "PLACE" ? "LOCAL VENUE \xB7 VERIFIED DIRECTORY" : cardType === "EVENT" ? "COMMUNITY EVENT \xB7 VERIFIED FACTSHEET" : cardType === "ACTIVITY" ? "COMMUNITY ACTIVITY \xB7 VERIFIED FACTSHEET" : cardType === "ORGANIZATION" ? "COMMUNITY ORGANIZATION \xB7 DIRECTORY" : "VERIFIED FACTSHEET");
57
+ const subtitle = clip(c.subtitle, 300);
58
+ const summary = clip(c.summary, 1e3);
59
+ const badge = clip(c.badge, 64);
60
+ const facts = [];
61
+ if (Array.isArray(c.facts)) {
62
+ for (const f of c.facts) {
63
+ if (!f || typeof f !== "object") continue;
64
+ const label = clip(f.label, 80);
65
+ const value = clip(f.value, 500);
66
+ if (!label || !value) continue;
67
+ let href = void 0;
68
+ const rawHref = f.href;
69
+ if (typeof rawHref === "string" && isSafeActionUrl(rawHref)) {
70
+ href = rawHref;
71
+ }
72
+ facts.push({ label, value, href });
73
+ }
74
+ } else {
75
+ const timeframe = clip(c.timeframe, 200);
76
+ if (timeframe) facts.push({ label: "Timeframe", value: timeframe });
77
+ const address = clip(c.address, 300);
78
+ if (address) {
79
+ facts.push({
80
+ label: "Address",
81
+ value: address,
82
+ href: `https://maps.google.com/?q=${encodeURIComponent(address)}`
83
+ });
84
+ }
85
+ const hours = clip(c.hours, 300);
86
+ if (hours) facts.push({ label: "Hours", value: hours });
87
+ const phone = clip(c.phone, 80);
88
+ if (phone) {
89
+ facts.push({
90
+ label: "Phone",
91
+ value: phone,
92
+ href: `tel:${phone.replace(/[^+\d]/g, "")}`
93
+ });
94
+ }
95
+ const cost = clip(c.cost, 120);
96
+ if (cost) facts.push({ label: "Cost", value: cost });
97
+ }
98
+ const actions = [];
99
+ if (Array.isArray(c.actions)) {
100
+ for (const act of c.actions) {
101
+ if (!act || typeof act !== "object") continue;
102
+ const actObj = act;
103
+ const label = clip(actObj.label, 80);
104
+ const url = typeof actObj.url === "string" ? actObj.url.trim() : "";
105
+ if (!label || !url || !isSafeActionUrl(url)) continue;
106
+ actions.push({
107
+ label,
108
+ url,
109
+ primary: Boolean(actObj.primary)
110
+ });
111
+ if (actions.length >= 5) break;
112
+ }
113
+ }
114
+ let image = void 0;
115
+ if (c.image && typeof c.image === "object") {
116
+ const img = c.image;
117
+ const masterUrl = typeof img.master_url === "string" && isSafeActionUrl(img.master_url) ? img.master_url : void 0;
118
+ const url = typeof img.url === "string" && isSafeActionUrl(img.url) ? img.url : masterUrl;
119
+ if (url) {
120
+ image = {
121
+ file_id: clip(img.file_id, 64),
122
+ master_url: masterUrl,
123
+ url,
124
+ width: typeof img.width === "number" ? img.width : void 0,
125
+ height: typeof img.height === "number" ? img.height : void 0,
126
+ alt: clip(img.alt, 500),
127
+ caption: clip(img.caption, 1e3),
128
+ attribution: clip(img.attribution, 500),
129
+ label: clip(img.label, 100),
130
+ ai_generated: Boolean(img.ai_generated)
131
+ };
132
+ }
133
+ }
134
+ const verificationNote = clip(c.verification_note || c.verificationNote, 300) || "Verified by publication reporting staff";
135
+ const verifiedAt = clip(c.verified_at || c.verifiedAt, 100) || "October 2026";
136
+ let rating = void 0;
137
+ if (typeof c.rating === "number" || typeof c.rating === "string") {
138
+ rating = c.rating;
139
+ }
140
+ cards.push({
141
+ id,
142
+ cardType,
143
+ kicker,
144
+ title,
145
+ subtitle,
146
+ summary,
147
+ badge,
148
+ facts,
149
+ actions,
150
+ verificationNote,
151
+ verifiedAt,
152
+ image,
153
+ rating
154
+ });
155
+ if (cards.length >= 10) break;
156
+ }
157
+ return cards;
158
+ }
159
+ function VerifiedCardComponent({
160
+ card,
161
+ className
162
+ }) {
163
+ return /* @__PURE__ */ jsxRuntime.jsxs(
164
+ "aside",
165
+ {
166
+ className: `article-verified-card ${className || ""}`.trim(),
167
+ "aria-label": `Verified Factsheet: ${card.title}`,
168
+ children: [
169
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "article-verified-card__header", children: [
170
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "article-verified-card__kicker", children: card.kicker }),
171
+ card.badge && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "article-verified-card__badge", children: card.badge })
172
+ ] }),
173
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "article-verified-card__title", children: card.title }),
174
+ card.subtitle && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "article-verified-card__subtitle", children: card.subtitle }),
175
+ card.summary && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "article-verified-card__summary", children: card.summary }),
176
+ card.facts.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("dl", { className: "article-verified-card__fact-grid", children: card.facts.map((fact, i) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "article-verified-card__fact-row", children: [
177
+ /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "article-verified-card__fact-label", children: fact.label }),
178
+ /* @__PURE__ */ jsxRuntime.jsx("dd", { className: "article-verified-card__fact-value", children: fact.href ? /* @__PURE__ */ jsxRuntime.jsx(
179
+ "a",
180
+ {
181
+ className: "article-verified-card__fact-link",
182
+ href: fact.href,
183
+ rel: "noopener noreferrer",
184
+ target: fact.href.startsWith("http") ? "_blank" : void 0,
185
+ children: fact.value
186
+ }
187
+ ) : fact.value })
188
+ ] }, i)) }),
189
+ card.actions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "article-verified-card__actions", children: card.actions.map((act, i) => /* @__PURE__ */ jsxRuntime.jsxs(
190
+ "a",
191
+ {
192
+ className: `article-verified-card__action ${act.primary ? "article-verified-card__action--primary" : "article-verified-card__action--secondary"}`,
193
+ href: act.url,
194
+ target: "_blank",
195
+ rel: "noopener noreferrer",
196
+ children: [
197
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: act.label }),
198
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", className: "article-verified-card__action-icon", children: "\u2197" })
199
+ ]
200
+ },
201
+ i
202
+ )) }),
203
+ /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "article-verified-card__footer", children: [
204
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "article-verified-card__seal", children: [
205
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "article-verified-card__seal-check", "aria-hidden": "true", children: "\u2713" }),
206
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: card.verificationNote })
207
+ ] }),
208
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "article-verified-card__date", children: card.verifiedAt })
209
+ ] })
210
+ ]
211
+ }
212
+ );
213
+ }
214
+ function VerifiedCardSection({
215
+ cards,
216
+ className
217
+ }) {
218
+ if (!cards || cards.length === 0) return null;
219
+ return /* @__PURE__ */ jsxRuntime.jsx(
220
+ "section",
221
+ {
222
+ className: `article-verified-card-section ${className || ""}`.trim(),
223
+ "aria-label": "Verified Community Information",
224
+ children: cards.map((card) => /* @__PURE__ */ jsxRuntime.jsx(VerifiedCardComponent, { card }, card.id))
225
+ }
226
+ );
227
+ }
228
+
229
+ exports.VerifiedCardComponent = VerifiedCardComponent;
230
+ exports.VerifiedCardSection = VerifiedCardSection;
231
+ exports.getArticleCards = getArticleCards;
232
+ exports.isSafeActionUrl = isSafeActionUrl;
@@ -0,0 +1,22 @@
1
+ import * as react from 'react';
2
+ import { V as VerifiedCard } from './types-DFDCZHbS.cjs';
3
+
4
+ /**
5
+ * Validate that an action link is safe for readers.
6
+ * Rejects javascript:, data:, blob:, credentials, and loopback/internal hosts.
7
+ */
8
+ declare function isSafeActionUrl(raw: unknown): boolean;
9
+ /**
10
+ * Sanitize and project raw API metadata cards into strictly validated `VerifiedCard[]`.
11
+ */
12
+ declare function getArticleCards(rawCards?: unknown): VerifiedCard[];
13
+ declare function VerifiedCardComponent({ card, className, }: {
14
+ card: VerifiedCard;
15
+ className?: string;
16
+ }): react.JSX.Element;
17
+ declare function VerifiedCardSection({ cards, className, }: {
18
+ cards?: VerifiedCard[];
19
+ className?: string;
20
+ }): react.JSX.Element | null;
21
+
22
+ export { VerifiedCardComponent, VerifiedCardSection, getArticleCards, isSafeActionUrl };
@@ -0,0 +1,22 @@
1
+ import * as react from 'react';
2
+ import { V as VerifiedCard } from './types-DFDCZHbS.js';
3
+
4
+ /**
5
+ * Validate that an action link is safe for readers.
6
+ * Rejects javascript:, data:, blob:, credentials, and loopback/internal hosts.
7
+ */
8
+ declare function isSafeActionUrl(raw: unknown): boolean;
9
+ /**
10
+ * Sanitize and project raw API metadata cards into strictly validated `VerifiedCard[]`.
11
+ */
12
+ declare function getArticleCards(rawCards?: unknown): VerifiedCard[];
13
+ declare function VerifiedCardComponent({ card, className, }: {
14
+ card: VerifiedCard;
15
+ className?: string;
16
+ }): react.JSX.Element;
17
+ declare function VerifiedCardSection({ cards, className, }: {
18
+ cards?: VerifiedCard[];
19
+ className?: string;
20
+ }): react.JSX.Element | null;
21
+
22
+ export { VerifiedCardComponent, VerifiedCardSection, getArticleCards, isSafeActionUrl };
@@ -0,0 +1 @@
1
+ export { VerifiedCardComponent, VerifiedCardSection, getArticleCards, isSafeActionUrl } from './chunk-I3ZTQCV3.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "description": "Shared server framework for ScaleMule local news publications. Walnut Creek Times is the broadsheet default; each paper supplies its own identity and feature flags.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -69,13 +69,20 @@
69
69
  "types": "./dist/network-bar.d.ts",
70
70
  "import": "./dist/network-bar.js",
71
71
  "require": "./dist/network-bar.cjs"
72
- }
72
+ },
73
+ "./verified-card": {
74
+ "types": "./dist/verified-card.d.ts",
75
+ "import": "./dist/verified-card.js",
76
+ "require": "./dist/verified-card.cjs"
77
+ },
78
+ "./verified-card.css": "./verified-card.css"
73
79
  },
74
80
  "files": [
75
81
  "dist",
76
82
  "tokens",
77
83
  "marketplace.css",
78
84
  "cookie-consent.css",
85
+ "verified-card.css",
79
86
  "README.md",
80
87
  "LICENSE"
81
88
  ],
@@ -263,3 +263,213 @@
263
263
  text-decoration: underline;
264
264
  text-underline-offset: 3px;
265
265
  }
266
+
267
+ /*
268
+ * Broadsheet Verified Factsheet Card
269
+ */
270
+ .article-verified-card {
271
+ margin: 2.25rem 0 2rem 0;
272
+ border: 1px solid var(--pub-rule, var(--news-border, #d7dcd7));
273
+ border-top: 3px solid var(--pub-accent, var(--news-nameplate, #bb171d));
274
+ background-color: var(--pub-paper, var(--news-paper, #ffffff));
275
+ color: var(--pub-ink, var(--news-ink, #171917));
276
+ padding: 1.5rem 1.75rem;
277
+ box-sizing: border-box;
278
+ }
279
+
280
+ @media (max-width: 640px) {
281
+ .article-verified-card {
282
+ padding: 1.25rem 1rem;
283
+ margin: 1.75rem 0 1.5rem 0;
284
+ }
285
+ }
286
+
287
+ .article-verified-card__header {
288
+ display: flex;
289
+ align-items: center;
290
+ justify-content: space-between;
291
+ gap: 0.75rem;
292
+ margin-bottom: 0.5rem;
293
+ }
294
+
295
+ .article-verified-card__kicker {
296
+ font-family: var(--pub-sans, Arial, Helvetica, sans-serif);
297
+ font-size: 0.6875rem;
298
+ font-weight: 700;
299
+ letter-spacing: 0.08em;
300
+ text-transform: uppercase;
301
+ color: var(--pub-accent, var(--news-nameplate, #bb171d));
302
+ margin: 0;
303
+ }
304
+
305
+ .article-verified-card__badge {
306
+ font-family: var(--pub-sans, Arial, Helvetica, sans-serif);
307
+ font-size: 0.6875rem;
308
+ font-weight: 600;
309
+ color: var(--pub-ink, var(--news-ink, #171917));
310
+ background-color: var(--pub-soft, #f0f2f0);
311
+ padding: 0.2rem 0.55rem;
312
+ border-radius: 2px;
313
+ white-space: nowrap;
314
+ }
315
+
316
+ .article-verified-card__title {
317
+ font-family: var(--pub-serif, Georgia, "Times New Roman", serif);
318
+ font-size: 1.375rem;
319
+ font-weight: 700;
320
+ line-height: 1.25;
321
+ color: var(--pub-ink, var(--news-ink, #171917));
322
+ margin: 0.25rem 0 0.25rem 0;
323
+ }
324
+
325
+ @media (max-width: 640px) {
326
+ .article-verified-card__title {
327
+ font-size: 1.25rem;
328
+ }
329
+ }
330
+
331
+ .article-verified-card__subtitle {
332
+ font-family: var(--pub-sans, Arial, Helvetica, sans-serif);
333
+ font-size: 0.875rem;
334
+ color: var(--pub-muted, var(--news-muted, #545c55));
335
+ margin: 0 0 0.875rem 0;
336
+ line-height: 1.4;
337
+ }
338
+
339
+ .article-verified-card__summary {
340
+ font-family: var(--pub-serif, Georgia, "Times New Roman", serif);
341
+ font-size: 0.9375rem;
342
+ line-height: 1.5;
343
+ color: var(--pub-ink, #2d312e);
344
+ margin: 0 0 1.25rem 0;
345
+ }
346
+
347
+ .article-verified-card__fact-grid {
348
+ display: grid;
349
+ grid-template-columns: 1fr;
350
+ gap: 0.625rem;
351
+ border-top: 1px solid var(--pub-rule, #eef0ee);
352
+ border-bottom: 1px solid var(--pub-rule, #eef0ee);
353
+ padding: 1rem 0;
354
+ margin: 0 0 1.25rem 0;
355
+ }
356
+
357
+ .article-verified-card__fact-row {
358
+ display: flex;
359
+ flex-direction: row;
360
+ align-items: baseline;
361
+ gap: 1rem;
362
+ font-family: var(--pub-sans, Arial, Helvetica, sans-serif);
363
+ font-size: 0.875rem;
364
+ line-height: 1.4;
365
+ }
366
+
367
+ @media (max-width: 540px) {
368
+ .article-verified-card__fact-row {
369
+ flex-direction: column;
370
+ gap: 0.125rem;
371
+ }
372
+ }
373
+
374
+ .article-verified-card__fact-label {
375
+ min-width: 140px;
376
+ font-size: 0.6875rem;
377
+ font-weight: 700;
378
+ letter-spacing: 0.05em;
379
+ text-transform: uppercase;
380
+ color: var(--pub-muted, var(--news-muted, #545c55));
381
+ flex-shrink: 0;
382
+ margin: 0;
383
+ }
384
+
385
+ .article-verified-card__fact-value {
386
+ color: var(--pub-ink, var(--news-ink, #171917));
387
+ font-weight: 500;
388
+ margin: 0;
389
+ }
390
+
391
+ .article-verified-card__fact-link {
392
+ color: var(--pub-ink, var(--news-ink, #171917));
393
+ text-decoration: underline;
394
+ text-underline-offset: 3px;
395
+ text-decoration-color: var(--pub-muted, #a0a6a0);
396
+ transition: color 0.15s ease, text-decoration-color 0.15s ease;
397
+ }
398
+
399
+ .article-verified-card__fact-link:hover {
400
+ color: var(--pub-accent, var(--news-nameplate, #bb171d));
401
+ text-decoration-color: var(--pub-accent, var(--news-nameplate, #bb171d));
402
+ }
403
+
404
+ .article-verified-card__actions {
405
+ display: flex;
406
+ flex-wrap: wrap;
407
+ gap: 0.75rem;
408
+ margin: 1.25rem 0 0 0;
409
+ }
410
+
411
+ .article-verified-card__action {
412
+ display: inline-flex;
413
+ align-items: center;
414
+ justify-content: center;
415
+ gap: 0.375rem;
416
+ font-family: var(--pub-sans, Arial, Helvetica, sans-serif);
417
+ font-size: 0.8125rem;
418
+ font-weight: 600;
419
+ border-radius: 2px;
420
+ text-decoration: none !important;
421
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
422
+ }
423
+
424
+ .article-verified-card__action--primary {
425
+ background-color: var(--pub-ink, var(--news-ink, #171917));
426
+ color: var(--pub-paper, #ffffff) !important;
427
+ padding: 0.625rem 1.125rem;
428
+ }
429
+
430
+ .article-verified-card__action--primary:hover {
431
+ background-color: var(--pub-accent, var(--news-nameplate, #bb171d));
432
+ }
433
+
434
+ .article-verified-card__action--secondary {
435
+ background-color: transparent;
436
+ color: var(--pub-ink, var(--news-ink, #171917)) !important;
437
+ border: 1px solid var(--pub-ink, var(--news-ink, #171917));
438
+ padding: 0.5625rem 1rem;
439
+ }
440
+
441
+ .article-verified-card__action--secondary:hover {
442
+ background-color: var(--pub-soft, #f2f4f2);
443
+ }
444
+
445
+ .article-verified-card__footer {
446
+ border-top: 1px solid var(--pub-rule, #eef0ee);
447
+ margin-top: 1.25rem;
448
+ padding-top: 0.75rem;
449
+ display: flex;
450
+ flex-wrap: wrap;
451
+ align-items: center;
452
+ justify-content: space-between;
453
+ gap: 0.5rem;
454
+ font-family: var(--pub-sans, Arial, Helvetica, sans-serif);
455
+ font-size: 0.75rem;
456
+ color: var(--pub-muted, var(--news-muted, #545c55));
457
+ }
458
+
459
+ .article-verified-card__seal {
460
+ display: inline-flex;
461
+ align-items: center;
462
+ gap: 0.375rem;
463
+ color: var(--pub-sponsor, #173e32);
464
+ font-weight: 600;
465
+ }
466
+
467
+ .article-verified-card__seal-check {
468
+ color: var(--pub-sponsor, #173e32);
469
+ font-weight: 700;
470
+ }
471
+
472
+ .article-verified-card__date {
473
+ color: var(--pub-muted, var(--news-muted, #545c55));
474
+ font-weight: 400;
475
+ }