@anthusai/papyrus 5.50.0 → 5.52.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,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",
@@ -369,6 +370,7 @@
369
370
  "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
370
371
  "./lib/site-stack": "./lib/site-stack.ts",
371
372
  "./lib/source-frameable": "./lib/source-frameable.ts",
373
+ "./lib/source-icon": "./lib/source-icon.ts",
372
374
  "./lib/staff-groups": "./lib/staff-groups.ts",
373
375
  "./lib/staging-gate": "./lib/staging-gate.ts",
374
376
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
@@ -421,6 +423,7 @@
421
423
  "./app/api/newsroom/curation/priming/route": "./app/api/newsroom/curation/priming/route.ts",
422
424
  "./app/api/newsroom/curation/publication/route": "./app/api/newsroom/curation/publication/route.ts",
423
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",
424
427
  "./app/api/reader/comment/delete/route": "./app/api/reader/comment/delete/route.ts",
425
428
  "./app/api/reader/comment/edit/route": "./app/api/reader/comment/edit/route.ts",
426
429
  "./app/api/reader/comment/route": "./app/api/reader/comment/route.ts",
@@ -497,7 +500,7 @@
497
500
  "optional": true
498
501
  }
499
502
  },
500
- "version": "5.50.0",
503
+ "version": "5.52.0",
501
504
  "dependencies": {
502
505
  "@aws-amplify/adapter-nextjs": "^1.7.3",
503
506
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.50.0",
2
+ "version": "5.52.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -296,6 +296,17 @@
296
296
  "runtime": "\"nodejs\""
297
297
  }
298
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
+ },
299
310
  {
300
311
  "file": "app/api/reader/comment/delete/route.ts",
301
312
  "hasDefault": false,