grok-digest 0.1.1 → 0.3.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,252 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronsDownUpIcon, ChevronsUpDownIcon, SearchIcon } from "lucide-react";
3
+ import { cn } from "cn";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
+ import { Button } from "./ui/button.js";
6
+ import { Input } from "./ui/input.js";
7
+ import { filterSearch, parseFilter, VIEWS } from "../lib/filter.js";
8
+ import { dailyReview, localDay } from "../lib/review.js";
9
+ import { matchingIds } from "../lib/search.js";
10
+ import { TOPIC_LABELS, TOPICS } from "../lib/topics.js";
11
+ const STORAGE_PREFIX = `grok-digest:${import.meta.env.BASE_URL}:`;
12
+ const DONE_KEY = `${STORAGE_PREFIX}done`;
13
+ const REVIEW_KEY = `${STORAGE_PREFIX}review`;
14
+ const COLLAPSED_KEY = `${STORAGE_PREFIX}collapsed`;
15
+ const VIEW_LABELS = { today: "Today", inbox: "Inbox", done: "Done", all: "All" };
16
+ const FORM_FIELDS = "input, textarea, select, [contenteditable]";
17
+ function readStored(key) {
18
+ try {
19
+ const raw = window.localStorage.getItem(key);
20
+ return raw === null ? null : JSON.parse(raw);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ function writeStored(key, value) {
27
+ try {
28
+ window.localStorage.setItem(key, JSON.stringify(value));
29
+ }
30
+ catch { }
31
+ }
32
+ function isStringArray(value) {
33
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
34
+ }
35
+ function isStoredReview(value) {
36
+ if (typeof value !== "object" || value === null || !("day" in value) || !("picks" in value))
37
+ return false;
38
+ const { day, picks } = value;
39
+ return (typeof day === "string" &&
40
+ Array.isArray(picks) &&
41
+ picks.every((pick) => typeof pick === "object" &&
42
+ pick !== null &&
43
+ "id" in pick &&
44
+ "reason" in pick &&
45
+ typeof pick.id === "string" &&
46
+ typeof pick.reason === "string"));
47
+ }
48
+ function storedDone() {
49
+ const stored = readStored(DONE_KEY);
50
+ return new Set(isStringArray(stored) ? stored : []);
51
+ }
52
+ function todaysReview(entries, done) {
53
+ const today = localDay(new Date());
54
+ const stored = readStored(REVIEW_KEY);
55
+ if (isStoredReview(stored) && stored.day === today) {
56
+ const ids = new Set(entries.map(({ id }) => id));
57
+ return stored.picks.filter(({ id }) => ids.has(id));
58
+ }
59
+ const picks = dailyReview({ bookmarks: entries, done, today });
60
+ writeStored(REVIEW_KEY, { day: today, picks });
61
+ return picks;
62
+ }
63
+ function linkedCard() {
64
+ const target = window.location.hash.slice(1);
65
+ return target === "" ? null : document.querySelector(`[data-bookmark-id][id="${CSS.escape(target)}"]`);
66
+ }
67
+ function initialFilter(reviewIsEmpty) {
68
+ const filter = parseFilter(window.location.search, { reviewIsEmpty });
69
+ return linkedCard() === null ? filter : { ...filter, view: "all" };
70
+ }
71
+ function cards() {
72
+ return [...document.querySelectorAll("[data-bookmark-id]")];
73
+ }
74
+ function focusCard(card) {
75
+ if (card === undefined)
76
+ return;
77
+ card.querySelector("summary")?.focus({ preventScroll: true });
78
+ card.scrollIntoView({ block: "start" });
79
+ }
80
+ const COPY_LABEL = "Copy";
81
+ const COPY_RESET_MS = 2000;
82
+ const copyResets = new WeakMap();
83
+ function showCopyResult(button, copied) {
84
+ const label = button.querySelector("[data-copy-label]");
85
+ if (label !== null)
86
+ label.textContent = copied ? "Copied" : "Couldn't copy";
87
+ button.toggleAttribute("data-copied", copied);
88
+ clearTimeout(copyResets.get(button));
89
+ copyResets.set(button, setTimeout(() => {
90
+ if (label !== null)
91
+ label.textContent = COPY_LABEL;
92
+ button.removeAttribute("data-copied");
93
+ }, COPY_RESET_MS));
94
+ }
95
+ function copyFrom(button) {
96
+ if (button === null || button === undefined)
97
+ return;
98
+ navigator.clipboard.writeText(button.dataset["copy"] ?? "").then(() => showCopyResult(button, true), () => showCopyResult(button, false));
99
+ }
100
+ function plural(count, noun) {
101
+ return `${count} ${count === 1 ? noun : `${noun}s`}`;
102
+ }
103
+ export function BookmarkControls({ entries }) {
104
+ const [review] = useState(() => todaysReview(entries, storedDone()));
105
+ const reviewIsEmpty = review.length === 0;
106
+ const [filter, setFilter] = useState(() => initialFilter(reviewIsEmpty));
107
+ const [done, setDone] = useState(storedDone);
108
+ const [collapsed, setCollapsed] = useState(() => readStored(COLLAPSED_KEY) === true);
109
+ const searchRef = useRef(null);
110
+ const { query, topic, view } = filter;
111
+ const searching = query.trim() !== "";
112
+ const reviewIds = useMemo(() => new Set(review.map(({ id }) => id)), [review]);
113
+ const reasons = useMemo(() => new Map(review.map(({ id, reason }) => [id, reason])), [review]);
114
+ const visible = useMemo(() => matchingIds({ entries, filter, triage: { done, review: reviewIds } }), [entries, filter, done, reviewIds]);
115
+ const topicsInUse = useMemo(() => TOPICS.filter((t) => entries.some((entry) => entry.topic === t)), [entries]);
116
+ const doneCount = entries.filter(({ id }) => done.has(id)).length;
117
+ const reviewedCount = review.filter(({ id }) => done.has(id)).length;
118
+ const counts = {
119
+ today: review.length,
120
+ inbox: entries.length - doneCount,
121
+ done: doneCount,
122
+ all: entries.length,
123
+ };
124
+ useEffect(() => {
125
+ const showReasons = view === "today" && !searching;
126
+ for (const card of cards()) {
127
+ const id = card.dataset["bookmarkId"] ?? "";
128
+ card.hidden = !visible.has(id);
129
+ card.toggleAttribute("data-done", done.has(id));
130
+ card.querySelector("[data-done-toggle]")?.setAttribute("aria-pressed", String(done.has(id)));
131
+ const reason = card.querySelector("[data-review-reason]");
132
+ const text = showReasons ? reasons.get(id) : undefined;
133
+ if (reason !== null) {
134
+ reason.hidden = text === undefined;
135
+ reason.textContent = text ?? "";
136
+ }
137
+ }
138
+ }, [visible, done, reasons, view, searching]);
139
+ useEffect(() => {
140
+ for (const details of document.querySelectorAll("[data-bookmark-id] details")) {
141
+ details.open = !collapsed;
142
+ }
143
+ writeStored(COLLAPSED_KEY, collapsed);
144
+ }, [collapsed]);
145
+ useEffect(() => {
146
+ writeStored(DONE_KEY, [...done]);
147
+ }, [done]);
148
+ useEffect(() => {
149
+ const next = `${window.location.pathname}${filterSearch(filter, { reviewIsEmpty })}${window.location.hash}`;
150
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
151
+ if (current !== next)
152
+ window.history.replaceState(null, "", next);
153
+ }, [filter, reviewIsEmpty]);
154
+ useEffect(() => {
155
+ document.querySelector("[data-bookmark-list]")?.toggleAttribute("data-ready", true);
156
+ const linked = linkedCard();
157
+ linked?.querySelector("details")?.setAttribute("open", "");
158
+ linked?.scrollIntoView({ block: "start" });
159
+ }, []);
160
+ useEffect(() => {
161
+ const toggleDone = (card) => {
162
+ const id = card.dataset["bookmarkId"] ?? "";
163
+ setDone((previous) => {
164
+ const next = new Set(previous);
165
+ if (!next.delete(id))
166
+ next.add(id);
167
+ return next;
168
+ });
169
+ };
170
+ const onClick = (event) => {
171
+ if (!(event.target instanceof Element))
172
+ return;
173
+ copyFrom(event.target.closest("[data-copy]"));
174
+ const card = event.target.closest("[data-done-toggle]")?.closest("[data-bookmark-id]");
175
+ if (card !== null && card !== undefined)
176
+ toggleDone(card);
177
+ };
178
+ const onKeyDown = (event) => {
179
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey)
180
+ return;
181
+ if (event.target instanceof HTMLElement && event.target.closest(FORM_FIELDS) !== null) {
182
+ if (event.key === "Escape")
183
+ event.target.blur();
184
+ return;
185
+ }
186
+ const shown = cards().filter((card) => !card.hidden);
187
+ const current = document.activeElement?.closest("[data-bookmark-id]") ?? null;
188
+ const index = current === null ? -1 : shown.indexOf(current);
189
+ switch (event.key) {
190
+ case "j":
191
+ focusCard(shown[Math.min(index + 1, shown.length - 1)]);
192
+ break;
193
+ case "k":
194
+ focusCard(shown[Math.max(index - 1, 0)]);
195
+ break;
196
+ case "e": {
197
+ if (current === null)
198
+ return;
199
+ const neighbor = shown[index + 1] ?? shown[index - 1];
200
+ toggleDone(current);
201
+ setTimeout(() => {
202
+ if (current.hidden)
203
+ focusCard(neighbor);
204
+ });
205
+ break;
206
+ }
207
+ case "c":
208
+ copyFrom(current?.querySelector("[data-copy]"));
209
+ break;
210
+ case "o": {
211
+ const url = current?.dataset["url"];
212
+ if (url !== undefined)
213
+ window.open(url, "_blank", "noopener,noreferrer");
214
+ break;
215
+ }
216
+ case "/":
217
+ searchRef.current?.focus();
218
+ break;
219
+ default:
220
+ return;
221
+ }
222
+ event.preventDefault();
223
+ };
224
+ document.addEventListener("click", onClick);
225
+ document.addEventListener("keydown", onKeyDown);
226
+ return () => {
227
+ document.removeEventListener("click", onClick);
228
+ document.removeEventListener("keydown", onKeyDown);
229
+ };
230
+ }, []);
231
+ const update = (change) => setFilter((previous) => ({ ...previous, ...change }));
232
+ return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "relative", children: [_jsx(SearchIcon, { className: "pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, type: "search", value: query, onChange: (event) => update({ query: event.target.value }), placeholder: "Search all bookmarks", "aria-label": "Search bookmarks", className: "h-10 rounded-full bg-card pl-9 dark:bg-card" })] }), _jsx("fieldset", { "aria-label": "Choose a view", className: "flex w-fit min-w-0 flex-wrap gap-1 rounded-full bg-muted p-1", children: VIEWS.map((v) => (_jsxs("button", { type: "button", "aria-pressed": view === v && !searching, onClick: () => update({ view: v, query: "" }), className: "inline-flex h-8 items-center gap-1.5 rounded-full px-3.5 text-sm font-medium text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 aria-pressed:bg-card aria-pressed:text-foreground aria-pressed:shadow-xs", children: [VIEW_LABELS[v], _jsx("span", { className: "text-xs text-muted-foreground tabular-nums in-aria-pressed:font-semibold in-aria-pressed:text-brand-ink", children: counts[v] })] }, v))) }), _jsxs("fieldset", { "aria-label": "Filter by topic", className: "flex min-w-0 flex-wrap gap-2", children: [_jsx(TopicChip, { label: "All topics", pressed: topic === null, onClick: () => update({ topic: null }) }), topicsInUse.map((t) => (_jsx(TopicChip, { topic: t, label: TOPIC_LABELS[t], pressed: topic === t, onClick: () => update({ topic: topic === t ? null : t }) }, t)))] }), _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-x-4 gap-y-2", children: [_jsx("p", { "aria-live": "polite", className: "text-sm text-muted-foreground", children: _jsx(Status, { view: view, searching: searching, visible: visible.size, reviewed: reviewedCount, reviewSize: review.length, inbox: counts.inbox }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("p", { className: "hidden text-xs text-muted-foreground sm:block", children: [_jsx("kbd", { children: "j" }), "/", _jsx("kbd", { children: "k" }), " move \u00B7 ", _jsx("kbd", { children: "e" }), " done \u00B7 ", _jsx("kbd", { children: "c" }), " copy \u00B7 ", _jsx("kbd", { children: "o" }), " open \u00B7 ", _jsx("kbd", { children: "/" }), " ", "search"] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setCollapsed(!collapsed), children: [collapsed ? _jsx(ChevronsUpDownIcon, { "aria-hidden": "true" }) : _jsx(ChevronsDownUpIcon, { "aria-hidden": "true" }), collapsed ? "Expand all" : "Collapse all"] })] })] })] }));
233
+ }
234
+ function Status({ view, searching, visible, reviewed, reviewSize, inbox, }) {
235
+ if (searching)
236
+ return visible === 0 ? "No bookmarks match your search." : `${plural(visible, "result")} in all bookmarks`;
237
+ if (view === "today") {
238
+ if (reviewSize === 0)
239
+ return `Nothing to review today. ${plural(inbox, "bookmark")} in your inbox.`;
240
+ if (reviewed === reviewSize)
241
+ return "All caught up for today. Come back tomorrow.";
242
+ return `${reviewed} of ${reviewSize} reviewed today`;
243
+ }
244
+ if (view === "inbox" && inbox === 0)
245
+ return "Inbox zero. Everything is done.";
246
+ if (view === "done" && visible === 0)
247
+ return "Nothing marked done yet.";
248
+ return plural(visible, "bookmark");
249
+ }
250
+ function TopicChip({ topic, label, pressed, onClick, }) {
251
+ return (_jsxs(Button, { type: "button", size: "sm", variant: pressed ? "default" : "outline", "aria-pressed": pressed, onClick: onClick, "data-topic": topic, className: cn("rounded-full px-3", topic !== undefined && pressed && "bg-topic text-topic-foreground hover:bg-topic/85"), children: [topic !== undefined && (_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-full bg-topic group-aria-pressed/button:bg-current" })), label] }));
252
+ }
@@ -2,7 +2,4 @@ import * as React from "react";
2
2
  declare function Card({ className, size, ...props }: React.ComponentProps<"div"> & {
3
3
  size?: "default" | "sm";
4
4
  }): React.JSX.Element;
5
- declare function CardHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
6
- declare function CardContent({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
7
- declare function CardFooter({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
8
- export { Card, CardHeader, CardFooter, CardContent };
5
+ export { Card };
@@ -4,13 +4,4 @@ import { cn } from "cn";
4
4
  function Card({ className, size = "default", ...props }) {
5
5
  return (_jsx("div", { "data-slot": "card", "data-size": size, className: cn("group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", className), ...props }));
6
6
  }
7
- function CardHeader({ className, ...props }) {
8
- return (_jsx("div", { "data-slot": "card-header", className: cn("group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)", className), ...props }));
9
- }
10
- function CardContent({ className, ...props }) {
11
- return _jsx("div", { "data-slot": "card-content", className: cn("px-(--card-spacing)", className), ...props });
12
- }
13
- function CardFooter({ className, ...props }) {
14
- return (_jsx("div", { "data-slot": "card-footer", className: cn("flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)", className), ...props }));
15
- }
16
- export { Card, CardHeader, CardFooter, CardContent };
7
+ export { Card };
package/dist/index.js CHANGED
@@ -13,9 +13,12 @@ const ROUTES = [
13
13
  { pattern: "/rss/[topic].xml", entrypoint: `pages/rss/[topic].xml${SCRIPT_EXTENSION}` },
14
14
  { pattern: "/favicon.svg", entrypoint: `routes/favicon.svg${SCRIPT_EXTENSION}` },
15
15
  { pattern: "/robots.txt", entrypoint: `routes/robots.txt${SCRIPT_EXTENSION}` },
16
+ { pattern: "/manifest.webmanifest", entrypoint: `routes/manifest.webmanifest${SCRIPT_EXTENSION}` },
17
+ { pattern: "/icon-[size].png", entrypoint: `routes/icon-[size].png${SCRIPT_EXTENSION}` },
16
18
  ];
17
19
  const RENDERER_PACKAGES = ["@astrojs/react", "react", "react-dom"];
18
20
  const PACKAGE_ENTRY = fileURLToPath(import.meta.url);
21
+ const PACKAGE_ROOT = path.resolve(path.dirname(PACKAGE_ENTRY), "..");
19
22
  const isRendererImport = (id) => RENDERER_PACKAGES.some((name) => id === name || id.startsWith(`${name}/`));
20
23
  function replaceInPlace(list, replacement) {
21
24
  list.splice(0, list.length, ...replacement);
@@ -47,6 +50,14 @@ function rendererFromPackage() {
47
50
  },
48
51
  };
49
52
  }
53
+ function servePackageFiles() {
54
+ return {
55
+ name: "grok-digest:serve-package-files",
56
+ configResolved(config) {
57
+ config.server.fs.allow.push(PACKAGE_ROOT);
58
+ },
59
+ };
60
+ }
50
61
  function bookmarksModule({ file, source }) {
51
62
  return {
52
63
  name: "grok-digest:bookmarks",
@@ -70,7 +81,9 @@ export default function grokDigest({ dataDir = "data" } = {}) {
70
81
  const source = path.relative(root, file).split(path.sep).join("/");
71
82
  updateConfig({
72
83
  integrations: [react()],
73
- vite: { plugins: [rendererFromPackage(), tailwindcss(), bookmarksModule({ file, source })] },
84
+ vite: {
85
+ plugins: [rendererFromPackage(), servePackageFiles(), tailwindcss(), bookmarksModule({ file, source })],
86
+ },
74
87
  });
75
88
  for (const { pattern, entrypoint } of ROUTES) {
76
89
  injectRoute({ pattern, entrypoint: new URL(entrypoint, import.meta.url) });
@@ -9,7 +9,8 @@ type Props = {
9
9
  };
10
10
 
11
11
  const { title, description, feedHref } = Astro.props;
12
- const favicon = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/favicon.svg`;
12
+ const root = import.meta.env.BASE_URL.replace(/\/$/, "");
13
+ const favicon = `${root}/favicon.svg`;
13
14
  ---
14
15
 
15
16
  <!doctype html>
@@ -22,6 +23,11 @@ const favicon = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/favicon.svg`;
22
23
  <meta name="description" content={description} />
23
24
  <meta name="color-scheme" content="light dark" />
24
25
  <link rel="icon" type="image/svg+xml" href={favicon} />
26
+ <link rel="manifest" href={`${root}/manifest.webmanifest`} />
27
+ <link rel="apple-touch-icon" href={`${root}/icon-180.png`} />
28
+ <meta name="apple-mobile-web-app-title" content="Grok Digest" />
29
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#f9f6f1" />
30
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#151311" />
25
31
  {feedHref && <link rel="alternate" type="application/rss+xml" title="Bookmarks" href={feedHref} />}
26
32
  <title>{title}</title>
27
33
  <script is:inline define:vars={{ storageKey: THEME_STORAGE_KEY, darkQuery: DARK_QUERY }}>
@@ -8,6 +8,7 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
8
8
  handle: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>;
9
9
  }, z.core.$strict>;
10
10
  posted_at: z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>;
11
+ saved_at: z.ZodISODateTime;
11
12
  topic: z.ZodEnum<{
12
13
  "ai-agents": "ai-agents";
13
14
  "models-releases": "models-releases";
@@ -17,11 +18,23 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
17
18
  design: "design";
18
19
  other: "other";
19
20
  }>;
21
+ shelf_life: z.ZodEnum<{
22
+ news: "news";
23
+ evergreen: "evergreen";
24
+ }>;
25
+ tldr: z.ZodString;
20
26
  summary: z.ZodString;
21
27
  text: z.ZodString;
22
28
  key_points: z.ZodArray<z.ZodString>;
29
+ action: z.ZodString;
30
+ links: z.ZodArray<z.ZodObject<{
31
+ title: z.ZodString;
32
+ url: z.ZodURL;
33
+ }, z.core.$strict>>;
23
34
  }, z.core.$strict>, z.ZodTransform<{
35
+ shelfLife: "news" | "evergreen";
24
36
  postedAt: Date;
37
+ savedAt: Date;
25
38
  keyPoints: string[];
26
39
  id: string;
27
40
  url: string;
@@ -30,8 +43,14 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
30
43
  handle: string;
31
44
  };
32
45
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
46
+ tldr: string;
33
47
  summary: string;
34
48
  text: string;
49
+ action: string;
50
+ links: {
51
+ title: string;
52
+ url: string;
53
+ }[];
35
54
  }, {
36
55
  id: string;
37
56
  url: string;
@@ -40,10 +59,18 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
40
59
  handle: string;
41
60
  };
42
61
  posted_at: string;
62
+ saved_at: string;
43
63
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
64
+ shelf_life: "news" | "evergreen";
65
+ tldr: string;
44
66
  summary: string;
45
67
  text: string;
46
68
  key_points: string[];
69
+ action: string;
70
+ links: {
71
+ title: string;
72
+ url: string;
73
+ }[];
47
74
  }>>;
48
75
  export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
49
76
  id: z.ZodString;
@@ -53,6 +80,7 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
53
80
  handle: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>;
54
81
  }, z.core.$strict>;
55
82
  posted_at: z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>;
83
+ saved_at: z.ZodISODateTime;
56
84
  topic: z.ZodEnum<{
57
85
  "ai-agents": "ai-agents";
58
86
  "models-releases": "models-releases";
@@ -62,11 +90,23 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
62
90
  design: "design";
63
91
  other: "other";
64
92
  }>;
93
+ shelf_life: z.ZodEnum<{
94
+ news: "news";
95
+ evergreen: "evergreen";
96
+ }>;
97
+ tldr: z.ZodString;
65
98
  summary: z.ZodString;
66
99
  text: z.ZodString;
67
100
  key_points: z.ZodArray<z.ZodString>;
101
+ action: z.ZodString;
102
+ links: z.ZodArray<z.ZodObject<{
103
+ title: z.ZodString;
104
+ url: z.ZodURL;
105
+ }, z.core.$strict>>;
68
106
  }, z.core.$strict>, z.ZodTransform<{
107
+ shelfLife: "news" | "evergreen";
69
108
  postedAt: Date;
109
+ savedAt: Date;
70
110
  keyPoints: string[];
71
111
  id: string;
72
112
  url: string;
@@ -75,8 +115,14 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
75
115
  handle: string;
76
116
  };
77
117
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
118
+ tldr: string;
78
119
  summary: string;
79
120
  text: string;
121
+ action: string;
122
+ links: {
123
+ title: string;
124
+ url: string;
125
+ }[];
80
126
  }, {
81
127
  id: string;
82
128
  url: string;
@@ -85,10 +131,18 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
85
131
  handle: string;
86
132
  };
87
133
  posted_at: string;
134
+ saved_at: string;
88
135
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
136
+ shelf_life: "news" | "evergreen";
137
+ tldr: string;
89
138
  summary: string;
90
139
  text: string;
91
140
  key_points: string[];
141
+ action: string;
142
+ links: {
143
+ title: string;
144
+ url: string;
145
+ }[];
92
146
  }>>>;
93
147
  export declare const stateFileSchema: z.ZodArray<z.ZodString>;
94
148
  export type BookmarkFileEntry = z.input<typeof bookmarkSchema>;
@@ -4,6 +4,8 @@ export const SCHEMA_VERSION = 1;
4
4
  const X_HOSTNAME = /^(www\.|mobile\.)?(x|twitter)\.com$/;
5
5
  const X_HANDLE = /^[A-Za-z0-9_]{1,15}$/;
6
6
  const nonEmpty = z.string().trim().min(1);
7
+ const instant = z.iso.datetime({ offset: true });
8
+ const SHELF_LIVES = ["news", "evergreen"];
7
9
  export const bookmarkSchema = z
8
10
  .strictObject({
9
11
  id: nonEmpty,
@@ -16,15 +18,22 @@ export const bookmarkSchema = z
16
18
  .transform((handle) => handle.replace(/^@/, ""))
17
19
  .pipe(z.string().regex(X_HANDLE, "must be an X handle such as @jack")),
18
20
  }),
19
- posted_at: z.union([z.iso.date(), z.iso.datetime({ offset: true })]),
21
+ posted_at: z.union([z.iso.date(), instant]),
22
+ saved_at: instant,
20
23
  topic: z.enum(TOPICS),
24
+ shelf_life: z.enum(SHELF_LIVES),
25
+ tldr: nonEmpty,
21
26
  summary: nonEmpty,
22
27
  text: z.string().trim(),
23
28
  key_points: z.array(nonEmpty),
29
+ action: z.string().trim(),
30
+ links: z.array(z.strictObject({ title: nonEmpty, url: z.url({ protocol: /^https$/ }) })),
24
31
  })
25
- .transform(({ posted_at, key_points, ...rest }) => ({
32
+ .transform(({ posted_at, saved_at, shelf_life, key_points, ...rest }) => ({
26
33
  ...rest,
34
+ shelfLife: shelf_life,
27
35
  postedAt: new Date(posted_at),
36
+ savedAt: new Date(saved_at),
28
37
  keyPoints: key_points,
29
38
  }));
30
39
  export const bookmarksFileSchema = z.array(bookmarkSchema).superRefine((bookmarks, ctx) => {
@@ -45,5 +54,5 @@ export function parseDataFile({ schema, raw, source, }) {
45
54
  return result.data;
46
55
  }
47
56
  export function parseBookmarks({ raw, source }) {
48
- return parseDataFile({ schema: bookmarksFileSchema, raw, source }).toSorted((a, b) => b.postedAt.getTime() - a.postedAt.getTime());
57
+ return parseDataFile({ schema: bookmarksFileSchema, raw, source }).toSorted((a, b) => b.savedAt.getTime() - a.savedAt.getTime());
49
58
  }
@@ -0,0 +1,2 @@
1
+ import type { Bookmark } from "./bookmarks.ts";
2
+ export declare function copyText({ url, author, postedAt, tldr, summary, text, keyPoints, action, links }: Bookmark): string;
@@ -0,0 +1,23 @@
1
+ const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
2
+ function quote(text) {
3
+ return text
4
+ .split("\n")
5
+ .map((line) => (line.trim() === "" ? ">" : `> ${line}`))
6
+ .join("\n");
7
+ }
8
+ function list(heading, items) {
9
+ return items.length === 0 ? "" : [`${heading}:`, ...items.map((item) => `- ${item}`)].join("\n");
10
+ }
11
+ export function copyText({ url, author, postedAt, tldr, summary, text, keyPoints, action, links }) {
12
+ return [
13
+ "A post I saved from X:",
14
+ `**${tldr}**\n${author.name} (@${author.handle}) · posted ${dateFormat.format(postedAt)} · ${url}`,
15
+ text === "" ? "" : quote(text),
16
+ `Summary: ${summary}`,
17
+ list("Key points", keyPoints),
18
+ action === "" ? "" : `Try: ${action}`,
19
+ list("Links", links.map((link) => `${link.title}: ${link.url}`)),
20
+ ]
21
+ .filter((section) => section !== "")
22
+ .join("\n\n");
23
+ }
package/dist/lib/feed.js CHANGED
@@ -25,25 +25,30 @@ export function topicFromParam(value) {
25
25
  return value;
26
26
  }
27
27
  function feedItem(bookmark, page) {
28
- const { author, summary, text, keyPoints, topic, url, postedAt, id } = bookmark;
28
+ const { author, tldr, summary, text, keyPoints, action, links, topic, url, savedAt, id } = bookmark;
29
29
  const link = new URL(page.href);
30
30
  link.hash = `b-${id}`;
31
31
  const points = keyPoints.map((point) => `<li>${escapeHtml(point)}</li>`).join("");
32
32
  const quote = text === "" ? "" : `<blockquote><p>${escapeHtml(text)}</p></blockquote>`;
33
+ const linkItems = links
34
+ .map((outbound) => `<li><a href="${escapeHtml(outbound.url)}">${escapeHtml(outbound.title)}</a></li>`)
35
+ .join("");
33
36
  return {
34
- title: summary,
37
+ title: tldr,
35
38
  link: link.href,
36
39
  description: [`@${author.handle}`, summary, text].filter((part) => part !== "").join("\n\n"),
37
40
  content: [
38
41
  `<p>${escapeHtml(author.name)} (@${escapeHtml(author.handle)})</p>`,
39
42
  `<p>${escapeHtml(summary)}</p>`,
43
+ action === "" ? "" : `<p>Try: ${escapeHtml(action)}</p>`,
40
44
  points === "" ? "" : `<ul>${points}</ul>`,
45
+ linkItems === "" ? "" : `<ul>${linkItems}</ul>`,
41
46
  quote,
42
47
  `<p><a href="${escapeHtml(url)}">Open on X</a></p>`,
43
48
  ]
44
49
  .filter((part) => part !== "")
45
50
  .join(""),
46
- pubDate: postedAt,
51
+ pubDate: savedAt,
47
52
  categories: [TOPIC_LABELS[topic]],
48
53
  };
49
54
  }
@@ -1,10 +1,15 @@
1
1
  import { type Topic } from "./topics.ts";
2
- export declare function isTopic(value: string | null | undefined): value is Topic;
3
- export declare function parseFilter(search: string): {
2
+ export declare const VIEWS: readonly ["today", "inbox", "done", "all"];
3
+ export type View = (typeof VIEWS)[number];
4
+ export type Filter = {
4
5
  query: string;
5
6
  topic: Topic | null;
7
+ view: View;
6
8
  };
7
- export declare function filterSearch({ query, topic }: {
8
- query: string;
9
- topic: Topic | null;
10
- }): string;
9
+ type Defaults = {
10
+ reviewIsEmpty?: boolean;
11
+ };
12
+ export declare function isTopic(value: string | null | undefined): value is Topic;
13
+ export declare function parseFilter(search: string, defaults?: Defaults): Filter;
14
+ export declare function filterSearch({ query, topic, view }: Filter, defaults?: Defaults): string;
15
+ export {};
@@ -1,22 +1,33 @@
1
1
  import { TOPICS } from "./topics.js";
2
+ export const VIEWS = ["today", "inbox", "done", "all"];
3
+ function defaultView({ reviewIsEmpty = false }) {
4
+ return reviewIsEmpty ? "inbox" : "today";
5
+ }
2
6
  export function isTopic(value) {
3
7
  return TOPICS.some((topic) => topic === value);
4
8
  }
5
- export function parseFilter(search) {
9
+ function isView(value) {
10
+ return VIEWS.some((view) => view === value);
11
+ }
12
+ export function parseFilter(search, defaults = {}) {
6
13
  const params = new URLSearchParams(search);
7
14
  const topic = params.get("topic");
15
+ const view = params.get("view");
8
16
  return {
9
17
  query: params.get("q") ?? "",
10
18
  topic: isTopic(topic) ? topic : null,
19
+ view: isView(view) ? view : defaultView(defaults),
11
20
  };
12
21
  }
13
- export function filterSearch({ query, topic }) {
22
+ export function filterSearch({ query, topic, view }, defaults = {}) {
14
23
  const params = new URLSearchParams();
15
24
  const trimmed = query.trim();
16
25
  if (trimmed !== "")
17
26
  params.set("q", trimmed);
18
27
  if (topic !== null)
19
28
  params.set("topic", topic);
29
+ if (view !== defaultView(defaults))
30
+ params.set("view", view);
20
31
  const search = params.toString();
21
32
  return search === "" ? "" : `?${search}`;
22
33
  }
@@ -1,5 +1,7 @@
1
1
  export declare const bookmarks: {
2
+ shelfLife: "news" | "evergreen";
2
3
  postedAt: Date;
4
+ savedAt: Date;
3
5
  keyPoints: string[];
4
6
  id: string;
5
7
  url: string;
@@ -8,6 +10,12 @@ export declare const bookmarks: {
8
10
  handle: string;
9
11
  };
10
12
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
13
+ tldr: string;
11
14
  summary: string;
12
15
  text: string;
16
+ action: string;
17
+ links: {
18
+ title: string;
19
+ url: string;
20
+ }[];
13
21
  }[];