@anthusai/papyrus 5.50.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.
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/components/markdown-context.tsx +412 -0
- package/components/needs-me-feed.tsx +36 -14
- package/components/newsroom-access-gate.tsx +3 -3
- package/components/newsroom-ops-shell.tsx +0 -22
- package/components/newsroom-organizer.css +19 -0
- package/components/newsroom-references-view.tsx +7 -9
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-panel.tsx +4 -2
- package/components/reference-feed.tsx +8 -4
- package/components/topic-steering-workspace.tsx +3 -428
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-references.ts +25 -1
- package/lib/reference-capture.ts +3 -2
- package/lib/source-frameable.ts +15 -7
- package/lib/source-icon.ts +104 -0
- package/package.json +4 -1
- package/routes.manifest.json +12 -1
|
@@ -9,7 +9,7 @@ import { usePathname } from "next/navigation";
|
|
|
9
9
|
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
|
10
10
|
import { Badge } from "./ui/badge";
|
|
11
11
|
import { Button } from "./ui/button";
|
|
12
|
-
import { Card,
|
|
12
|
+
import { Card, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
|
13
13
|
import { ScrollArea } from "./ui/scroll-area";
|
|
14
14
|
import { Separator } from "./ui/separator";
|
|
15
15
|
import {
|
|
@@ -661,7 +661,6 @@ export function NewsroomReferencesView({
|
|
|
661
661
|
return (
|
|
662
662
|
<div
|
|
663
663
|
className="flex min-h-0 flex-1 flex-col gap-4 md:max-h-[100dvh] md:flex-row"
|
|
664
|
-
data-detail-reserved={view === "feed" ? "true" : undefined}
|
|
665
664
|
data-news-desk-section="references"
|
|
666
665
|
data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
|
|
667
666
|
>
|
|
@@ -762,8 +761,9 @@ export function NewsroomReferencesView({
|
|
|
762
761
|
</button>
|
|
763
762
|
</div>
|
|
764
763
|
) : null}
|
|
764
|
+
{/* 6px of room on every side, so a selected card's 4px ring (drawn outside the card) is never cut off by the scroller. */}
|
|
765
765
|
<ScrollArea className="min-h-0 flex-1 [overflow-anchor:none]" data-reference-feed-scroll>
|
|
766
|
-
<div className="pb-6 pr-
|
|
766
|
+
<div className="px-1.5 pb-6 pt-1.5 pr-2.5">
|
|
767
767
|
<ReferenceFeed
|
|
768
768
|
disabledReason={feedDisabledReason}
|
|
769
769
|
loading={previewSkeleton || (loading && !references.length)}
|
|
@@ -871,7 +871,9 @@ export function NewsroomReferencesView({
|
|
|
871
871
|
|
|
872
872
|
<div
|
|
873
873
|
className={cn(
|
|
874
|
-
"hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:
|
|
874
|
+
"hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]",
|
|
875
|
+
// The pane appears once a reference is selected (or when the queue is done); until then the feed has the whole width.
|
|
876
|
+
(selectedReference || caughtUp) && "md:flex",
|
|
875
877
|
)}
|
|
876
878
|
data-newsroom-list-detail-shell
|
|
877
879
|
data-news-desk-section="references"
|
|
@@ -907,11 +909,7 @@ export function NewsroomReferencesView({
|
|
|
907
909
|
previewAttachments={previewAttachments}
|
|
908
910
|
reference={selectedReference}
|
|
909
911
|
/>
|
|
910
|
-
) : caughtUp ? caughtUpPanel :
|
|
911
|
-
<CardContent className="p-4 text-sm text-muted-foreground">
|
|
912
|
-
Select a reference to inspect curation details.
|
|
913
|
-
</CardContent>
|
|
914
|
-
)}
|
|
912
|
+
) : caughtUp ? caughtUpPanel : null}
|
|
915
913
|
</ScrollArea>
|
|
916
914
|
</div>
|
|
917
915
|
{queueBar}
|
|
@@ -36,7 +36,9 @@ export function ReaderAuthControl({
|
|
|
36
36
|
if (!nextPath) return;
|
|
37
37
|
if (window.location.pathname !== "/" || window.location.search || window.location.hash) return;
|
|
38
38
|
if (storedPath) window.sessionStorage.removeItem(POST_AUTH_PATH_KEY);
|
|
39
|
-
|
|
39
|
+
if (nextPath === "/") return;
|
|
40
|
+
// Sign-in comes back to "/"; the page asked for (a deep link such as /references/<id>) may be another route, so load it.
|
|
41
|
+
window.location.replace(nextPath);
|
|
40
42
|
}, [postAuthPath]);
|
|
41
43
|
|
|
42
44
|
const refreshAuthState = useCallback(async () => {
|
|
@@ -6,6 +6,7 @@ import { captureLine, type CaptureAssessment, type CaptureState, type CaptureSta
|
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
8
8
|
import type { CaptureView } from "./reference-capture-client";
|
|
9
|
+
import { MarkdownContext } from "./markdown-context";
|
|
9
10
|
|
|
10
11
|
const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
|
|
11
12
|
/** Colour never stands alone: each state has its own icon and words. */
|
|
@@ -147,8 +148,9 @@ export function ReferenceCapturePanel({ capture, showSummary = true, startEditin
|
|
|
147
148
|
{text && !editing ? (
|
|
148
149
|
<div className="space-y-2">
|
|
149
150
|
{/* No scroll box of its own: on a phone a swipe here scrolls the page. "Show all" grows it in place. */}
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
{/* Captured pages are stored as Markdown: render it (as React elements, never HTML) so headings and lists read as such. */}
|
|
152
|
+
<div className="rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text>
|
|
153
|
+
<MarkdownContext className="reference-capture-markdown" text={expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`} />
|
|
152
154
|
</div>
|
|
153
155
|
{long ? (
|
|
154
156
|
<button className="rounded-full bg-[var(--feed-control)] px-3 py-1.5 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-toggle onClick={() => setExpanded((value) => !value)} type="button">
|
|
@@ -81,6 +81,10 @@ export type ReferenceFeedProps = {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
/** Columns follow the width the feed has (a container query, not the screen), so they stay put while a detail pane holds its place beside it. */
|
|
84
|
+
/** A selected card: a thick ring drawn outside it on every side (a box-shadow, so it takes no layout space and covers nothing inside
|
|
85
|
+
* the card). The grid's gap leaves room for it between cards. The fallback keeps the ring if a theme has no --primary. */
|
|
86
|
+
export const SELECTED_RING = { boxShadow: "0 0 0 4px var(--primary, currentColor)" } as const;
|
|
87
|
+
|
|
84
88
|
export const FEED_GRID = "grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-5";
|
|
85
89
|
const GRID = FEED_GRID;
|
|
86
90
|
/** The lead spans two columns only where there are at least two to span (two 17rem columns and the gap). */
|
|
@@ -115,7 +119,7 @@ function hueOf(name: string): number {
|
|
|
115
119
|
return Math.abs(hash) % 360;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
/** A small mark for a source: its own
|
|
122
|
+
/** A small mark for a source: its own icon (found on the server, see lib/source-icon.ts) on a light tile when there is one, else its first letter. */
|
|
119
123
|
export function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
|
|
120
124
|
const [failed, setFailed] = useState(false);
|
|
121
125
|
const box = size === "lg" ? "size-16 rounded-2xl text-3xl" : "size-5 rounded-md text-[0.65rem]";
|
|
@@ -124,7 +128,7 @@ export function SourceMark({ host, size = "sm" }: { host: string | null; size?:
|
|
|
124
128
|
<span aria-hidden="true" className={cn("inline-flex shrink-0 items-center justify-center overflow-hidden bg-white font-bold uppercase text-neutral-700", box)} data-feed-source-mark>
|
|
125
129
|
{host && !failed ? (
|
|
126
130
|
// eslint-disable-next-line @next/next/no-img-element
|
|
127
|
-
<img alt="" className={size === "lg" ? "size-10 object-contain" : "size-3.5 object-contain"} loading="lazy" onError={() => setFailed(true)}
|
|
131
|
+
<img alt="" className={size === "lg" ? "size-10 object-contain" : "size-3.5 object-contain"} loading="lazy" onError={() => setFailed(true)} src={`/api/newsroom/source-icon?host=${encodeURIComponent(host)}`} />
|
|
128
132
|
) : letter}
|
|
129
133
|
</span>
|
|
130
134
|
);
|
|
@@ -254,17 +258,17 @@ export function FeedCard({
|
|
|
254
258
|
aria-current={selected ? "true" : undefined}
|
|
255
259
|
className={cn(
|
|
256
260
|
"relative flex flex-col overflow-hidden rounded-[20px] text-card-foreground transition-[background-color,box-shadow] duration-200",
|
|
257
|
-
selected ? "bg-[var(--feed-control)]
|
|
261
|
+
selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
|
|
258
262
|
lead && LEAD_SPAN,
|
|
259
263
|
)}
|
|
260
264
|
data-feed-card={item.lineageId}
|
|
261
265
|
data-feed-lead={lead || undefined}
|
|
262
266
|
data-feed-rated={answer ?? undefined}
|
|
263
267
|
data-feed-selected={selected || undefined}
|
|
268
|
+
style={selected ? SELECTED_RING : undefined}
|
|
264
269
|
data-feed-visit-rated={visitRated || undefined}
|
|
265
270
|
data-newsroom-card-id={item.lineageId}
|
|
266
271
|
>
|
|
267
|
-
{selected ? <span aria-hidden="true" className="absolute inset-y-0 left-0 z-10 w-1.5 bg-[var(--primary)]" data-feed-selected-bar /> : null}
|
|
268
272
|
<button
|
|
269
273
|
aria-label={`Open: ${title}`}
|
|
270
274
|
className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
@@ -103,7 +103,8 @@ import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
|
|
|
103
103
|
import { NewsroomReferencesView } from "./newsroom-references-view";
|
|
104
104
|
import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
|
|
105
105
|
import { NewsroomTopicsView } from "./newsroom-topics-view";
|
|
106
|
-
import {
|
|
106
|
+
import { MarkdownContext } from "./markdown-context";
|
|
107
|
+
import { NewsroomOpsSectionIntro, NewsroomOpsShell, NewsroomOpsStatusBanner, type NewsroomNavCount } from "./newsroom-ops-shell";
|
|
107
108
|
import { Button, buttonVariants } from "./ui/button";
|
|
108
109
|
import { Badge } from "./ui/badge";
|
|
109
110
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
|
@@ -3060,14 +3061,8 @@ function NewsDeskDashboard({
|
|
|
3060
3061
|
{dashboard.isDemo ? (
|
|
3061
3062
|
dashboard.realDataPreview ? null : <Badge variant="outline">Demo</Badge>
|
|
3062
3063
|
) : (
|
|
3063
|
-
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
3064
|
+
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "organizer-header-settings")} href="/settings">Settings</Link>
|
|
3064
3065
|
)}
|
|
3065
|
-
{canEdit && editorShellReady && !dashboard.isDemo ? (
|
|
3066
|
-
<NewsroomOpsSearchButton
|
|
3067
|
-
disabled={false}
|
|
3068
|
-
onPress={activeTab === "search" ? focusNewsroomSearchForm : topBarSearchControl.open}
|
|
3069
|
-
/>
|
|
3070
|
-
) : null}
|
|
3071
3066
|
<NewsroomConsoleProgressToggle />
|
|
3072
3067
|
</>
|
|
3073
3068
|
)}
|
|
@@ -9861,415 +9856,6 @@ function SearchDeskView({
|
|
|
9861
9856
|
);
|
|
9862
9857
|
}
|
|
9863
9858
|
|
|
9864
|
-
function MarkdownContext({ className, text }: { className?: string; text: string }) {
|
|
9865
|
-
const blocks = useMemo(() => parseMarkdownBlocks(normalizeMarkdownForRendering(text)), [text]);
|
|
9866
|
-
return (
|
|
9867
|
-
<div className={className ? `news-desk-markdown-context ${className}` : "news-desk-markdown-context"}>
|
|
9868
|
-
{blocks.map((block, index) => renderMarkdownBlock(block, index))}
|
|
9869
|
-
</div>
|
|
9870
|
-
);
|
|
9871
|
-
}
|
|
9872
|
-
|
|
9873
|
-
type MarkdownBlock =
|
|
9874
|
-
| { type: "code"; text: string }
|
|
9875
|
-
| { type: "heading"; depth: number; text: string }
|
|
9876
|
-
| { type: "list"; items: string[]; ordered: boolean }
|
|
9877
|
-
| { type: "table"; headers: string[]; rows: string[][] }
|
|
9878
|
-
| { type: "paragraph"; text: string };
|
|
9879
|
-
|
|
9880
|
-
function normalizeMarkdownForRendering(text: string): string {
|
|
9881
|
-
const trimmed = text.trim();
|
|
9882
|
-
const fencedMarkdown = /^```(?:markdown|md|mdx)?\s*\n([\s\S]*?)\n```$/i.exec(trimmed);
|
|
9883
|
-
const unwrapped = fencedMarkdown ? (fencedMarkdown[1] ?? "") : text;
|
|
9884
|
-
return normalizeMultilineMarkdownLinks(unwrapped);
|
|
9885
|
-
}
|
|
9886
|
-
|
|
9887
|
-
function parseMarkdownBlocks(text: string): MarkdownBlock[] {
|
|
9888
|
-
const blocks: MarkdownBlock[] = [];
|
|
9889
|
-
const lines = text.split(/\r?\n/);
|
|
9890
|
-
let paragraph: string[] = [];
|
|
9891
|
-
let list: string[] = [];
|
|
9892
|
-
let listOrdered = false;
|
|
9893
|
-
let code: string[] = [];
|
|
9894
|
-
let inCode = false;
|
|
9895
|
-
const flushParagraph = () => {
|
|
9896
|
-
if (paragraph.length) {
|
|
9897
|
-
blocks.push({ type: "paragraph", text: paragraph.join(" ").trim() });
|
|
9898
|
-
paragraph = [];
|
|
9899
|
-
}
|
|
9900
|
-
};
|
|
9901
|
-
const flushList = () => {
|
|
9902
|
-
if (list.length) {
|
|
9903
|
-
blocks.push({ type: "list", items: list, ordered: listOrdered });
|
|
9904
|
-
list = [];
|
|
9905
|
-
listOrdered = false;
|
|
9906
|
-
}
|
|
9907
|
-
};
|
|
9908
|
-
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
9909
|
-
const line = lines[lineIndex];
|
|
9910
|
-
if (line.trim().startsWith("```")) {
|
|
9911
|
-
if (inCode) {
|
|
9912
|
-
blocks.push({ type: "code", text: code.join("\n") });
|
|
9913
|
-
code = [];
|
|
9914
|
-
inCode = false;
|
|
9915
|
-
} else {
|
|
9916
|
-
flushParagraph();
|
|
9917
|
-
flushList();
|
|
9918
|
-
inCode = true;
|
|
9919
|
-
}
|
|
9920
|
-
continue;
|
|
9921
|
-
}
|
|
9922
|
-
if (inCode) {
|
|
9923
|
-
code.push(line);
|
|
9924
|
-
continue;
|
|
9925
|
-
}
|
|
9926
|
-
const trimmed = line.trim();
|
|
9927
|
-
if (!trimmed) {
|
|
9928
|
-
flushParagraph();
|
|
9929
|
-
flushList();
|
|
9930
|
-
continue;
|
|
9931
|
-
}
|
|
9932
|
-
if (
|
|
9933
|
-
lineIndex + 1 < lines.length
|
|
9934
|
-
&& looksLikeMarkdownTableHeaderLine(line)
|
|
9935
|
-
&& isMarkdownTableDividerLine(lines[lineIndex + 1] ?? "")
|
|
9936
|
-
) {
|
|
9937
|
-
flushParagraph();
|
|
9938
|
-
flushList();
|
|
9939
|
-
const headerCells = splitMarkdownTableCells(line);
|
|
9940
|
-
const dividerCells = splitMarkdownTableCells(lines[lineIndex + 1] ?? "");
|
|
9941
|
-
const columnCount = Math.max(headerCells.length, dividerCells.length, 1);
|
|
9942
|
-
const headers = normalizeMarkdownTableCells(headerCells, columnCount);
|
|
9943
|
-
const rows: string[][] = [];
|
|
9944
|
-
lineIndex += 2;
|
|
9945
|
-
while (lineIndex < lines.length) {
|
|
9946
|
-
const rowLine = lines[lineIndex] ?? "";
|
|
9947
|
-
if (!looksLikeMarkdownTableRowLine(rowLine)) {
|
|
9948
|
-
lineIndex -= 1;
|
|
9949
|
-
break;
|
|
9950
|
-
}
|
|
9951
|
-
rows.push(normalizeMarkdownTableCells(splitMarkdownTableCells(rowLine), columnCount));
|
|
9952
|
-
lineIndex += 1;
|
|
9953
|
-
}
|
|
9954
|
-
if (lineIndex >= lines.length) lineIndex -= 1;
|
|
9955
|
-
blocks.push({ type: "table", headers, rows });
|
|
9956
|
-
continue;
|
|
9957
|
-
}
|
|
9958
|
-
const heading = /^(#{1,4})\s+(.+)$/.exec(trimmed);
|
|
9959
|
-
if (heading) {
|
|
9960
|
-
flushParagraph();
|
|
9961
|
-
flushList();
|
|
9962
|
-
blocks.push({ type: "heading", depth: heading[1].length, text: heading[2].trim() });
|
|
9963
|
-
continue;
|
|
9964
|
-
}
|
|
9965
|
-
const bullet = /^(?:([-*+])\s+|(\d+)[.)]\s+)(.+)$/.exec(trimmed);
|
|
9966
|
-
if (bullet) {
|
|
9967
|
-
flushParagraph();
|
|
9968
|
-
const ordered = Boolean(bullet[2]);
|
|
9969
|
-
if (list.length && listOrdered !== ordered) flushList();
|
|
9970
|
-
listOrdered = ordered;
|
|
9971
|
-
list.push((bullet[3] ?? "").trim());
|
|
9972
|
-
continue;
|
|
9973
|
-
}
|
|
9974
|
-
flushList();
|
|
9975
|
-
paragraph.push(trimmed);
|
|
9976
|
-
}
|
|
9977
|
-
flushParagraph();
|
|
9978
|
-
flushList();
|
|
9979
|
-
if (code.length) blocks.push({ type: "code", text: code.join("\n") });
|
|
9980
|
-
return blocks;
|
|
9981
|
-
}
|
|
9982
|
-
|
|
9983
|
-
function renderMarkdownBlock(block: MarkdownBlock, index: number): ReactNode {
|
|
9984
|
-
const key = `${block.type}-${index}`;
|
|
9985
|
-
if (block.type === "code") return <pre className="news-desk-analysis-command" key={key}>{block.text}</pre>;
|
|
9986
|
-
if (block.type === "list") {
|
|
9987
|
-
const ListTag = block.ordered ? "ol" : "ul";
|
|
9988
|
-
return (
|
|
9989
|
-
<ListTag key={key}>
|
|
9990
|
-
{block.items.map((item, itemIndex) => <li key={`${key}-${itemIndex}`}>{renderInlineMarkdown(item)}</li>)}
|
|
9991
|
-
</ListTag>
|
|
9992
|
-
);
|
|
9993
|
-
}
|
|
9994
|
-
if (block.type === "heading") {
|
|
9995
|
-
if (block.depth <= 1) return <h3 key={key}>{block.text}</h3>;
|
|
9996
|
-
if (block.depth === 2) return <h4 key={key}>{block.text}</h4>;
|
|
9997
|
-
return <h5 key={key}>{block.text}</h5>;
|
|
9998
|
-
}
|
|
9999
|
-
if (block.type === "table") {
|
|
10000
|
-
return (
|
|
10001
|
-
<table key={key}>
|
|
10002
|
-
<thead>
|
|
10003
|
-
<tr>
|
|
10004
|
-
{block.headers.map((header, headerIndex) => (
|
|
10005
|
-
<th key={`${key}-head-${headerIndex}`} scope="col">{renderInlineMarkdown(header)}</th>
|
|
10006
|
-
))}
|
|
10007
|
-
</tr>
|
|
10008
|
-
</thead>
|
|
10009
|
-
{block.rows.length ? (
|
|
10010
|
-
<tbody>
|
|
10011
|
-
{block.rows.map((row, rowIndex) => (
|
|
10012
|
-
<tr key={`${key}-row-${rowIndex}`}>
|
|
10013
|
-
{row.map((cell, cellIndex) => (
|
|
10014
|
-
<td key={`${key}-row-${rowIndex}-cell-${cellIndex}`}>{renderInlineMarkdown(cell)}</td>
|
|
10015
|
-
))}
|
|
10016
|
-
</tr>
|
|
10017
|
-
))}
|
|
10018
|
-
</tbody>
|
|
10019
|
-
) : null}
|
|
10020
|
-
</table>
|
|
10021
|
-
);
|
|
10022
|
-
}
|
|
10023
|
-
return <p key={key}>{renderInlineMarkdown(block.text)}</p>;
|
|
10024
|
-
}
|
|
10025
|
-
|
|
10026
|
-
function renderInlineMarkdown(text: string): ReactNode[] {
|
|
10027
|
-
const nodes: ReactNode[] = [];
|
|
10028
|
-
let index = 0;
|
|
10029
|
-
let plain = "";
|
|
10030
|
-
const flushPlain = () => {
|
|
10031
|
-
if (!plain) return;
|
|
10032
|
-
nodes.push(plain);
|
|
10033
|
-
plain = "";
|
|
10034
|
-
};
|
|
10035
|
-
|
|
10036
|
-
while (index < text.length) {
|
|
10037
|
-
const linkLike = parseMarkdownLinkLikeAt(text, index);
|
|
10038
|
-
if (linkLike) {
|
|
10039
|
-
flushPlain();
|
|
10040
|
-
if (!linkLike.image) {
|
|
10041
|
-
const href = normalizeInlineMarkdownHref(linkLike.hrefRaw);
|
|
10042
|
-
const label = collapseMarkdownLinkLabel(linkLike.label);
|
|
10043
|
-
if (href) {
|
|
10044
|
-
nodes.push(
|
|
10045
|
-
<a href={href} key={`${index}-link`} rel="noopener noreferrer" target="_blank">
|
|
10046
|
-
{renderInlineMarkdown(label)}
|
|
10047
|
-
</a>,
|
|
10048
|
-
);
|
|
10049
|
-
} else {
|
|
10050
|
-
nodes.push(linkLike.raw);
|
|
10051
|
-
}
|
|
10052
|
-
}
|
|
10053
|
-
index += linkLike.length;
|
|
10054
|
-
continue;
|
|
10055
|
-
}
|
|
10056
|
-
|
|
10057
|
-
if (text.startsWith("**", index) || text.startsWith("__", index)) {
|
|
10058
|
-
const marker = text.slice(index, index + 2);
|
|
10059
|
-
const end = text.indexOf(marker, index + 2);
|
|
10060
|
-
if (end > index + 2) {
|
|
10061
|
-
flushPlain();
|
|
10062
|
-
nodes.push(<strong key={`${index}-strong`}>{renderInlineMarkdown(text.slice(index + 2, end))}</strong>);
|
|
10063
|
-
index = end + 2;
|
|
10064
|
-
continue;
|
|
10065
|
-
}
|
|
10066
|
-
}
|
|
10067
|
-
|
|
10068
|
-
if (text.startsWith("~~", index)) {
|
|
10069
|
-
const end = text.indexOf("~~", index + 2);
|
|
10070
|
-
if (end > index + 2) {
|
|
10071
|
-
flushPlain();
|
|
10072
|
-
nodes.push(<del key={`${index}-strike`}>{renderInlineMarkdown(text.slice(index + 2, end))}</del>);
|
|
10073
|
-
index = end + 2;
|
|
10074
|
-
continue;
|
|
10075
|
-
}
|
|
10076
|
-
}
|
|
10077
|
-
|
|
10078
|
-
if (text[index] === "*" || text[index] === "_") {
|
|
10079
|
-
const marker = text[index];
|
|
10080
|
-
const end = text.indexOf(marker, index + 1);
|
|
10081
|
-
if (end > index + 1) {
|
|
10082
|
-
flushPlain();
|
|
10083
|
-
nodes.push(<em key={`${index}-em`}>{renderInlineMarkdown(text.slice(index + 1, end))}</em>);
|
|
10084
|
-
index = end + 1;
|
|
10085
|
-
continue;
|
|
10086
|
-
}
|
|
10087
|
-
}
|
|
10088
|
-
|
|
10089
|
-
if (text[index] === "`") {
|
|
10090
|
-
const end = text.indexOf("`", index + 1);
|
|
10091
|
-
if (end > index + 1) {
|
|
10092
|
-
flushPlain();
|
|
10093
|
-
nodes.push(<code key={`${index}-code`}>{text.slice(index + 1, end)}</code>);
|
|
10094
|
-
index = end + 1;
|
|
10095
|
-
continue;
|
|
10096
|
-
}
|
|
10097
|
-
}
|
|
10098
|
-
|
|
10099
|
-
const autolinkMatch = /^(<https?:\/\/[^>\s]+>|https?:\/\/[^\s<]+)/.exec(text.slice(index));
|
|
10100
|
-
if (autolinkMatch) {
|
|
10101
|
-
flushPlain();
|
|
10102
|
-
const token = autolinkMatch[1];
|
|
10103
|
-
const href = normalizeInlineMarkdownHref(token);
|
|
10104
|
-
if (href) {
|
|
10105
|
-
nodes.push(
|
|
10106
|
-
<a href={href} key={`${index}-autolink`} rel="noopener noreferrer" target="_blank">
|
|
10107
|
-
{href}
|
|
10108
|
-
</a>,
|
|
10109
|
-
);
|
|
10110
|
-
} else {
|
|
10111
|
-
nodes.push(token);
|
|
10112
|
-
}
|
|
10113
|
-
index += token.length;
|
|
10114
|
-
continue;
|
|
10115
|
-
}
|
|
10116
|
-
|
|
10117
|
-
plain += text[index];
|
|
10118
|
-
index += 1;
|
|
10119
|
-
}
|
|
10120
|
-
|
|
10121
|
-
flushPlain();
|
|
10122
|
-
return nodes;
|
|
10123
|
-
}
|
|
10124
|
-
|
|
10125
|
-
function normalizeInlineMarkdownHref(rawHref: string): string | null {
|
|
10126
|
-
const href = extractMarkdownHrefDestination(rawHref);
|
|
10127
|
-
if (!href) return null;
|
|
10128
|
-
if (/^(https?:\/\/|mailto:)/i.test(href)) return href;
|
|
10129
|
-
return null;
|
|
10130
|
-
}
|
|
10131
|
-
|
|
10132
|
-
function normalizeMultilineMarkdownLinks(text: string): string {
|
|
10133
|
-
let normalized = "";
|
|
10134
|
-
let index = 0;
|
|
10135
|
-
while (index < text.length) {
|
|
10136
|
-
const linkLike = parseMarkdownLinkLikeAt(text, index);
|
|
10137
|
-
if (!linkLike || linkLike.image || !linkLike.raw.includes("\n")) {
|
|
10138
|
-
normalized += text[index];
|
|
10139
|
-
index += 1;
|
|
10140
|
-
continue;
|
|
10141
|
-
}
|
|
10142
|
-
const href = extractMarkdownHrefDestination(linkLike.hrefRaw);
|
|
10143
|
-
const label = collapseMarkdownLinkLabel(linkLike.label);
|
|
10144
|
-
if (!href || !label) {
|
|
10145
|
-
normalized += linkLike.raw;
|
|
10146
|
-
index += linkLike.length;
|
|
10147
|
-
continue;
|
|
10148
|
-
}
|
|
10149
|
-
normalized += `[${label}](${href})`;
|
|
10150
|
-
index += linkLike.length;
|
|
10151
|
-
}
|
|
10152
|
-
return normalized;
|
|
10153
|
-
}
|
|
10154
|
-
|
|
10155
|
-
function collapseMarkdownLinkLabel(label: string): string {
|
|
10156
|
-
const withoutImages = label.replace(/!\[[^\]]*\]\((?:[^)(]|\([^)(]*\))*\)/g, " ");
|
|
10157
|
-
return withoutImages
|
|
10158
|
-
.replace(/\r?\n+/g, " ")
|
|
10159
|
-
.replace(/(^|\s)#{1,6}\s+/g, "$1")
|
|
10160
|
-
.replace(/\s+/g, " ")
|
|
10161
|
-
.trim();
|
|
10162
|
-
}
|
|
10163
|
-
|
|
10164
|
-
function extractMarkdownHrefDestination(rawHref: string): string {
|
|
10165
|
-
const trimmed = rawHref.trim();
|
|
10166
|
-
if (!trimmed) return "";
|
|
10167
|
-
if (trimmed.startsWith("<")) {
|
|
10168
|
-
const closing = trimmed.indexOf(">");
|
|
10169
|
-
if (closing > 1) return trimmed.slice(1, closing).trim();
|
|
10170
|
-
}
|
|
10171
|
-
const match = /^(\S+)/.exec(trimmed);
|
|
10172
|
-
return match?.[1]?.trim() ?? "";
|
|
10173
|
-
}
|
|
10174
|
-
|
|
10175
|
-
function parseMarkdownLinkLikeAt(
|
|
10176
|
-
source: string,
|
|
10177
|
-
startIndex: number,
|
|
10178
|
-
): { hrefRaw: string; image: boolean; label: string; length: number; raw: string } | null {
|
|
10179
|
-
const image = source.startsWith("![", startIndex);
|
|
10180
|
-
const link = source[startIndex] === "[";
|
|
10181
|
-
if (!image && !link) return null;
|
|
10182
|
-
const labelStart = startIndex + (image ? 2 : 1);
|
|
10183
|
-
const labelEnd = findMatchingBracketIndex(source, labelStart, "[", "]");
|
|
10184
|
-
if (labelEnd < 0) return null;
|
|
10185
|
-
let next = labelEnd + 1;
|
|
10186
|
-
while (next < source.length && /\s/.test(source[next])) next += 1;
|
|
10187
|
-
if (source[next] !== "(") return null;
|
|
10188
|
-
const hrefStart = next + 1;
|
|
10189
|
-
const hrefEnd = findMatchingBracketIndex(source, hrefStart, "(", ")");
|
|
10190
|
-
if (hrefEnd < 0) return null;
|
|
10191
|
-
const raw = source.slice(startIndex, hrefEnd + 1);
|
|
10192
|
-
return {
|
|
10193
|
-
image,
|
|
10194
|
-
label: source.slice(labelStart, labelEnd),
|
|
10195
|
-
hrefRaw: source.slice(hrefStart, hrefEnd),
|
|
10196
|
-
length: raw.length,
|
|
10197
|
-
raw,
|
|
10198
|
-
};
|
|
10199
|
-
}
|
|
10200
|
-
|
|
10201
|
-
function findMatchingBracketIndex(source: string, contentStart: number, open: string, close: string): number {
|
|
10202
|
-
let depth = 1;
|
|
10203
|
-
for (let index = contentStart; index < source.length; index += 1) {
|
|
10204
|
-
const ch = source[index];
|
|
10205
|
-
if (ch === "\\") {
|
|
10206
|
-
index += 1;
|
|
10207
|
-
continue;
|
|
10208
|
-
}
|
|
10209
|
-
if (ch === open) {
|
|
10210
|
-
depth += 1;
|
|
10211
|
-
continue;
|
|
10212
|
-
}
|
|
10213
|
-
if (ch !== close) continue;
|
|
10214
|
-
depth -= 1;
|
|
10215
|
-
if (depth === 0) return index;
|
|
10216
|
-
}
|
|
10217
|
-
return -1;
|
|
10218
|
-
}
|
|
10219
|
-
|
|
10220
|
-
function looksLikeMarkdownTableHeaderLine(line: string): boolean {
|
|
10221
|
-
return /\|/.test(line.trim());
|
|
10222
|
-
}
|
|
10223
|
-
|
|
10224
|
-
function looksLikeMarkdownTableRowLine(line: string): boolean {
|
|
10225
|
-
const trimmed = line.trim();
|
|
10226
|
-
if (!trimmed) return false;
|
|
10227
|
-
if (!/\|/.test(trimmed)) return false;
|
|
10228
|
-
if (isMarkdownTableDividerLine(trimmed)) return false;
|
|
10229
|
-
return true;
|
|
10230
|
-
}
|
|
10231
|
-
|
|
10232
|
-
function isMarkdownTableDividerLine(line: string): boolean {
|
|
10233
|
-
const cells = splitMarkdownTableCells(line);
|
|
10234
|
-
if (!cells.length) return false;
|
|
10235
|
-
return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
|
10236
|
-
}
|
|
10237
|
-
|
|
10238
|
-
function splitMarkdownTableCells(line: string): string[] {
|
|
10239
|
-
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
10240
|
-
if (!trimmed) return [];
|
|
10241
|
-
const cells: string[] = [];
|
|
10242
|
-
let current = "";
|
|
10243
|
-
let escaping = false;
|
|
10244
|
-
for (let index = 0; index < trimmed.length; index += 1) {
|
|
10245
|
-
const ch = trimmed[index];
|
|
10246
|
-
if (escaping) {
|
|
10247
|
-
current += ch;
|
|
10248
|
-
escaping = false;
|
|
10249
|
-
continue;
|
|
10250
|
-
}
|
|
10251
|
-
if (ch === "\\") {
|
|
10252
|
-
escaping = true;
|
|
10253
|
-
current += ch;
|
|
10254
|
-
continue;
|
|
10255
|
-
}
|
|
10256
|
-
if (ch === "|") {
|
|
10257
|
-
cells.push(current.trim());
|
|
10258
|
-
current = "";
|
|
10259
|
-
continue;
|
|
10260
|
-
}
|
|
10261
|
-
current += ch;
|
|
10262
|
-
}
|
|
10263
|
-
cells.push(current.trim());
|
|
10264
|
-
return cells;
|
|
10265
|
-
}
|
|
10266
|
-
|
|
10267
|
-
function normalizeMarkdownTableCells(cells: string[], columnCount: number): string[] {
|
|
10268
|
-
const normalized = cells.slice(0, columnCount);
|
|
10269
|
-
while (normalized.length < columnCount) normalized.push("");
|
|
10270
|
-
return normalized;
|
|
10271
|
-
}
|
|
10272
|
-
|
|
10273
9859
|
function PayloadDetailBlock({
|
|
10274
9860
|
fallback,
|
|
10275
9861
|
hideLabel = false,
|
|
@@ -14571,17 +14157,6 @@ function buildNewsroomSearchHref(request: NewsroomSearchRequest, demo?: boolean)
|
|
|
14571
14157
|
return toPublicNewsroomPath(queryString ? `/newsroom/search?${queryString}` : "/newsroom/search");
|
|
14572
14158
|
}
|
|
14573
14159
|
|
|
14574
|
-
function focusNewsroomSearchForm() {
|
|
14575
|
-
if (typeof window === "undefined") return;
|
|
14576
|
-
const input = document.getElementById("newsroom-search-query");
|
|
14577
|
-
if (input instanceof HTMLElement) {
|
|
14578
|
-
input.focus();
|
|
14579
|
-
input.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
14580
|
-
return;
|
|
14581
|
-
}
|
|
14582
|
-
window.dispatchEvent(new CustomEvent("papyrus:newsroom-search-focus"));
|
|
14583
|
-
}
|
|
14584
|
-
|
|
14585
14160
|
function resolveCurrentRouteAnchor(activeTab: NewsDeskTab): KnowledgeQueryAnchor | null {
|
|
14586
14161
|
if (typeof window === "undefined") return null;
|
|
14587
14162
|
const url = new URL(window.location.href);
|
|
@@ -167,3 +167,21 @@ export function parseAssignmentIdFromNewsroomPathname(pathname: string | null |
|
|
|
167
167
|
return segment;
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
|
|
171
|
+
/** The Notification Center's deep link to one item: `/newsroom/needs-me/<id>` (public `/needs-me/<id>` on a root-path newsroom). */
|
|
172
|
+
export function buildNeedsMeItemPath(itemId: string): string {
|
|
173
|
+
return toPublicNewsroomPath(`/newsroom/needs-me/${encodeURIComponent(itemId)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Item id from `/newsroom/needs-me/<id>`, else null. */
|
|
177
|
+
export function parseNeedsMeItemIdFromNewsroomPathname(pathname: string | null | undefined): string | null {
|
|
178
|
+
const internalPathname = pathname ? browserPathToInternalNewsroomPath(pathname) : null;
|
|
179
|
+
if (!internalPathname?.startsWith("/newsroom/needs-me/")) return null;
|
|
180
|
+
const segment = internalPathname.slice("/newsroom/needs-me/".length).split("/")[0]?.trim() ?? "";
|
|
181
|
+
if (!segment) return null;
|
|
182
|
+
try {
|
|
183
|
+
return decodeURIComponent(segment);
|
|
184
|
+
} catch {
|
|
185
|
+
return segment;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -96,7 +96,31 @@ export function formatReferenceListDate(reference: ReferenceRecord): string {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export function referenceDisplayTitle(reference: ReferenceRecord): string {
|
|
99
|
-
|
|
99
|
+
const title = reference.title?.trim();
|
|
100
|
+
if (!title) return reference.externalItemId;
|
|
101
|
+
let host: string | null = null;
|
|
102
|
+
try { host = reference.sourceUri ? new URL(reference.sourceUri).hostname : null; } catch { host = null; }
|
|
103
|
+
return withoutSiteName(title, host);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const TITLE_SEPARATORS = [" | ", " – ", " — ", " - "] as const;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A page title without the site's name tacked on: "Auditing employment algorithms | Brookings" reads "Auditing employment
|
|
110
|
+
* algorithms". Only a last segment that names the site (its host's name, such as "brookings" in brookings.edu) is dropped,
|
|
111
|
+
* and with a pipe, which sites use for furniture, the section names before it go too ("… | Poverty and Development | Al Jazeera").
|
|
112
|
+
*/
|
|
113
|
+
export function withoutSiteName(title: string, host: string | null): string {
|
|
114
|
+
const label = (host ?? "").toLowerCase().replace(/^www\./, "").split(".").slice(0, -1).filter((part) => part.length >= 4).at(-1);
|
|
115
|
+
if (!label) return title;
|
|
116
|
+
const plain = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
117
|
+
// The separator whose last segment is shortest wins, so "… groups – report - Amnesty International" keeps "– report".
|
|
118
|
+
const match = TITLE_SEPARATORS.map((separator) => ({ separator, parts: title.split(separator) }))
|
|
119
|
+
.filter(({ parts }) => parts.length > 1 && plain(parts.at(-1)!).includes(label))
|
|
120
|
+
.sort((left, right) => left.parts.at(-1)!.length - right.parts.at(-1)!.length)[0];
|
|
121
|
+
if (!match) return title;
|
|
122
|
+
const kept = match.separator === " | " ? match.parts[0] : match.parts.slice(0, -1).join(match.separator);
|
|
123
|
+
return kept.trim() || title;
|
|
100
124
|
}
|
|
101
125
|
|
|
102
126
|
export function referenceStatusLabel(status: string): string {
|
package/lib/reference-capture.ts
CHANGED
|
@@ -196,8 +196,9 @@ export function captureStatus(attachments: readonly ReferenceAttachmentRecord[],
|
|
|
196
196
|
const flags = completenessFlags(card.editedSignals ?? card.signals);
|
|
197
197
|
const thin = words !== null && words < teaserLine;
|
|
198
198
|
const edited = Boolean(card.current?.textSortKey?.startsWith("curation-edit"));
|
|
199
|
-
// A complete text an editor vouched for is not second-guessed by the signals.
|
|
200
|
-
|
|
199
|
+
// A complete text an editor vouched for is not second-guessed by the signals. A long text that merely ends without a full stop
|
|
200
|
+
// (a byline, a footnote, a list) is listed as a fact but is not on its own a reason to ask for a capture check.
|
|
201
|
+
const doubtful = !edited && assessment !== "complete" && flags.length > 0 && !thin ? flags.some((flag) => /ends with|stops with|no longer|not free|cut|declares/.test(flag)) : false;
|
|
201
202
|
return { state: thin || doubtful ? "thin" : "full", words, estimated: false, source: "curation", curation: card.status, reason: null, flags, assessment, summaryStale, ...(thin ? { thinBy: "length" as const } : doubtful ? { thinBy: "signals" as const } : {}) };
|
|
202
203
|
}
|
|
203
204
|
if (primaryTextAttachment(attachments)) {
|