grok-digest 0.2.0 → 0.3.1

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/BOT.md ADDED
@@ -0,0 +1,196 @@
1
+ # Grok Digest: the bot's manual
2
+
3
+ You keep a Grok Digest site. Its owner shares X posts with you. You turn each post into one entry in `data/bookmarks.json` and push it to `main`. GitHub Actions then rebuilds the page, where the owner reads the summaries and comes back to old bookmarks in a daily review.
4
+
5
+ The owner saves posts to read them later and then forgets them. Everything you write exists to make a post worth coming back to: a headline that says what it's about, a summary, the key points, and one thing to try.
6
+
7
+ This file ships with the `grok-digest` package. After `bun install`, the copy at `node_modules/grok-digest/BOT.md` matches the version the site builds with, so read that copy when this one and the installed one differ.
8
+
9
+ ## The site repository
10
+
11
+ | Path | What it is | You |
12
+ | ------------------------------ | ------------------------------------------------------------------------- | ----------------------- |
13
+ | `data/bookmarks.json` | The bookmarks, one object per post. | Add, edit, remove |
14
+ | `data/processed.json` | Ids of the shares you have handled, such as email message ids. | Add one id per share |
15
+ | `data/removed.json` | Status ids of bookmarks the owner asked you to remove. | Add an id when removing |
16
+ | `package.json`, `bun.lock` | Depend on `grok-digest`. Every deploy takes the newest release by itself. | Never commit changes |
17
+ | `.github/workflows/deploy.yml` | Validates the data, builds the page, and publishes it to GitHub Pages. | Never change |
18
+
19
+ `processed.json` and `removed.json` are JSON arrays of non-empty strings. The page reads only `bookmarks.json`.
20
+
21
+ ## Set up a checkout
22
+
23
+ ```sh
24
+ bun install
25
+ bun update grok-digest
26
+ bun run validate
27
+ ```
28
+
29
+ `bun update grok-digest` gives you the release the next deploy will use. It changes `bun.lock`; leave that change out of your commits.
30
+
31
+ ## Save a shared post
32
+
33
+ 1. If the share's id is already in `data/processed.json`, stop. You have handled it.
34
+ 2. Find the post's status id: the number at the end of its URL.
35
+ 3. If that id is in `data/removed.json`, the owner removed it. Do not add it again; add the share's id to `processed.json` and stop.
36
+ 4. If that id is already in `data/bookmarks.json`, do not add a second entry. Add the share's id to `processed.json` and stop.
37
+ 5. Read the post. If the author continues it as a thread (it says 🧵 or "1/", or the author replies to themselves to go on), read the whole thread. Follow every link in it to the page it really lands on.
38
+ 6. Write the entry described below and append it to the array in `data/bookmarks.json`.
39
+ 7. Add the share's id to `data/processed.json`.
40
+ 8. If any entry's author name contains `(sample)`, delete those entries. They are the template's placeholders.
41
+ 9. Run `bun run validate` and fix the entry until it passes.
42
+ 10. Commit with the message `Save bookmark (@handle)`, using the post author's handle, and push to `main`.
43
+
44
+ ## The entry
45
+
46
+ ```json
47
+ {
48
+ "id": "2110149626133438732",
49
+ "url": "https://x.com/poteto/status/2110149626133438732",
50
+ "author": { "name": "lauren", "handle": "poteto" },
51
+ "posted_at": "2026-10-13T23:24:10.000Z",
52
+ "saved_at": "2026-10-15T11:21:40+09:00",
53
+ "topic": "ai-agents",
54
+ "shelf_life": "evergreen",
55
+ "tldr": "Stale workaround comments spread because coding agents copy them",
56
+ "summary": "Argues you should delete most code comments, because coding agents copy existing workaround comments and spread hacks through the codebase.",
57
+ "text": "ban and delete most if not all code comments!\n\nagents love copying what already exists.",
58
+ "key_points": [
59
+ "Agents imitate existing code, so stale workaround comments spread like weeds.",
60
+ "Keep only comments that must exist, such as browser-quirk explanations."
61
+ ],
62
+ "action": "Have an agent run /no-comments on one repo and sort each comment into refactor or delete.",
63
+ "links": [{ "title": "pstack: the /no-comments skill", "url": "https://github.com/example/pstack" }],
64
+ "media": [
65
+ { "url": "https://pbs.twimg.com/media/G3xAmPLeAAAbCdE.jpg", "alt": "A diff that deletes a workaround comment" }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ Write every field, in this order. The build rejects an entry with a missing field or a field not listed here. `text` and `action` may be empty strings, and `key_points`, `links`, and `media` may be empty arrays. Entries saved before `media` existed have no `media` field; that is valid, and you don't need to add one.
71
+
72
+ ### id
73
+
74
+ The post's status id as a string, the number at the end of its URL. It must be unique in the file.
75
+
76
+ ### url
77
+
78
+ The post's `https` link on `x.com` or `twitter.com`. A `www.` or `mobile.` prefix is allowed.
79
+
80
+ ### author
81
+
82
+ `name` is the display name. `handle` is the X handle without `@`: 1 to 15 letters, digits, or underscores.
83
+
84
+ ### posted_at
85
+
86
+ When the author published the post, not when you received it. Write a date and time with an offset, such as `2026-10-13T23:24:10.000Z`. A date alone, such as `2026-10-13`, is also accepted.
87
+
88
+ If you can't read the time from the post, work it out from the status id: milliseconds since the Unix epoch = (id >> 22) + 1288834974657.
89
+
90
+ ### saved_at
91
+
92
+ When you received the share: the current date and time as you handle it. Write it once and never change it, even when you edit the entry later.
93
+
94
+ It must be a date and time with an offset, such as `2026-10-15T11:21:40+09:00`, or UTC with `Z`. A date alone is rejected. Use the owner's local offset when you know it. The daily review counts days in the owner's time zone, so the right offset keeps "Saved yesterday" true around midnight.
95
+
96
+ ### topic
97
+
98
+ One of these ids:
99
+
100
+ | Topic id | Label on the page |
101
+ | ------------------- | ------------------- |
102
+ | `ai-agents` | AI agents |
103
+ | `models-releases` | Models & releases |
104
+ | `dev-tools` | Dev tools |
105
+ | `startups-business` | Startups & business |
106
+ | `japan-tech` | Japan tech |
107
+ | `design` | Design |
108
+ | `other` | Other |
109
+
110
+ ### shelf_life
111
+
112
+ `news` when the post's value fades within weeks: launches, releases, announcements, funding, events, outages, drama. `evergreen` when it stays useful: ideas, techniques, how-tos, prompts, tools worth adopting later, essays. If a launch post also teaches how to use the thing, choose `evergreen`.
113
+
114
+ The daily review brings `news` back only the day after it was saved and a week later. `evergreen` comes back a day, a week, a month, and three months later, and on the same date in later years.
115
+
116
+ ### tldr
117
+
118
+ A headline of at most 12 words that says what the reader gets from the post. It is the card's title and the feed item's title.
119
+
120
+ - Say the concrete thing: "Bun 1.4 ships a built-in Postgres client", not "Interesting news about Bun".
121
+ - Plain text: no emoji, hashtags, @handles, quotes, or final period.
122
+ - Don't start with the author's name; the card shows it.
123
+ - Use the language of `summary`. Don't repeat `summary` or its first sentence.
124
+
125
+ ### summary
126
+
127
+ One or two sentences on what the post says and why it matters. For a thread, summarize the whole thread.
128
+
129
+ ### text
130
+
131
+ The post's own words, copied exactly as posted, with its line breaks. For a thread, the author's posts in order, separated by a blank line. Keep a `t.co` line that stands for an image or video as it is; the image itself goes in `media`. Write `""` when the post has no text.
132
+
133
+ Keep it complete. Most AI assistants can't open x.com, so when the owner copies a bookmark into one, this text is all it gets of the post.
134
+
135
+ ### key_points
136
+
137
+ The takeaways, one short sentence each, usually two to four. Say what the post teaches, not what it covers: "Agents imitate existing code, so stale comments spread", not "The thread explains how comments spread". Write `[]` when there is nothing beyond the summary.
138
+
139
+ ### action
140
+
141
+ One sentence on the most useful thing the owner could do because of the post: a command to run, a tool or prompt to try, a setting to change. It shows under the card's title even when the card is collapsed.
142
+
143
+ - Start with a verb, and be specific enough to do without opening the post.
144
+ - When the post includes a prompt, command, or snippet, base the action on it.
145
+ - At most about 25 words, in the language of `summary`.
146
+ - Write `""` when there is honestly nothing to do, such as news, an opinion, or a joke. Never invent busywork.
147
+
148
+ ### links
149
+
150
+ Every link in the post and its thread, followed to where it really lands. Expand `t.co` links.
151
+
152
+ - `url`: the final `https` address without tracking parameters such as `utm_*`. Skip links that are not `https`.
153
+ - `title`: the page's own title, cut to about 8 words, or a plain description such as `GitHub repo: example/pstack`.
154
+ - Leave out links to the post itself, to the author's profile, and to the post's images or videos.
155
+ - Write `[]` when there are none.
156
+
157
+ ### media
158
+
159
+ The images in the post and its thread, in thread order, so the card can show them. One object per photo:
160
+
161
+ - `url`: the image's `https://pbs.twimg.com/` address, such as `https://pbs.twimg.com/media/G3xAmPLeAAAbCdE.jpg`. A `?format=jpg&name=large` query is fine. Never a `t.co` link or an `x.com/…/photo/1` page; the build rejects any other host.
162
+ - `alt`: the author's alt text for the image when it has one, otherwise a few words on what it shows. Leave `alt` out rather than writing `""`.
163
+ - For a video or GIF, add its poster image (the `pbs.twimg.com` thumbnail X shows before it plays) when you can find it. Skip it otherwise.
164
+ - Don't add link-preview cards or the author's profile picture.
165
+ - Write `[]` when the post has no images.
166
+
167
+ Images never go in `links`, and `text` keeps the post's exact words, including any `t.co` lines that stood for the images.
168
+
169
+ ## Change or remove a bookmark
170
+
171
+ To change one, edit its fields in place. Keep `id` and `saved_at`. Commit with `Update bookmark (@handle)`.
172
+
173
+ To remove one, delete its entry from `data/bookmarks.json` and add its id to `data/removed.json`, so a later share of the same post doesn't bring it back. Commit with `Remove bookmark (@handle)`.
174
+
175
+ Run `bun run validate` before every commit.
176
+
177
+ ## When validation fails
178
+
179
+ `bun run validate` names the file, the problem, and where it is:
180
+
181
+ ```text
182
+ data/bookmarks.json is invalid:
183
+ ✖ Invalid option: expected one of "news"|"evergreen"
184
+ → at [3].shelf_life
185
+ ```
186
+
187
+ `[3]` is the entry's position in the array, counting from 0. Fix the field and run it again. A deploy with invalid data fails, and the page that was live stays live, so never push data that fails validation.
188
+
189
+ ## A daily digest (optional routine)
190
+
191
+ If the owner wants a nudge to come back, run this every morning:
192
+
193
+ 1. Read `data/bookmarks.json` from the site repository.
194
+ 2. Using today's date in the owner's time zone, pick up to 3 bookmarks: first any `evergreen` bookmark saved exactly 1, 7, 30 or 90 days ago or on today's date in an earlier year, and any `news` bookmark saved 1 or 7 days ago; then fill the rest with bookmarks saved at least a day ago that you have not sent in the last 14 days. Skip anything saved today. Send nothing if nothing qualifies.
195
+ 3. For each, send the `tldr`, why it is back ("Saved a week ago"), the `action` as "Try: …" when it is not empty, and a link to `<site>/#b-<id>`.
196
+ 4. End with a link to `<site>/?view=today`, where `<site>` is the page's address.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  `grok-digest` renders a Grok Digest site: X bookmarks summarized by Grok, searchable, sorted by topic, with one RSS feed per topic. A site repository holds only its `data/` files, a `package.json`, and a deploy workflow. The page, the feeds, and the schema come from this package.
4
4
 
5
+ [`BOT.md`](BOT.md) is the manual for the Grok bot that writes a site's data: what to do with a shared post, every field of an entry, and how to change or remove one. It ships in the package, so `node_modules/grok-digest/BOT.md` always matches the installed version.
6
+
5
7
  Every deploy installs the newest `grok-digest`, so a release reaches each site the next time its Grok bot pushes a bookmark.
6
8
 
7
9
  ## Set up a site
@@ -61,13 +63,13 @@ The site needs no Astro config. The package depends on the Astro version it was
61
63
  import { SCHEMA_VERSION, bookmarkSchema, parseBookmarks, TOPICS } from "grok-digest/schema";
62
64
  ```
63
65
 
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.
66
+ `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. The optional `media` field, a post's images, is part of version 1 too: a bookmark without it parses with `media` set to `[]`.
65
67
 
66
- | File | Version 1 shape |
67
- | --------------------- | ---------------------------------------------------------------------- |
68
- | `data/bookmarks.json` | Array of bookmark objects. The field table is in `template/README.md`. |
69
- | `data/processed.json` | Array of non-empty id strings. Optional. The page does not read it. |
70
- | `data/removed.json` | Same shape as `processed.json`. Optional. The page does not read it. |
68
+ | File | Version 1 shape |
69
+ | --------------------- | -------------------------------------------------------------------- |
70
+ | `data/bookmarks.json` | Array of bookmark objects. Every field is described in `BOT.md`. |
71
+ | `data/processed.json` | Array of non-empty id strings. Optional. The page does not read it. |
72
+ | `data/removed.json` | Same shape as `processed.json`. Optional. The page does not read it. |
71
73
 
72
74
  Every site takes every release, so every release reads every data file an earlier release accepted. The policy is in `MIGRATION.md`.
73
75
 
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><radialGradient id="gs" cx="30%" cy="22%" r="85%"><stop offset="0" stop-color="#fffdf9"/><stop offset=".55" stop-color="#ece5da"/><stop offset="1" stop-color="#bfb5a8"/></radialGradient>
2
+ <linearGradient id="bgs" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3a332e"/><stop offset="1" stop-color="#1f1b18"/></linearGradient>
3
+ <linearGradient id="ams" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f0b45e"/><stop offset="1" stop-color="#d9913a"/></linearGradient></defs><rect width="100" height="100" fill="url(#bgs)"/>
4
+ <circle cx="50" cy="64" r="31" fill="url(#gs)"/>
5
+ <ellipse cx="46" cy="52" rx="4" ry="7.5" transform="rotate(14 46 52)" fill="#2b2622"/><ellipse cx="61" cy="49" rx="4" ry="7.5" transform="rotate(14 61 49)" fill="#2b2622"/>
6
+ <rect x="8" y="62" width="84" height="60" rx="11" fill="#e8a54b"/>
7
+ <rect x="20" y="74" width="40" height="5" rx="2.5" fill="#2b2622" opacity=".35"/>
8
+ <rect x="20" y="84" width="28" height="5" rx="2.5" fill="#2b2622" opacity=".35"/>
9
+ <path d="M68 62 h10 v16 l-5 -3.5 l-5 3.5z" fill="#2b2622"/></svg>
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2b2622"/><path d="M10 7h12v18l-6-4.5L10 25z" fill="#f6f1ea"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><clipPath id="tile"><rect width="32" height="32" rx="7"/></clipPath></defs><g clip-path="url(#tile)"><rect width="32" height="32" fill="#2b2622"/><circle cx="16" cy="20.5" r="10.5" fill="#f6f1ea"/><ellipse cx="14.4" cy="16.4" rx="1.5" ry="2.6" transform="rotate(14 14.4 16.4)" fill="#2b2622"/><ellipse cx="19.4" cy="15.4" rx="1.5" ry="2.6" transform="rotate(14 19.4 15.4)" fill="#2b2622"/><rect x="2" y="20" width="28" height="16" rx="3.5" fill="#e8a54b"/></g></svg>
Binary file
Binary file
Binary file
@@ -1,9 +1,11 @@
1
1
  ---
2
- import { ArrowUpRightIcon, CheckIcon, ChevronDownIcon } from "lucide-react";
2
+ import { ArrowUpRightIcon, CheckIcon, ChevronDownIcon, CopyIcon, LinkIcon, ZapIcon } from "lucide-react";
3
3
  import { Badge } from "./ui/badge";
4
4
  import { Button } from "./ui/button";
5
5
  import { Card } from "./ui/card";
6
6
  import type { Bookmark } from "../lib/bookmarks";
7
+ import { copyText } from "../lib/copy";
8
+ import { imagesWithAlt } from "../lib/media";
7
9
  import { TOPIC_LABELS } from "../lib/topics";
8
10
 
9
11
  type Props = {
@@ -11,15 +13,18 @@ type Props = {
11
13
  };
12
14
 
13
15
  const { bookmark } = Astro.props;
14
- const { id, url, author, postedAt, savedAt, topic, tldr, summary, text, keyPoints } = bookmark;
16
+ const { id, url, author, postedAt, savedAt, topic, shelfLife, tldr, summary, text, keyPoints, action, links } =
17
+ bookmark;
15
18
 
16
19
  const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
17
20
  const isoDay = (date: Date) => date.toISOString().slice(0, 10);
18
21
  const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
22
+ const hostOf = (link: string) => new URL(link).hostname.replace(/^www\./, "");
23
+ const images = imagesWithAlt(bookmark);
19
24
  ---
20
25
 
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)]">
26
+ <li id={`b-${id}`} data-bookmark-id={id} data-url={url} data-topic={topic} class="group/bookmark scroll-mt-6">
27
+ <Card className="relative gap-0 py-0 shadow-xs before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-topic before:transition-opacity group-data-[done]/bookmark:before:opacity-55 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
28
  <details open class="group/details transition-opacity group-data-[done]/bookmark:opacity-55">
24
29
  <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
30
  <span class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-sm">
@@ -36,11 +41,7 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
36
41
  )}
37
42
  </span>
38
43
  </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 data-review-reason class="text-xs font-medium tracking-wide text-brand-ink uppercase" hidden></span>
44
45
  <span class="flex items-start justify-between gap-3">
45
46
  <h2 class="font-heading text-xl leading-snug font-semibold text-balance text-foreground sm:text-[1.375rem]">
46
47
  {tldr}
@@ -50,6 +51,14 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
50
51
  aria-hidden="true"
51
52
  />
52
53
  </span>
54
+ {action !== "" && (
55
+ <span class="flex items-start gap-2 rounded-lg bg-brand-soft px-3 py-2 text-sm leading-relaxed text-foreground">
56
+ <ZapIcon className="mt-1 size-3.5 shrink-0 fill-brand text-brand-ink" aria-hidden="true" />
57
+ <span class="text-pretty">
58
+ <span class="font-medium text-brand-ink">Try:</span> {action}
59
+ </span>
60
+ </span>
61
+ )}
53
62
  </summary>
54
63
 
55
64
  <div class="flex flex-col gap-4 px-(--card-spacing) pb-4">
@@ -61,20 +70,88 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
61
70
  ))}
62
71
  </ul>
63
72
  )}
64
- {text !== "" && (
73
+ {links.length > 0 && (
74
+ <ul class="flex flex-col gap-1.5 text-sm" aria-label="Links in the post">
75
+ {links.map((link) => (
76
+ <li>
77
+ <a
78
+ href={link.url}
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ class="group/link inline-flex items-baseline gap-2 rounded-sm focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
82
+ >
83
+ <LinkIcon className="size-3.5 shrink-0 translate-y-0.5 text-muted-foreground" aria-hidden="true" />
84
+ <span class="text-pretty text-foreground underline decoration-border underline-offset-4 transition-colors group-hover/link:decoration-brand">
85
+ {link.title}
86
+ </span>
87
+ <span class="shrink-0 text-xs text-muted-foreground">{hostOf(link.url)}</span>
88
+ </a>
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ )}
93
+ {(text !== "" || images.length > 0) && (
65
94
  <figure class="border-t border-border pt-4">
66
95
  <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>
96
+ {text !== "" && (
97
+ <blockquote class="text-sm leading-relaxed whitespace-pre-wrap text-pretty text-foreground/80">
98
+ {text}
99
+ </blockquote>
100
+ )}
101
+ {images.length > 0 && (
102
+ <ul
103
+ class:list={["grid max-w-md gap-2", { "grid-cols-2": images.length > 1, "mt-3": text !== "" }]}
104
+ aria-label="Images in the post"
105
+ >
106
+ {images.map((image, index) => (
107
+ <li class:list={{ "col-span-2": index === 0 && images.length > 1 && images.length % 2 === 1 }}>
108
+ <a
109
+ href={image.url}
110
+ target="_blank"
111
+ rel="noopener noreferrer"
112
+ class="block overflow-hidden rounded-lg border border-border bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
113
+ >
114
+ <img
115
+ src={image.url}
116
+ alt={image.alt}
117
+ loading="lazy"
118
+ decoding="async"
119
+ class="aspect-video w-full object-cover"
120
+ />
121
+ </a>
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ )}
70
126
  </figure>
71
127
  )}
72
128
  </div>
73
129
  </details>
74
130
 
75
- <div class="flex items-center justify-between gap-3 px-(--card-spacing) pb-(--card-spacing)">
76
- <Badge variant="secondary">{TOPIC_LABELS[topic]}</Badge>
77
- <div class="flex items-center gap-4">
131
+ <div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-(--card-spacing) pb-(--card-spacing)">
132
+ <div class="flex flex-wrap items-center gap-1.5">
133
+ <Badge className="bg-topic-soft text-topic">{TOPIC_LABELS[topic]}</Badge>
134
+ {shelfLife === "news" && (
135
+ <Badge variant="outline" className="border-brand/60 text-brand-ink">
136
+ News
137
+ </Badge>
138
+ )}
139
+ </div>
140
+ <div class="ml-auto flex items-center gap-3">
141
+ <Button
142
+ type="button"
143
+ variant="ghost"
144
+ size="sm"
145
+ data-copy={copyText(bookmark)}
146
+ title="Copy for an AI assistant (c)"
147
+ className="rounded-full"
148
+ >
149
+ <CopyIcon className="group-data-[copied]/button:hidden" aria-hidden="true" />
150
+ <CheckIcon className="hidden group-data-[copied]/button:block" aria-hidden="true" />
151
+ <span data-copy-label aria-live="polite">
152
+ Copy
153
+ </span>
154
+ </Button>
78
155
  <Button
79
156
  type="button"
80
157
  variant="outline"
@@ -82,7 +159,7 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
82
159
  data-done-toggle
83
160
  aria-pressed="false"
84
161
  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"
162
+ className="rounded-full aria-pressed:border-done aria-pressed:bg-done aria-pressed:text-done-foreground aria-pressed:hover:bg-done/85 aria-pressed:hover:text-done-foreground dark:aria-pressed:border-done dark:aria-pressed:bg-done dark:aria-pressed:hover:bg-done/85"
86
163
  >
87
164
  <CheckIcon aria-hidden="true" />
88
165
  Done
@@ -91,7 +168,7 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
91
168
  href={url}
92
169
  target="_blank"
93
170
  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"
171
+ class="inline-flex items-center gap-1 rounded-sm text-sm font-medium whitespace-nowrap text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-brand focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
95
172
  >
96
173
  Open on X<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
97
174
  </a>
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ChevronsDownUpIcon, ChevronsUpDownIcon, SearchIcon } from "lucide-react";
3
+ import { cn } from "cn";
3
4
  import { useEffect, useMemo, useRef, useState } from "react";
4
5
  import { Button } from "./ui/button.js";
5
6
  import { Input } from "./ui/input.js";
@@ -63,8 +64,8 @@ function linkedCard() {
63
64
  const target = window.location.hash.slice(1);
64
65
  return target === "" ? null : document.querySelector(`[data-bookmark-id][id="${CSS.escape(target)}"]`);
65
66
  }
66
- function initialFilter() {
67
- const filter = parseFilter(window.location.search);
67
+ function initialFilter(reviewIsEmpty) {
68
+ const filter = parseFilter(window.location.search, { reviewIsEmpty });
68
69
  return linkedCard() === null ? filter : { ...filter, view: "all" };
69
70
  }
70
71
  function cards() {
@@ -76,13 +77,34 @@ function focusCard(card) {
76
77
  card.querySelector("summary")?.focus({ preventScroll: true });
77
78
  card.scrollIntoView({ block: "start" });
78
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
+ }
79
100
  function plural(count, noun) {
80
101
  return `${count} ${count === 1 ? noun : `${noun}s`}`;
81
102
  }
82
103
  export function BookmarkControls({ entries }) {
83
- const [filter, setFilter] = useState(initialFilter);
84
- const [done, setDone] = useState(storedDone);
85
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);
86
108
  const [collapsed, setCollapsed] = useState(() => readStored(COLLAPSED_KEY) === true);
87
109
  const searchRef = useRef(null);
88
110
  const { query, topic, view } = filter;
@@ -124,11 +146,11 @@ export function BookmarkControls({ entries }) {
124
146
  writeStored(DONE_KEY, [...done]);
125
147
  }, [done]);
126
148
  useEffect(() => {
127
- const next = `${window.location.pathname}${filterSearch(filter)}${window.location.hash}`;
149
+ const next = `${window.location.pathname}${filterSearch(filter, { reviewIsEmpty })}${window.location.hash}`;
128
150
  const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
129
151
  if (current !== next)
130
152
  window.history.replaceState(null, "", next);
131
- }, [filter]);
153
+ }, [filter, reviewIsEmpty]);
132
154
  useEffect(() => {
133
155
  document.querySelector("[data-bookmark-list]")?.toggleAttribute("data-ready", true);
134
156
  const linked = linkedCard();
@@ -148,6 +170,7 @@ export function BookmarkControls({ entries }) {
148
170
  const onClick = (event) => {
149
171
  if (!(event.target instanceof Element))
150
172
  return;
173
+ copyFrom(event.target.closest("[data-copy]"));
151
174
  const card = event.target.closest("[data-done-toggle]")?.closest("[data-bookmark-id]");
152
175
  if (card !== null && card !== undefined)
153
176
  toggleDone(card);
@@ -181,6 +204,9 @@ export function BookmarkControls({ entries }) {
181
204
  });
182
205
  break;
183
206
  }
207
+ case "c":
208
+ copyFrom(current?.querySelector("[data-copy]"));
209
+ break;
184
210
  case "o": {
185
211
  const url = current?.dataset["url"];
186
212
  if (url !== undefined)
@@ -203,7 +229,7 @@ export function BookmarkControls({ entries }) {
203
229
  };
204
230
  }, []);
205
231
  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"] })] })] })] }));
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"] })] })] })] }));
207
233
  }
208
234
  function Status({ view, searching, visible, reviewed, reviewSize, inbox, }) {
209
235
  if (searching)
@@ -221,6 +247,6 @@ function Status({ view, searching, visible, reviewed, reviewSize, inbox, }) {
221
247
  return "Nothing marked done yet.";
222
248
  return plural(visible, "bookmark");
223
249
  }
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 }));
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] }));
226
252
  }
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ 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);
@@ -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 }}>
@@ -18,11 +18,25 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
18
18
  design: "design";
19
19
  other: "other";
20
20
  }>;
21
+ shelf_life: z.ZodEnum<{
22
+ news: "news";
23
+ evergreen: "evergreen";
24
+ }>;
21
25
  tldr: z.ZodString;
22
26
  summary: z.ZodString;
23
27
  text: z.ZodString;
24
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>>;
34
+ media: z.ZodDefault<z.ZodArray<z.ZodObject<{
35
+ url: z.ZodURL;
36
+ alt: z.ZodOptional<z.ZodString>;
37
+ }, z.core.$strict>>>;
25
38
  }, z.core.$strict>, z.ZodTransform<{
39
+ shelfLife: "news" | "evergreen";
26
40
  postedAt: Date;
27
41
  savedAt: Date;
28
42
  keyPoints: string[];
@@ -36,6 +50,15 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
36
50
  tldr: string;
37
51
  summary: string;
38
52
  text: string;
53
+ action: string;
54
+ links: {
55
+ title: string;
56
+ url: string;
57
+ }[];
58
+ media: {
59
+ url: string;
60
+ alt?: string | undefined;
61
+ }[];
39
62
  }, {
40
63
  id: string;
41
64
  url: string;
@@ -46,10 +69,20 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
46
69
  posted_at: string;
47
70
  saved_at: string;
48
71
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
72
+ shelf_life: "news" | "evergreen";
49
73
  tldr: string;
50
74
  summary: string;
51
75
  text: string;
52
76
  key_points: string[];
77
+ action: string;
78
+ links: {
79
+ title: string;
80
+ url: string;
81
+ }[];
82
+ media: {
83
+ url: string;
84
+ alt?: string | undefined;
85
+ }[];
53
86
  }>>;
54
87
  export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
55
88
  id: z.ZodString;
@@ -69,11 +102,25 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
69
102
  design: "design";
70
103
  other: "other";
71
104
  }>;
105
+ shelf_life: z.ZodEnum<{
106
+ news: "news";
107
+ evergreen: "evergreen";
108
+ }>;
72
109
  tldr: z.ZodString;
73
110
  summary: z.ZodString;
74
111
  text: z.ZodString;
75
112
  key_points: z.ZodArray<z.ZodString>;
113
+ action: z.ZodString;
114
+ links: z.ZodArray<z.ZodObject<{
115
+ title: z.ZodString;
116
+ url: z.ZodURL;
117
+ }, z.core.$strict>>;
118
+ media: z.ZodDefault<z.ZodArray<z.ZodObject<{
119
+ url: z.ZodURL;
120
+ alt: z.ZodOptional<z.ZodString>;
121
+ }, z.core.$strict>>>;
76
122
  }, z.core.$strict>, z.ZodTransform<{
123
+ shelfLife: "news" | "evergreen";
77
124
  postedAt: Date;
78
125
  savedAt: Date;
79
126
  keyPoints: string[];
@@ -87,6 +134,15 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
87
134
  tldr: string;
88
135
  summary: string;
89
136
  text: string;
137
+ action: string;
138
+ links: {
139
+ title: string;
140
+ url: string;
141
+ }[];
142
+ media: {
143
+ url: string;
144
+ alt?: string | undefined;
145
+ }[];
90
146
  }, {
91
147
  id: string;
92
148
  url: string;
@@ -97,10 +153,20 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
97
153
  posted_at: string;
98
154
  saved_at: string;
99
155
  topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
156
+ shelf_life: "news" | "evergreen";
100
157
  tldr: string;
101
158
  summary: string;
102
159
  text: string;
103
160
  key_points: string[];
161
+ action: string;
162
+ links: {
163
+ title: string;
164
+ url: string;
165
+ }[];
166
+ media: {
167
+ url: string;
168
+ alt?: string | undefined;
169
+ }[];
104
170
  }>>>;
105
171
  export declare const stateFileSchema: z.ZodArray<z.ZodString>;
106
172
  export type BookmarkFileEntry = z.input<typeof bookmarkSchema>;
@@ -3,8 +3,10 @@ import { TOPICS } from "./topics.js";
3
3
  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
+ const X_MEDIA_HOSTNAME = /^pbs\.twimg\.com$/;
6
7
  const nonEmpty = z.string().trim().min(1);
7
8
  const instant = z.iso.datetime({ offset: true });
9
+ const SHELF_LIVES = ["news", "evergreen"];
8
10
  export const bookmarkSchema = z
9
11
  .strictObject({
10
12
  id: nonEmpty,
@@ -20,13 +22,20 @@ export const bookmarkSchema = z
20
22
  posted_at: z.union([z.iso.date(), instant]),
21
23
  saved_at: instant,
22
24
  topic: z.enum(TOPICS),
25
+ shelf_life: z.enum(SHELF_LIVES),
23
26
  tldr: nonEmpty,
24
27
  summary: nonEmpty,
25
28
  text: z.string().trim(),
26
29
  key_points: z.array(nonEmpty),
30
+ action: z.string().trim(),
31
+ links: z.array(z.strictObject({ title: nonEmpty, url: z.url({ protocol: /^https$/ }) })),
32
+ media: z
33
+ .array(z.strictObject({ url: z.url({ protocol: /^https$/, hostname: X_MEDIA_HOSTNAME }), alt: nonEmpty.optional() }))
34
+ .default([]),
27
35
  })
28
- .transform(({ posted_at, saved_at, key_points, ...rest }) => ({
36
+ .transform(({ posted_at, saved_at, shelf_life, key_points, ...rest }) => ({
29
37
  ...rest,
38
+ shelfLife: shelf_life,
30
39
  postedAt: new Date(posted_at),
31
40
  savedAt: new Date(saved_at),
32
41
  keyPoints: key_points,
@@ -0,0 +1,2 @@
1
+ import type { Bookmark } from "./bookmarks.ts";
2
+ export declare function copyText({ url, author, postedAt, tldr, summary, text, media, keyPoints, action, links, }: Bookmark): string;
@@ -0,0 +1,24 @@
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, media, 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
+ list("Images", media.map((image) => (image.alt === undefined ? image.url : `${image.alt}: ${image.url}`))),
17
+ `Summary: ${summary}`,
18
+ list("Key points", keyPoints),
19
+ action === "" ? "" : `Try: ${action}`,
20
+ list("Links", links.map((link) => `${link.title}: ${link.url}`)),
21
+ ]
22
+ .filter((section) => section !== "")
23
+ .join("\n\n");
24
+ }
package/dist/lib/feed.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isTopic } from "./filter.js";
2
+ import { imagesWithAlt } from "./media.js";
2
3
  import { TOPIC_LABELS } from "./topics.js";
3
4
  const LOCAL_ORIGIN = "http://localhost:4321";
4
5
  export function feedOrigin(site) {
@@ -25,11 +26,17 @@ export function topicFromParam(value) {
25
26
  return value;
26
27
  }
27
28
  function feedItem(bookmark, page) {
28
- const { author, tldr, summary, text, keyPoints, topic, url, savedAt, id } = bookmark;
29
+ const { author, tldr, summary, text, keyPoints, action, links, topic, url, savedAt, id } = bookmark;
29
30
  const link = new URL(page.href);
30
31
  link.hash = `b-${id}`;
31
32
  const points = keyPoints.map((point) => `<li>${escapeHtml(point)}</li>`).join("");
32
33
  const quote = text === "" ? "" : `<blockquote><p>${escapeHtml(text)}</p></blockquote>`;
34
+ const images = imagesWithAlt(bookmark)
35
+ .map((image) => `<p><img src="${escapeHtml(image.url)}" alt="${escapeHtml(image.alt)}"></p>`)
36
+ .join("");
37
+ const linkItems = links
38
+ .map((outbound) => `<li><a href="${escapeHtml(outbound.url)}">${escapeHtml(outbound.title)}</a></li>`)
39
+ .join("");
33
40
  return {
34
41
  title: tldr,
35
42
  link: link.href,
@@ -37,8 +44,11 @@ function feedItem(bookmark, page) {
37
44
  content: [
38
45
  `<p>${escapeHtml(author.name)} (@${escapeHtml(author.handle)})</p>`,
39
46
  `<p>${escapeHtml(summary)}</p>`,
47
+ action === "" ? "" : `<p>Try: ${escapeHtml(action)}</p>`,
40
48
  points === "" ? "" : `<ul>${points}</ul>`,
49
+ linkItems === "" ? "" : `<ul>${linkItems}</ul>`,
41
50
  quote,
51
+ images,
42
52
  `<p><a href="${escapeHtml(url)}">Open on X</a></p>`,
43
53
  ]
44
54
  .filter((part) => part !== "")
@@ -6,6 +6,10 @@ export type Filter = {
6
6
  topic: Topic | null;
7
7
  view: View;
8
8
  };
9
+ type Defaults = {
10
+ reviewIsEmpty?: boolean;
11
+ };
9
12
  export declare function isTopic(value: string | null | undefined): value is Topic;
10
- export declare function parseFilter(search: string): Filter;
11
- export declare function filterSearch({ query, topic, view }: Filter): string;
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,30 +1,32 @@
1
1
  import { TOPICS } from "./topics.js";
2
2
  export const VIEWS = ["today", "inbox", "done", "all"];
3
- const DEFAULT_VIEW = "today";
3
+ function defaultView({ reviewIsEmpty = false }) {
4
+ return reviewIsEmpty ? "inbox" : "today";
5
+ }
4
6
  export function isTopic(value) {
5
7
  return TOPICS.some((topic) => topic === value);
6
8
  }
7
9
  function isView(value) {
8
10
  return VIEWS.some((view) => view === value);
9
11
  }
10
- export function parseFilter(search) {
12
+ export function parseFilter(search, defaults = {}) {
11
13
  const params = new URLSearchParams(search);
12
14
  const topic = params.get("topic");
13
15
  const view = params.get("view");
14
16
  return {
15
17
  query: params.get("q") ?? "",
16
18
  topic: isTopic(topic) ? topic : null,
17
- view: isView(view) ? view : DEFAULT_VIEW,
19
+ view: isView(view) ? view : defaultView(defaults),
18
20
  };
19
21
  }
20
- export function filterSearch({ query, topic, view }) {
22
+ export function filterSearch({ query, topic, view }, defaults = {}) {
21
23
  const params = new URLSearchParams();
22
24
  const trimmed = query.trim();
23
25
  if (trimmed !== "")
24
26
  params.set("q", trimmed);
25
27
  if (topic !== null)
26
28
  params.set("topic", topic);
27
- if (view !== DEFAULT_VIEW)
29
+ if (view !== defaultView(defaults))
28
30
  params.set("view", view);
29
31
  const search = params.toString();
30
32
  return search === "" ? "" : `?${search}`;
@@ -1,4 +1,5 @@
1
1
  export declare const bookmarks: {
2
+ shelfLife: "news" | "evergreen";
2
3
  postedAt: Date;
3
4
  savedAt: Date;
4
5
  keyPoints: string[];
@@ -12,4 +13,13 @@ export declare const bookmarks: {
12
13
  tldr: string;
13
14
  summary: string;
14
15
  text: string;
16
+ action: string;
17
+ links: {
18
+ title: string;
19
+ url: string;
20
+ }[];
21
+ media: {
22
+ url: string;
23
+ alt?: string | undefined;
24
+ }[];
15
25
  }[];
@@ -0,0 +1,5 @@
1
+ import type { Bookmark } from "./bookmarks.ts";
2
+ export declare function imagesWithAlt({ author, media }: Bookmark): {
3
+ url: string;
4
+ alt: string;
5
+ }[];
@@ -0,0 +1,7 @@
1
+ export function imagesWithAlt({ author, media }) {
2
+ const position = (index) => (media.length === 1 ? "" : ` ${index + 1} of ${media.length}`);
3
+ return media.map(({ url, alt }, index) => ({
4
+ url,
5
+ alt: alt ?? `Image${position(index)} from the post by @${author.handle}`,
6
+ }));
7
+ }
@@ -6,6 +6,7 @@ export type ReviewPick = {
6
6
  type Saved = {
7
7
  id: string;
8
8
  savedAt: Date;
9
+ shelfLife: "news" | "evergreen";
9
10
  };
10
11
  export declare function localDay(date: Date, timeZone?: string): string;
11
12
  export declare function dailyReview({ bookmarks, done, today, timeZone, }: {
@@ -5,6 +5,7 @@ const REVIEW_AGES = new Map([
5
5
  [30, "Saved a month ago"],
6
6
  [90, "Saved 3 months ago"],
7
7
  ]);
8
+ const NEWS_AGES = new Set([1, 7]);
8
9
  const BACKLOG_REASON = "From your backlog";
9
10
  const DAY_MS = 86_400_000;
10
11
  export function localDay(date, timeZone) {
@@ -30,10 +31,16 @@ function hash(text) {
30
31
  export function dailyReview({ bookmarks, done, today, timeZone, }) {
31
32
  const candidates = bookmarks
32
33
  .filter(({ id }) => !done.has(id))
33
- .map(({ id, savedAt }) => {
34
+ .map(({ id, savedAt, shelfLife }) => {
34
35
  const saved = localDay(savedAt, timeZone);
35
36
  const age = daysBetween(saved, today);
36
- return { id, age, reason: REVIEW_AGES.get(age) ?? anniversaryReason(saved, today) };
37
+ const news = shelfLife === "news";
38
+ const reason = news
39
+ ? NEWS_AGES.has(age)
40
+ ? REVIEW_AGES.get(age)
41
+ : undefined
42
+ : (REVIEW_AGES.get(age) ?? anniversaryReason(saved, today));
43
+ return { id, age, news, reason };
37
44
  })
38
45
  .filter(({ age }) => age >= 1);
39
46
  const due = candidates
@@ -42,7 +49,7 @@ export function dailyReview({ bookmarks, done, today, timeZone, }) {
42
49
  .slice(0, REVIEW_SIZE)
43
50
  .map(({ id, reason }) => ({ id, reason }));
44
51
  const backlog = candidates
45
- .filter(({ reason }) => reason === undefined)
52
+ .filter(({ news, reason }) => !news && reason === undefined)
46
53
  .toSorted((a, b) => hash(`${today}:${a.id}`) - hash(`${today}:${b.id}`))
47
54
  .slice(0, REVIEW_SIZE - due.length)
48
55
  .map(({ id }) => ({ id, reason: BACKLOG_REASON }));
@@ -5,6 +5,7 @@ export type SearchEntry = {
5
5
  id: string;
6
6
  topic: Topic;
7
7
  savedAt: Date;
8
+ shelfLife: Bookmark["shelfLife"];
8
9
  text: string;
9
10
  };
10
11
  export declare function toSearchEntry(bookmark: Bookmark): SearchEntry;
@@ -3,12 +3,23 @@ function normalize(text) {
3
3
  return text.normalize("NFKC").toLowerCase();
4
4
  }
5
5
  export function toSearchEntry(bookmark) {
6
- const { id, topic, savedAt, author, tldr, summary, text, keyPoints } = bookmark;
6
+ const { id, topic, savedAt, shelfLife, author, tldr, summary, text, keyPoints, action, links } = bookmark;
7
7
  return {
8
8
  id,
9
9
  topic,
10
10
  savedAt,
11
- text: normalize([author.name, `@${author.handle}`, TOPIC_LABELS[topic], tldr, summary, text, ...keyPoints].join("\n")),
11
+ shelfLife,
12
+ text: normalize([
13
+ author.name,
14
+ `@${author.handle}`,
15
+ TOPIC_LABELS[topic],
16
+ tldr,
17
+ summary,
18
+ text,
19
+ ...keyPoints,
20
+ action,
21
+ ...links.map(({ title }) => title),
22
+ ].join("\n")),
12
23
  };
13
24
  }
14
25
  const IN_VIEW = {
@@ -47,11 +47,11 @@ const designFeedHref = `${base}rss/design.xml`;
47
47
 
48
48
  <footer class="flex flex-col gap-2 border-t py-8 text-sm text-muted-foreground">
49
49
  <p>
50
- <a class="underline decoration-border underline-offset-4 hover:decoration-current" href={feedHref}>
50
+ <a class="underline decoration-border underline-offset-4 hover:decoration-brand" href={feedHref}>
51
51
  Follow new bookmarks
52
52
  </a>{" "}
53
53
  in a feed reader. Each topic has its own feed, such as{" "}
54
- <a class="underline decoration-border underline-offset-4 hover:decoration-current" href={designFeedHref}>
54
+ <a class="underline decoration-border underline-offset-4 hover:decoration-brand" href={designFeedHref}>
55
55
  Design
56
56
  </a>
57
57
  .
@@ -0,0 +1,10 @@
1
+ export declare const getStaticPaths: () => {
2
+ params: {
3
+ size: string;
4
+ };
5
+ }[];
6
+ export declare const GET: ({ params }: {
7
+ params: {
8
+ size?: string;
9
+ };
10
+ }) => Response;
@@ -0,0 +1,10 @@
1
+ import icon180 from "../assets/icon-180.png?inline";
2
+ import icon192 from "../assets/icon-192.png?inline";
3
+ import icon512 from "../assets/icon-512.png?inline";
4
+ const ICONS = { "180": icon180, "192": icon192, "512": icon512 };
5
+ export const getStaticPaths = () => Object.keys(ICONS).map((size) => ({ params: { size } }));
6
+ export const GET = ({ params }) => {
7
+ const dataUrl = ICONS[params.size ?? ""] ?? "";
8
+ const bytes = Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64");
9
+ return new Response(bytes, { headers: { "Content-Type": "image/png" } });
10
+ };
@@ -0,0 +1 @@
1
+ export declare const GET: () => Response;
@@ -0,0 +1,14 @@
1
+ const manifest = {
2
+ name: "Grok Digest",
3
+ short_name: "Digest",
4
+ start_url: "./",
5
+ scope: "./",
6
+ display: "standalone",
7
+ background_color: "#f9f6f1",
8
+ theme_color: "#f9f6f1",
9
+ icons: [
10
+ { src: "icon-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
11
+ { src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
12
+ ],
13
+ };
14
+ export const GET = () => new Response(JSON.stringify(manifest), { headers: { "Content-Type": "application/manifest+json" } });
@@ -30,6 +30,14 @@
30
30
  --color-card: var(--card);
31
31
  --color-foreground: var(--foreground);
32
32
  --color-background: var(--background);
33
+ --color-brand: var(--brand);
34
+ --color-brand-ink: var(--brand-ink);
35
+ --color-brand-soft: var(--brand-soft);
36
+ --color-done: var(--done);
37
+ --color-done-foreground: var(--done-foreground);
38
+ --color-topic: var(--topic);
39
+ --color-topic-soft: var(--topic-soft);
40
+ --color-topic-foreground: var(--topic-foreground);
33
41
  --radius-sm: calc(var(--radius) * 0.6);
34
42
  --radius-md: calc(var(--radius) * 0.8);
35
43
  --radius-lg: var(--radius);
@@ -57,8 +65,13 @@
57
65
  --destructive: oklch(0.577 0.245 27.325);
58
66
  --border: oklch(0.9 0.01 80);
59
67
  --input: oklch(0.88 0.01 80);
60
- --ring: oklch(0.65 0.02 70);
68
+ --ring: oklch(0.76 0.15 70);
61
69
  --radius: 0.625rem;
70
+ --brand: oklch(0.76 0.15 70);
71
+ --brand-ink: oklch(0.5 0.11 60);
72
+ --brand-soft: oklch(0.955 0.04 85);
73
+ --done: oklch(0.52 0.12 155);
74
+ --done-foreground: oklch(0.985 0.004 85);
62
75
  }
63
76
 
64
77
  .dark {
@@ -79,7 +92,48 @@
79
92
  --destructive: oklch(0.704 0.191 22.216);
80
93
  --border: oklch(1 0 0 / 9%);
81
94
  --input: oklch(1 0 0 / 14%);
82
- --ring: oklch(0.6 0.015 70);
95
+ --ring: oklch(0.78 0.14 72);
96
+ --brand: oklch(0.78 0.14 72);
97
+ --brand-ink: oklch(0.82 0.12 78);
98
+ --brand-soft: oklch(0.28 0.04 72);
99
+ --done: oklch(0.76 0.13 155);
100
+ --done-foreground: oklch(0.2 0.008 60);
101
+ }
102
+
103
+ [data-topic] {
104
+ --topic-chroma: 1;
105
+ --topic: oklch(0.5 calc(0.11 * var(--topic-chroma)) var(--topic-hue));
106
+ --topic-soft: oklch(0.95 calc(0.025 * var(--topic-chroma)) var(--topic-hue));
107
+ --topic-foreground: oklch(0.985 0.004 85);
108
+ }
109
+
110
+ .dark [data-topic] {
111
+ --topic: oklch(0.8 calc(0.1 * var(--topic-chroma)) var(--topic-hue));
112
+ --topic-soft: oklch(0.3 calc(0.045 * var(--topic-chroma)) var(--topic-hue));
113
+ --topic-foreground: oklch(0.2 0.008 60);
114
+ }
115
+
116
+ [data-topic="ai-agents"] {
117
+ --topic-hue: 295;
118
+ }
119
+ [data-topic="models-releases"] {
120
+ --topic-hue: 250;
121
+ }
122
+ [data-topic="dev-tools"] {
123
+ --topic-hue: 195;
124
+ }
125
+ [data-topic="startups-business"] {
126
+ --topic-hue: 120;
127
+ }
128
+ [data-topic="japan-tech"] {
129
+ --topic-hue: 20;
130
+ }
131
+ [data-topic="design"] {
132
+ --topic-hue: 340;
133
+ }
134
+ [data-topic="other"] {
135
+ --topic-hue: 70;
136
+ --topic-chroma: 0.15;
83
137
  }
84
138
 
85
139
  @layer base {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-digest",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Astro integration that renders a Grok Digest site: X bookmarks summarized by Grok, searchable, sorted by topic, with RSS feeds.",
5
5
  "keywords": [
6
6
  "astro",
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "files": [
22
22
  "dist",
23
+ "BOT.md",
23
24
  "LICENSE-THIRD-PARTY"
24
25
  ],
25
26
  "type": "module",