@anthusai/papyrus 5.49.0 → 5.51.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.
@@ -0,0 +1,77 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+
4
+ /**
5
+ * Whether a source page lets another site show it in a frame. Most news sites refuse (X-Frame-Options or a CSP frame-ancestors
6
+ * rule), and a refused frame is just an empty box, which the browser cannot detect from outside. So the newsroom asks here,
7
+ * on the server, before it shows a web page's live preview.
8
+ */
9
+
10
+ /** True when the response headers allow framing by any site. A rule naming specific origins is treated as a refusal. */
11
+ export function frameableFromHeaders(headers: Headers): boolean {
12
+ const xfo = headers.get("x-frame-options")?.trim().toLowerCase();
13
+ if (xfo && (xfo.includes("deny") || xfo.includes("sameorigin") || xfo.startsWith("allow-from"))) return false;
14
+ const csp = headers.get("content-security-policy");
15
+ if (csp) {
16
+ for (const directive of csp.split(";")) {
17
+ const [name, ...sources] = directive.trim().split(/\s+/);
18
+ if (name?.toLowerCase() !== "frame-ancestors") continue;
19
+ if (!sources.some((source) => source === "*" || source === "https:" || source === "http:")) return false;
20
+ }
21
+ }
22
+ return true;
23
+ }
24
+
25
+ /** Loopback, private, link-local and other non-public addresses, which the server must never be made to fetch. */
26
+ export function isPrivateAddress(address: string): boolean {
27
+ const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
28
+ if (isIP(v4) === 4) {
29
+ const [a, b] = v4.split(".").map(Number);
30
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
31
+ || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
32
+ }
33
+ const v6 = address.toLowerCase();
34
+ return v6 === "::" || v6 === "::1" || v6.startsWith("fc") || v6.startsWith("fd") || v6.startsWith("fe80");
35
+ }
36
+
37
+ /** A plain public http(s) address on the default port whose host resolves only to public addresses, else null. */
38
+ export async function publicHttpUrl(raw: string): Promise<URL | null> {
39
+ let url: URL;
40
+ try { url = new URL(raw); } catch { return null; }
41
+ if ((url.protocol !== "https:" && url.protocol !== "http:") || url.port || url.username || url.password) return null;
42
+ try {
43
+ const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true });
44
+ return addresses.length && addresses.every(({ address }) => !isPrivateAddress(address)) ? url : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** GETs a public address, following up to `maxRedirects` redirects and checking every hop with publicHttpUrl. Null on any failure. */
51
+ export async function fetchPublic(raw: string, { accept, timeoutMs = 5000, maxRedirects = 4 }: { accept: string; timeoutMs?: number; maxRedirects?: number }): Promise<Response | null> {
52
+ let next = await publicHttpUrl(raw);
53
+ for (let hop = 0; next && hop <= maxRedirects; hop += 1) {
54
+ let response: Response;
55
+ try {
56
+ response = await fetch(next, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(timeoutMs), headers: { accept } });
57
+ } catch {
58
+ return null;
59
+ }
60
+ const location = response.headers.get("location");
61
+ if (response.status >= 300 && response.status < 400 && location) {
62
+ void response.body?.cancel();
63
+ next = await publicHttpUrl(new URL(location, next).toString());
64
+ continue;
65
+ }
66
+ return response;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** Asks the page itself. Anything that fails (bad address, error status, timeout, too many redirects) counts as not frameable. */
72
+ export async function checkSourceFrameable(raw: string, options: { timeoutMs?: number; maxRedirects?: number } = {}): Promise<boolean> {
73
+ const response = await fetchPublic(raw, { accept: "text/html", ...options });
74
+ if (!response) return false;
75
+ void response.body?.cancel();
76
+ return response.ok && frameableFromHeaders(response.headers);
77
+ }
@@ -0,0 +1,104 @@
1
+ import { fetchPublic } from "./source-frameable";
2
+
3
+ /**
4
+ * A source's own icon, fetched on the server. Guessing `/favicon.ico` from the browser fails often: some sites serve a fully
5
+ * transparent placeholder there, and others refuse requests from another site. The page's own `<link rel="icon">` tags name
6
+ * the icon the site actually uses, so the newsroom reads those first and passes the image through.
7
+ */
8
+
9
+ const MAX_HTML_BYTES = 256 * 1024;
10
+ const MAX_ICON_BYTES = 256 * 1024;
11
+ /** A bare 16x16 ICO is about 200 bytes; at that size it is often an empty placeholder, so the guessed path must beat it. */
12
+ const MIN_GUESSED_ICON_BYTES = 300;
13
+
14
+ export type SourceIcon = { body: ArrayBuffer; contentType: string };
15
+
16
+ /** A bare host name such as `www.brookings.edu`: letters, digits, hyphens and dots only. */
17
+ export function isHostName(host: string): boolean {
18
+ return host.length <= 253 && /^(?=.*[a-z])[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(host);
19
+ }
20
+
21
+ function attribute(tag: string, name: string): string | null {
22
+ const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
23
+ return match ? (match[1] ?? match[2] ?? match[3] ?? "") : null;
24
+ }
25
+
26
+ /** The icons a page declares, best first: touch icons, then the largest declared size. Mask icons are single-colour stencils and are skipped. */
27
+ export function iconCandidates(html: string, base: string): string[] {
28
+ const found: { href: string; score: number }[] = [];
29
+ for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
30
+ const rel = (attribute(tag, "rel") ?? "").toLowerCase().split(/\s+/);
31
+ if (!rel.some((word) => word === "icon" || word === "apple-touch-icon" || word === "apple-touch-icon-precomposed")) continue;
32
+ const href = attribute(tag, "href");
33
+ if (!href) continue;
34
+ let absolute: string;
35
+ try { absolute = new URL(href, base).toString(); } catch { continue; }
36
+ const size = Math.max(0, ...(attribute(tag, "sizes") ?? "").split(/\s+/).map((s) => Number.parseInt(s, 10)).filter(Number.isFinite));
37
+ const touch = rel.some((word) => word.startsWith("apple-touch-icon"));
38
+ found.push({ href: absolute, score: (touch ? 10_000 : 0) + (size || (touch ? 180 : 16)) });
39
+ }
40
+ return [...new Set(found.sort((a, b) => b.score - a.score).map(({ href }) => href))];
41
+ }
42
+
43
+ async function readLimited(response: Response, limit: number): Promise<ArrayBuffer | null> {
44
+ const declared = Number(response.headers.get("content-length"));
45
+ if (declared > limit) { void response.body?.cancel(); return null; }
46
+ const body = await response.arrayBuffer().catch(() => null);
47
+ return body && body.byteLength <= limit ? body : null;
48
+ }
49
+
50
+ /** The first `limit` bytes of a page: icons are declared in its head, and some home pages run to a megabyte. */
51
+ async function readPrefix(response: Response, limit: number): Promise<string> {
52
+ const reader = response.body?.getReader();
53
+ if (!reader) return "";
54
+ const chunks: Uint8Array[] = [];
55
+ let total = 0;
56
+ try {
57
+ while (total < limit) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ chunks.push(value);
61
+ total += value.byteLength;
62
+ }
63
+ } catch {
64
+ // keep what arrived
65
+ } finally {
66
+ void reader.cancel().catch(() => undefined);
67
+ }
68
+ const bytes = new Uint8Array(Math.min(total, limit));
69
+ let offset = 0;
70
+ for (const chunk of chunks) {
71
+ const part = chunk.subarray(0, bytes.length - offset);
72
+ bytes.set(part, offset);
73
+ offset += part.byteLength;
74
+ if (offset >= bytes.length) break;
75
+ }
76
+ return new TextDecoder().decode(bytes);
77
+ }
78
+
79
+ async function fetchIcon(url: string, minBytes: number): Promise<SourceIcon | null> {
80
+ const response = await fetchPublic(url, { accept: "image/*" });
81
+ if (!response) return null;
82
+ const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
83
+ if (!response.ok || !contentType.startsWith("image/")) { void response.body?.cancel(); return null; }
84
+ const body = await readLimited(response, MAX_ICON_BYTES);
85
+ return body && body.byteLength >= minBytes ? { body, contentType } : null;
86
+ }
87
+
88
+ /** The host's best declared icon, else its `/favicon.ico` when that is more than a placeholder, else null. */
89
+ export async function fetchSourceIcon(host: string): Promise<SourceIcon | null> {
90
+ if (!isHostName(host)) return null;
91
+ const home = `https://${host}/`;
92
+ const page = await fetchPublic(home, { accept: "text/html" });
93
+ let candidates: string[] = [];
94
+ if (page?.ok && (page.headers.get("content-type") ?? "").includes("html")) {
95
+ candidates = iconCandidates(await readPrefix(page, MAX_HTML_BYTES), page.url || home);
96
+ } else {
97
+ void page?.body?.cancel();
98
+ }
99
+ for (const candidate of candidates.slice(0, 4)) {
100
+ const icon = await fetchIcon(candidate, 1);
101
+ if (icon) return icon;
102
+ }
103
+ return fetchIcon(new URL("/favicon.ico", home).toString(), MIN_GUESSED_ICON_BYTES);
104
+ }
package/package.json CHANGED
@@ -86,6 +86,7 @@
86
86
  "./components/editorial-findings-panel": "./components/editorial-findings-panel.tsx",
87
87
  "./components/editorial-preview-popover": "./components/editorial-preview-popover.tsx",
88
88
  "./components/item-stream-view": "./components/item-stream-view.tsx",
89
+ "./components/markdown-context": "./components/markdown-context.tsx",
89
90
  "./components/markus-body-editor": "./components/markus-body-editor.tsx",
90
91
  "./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
91
92
  "./components/moderation-panel": "./components/moderation-panel.tsx",
@@ -114,7 +115,6 @@
114
115
  "./components/newsroom-mobile-navigation": "./components/newsroom-mobile-navigation.tsx",
115
116
  "./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
116
117
  "./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
117
- "./components/newsroom-overview-skeleton": "./components/newsroom-overview-skeleton.tsx",
118
118
  "./components/newsroom-references-view": "./components/newsroom-references-view.tsx",
119
119
  "./components/newsroom-theme-fonts/broadsheet-green": "./components/newsroom-theme-fonts/broadsheet-green.tsx",
120
120
  "./components/newsroom-theme-fonts/newspaper-gothic": "./components/newsroom-theme-fonts/newspaper-gothic.tsx",
@@ -164,6 +164,7 @@
164
164
  "./components/story-reporting-trail-client": "./components/story-reporting-trail-client.ts",
165
165
  "./components/story-reporting-trail": "./components/story-reporting-trail.tsx",
166
166
  "./components/topic-steering-workspace": "./components/topic-steering-workspace.tsx",
167
+ "./components/ui/auto-grow-textarea": "./components/ui/auto-grow-textarea.tsx",
167
168
  "./components/ui/badge": "./components/ui/badge.tsx",
168
169
  "./components/ui/button-group": "./components/ui/button-group.tsx",
169
170
  "./components/ui/button": "./components/ui/button.tsx",
@@ -368,6 +369,8 @@
368
369
  "./lib/site-renderer": "./lib/site-renderer.ts",
369
370
  "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
370
371
  "./lib/site-stack": "./lib/site-stack.ts",
372
+ "./lib/source-frameable": "./lib/source-frameable.ts",
373
+ "./lib/source-icon": "./lib/source-icon.ts",
371
374
  "./lib/staff-groups": "./lib/staff-groups.ts",
372
375
  "./lib/staging-gate": "./lib/staging-gate.ts",
373
376
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
@@ -419,6 +422,8 @@
419
422
  "./app/api/newsroom/curation/context/route": "./app/api/newsroom/curation/context/route.ts",
420
423
  "./app/api/newsroom/curation/priming/route": "./app/api/newsroom/curation/priming/route.ts",
421
424
  "./app/api/newsroom/curation/publication/route": "./app/api/newsroom/curation/publication/route.ts",
425
+ "./app/api/newsroom/source-frameable/route": "./app/api/newsroom/source-frameable/route.ts",
426
+ "./app/api/newsroom/source-icon/route": "./app/api/newsroom/source-icon/route.ts",
422
427
  "./app/api/reader/comment/delete/route": "./app/api/reader/comment/delete/route.ts",
423
428
  "./app/api/reader/comment/edit/route": "./app/api/reader/comment/edit/route.ts",
424
429
  "./app/api/reader/comment/route": "./app/api/reader/comment/route.ts",
@@ -495,7 +500,7 @@
495
500
  "optional": true
496
501
  }
497
502
  },
498
- "version": "5.49.0",
503
+ "version": "5.51.0",
499
504
  "dependencies": {
500
505
  "@aws-amplify/adapter-nextjs": "^1.7.3",
501
506
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.49.0",
2
+ "version": "5.51.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -285,6 +285,28 @@
285
285
  "runtime": "\"nodejs\""
286
286
  }
287
287
  },
288
+ {
289
+ "file": "app/api/newsroom/source-frameable/route.ts",
290
+ "hasDefault": false,
291
+ "exports": [
292
+ "GET"
293
+ ],
294
+ "config": {
295
+ "dynamic": "\"force-dynamic\"",
296
+ "runtime": "\"nodejs\""
297
+ }
298
+ },
299
+ {
300
+ "file": "app/api/newsroom/source-icon/route.ts",
301
+ "hasDefault": false,
302
+ "exports": [
303
+ "GET"
304
+ ],
305
+ "config": {
306
+ "dynamic": "\"force-dynamic\"",
307
+ "runtime": "\"nodejs\""
308
+ }
309
+ },
288
310
  {
289
311
  "file": "app/api/reader/comment/delete/route.ts",
290
312
  "hasDefault": false,
@@ -1,25 +0,0 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
-
5
- /** Placeholder geometry for the overview while private records or access are unresolved. */
6
- export function NewsroomOverviewSkeleton() {
7
- return (
8
- <div aria-label="Loading newsroom overview" className="space-y-8 font-sans" data-newsroom-overview-skeleton role="status">
9
- <section className="newsroom-overview-feed-frame space-y-4 overflow-hidden rounded-xl border bg-card p-4" aria-label="Notification Center">
10
- <div className="h-6 w-48 rounded bg-muted" />
11
- <div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
12
- <div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
13
- <div className="grid gap-3 sm:grid-cols-2">
14
- {Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
15
- <div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
16
- </div>)}
17
- </div>
18
- </section>
19
- <section aria-label="Needs attention" className="space-y-3">
20
- <div className="h-4 w-32 rounded bg-muted" />
21
- <div className="h-16 rounded-xl border bg-card p-4"><div className="h-4 w-1/2 rounded bg-muted" /><div className="mt-2 h-3 w-1/3 rounded bg-muted" /></div>
22
- </section>
23
- </div>
24
- );
25
- }