grok-digest 0.1.0 → 0.2.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,24 @@
1
+ src/styles/shadcn-tailwind.css (dist/styles/shadcn-tailwind.css in the npm package) is
2
+ copied from shadcn (https://ui.shadcn.com) v4.21.5, dist/tailwind.css.
3
+
4
+ MIT License
5
+
6
+ Copyright (c) 2023 shadcn
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
package/README.md CHANGED
@@ -61,7 +61,7 @@ The site needs no Astro config. The package depends on the Astro version it was
61
61
  import { SCHEMA_VERSION, bookmarkSchema, parseBookmarks, TOPICS } from "grok-digest/schema";
62
62
  ```
63
63
 
64
- `SCHEMA_VERSION` is `1`. The JSON files have no version field. A `bookmarks.json` that already validates is version 1, including files written before this package existed. `posted_at` with milliseconds and a `Z` offset, as the bot writes it, is version 1.
64
+ `SCHEMA_VERSION` is `1`. The JSON files have no version field. A `bookmarks.json` that already validates is version 1. `posted_at` with milliseconds and a `Z` offset, as the bot writes it, is version 1.
65
65
 
66
66
  | File | Version 1 shape |
67
67
  | --------------------- | ---------------------------------------------------------------------- |
@@ -75,7 +75,7 @@ Every site takes every release, so every release reads every data file an earlie
75
75
 
76
76
  The page title is "Grok Digest". The footer reads "Unofficial. Summaries created with Grok." Every page sends `<meta name="robots" content="noindex,nofollow">` and `<meta name="referrer" content="no-referrer">`. `robots.txt` disallows all crawlers. Feeds live at `rss.xml` and `rss/<topic-id>.xml`. Card anchors are `#b-<id>`.
77
77
 
78
- Search and topic filters are `?q=` and `?topic=` on the page address. The behavior is documented in `template/README.md`.
78
+ The page opens on Today, a daily review of up to five bookmarks, and has Inbox, Done, and All views. Search, topic, and view are `?q=`, `?topic=`, and `?view=` on the page address. The behavior is documented in `template/README.md`.
79
79
 
80
80
  ## Package exports
81
81
 
@@ -101,27 +101,21 @@ bun run format
101
101
 
102
102
  ## Publish
103
103
 
104
- Releases are published by `.github/workflows/release.yml` when a `v<version>` tag is pushed. It refuses a tag that does not match `package.json`, runs `bun run check`, then runs `npm publish --provenance`.
104
+ Releases are cut from GitHub Actions. Open **Actions** > **Prepare Release** > **Run workflow** and choose:
105
105
 
106
- 1. Set `version` in `package.json` and write the matching entry in `CHANGELOG.md`.
107
- 2. Merge that commit to `main`.
108
- 3. Tag the merge commit and push the tag:
106
+ | Input | Meaning |
107
+ | --------- | ------------------------------------------------------------------------------ |
108
+ | `bump` | `patch`, `minor`, or `major`, applied to the version in `package.json`. |
109
+ | `version` | Optional. An exact version such as `0.2.0`, without the `v`. Overrides `bump`. |
110
+ | `dry_run` | Run every check on the new version and stop before changing Git. |
109
111
 
110
- ```sh
111
- git tag v0.1.0
112
- git push origin v0.1.0
113
- ```
112
+ `prepare-release.yml` refuses a version that is not newer, whose tag exists, or that is already on npm. It sets the new version, runs `bun run check`, commits `release: v<version>` to `main`, pushes the commit and the `v<version>` tag together, and starts `publish.yml` on that tag.
114
113
 
115
- The workflow logs in to npm with trusted publishing, so the repository holds no npm token. On npmjs.com, the package's **Settings** list `kenryu42/grok-digest` with workflow `release.yml` as a trusted publisher.
114
+ `publish.yml` checks the tag again, packs the tarball, publishes that exact tarball to npm with provenance, and creates a GitHub release with generated notes and the tarball attached. Rerunning it for a tag that is already on npm skips the publish and finishes the release.
116
115
 
117
- npm offers trusted publishing only for a package that already exists, so version 0.1.0 was published from a maintainer's machine. Provenance needs CI, so that one publish turns it off:
116
+ npm accepts the publish through trusted publishing, so the repository holds no npm token. On npmjs.com, the package's **Settings** list `kenryu42/grok-digest` with workflow `publish.yml` as a trusted publisher.
118
117
 
119
- ```sh
120
- npm login
121
- bun install --frozen-lockfile
122
- bun run check
123
- npm publish --provenance=false
124
- ```
118
+ Version 0.1.0 was published from a maintainer's machine, because npm offers trusted publishing only for a package that already exists.
125
119
 
126
120
  ## License
127
121
 
@@ -1,7 +1,8 @@
1
1
  ---
2
- import { ArrowUpRightIcon } from "lucide-react";
2
+ import { ArrowUpRightIcon, CheckIcon, ChevronDownIcon } from "lucide-react";
3
3
  import { Badge } from "./ui/badge";
4
- import { Card, CardContent, CardFooter, CardHeader } from "./ui/card";
4
+ import { Button } from "./ui/button";
5
+ import { Card } from "./ui/card";
5
6
  import type { Bookmark } from "../lib/bookmarks";
6
7
  import { TOPIC_LABELS } from "../lib/topics";
7
8
 
@@ -10,52 +11,91 @@ type Props = {
10
11
  };
11
12
 
12
13
  const { bookmark } = Astro.props;
13
- const { id, url, author, postedAt, topic, summary, text, keyPoints } = bookmark;
14
+ const { id, url, author, postedAt, savedAt, topic, tldr, summary, text, keyPoints } = bookmark;
14
15
 
15
16
  const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
17
+ const isoDay = (date: Date) => date.toISOString().slice(0, 10);
18
+ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
16
19
  ---
17
20
 
18
- <li id={`b-${id}`} data-bookmark-id={id}>
19
- <Card className="gap-4 py-5 shadow-xs ring-foreground/8 [--card-spacing:--spacing(5)] sm:[--card-spacing:--spacing(6)] sm:py-6">
20
- <CardHeader className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
21
- <p class="flex min-w-0 flex-wrap items-baseline gap-x-1.5 text-sm">
22
- <span class="font-medium text-foreground">{author.name}</span>
23
- <span class="text-muted-foreground">@{author.handle}</span>
24
- </p>
25
- <time datetime={postedAt.toISOString().slice(0, 10)} class="shrink-0 text-xs text-muted-foreground tabular-nums">
26
- {dateFormat.format(postedAt)}
27
- </time>
28
- </CardHeader>
21
+ <li id={`b-${id}`} data-bookmark-id={id} data-url={url} class="group/bookmark scroll-mt-6">
22
+ <Card className="gap-0 py-0 shadow-xs ring-foreground/8 [--card-spacing:--spacing(5)] has-[summary:focus-visible]:ring-2 has-[summary:focus-visible]:ring-ring sm:[--card-spacing:--spacing(6)]">
23
+ <details open class="group/details transition-opacity group-data-[done]/bookmark:opacity-55">
24
+ <summary class="flex cursor-pointer list-none flex-col gap-2 px-(--card-spacing) pt-(--card-spacing) pb-4 outline-none [&::-webkit-details-marker]:hidden">
25
+ <span class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-sm">
26
+ <span class="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
27
+ <span class="font-medium text-foreground">{author.name}</span>
28
+ <span class="text-muted-foreground">@{author.handle}</span>
29
+ </span>
30
+ <span class="shrink-0 text-xs text-muted-foreground tabular-nums">
31
+ Saved <time datetime={savedAt.toISOString()}>{dateFormat.format(savedAt)}</time>
32
+ {postedOtherDay && (
33
+ <>
34
+ {" · "}Posted <time datetime={isoDay(postedAt)}>{dateFormat.format(postedAt)}</time>
35
+ </>
36
+ )}
37
+ </span>
38
+ </span>
39
+ <span
40
+ data-review-reason
41
+ class="text-xs font-medium tracking-wide text-muted-foreground uppercase"
42
+ hidden
43
+ ></span>
44
+ <span class="flex items-start justify-between gap-3">
45
+ <h2 class="font-heading text-xl leading-snug font-semibold text-balance text-foreground sm:text-[1.375rem]">
46
+ {tldr}
47
+ </h2>
48
+ <ChevronDownIcon
49
+ className="mt-1.5 size-4 shrink-0 text-muted-foreground transition-transform group-open/details:rotate-180"
50
+ aria-hidden="true"
51
+ />
52
+ </span>
53
+ </summary>
29
54
 
30
- <CardContent className="flex flex-col gap-4">
31
- <p class="font-serif text-[1.0625rem] leading-relaxed text-pretty text-foreground sm:text-lg">{summary}</p>
32
- {keyPoints.length > 0 && (
33
- <ul class="flex flex-col gap-2 border-l-2 border-border pl-4 text-[0.9375rem] leading-relaxed text-foreground/85">
34
- {keyPoints.map((point) => (
35
- <li class="text-pretty">{point}</li>
36
- ))}
37
- </ul>
38
- )}
39
- {text !== "" && (
40
- <figure class="border-t border-border pt-4">
41
- <figcaption class="mb-2 text-xs font-medium text-muted-foreground">Original post</figcaption>
42
- <blockquote class="text-sm leading-relaxed whitespace-pre-wrap text-pretty text-foreground/80">
43
- {text}
44
- </blockquote>
45
- </figure>
46
- )}
47
- </CardContent>
55
+ <div class="flex flex-col gap-4 px-(--card-spacing) pb-4">
56
+ <p class="font-serif text-[1.0625rem] leading-relaxed text-pretty text-foreground/90">{summary}</p>
57
+ {keyPoints.length > 0 && (
58
+ <ul class="flex flex-col gap-2 border-l-2 border-border pl-4 text-[0.9375rem] leading-relaxed text-foreground/85">
59
+ {keyPoints.map((point) => (
60
+ <li class="text-pretty">{point}</li>
61
+ ))}
62
+ </ul>
63
+ )}
64
+ {text !== "" && (
65
+ <figure class="border-t border-border pt-4">
66
+ <figcaption class="mb-2 text-xs font-medium text-muted-foreground">Original post</figcaption>
67
+ <blockquote class="text-sm leading-relaxed whitespace-pre-wrap text-pretty text-foreground/80">
68
+ {text}
69
+ </blockquote>
70
+ </figure>
71
+ )}
72
+ </div>
73
+ </details>
48
74
 
49
- <CardFooter className="justify-between gap-3 border-t-0 bg-transparent pt-1">
75
+ <div class="flex items-center justify-between gap-3 px-(--card-spacing) pb-(--card-spacing)">
50
76
  <Badge variant="secondary">{TOPIC_LABELS[topic]}</Badge>
51
- <a
52
- href={url}
53
- target="_blank"
54
- rel="noopener noreferrer"
55
- class="inline-flex items-center gap-1 rounded-sm text-sm font-medium text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
56
- >
57
- Open on X<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
58
- </a>
59
- </CardFooter>
77
+ <div class="flex items-center gap-4">
78
+ <Button
79
+ type="button"
80
+ variant="outline"
81
+ size="sm"
82
+ data-done-toggle
83
+ aria-pressed="false"
84
+ title="Mark done (e)"
85
+ className="rounded-full aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground dark:aria-pressed:bg-primary dark:aria-pressed:hover:bg-primary/80"
86
+ >
87
+ <CheckIcon aria-hidden="true" />
88
+ Done
89
+ </Button>
90
+ <a
91
+ href={url}
92
+ target="_blank"
93
+ rel="noopener noreferrer"
94
+ class="inline-flex items-center gap-1 rounded-sm text-sm font-medium text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
95
+ >
96
+ Open on X<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
97
+ </a>
98
+ </div>
99
+ </div>
60
100
  </Card>
61
101
  </li>
@@ -2,5 +2,5 @@ import { type SearchEntry } from "../lib/search.ts";
2
2
  type Props = {
3
3
  entries: SearchEntry[];
4
4
  };
5
- export declare function BookmarkFilters({ entries }: Props): import("react").JSX.Element;
5
+ export declare function BookmarkControls({ entries }: Props): import("react").JSX.Element;
6
6
  export {};
@@ -0,0 +1,226 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronsDownUpIcon, ChevronsUpDownIcon, SearchIcon } from "lucide-react";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { Button } from "./ui/button.js";
5
+ import { Input } from "./ui/input.js";
6
+ import { filterSearch, parseFilter, VIEWS } from "../lib/filter.js";
7
+ import { dailyReview, localDay } from "../lib/review.js";
8
+ import { matchingIds } from "../lib/search.js";
9
+ import { TOPIC_LABELS, TOPICS } from "../lib/topics.js";
10
+ const STORAGE_PREFIX = `grok-digest:${import.meta.env.BASE_URL}:`;
11
+ const DONE_KEY = `${STORAGE_PREFIX}done`;
12
+ const REVIEW_KEY = `${STORAGE_PREFIX}review`;
13
+ const COLLAPSED_KEY = `${STORAGE_PREFIX}collapsed`;
14
+ const VIEW_LABELS = { today: "Today", inbox: "Inbox", done: "Done", all: "All" };
15
+ const FORM_FIELDS = "input, textarea, select, [contenteditable]";
16
+ function readStored(key) {
17
+ try {
18
+ const raw = window.localStorage.getItem(key);
19
+ return raw === null ? null : JSON.parse(raw);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function writeStored(key, value) {
26
+ try {
27
+ window.localStorage.setItem(key, JSON.stringify(value));
28
+ }
29
+ catch { }
30
+ }
31
+ function isStringArray(value) {
32
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
33
+ }
34
+ function isStoredReview(value) {
35
+ if (typeof value !== "object" || value === null || !("day" in value) || !("picks" in value))
36
+ return false;
37
+ const { day, picks } = value;
38
+ return (typeof day === "string" &&
39
+ Array.isArray(picks) &&
40
+ picks.every((pick) => typeof pick === "object" &&
41
+ pick !== null &&
42
+ "id" in pick &&
43
+ "reason" in pick &&
44
+ typeof pick.id === "string" &&
45
+ typeof pick.reason === "string"));
46
+ }
47
+ function storedDone() {
48
+ const stored = readStored(DONE_KEY);
49
+ return new Set(isStringArray(stored) ? stored : []);
50
+ }
51
+ function todaysReview(entries, done) {
52
+ const today = localDay(new Date());
53
+ const stored = readStored(REVIEW_KEY);
54
+ if (isStoredReview(stored) && stored.day === today) {
55
+ const ids = new Set(entries.map(({ id }) => id));
56
+ return stored.picks.filter(({ id }) => ids.has(id));
57
+ }
58
+ const picks = dailyReview({ bookmarks: entries, done, today });
59
+ writeStored(REVIEW_KEY, { day: today, picks });
60
+ return picks;
61
+ }
62
+ function linkedCard() {
63
+ const target = window.location.hash.slice(1);
64
+ return target === "" ? null : document.querySelector(`[data-bookmark-id][id="${CSS.escape(target)}"]`);
65
+ }
66
+ function initialFilter() {
67
+ const filter = parseFilter(window.location.search);
68
+ return linkedCard() === null ? filter : { ...filter, view: "all" };
69
+ }
70
+ function cards() {
71
+ return [...document.querySelectorAll("[data-bookmark-id]")];
72
+ }
73
+ function focusCard(card) {
74
+ if (card === undefined)
75
+ return;
76
+ card.querySelector("summary")?.focus({ preventScroll: true });
77
+ card.scrollIntoView({ block: "start" });
78
+ }
79
+ function plural(count, noun) {
80
+ return `${count} ${count === 1 ? noun : `${noun}s`}`;
81
+ }
82
+ export function BookmarkControls({ entries }) {
83
+ const [filter, setFilter] = useState(initialFilter);
84
+ const [done, setDone] = useState(storedDone);
85
+ const [review] = useState(() => todaysReview(entries, storedDone()));
86
+ const [collapsed, setCollapsed] = useState(() => readStored(COLLAPSED_KEY) === true);
87
+ const searchRef = useRef(null);
88
+ const { query, topic, view } = filter;
89
+ const searching = query.trim() !== "";
90
+ const reviewIds = useMemo(() => new Set(review.map(({ id }) => id)), [review]);
91
+ const reasons = useMemo(() => new Map(review.map(({ id, reason }) => [id, reason])), [review]);
92
+ const visible = useMemo(() => matchingIds({ entries, filter, triage: { done, review: reviewIds } }), [entries, filter, done, reviewIds]);
93
+ const topicsInUse = useMemo(() => TOPICS.filter((t) => entries.some((entry) => entry.topic === t)), [entries]);
94
+ const doneCount = entries.filter(({ id }) => done.has(id)).length;
95
+ const reviewedCount = review.filter(({ id }) => done.has(id)).length;
96
+ const counts = {
97
+ today: review.length,
98
+ inbox: entries.length - doneCount,
99
+ done: doneCount,
100
+ all: entries.length,
101
+ };
102
+ useEffect(() => {
103
+ const showReasons = view === "today" && !searching;
104
+ for (const card of cards()) {
105
+ const id = card.dataset["bookmarkId"] ?? "";
106
+ card.hidden = !visible.has(id);
107
+ card.toggleAttribute("data-done", done.has(id));
108
+ card.querySelector("[data-done-toggle]")?.setAttribute("aria-pressed", String(done.has(id)));
109
+ const reason = card.querySelector("[data-review-reason]");
110
+ const text = showReasons ? reasons.get(id) : undefined;
111
+ if (reason !== null) {
112
+ reason.hidden = text === undefined;
113
+ reason.textContent = text ?? "";
114
+ }
115
+ }
116
+ }, [visible, done, reasons, view, searching]);
117
+ useEffect(() => {
118
+ for (const details of document.querySelectorAll("[data-bookmark-id] details")) {
119
+ details.open = !collapsed;
120
+ }
121
+ writeStored(COLLAPSED_KEY, collapsed);
122
+ }, [collapsed]);
123
+ useEffect(() => {
124
+ writeStored(DONE_KEY, [...done]);
125
+ }, [done]);
126
+ useEffect(() => {
127
+ const next = `${window.location.pathname}${filterSearch(filter)}${window.location.hash}`;
128
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
129
+ if (current !== next)
130
+ window.history.replaceState(null, "", next);
131
+ }, [filter]);
132
+ useEffect(() => {
133
+ document.querySelector("[data-bookmark-list]")?.toggleAttribute("data-ready", true);
134
+ const linked = linkedCard();
135
+ linked?.querySelector("details")?.setAttribute("open", "");
136
+ linked?.scrollIntoView({ block: "start" });
137
+ }, []);
138
+ useEffect(() => {
139
+ const toggleDone = (card) => {
140
+ const id = card.dataset["bookmarkId"] ?? "";
141
+ setDone((previous) => {
142
+ const next = new Set(previous);
143
+ if (!next.delete(id))
144
+ next.add(id);
145
+ return next;
146
+ });
147
+ };
148
+ const onClick = (event) => {
149
+ if (!(event.target instanceof Element))
150
+ return;
151
+ const card = event.target.closest("[data-done-toggle]")?.closest("[data-bookmark-id]");
152
+ if (card !== null && card !== undefined)
153
+ toggleDone(card);
154
+ };
155
+ const onKeyDown = (event) => {
156
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey)
157
+ return;
158
+ if (event.target instanceof HTMLElement && event.target.closest(FORM_FIELDS) !== null) {
159
+ if (event.key === "Escape")
160
+ event.target.blur();
161
+ return;
162
+ }
163
+ const shown = cards().filter((card) => !card.hidden);
164
+ const current = document.activeElement?.closest("[data-bookmark-id]") ?? null;
165
+ const index = current === null ? -1 : shown.indexOf(current);
166
+ switch (event.key) {
167
+ case "j":
168
+ focusCard(shown[Math.min(index + 1, shown.length - 1)]);
169
+ break;
170
+ case "k":
171
+ focusCard(shown[Math.max(index - 1, 0)]);
172
+ break;
173
+ case "e": {
174
+ if (current === null)
175
+ return;
176
+ const neighbor = shown[index + 1] ?? shown[index - 1];
177
+ toggleDone(current);
178
+ setTimeout(() => {
179
+ if (current.hidden)
180
+ focusCard(neighbor);
181
+ });
182
+ break;
183
+ }
184
+ case "o": {
185
+ const url = current?.dataset["url"];
186
+ if (url !== undefined)
187
+ window.open(url, "_blank", "noopener,noreferrer");
188
+ break;
189
+ }
190
+ case "/":
191
+ searchRef.current?.focus();
192
+ break;
193
+ default:
194
+ return;
195
+ }
196
+ event.preventDefault();
197
+ };
198
+ document.addEventListener("click", onClick);
199
+ document.addEventListener("keydown", onKeyDown);
200
+ return () => {
201
+ document.removeEventListener("click", onClick);
202
+ document.removeEventListener("keydown", onKeyDown);
203
+ };
204
+ }, []);
205
+ const update = (change) => setFilter((previous) => ({ ...previous, ...change }));
206
+ 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", 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, { 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: "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"] })] })] })] }));
207
+ }
208
+ function Status({ view, searching, visible, reviewed, reviewSize, inbox, }) {
209
+ if (searching)
210
+ return visible === 0 ? "No bookmarks match your search." : `${plural(visible, "result")} in all bookmarks`;
211
+ if (view === "today") {
212
+ if (reviewSize === 0)
213
+ return `Nothing to review today. ${plural(inbox, "bookmark")} in your inbox.`;
214
+ if (reviewed === reviewSize)
215
+ return "All caught up for today. Come back tomorrow.";
216
+ return `${reviewed} of ${reviewSize} reviewed today`;
217
+ }
218
+ if (view === "inbox" && inbox === 0)
219
+ return "Inbox zero. Everything is done.";
220
+ if (view === "done" && visible === 0)
221
+ return "Nothing marked done yet.";
222
+ return plural(visible, "bookmark");
223
+ }
224
+ function TopicChip({ label, pressed, onClick }) {
225
+ return (_jsx(Button, { type: "button", size: "sm", variant: pressed ? "default" : "outline", "aria-pressed": pressed, onClick: onClick, className: "rounded-full px-3", children: label }));
226
+ }
@@ -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
@@ -16,6 +16,7 @@ const ROUTES = [
16
16
  ];
17
17
  const RENDERER_PACKAGES = ["@astrojs/react", "react", "react-dom"];
18
18
  const PACKAGE_ENTRY = fileURLToPath(import.meta.url);
19
+ const PACKAGE_ROOT = path.resolve(path.dirname(PACKAGE_ENTRY), "..");
19
20
  const isRendererImport = (id) => RENDERER_PACKAGES.some((name) => id === name || id.startsWith(`${name}/`));
20
21
  function replaceInPlace(list, replacement) {
21
22
  list.splice(0, list.length, ...replacement);
@@ -47,6 +48,14 @@ function rendererFromPackage() {
47
48
  },
48
49
  };
49
50
  }
51
+ function servePackageFiles() {
52
+ return {
53
+ name: "grok-digest:serve-package-files",
54
+ configResolved(config) {
55
+ config.server.fs.allow.push(PACKAGE_ROOT);
56
+ },
57
+ };
58
+ }
50
59
  function bookmarksModule({ file, source }) {
51
60
  return {
52
61
  name: "grok-digest:bookmarks",
@@ -70,7 +79,9 @@ export default function grokDigest({ dataDir = "data" } = {}) {
70
79
  const source = path.relative(root, file).split(path.sep).join("/");
71
80
  updateConfig({
72
81
  integrations: [react()],
73
- vite: { plugins: [rendererFromPackage(), tailwindcss(), bookmarksModule({ file, source })] },
82
+ vite: {
83
+ plugins: [rendererFromPackage(), servePackageFiles(), tailwindcss(), bookmarksModule({ file, source })],
84
+ },
74
85
  });
75
86
  for (const { pattern, entrypoint } of ROUTES) {
76
87
  injectRoute({ pattern, entrypoint: new URL(entrypoint, import.meta.url) });
@@ -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,13 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
17
18
  design: "design";
18
19
  other: "other";
19
20
  }>;
21
+ tldr: z.ZodString;
20
22
  summary: z.ZodString;
21
23
  text: z.ZodString;
22
24
  key_points: z.ZodArray<z.ZodString>;
23
25
  }, z.core.$strict>, z.ZodTransform<{
24
26
  postedAt: Date;
27
+ savedAt: Date;
25
28
  keyPoints: string[];
26
29
  id: string;
27
30
  url: string;
@@ -30,6 +33,7 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
30
33
  handle: string;
31
34
  };
32
35
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
36
+ tldr: string;
33
37
  summary: string;
34
38
  text: string;
35
39
  }, {
@@ -40,7 +44,9 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
40
44
  handle: string;
41
45
  };
42
46
  posted_at: string;
47
+ saved_at: string;
43
48
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
49
+ tldr: string;
44
50
  summary: string;
45
51
  text: string;
46
52
  key_points: string[];
@@ -53,6 +59,7 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
53
59
  handle: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>;
54
60
  }, z.core.$strict>;
55
61
  posted_at: z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>;
62
+ saved_at: z.ZodISODateTime;
56
63
  topic: z.ZodEnum<{
57
64
  "ai-agents": "ai-agents";
58
65
  "models-releases": "models-releases";
@@ -62,11 +69,13 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
62
69
  design: "design";
63
70
  other: "other";
64
71
  }>;
72
+ tldr: z.ZodString;
65
73
  summary: z.ZodString;
66
74
  text: z.ZodString;
67
75
  key_points: z.ZodArray<z.ZodString>;
68
76
  }, z.core.$strict>, z.ZodTransform<{
69
77
  postedAt: Date;
78
+ savedAt: Date;
70
79
  keyPoints: string[];
71
80
  id: string;
72
81
  url: string;
@@ -75,6 +84,7 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
75
84
  handle: string;
76
85
  };
77
86
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
87
+ tldr: string;
78
88
  summary: string;
79
89
  text: string;
80
90
  }, {
@@ -85,7 +95,9 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
85
95
  handle: string;
86
96
  };
87
97
  posted_at: string;
98
+ saved_at: string;
88
99
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
100
+ tldr: string;
89
101
  summary: string;
90
102
  text: string;
91
103
  key_points: string[];
@@ -4,6 +4,7 @@ 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 });
7
8
  export const bookmarkSchema = z
8
9
  .strictObject({
9
10
  id: nonEmpty,
@@ -16,15 +17,18 @@ export const bookmarkSchema = z
16
17
  .transform((handle) => handle.replace(/^@/, ""))
17
18
  .pipe(z.string().regex(X_HANDLE, "must be an X handle such as @jack")),
18
19
  }),
19
- posted_at: z.union([z.iso.date(), z.iso.datetime({ offset: true })]),
20
+ posted_at: z.union([z.iso.date(), instant]),
21
+ saved_at: instant,
20
22
  topic: z.enum(TOPICS),
23
+ tldr: nonEmpty,
21
24
  summary: nonEmpty,
22
25
  text: z.string().trim(),
23
26
  key_points: z.array(nonEmpty),
24
27
  })
25
- .transform(({ posted_at, key_points, ...rest }) => ({
28
+ .transform(({ posted_at, saved_at, key_points, ...rest }) => ({
26
29
  ...rest,
27
30
  postedAt: new Date(posted_at),
31
+ savedAt: new Date(saved_at),
28
32
  keyPoints: key_points,
29
33
  }));
30
34
  export const bookmarksFileSchema = z.array(bookmarkSchema).superRefine((bookmarks, ctx) => {
@@ -45,5 +49,5 @@ export function parseDataFile({ schema, raw, source, }) {
45
49
  return result.data;
46
50
  }
47
51
  export function parseBookmarks({ raw, source }) {
48
- return parseDataFile({ schema: bookmarksFileSchema, raw, source }).toSorted((a, b) => b.postedAt.getTime() - a.postedAt.getTime());
52
+ return parseDataFile({ schema: bookmarksFileSchema, raw, source }).toSorted((a, b) => b.savedAt.getTime() - a.savedAt.getTime());
49
53
  }