grok-digest 0.2.0 → 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.
- package/BOT.md +181 -0
- package/README.md +7 -5
- package/dist/assets/app-icon.svg +9 -0
- package/dist/assets/favicon.svg +1 -1
- package/dist/assets/icon-180.png +0 -0
- package/dist/assets/icon-192.png +0 -0
- package/dist/assets/icon-512.png +0 -0
- package/dist/components/BookmarkCard.astro +62 -14
- package/dist/components/BookmarkControls.js +35 -9
- package/dist/index.js +2 -0
- package/dist/layouts/Layout.astro +7 -1
- package/dist/lib/bookmarks.d.ts +42 -0
- package/dist/lib/bookmarks.js +6 -1
- package/dist/lib/copy.d.ts +2 -0
- package/dist/lib/copy.js +23 -0
- package/dist/lib/feed.js +6 -1
- package/dist/lib/filter.d.ts +6 -2
- package/dist/lib/filter.js +7 -5
- package/dist/lib/load-bookmarks.d.ts +6 -0
- package/dist/lib/review.d.ts +1 -0
- package/dist/lib/review.js +10 -3
- package/dist/lib/search.d.ts +1 -0
- package/dist/lib/search.js +13 -2
- package/dist/pages/index.astro +2 -2
- package/dist/routes/icon-[size].png.d.ts +10 -0
- package/dist/routes/icon-[size].png.js +10 -0
- package/dist/routes/manifest.webmanifest.d.ts +1 -0
- package/dist/routes/manifest.webmanifest.js +14 -0
- package/dist/styles/global.css +56 -2
- package/package.json +2 -1
package/BOT.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
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` and `links` may be empty arrays.
|
|
68
|
+
|
|
69
|
+
### id
|
|
70
|
+
|
|
71
|
+
The post's status id as a string, the number at the end of its URL. It must be unique in the file.
|
|
72
|
+
|
|
73
|
+
### url
|
|
74
|
+
|
|
75
|
+
The post's `https` link on `x.com` or `twitter.com`. A `www.` or `mobile.` prefix is allowed.
|
|
76
|
+
|
|
77
|
+
### author
|
|
78
|
+
|
|
79
|
+
`name` is the display name. `handle` is the X handle without `@`: 1 to 15 letters, digits, or underscores.
|
|
80
|
+
|
|
81
|
+
### posted_at
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
### saved_at
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
### topic
|
|
94
|
+
|
|
95
|
+
One of these ids:
|
|
96
|
+
|
|
97
|
+
| Topic id | Label on the page |
|
|
98
|
+
| ------------------- | ------------------- |
|
|
99
|
+
| `ai-agents` | AI agents |
|
|
100
|
+
| `models-releases` | Models & releases |
|
|
101
|
+
| `dev-tools` | Dev tools |
|
|
102
|
+
| `startups-business` | Startups & business |
|
|
103
|
+
| `japan-tech` | Japan tech |
|
|
104
|
+
| `design` | Design |
|
|
105
|
+
| `other` | Other |
|
|
106
|
+
|
|
107
|
+
### shelf_life
|
|
108
|
+
|
|
109
|
+
`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`.
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
113
|
+
### tldr
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
- Say the concrete thing: "Bun 1.4 ships a built-in Postgres client", not "Interesting news about Bun".
|
|
118
|
+
- Plain text: no emoji, hashtags, @handles, quotes, or final period.
|
|
119
|
+
- Don't start with the author's name; the card shows it.
|
|
120
|
+
- Use the language of `summary`. Don't repeat `summary` or its first sentence.
|
|
121
|
+
|
|
122
|
+
### summary
|
|
123
|
+
|
|
124
|
+
One or two sentences on what the post says and why it matters. For a thread, summarize the whole thread.
|
|
125
|
+
|
|
126
|
+
### text
|
|
127
|
+
|
|
128
|
+
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. Write `""` when the post has no text.
|
|
129
|
+
|
|
130
|
+
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.
|
|
131
|
+
|
|
132
|
+
### key_points
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
### action
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
- Start with a verb, and be specific enough to do without opening the post.
|
|
141
|
+
- When the post includes a prompt, command, or snippet, base the action on it.
|
|
142
|
+
- At most about 25 words, in the language of `summary`.
|
|
143
|
+
- Write `""` when there is honestly nothing to do, such as news, an opinion, or a joke. Never invent busywork.
|
|
144
|
+
|
|
145
|
+
### links
|
|
146
|
+
|
|
147
|
+
Every link in the post and its thread, followed to where it really lands. Expand `t.co` links.
|
|
148
|
+
|
|
149
|
+
- `url`: the final `https` address without tracking parameters such as `utm_*`. Skip links that are not `https`.
|
|
150
|
+
- `title`: the page's own title, cut to about 8 words, or a plain description such as `GitHub repo: example/pstack`.
|
|
151
|
+
- Leave out links to the post itself, to the author's profile, and to the post's images or videos.
|
|
152
|
+
- Write `[]` when there are none.
|
|
153
|
+
|
|
154
|
+
## Change or remove a bookmark
|
|
155
|
+
|
|
156
|
+
To change one, edit its fields in place. Keep `id` and `saved_at`. Commit with `Update bookmark (@handle)`.
|
|
157
|
+
|
|
158
|
+
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)`.
|
|
159
|
+
|
|
160
|
+
Run `bun run validate` before every commit.
|
|
161
|
+
|
|
162
|
+
## When validation fails
|
|
163
|
+
|
|
164
|
+
`bun run validate` names the file, the problem, and where it is:
|
|
165
|
+
|
|
166
|
+
```text
|
|
167
|
+
data/bookmarks.json is invalid:
|
|
168
|
+
✖ Invalid option: expected one of "news"|"evergreen"
|
|
169
|
+
→ at [3].shelf_life
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`[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.
|
|
173
|
+
|
|
174
|
+
## A daily digest (optional routine)
|
|
175
|
+
|
|
176
|
+
If the owner wants a nudge to come back, run this every morning:
|
|
177
|
+
|
|
178
|
+
1. Read `data/bookmarks.json` from the site repository.
|
|
179
|
+
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.
|
|
180
|
+
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>`.
|
|
181
|
+
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
|
|
@@ -63,11 +65,11 @@ import { SCHEMA_VERSION, bookmarkSchema, parseBookmarks, TOPICS } from "grok-dig
|
|
|
63
65
|
|
|
64
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.
|
|
65
67
|
|
|
66
|
-
| File | Version 1 shape
|
|
67
|
-
| --------------------- |
|
|
68
|
-
| `data/bookmarks.json` | Array of bookmark objects.
|
|
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>
|
package/dist/assets/favicon.svg
CHANGED
|
@@ -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"/><
|
|
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,10 @@
|
|
|
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";
|
|
7
8
|
import { TOPIC_LABELS } from "../lib/topics";
|
|
8
9
|
|
|
9
10
|
type Props = {
|
|
@@ -11,15 +12,17 @@ type Props = {
|
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
const { bookmark } = Astro.props;
|
|
14
|
-
const { id, url, author, postedAt, savedAt, topic, tldr, summary, text, keyPoints } =
|
|
15
|
+
const { id, url, author, postedAt, savedAt, topic, shelfLife, tldr, summary, text, keyPoints, action, links } =
|
|
16
|
+
bookmark;
|
|
15
17
|
|
|
16
18
|
const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
|
|
17
19
|
const isoDay = (date: Date) => date.toISOString().slice(0, 10);
|
|
18
20
|
const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
|
|
21
|
+
const hostOf = (link: string) => new URL(link).hostname.replace(/^www\./, "");
|
|
19
22
|
---
|
|
20
23
|
|
|
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)]">
|
|
24
|
+
<li id={`b-${id}`} data-bookmark-id={id} data-url={url} data-topic={topic} class="group/bookmark scroll-mt-6">
|
|
25
|
+
<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
26
|
<details open class="group/details transition-opacity group-data-[done]/bookmark:opacity-55">
|
|
24
27
|
<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
28
|
<span class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 text-sm">
|
|
@@ -36,11 +39,7 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
|
|
|
36
39
|
)}
|
|
37
40
|
</span>
|
|
38
41
|
</span>
|
|
39
|
-
<span
|
|
40
|
-
data-review-reason
|
|
41
|
-
class="text-xs font-medium tracking-wide text-muted-foreground uppercase"
|
|
42
|
-
hidden
|
|
43
|
-
></span>
|
|
42
|
+
<span data-review-reason class="text-xs font-medium tracking-wide text-brand-ink uppercase" hidden></span>
|
|
44
43
|
<span class="flex items-start justify-between gap-3">
|
|
45
44
|
<h2 class="font-heading text-xl leading-snug font-semibold text-balance text-foreground sm:text-[1.375rem]">
|
|
46
45
|
{tldr}
|
|
@@ -50,6 +49,14 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
|
|
|
50
49
|
aria-hidden="true"
|
|
51
50
|
/>
|
|
52
51
|
</span>
|
|
52
|
+
{action !== "" && (
|
|
53
|
+
<span class="flex items-start gap-2 rounded-lg bg-brand-soft px-3 py-2 text-sm leading-relaxed text-foreground">
|
|
54
|
+
<ZapIcon className="mt-1 size-3.5 shrink-0 fill-brand text-brand-ink" aria-hidden="true" />
|
|
55
|
+
<span class="text-pretty">
|
|
56
|
+
<span class="font-medium text-brand-ink">Try:</span> {action}
|
|
57
|
+
</span>
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
53
60
|
</summary>
|
|
54
61
|
|
|
55
62
|
<div class="flex flex-col gap-4 px-(--card-spacing) pb-4">
|
|
@@ -61,6 +68,26 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
|
|
|
61
68
|
))}
|
|
62
69
|
</ul>
|
|
63
70
|
)}
|
|
71
|
+
{links.length > 0 && (
|
|
72
|
+
<ul class="flex flex-col gap-1.5 text-sm" aria-label="Links in the post">
|
|
73
|
+
{links.map((link) => (
|
|
74
|
+
<li>
|
|
75
|
+
<a
|
|
76
|
+
href={link.url}
|
|
77
|
+
target="_blank"
|
|
78
|
+
rel="noopener noreferrer"
|
|
79
|
+
class="group/link inline-flex items-baseline gap-2 rounded-sm focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
|
80
|
+
>
|
|
81
|
+
<LinkIcon className="size-3.5 shrink-0 translate-y-0.5 text-muted-foreground" aria-hidden="true" />
|
|
82
|
+
<span class="text-pretty text-foreground underline decoration-border underline-offset-4 transition-colors group-hover/link:decoration-brand">
|
|
83
|
+
{link.title}
|
|
84
|
+
</span>
|
|
85
|
+
<span class="shrink-0 text-xs text-muted-foreground">{hostOf(link.url)}</span>
|
|
86
|
+
</a>
|
|
87
|
+
</li>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
)}
|
|
64
91
|
{text !== "" && (
|
|
65
92
|
<figure class="border-t border-border pt-4">
|
|
66
93
|
<figcaption class="mb-2 text-xs font-medium text-muted-foreground">Original post</figcaption>
|
|
@@ -72,9 +99,30 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
|
|
|
72
99
|
</div>
|
|
73
100
|
</details>
|
|
74
101
|
|
|
75
|
-
<div class="flex items-center justify-between gap-3 px-(--card-spacing) pb-(--card-spacing)">
|
|
76
|
-
<
|
|
77
|
-
|
|
102
|
+
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-(--card-spacing) pb-(--card-spacing)">
|
|
103
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
104
|
+
<Badge className="bg-topic-soft text-topic">{TOPIC_LABELS[topic]}</Badge>
|
|
105
|
+
{shelfLife === "news" && (
|
|
106
|
+
<Badge variant="outline" className="border-brand/60 text-brand-ink">
|
|
107
|
+
News
|
|
108
|
+
</Badge>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
<div class="ml-auto flex items-center gap-3">
|
|
112
|
+
<Button
|
|
113
|
+
type="button"
|
|
114
|
+
variant="ghost"
|
|
115
|
+
size="sm"
|
|
116
|
+
data-copy={copyText(bookmark)}
|
|
117
|
+
title="Copy for an AI assistant (c)"
|
|
118
|
+
className="rounded-full"
|
|
119
|
+
>
|
|
120
|
+
<CopyIcon className="group-data-[copied]/button:hidden" aria-hidden="true" />
|
|
121
|
+
<CheckIcon className="hidden group-data-[copied]/button:block" aria-hidden="true" />
|
|
122
|
+
<span data-copy-label aria-live="polite">
|
|
123
|
+
Copy
|
|
124
|
+
</span>
|
|
125
|
+
</Button>
|
|
78
126
|
<Button
|
|
79
127
|
type="button"
|
|
80
128
|
variant="outline"
|
|
@@ -82,7 +130,7 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
|
|
|
82
130
|
data-done-toggle
|
|
83
131
|
aria-pressed="false"
|
|
84
132
|
title="Mark done (e)"
|
|
85
|
-
className="rounded-full aria-pressed:border-
|
|
133
|
+
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
134
|
>
|
|
87
135
|
<CheckIcon aria-hidden="true" />
|
|
88
136
|
Done
|
|
@@ -91,7 +139,7 @@ const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
|
|
|
91
139
|
href={url}
|
|
92
140
|
target="_blank"
|
|
93
141
|
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-
|
|
142
|
+
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
143
|
>
|
|
96
144
|
Open on X<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
|
|
97
145
|
</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 (
|
|
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
|
|
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 }}>
|
package/dist/lib/bookmarks.d.ts
CHANGED
|
@@ -18,11 +18,21 @@ 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>>;
|
|
25
34
|
}, z.core.$strict>, z.ZodTransform<{
|
|
35
|
+
shelfLife: "news" | "evergreen";
|
|
26
36
|
postedAt: Date;
|
|
27
37
|
savedAt: Date;
|
|
28
38
|
keyPoints: string[];
|
|
@@ -36,6 +46,11 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
|
|
|
36
46
|
tldr: string;
|
|
37
47
|
summary: string;
|
|
38
48
|
text: string;
|
|
49
|
+
action: string;
|
|
50
|
+
links: {
|
|
51
|
+
title: string;
|
|
52
|
+
url: string;
|
|
53
|
+
}[];
|
|
39
54
|
}, {
|
|
40
55
|
id: string;
|
|
41
56
|
url: string;
|
|
@@ -46,10 +61,16 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
|
|
|
46
61
|
posted_at: string;
|
|
47
62
|
saved_at: string;
|
|
48
63
|
topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
|
|
64
|
+
shelf_life: "news" | "evergreen";
|
|
49
65
|
tldr: string;
|
|
50
66
|
summary: string;
|
|
51
67
|
text: string;
|
|
52
68
|
key_points: string[];
|
|
69
|
+
action: string;
|
|
70
|
+
links: {
|
|
71
|
+
title: string;
|
|
72
|
+
url: string;
|
|
73
|
+
}[];
|
|
53
74
|
}>>;
|
|
54
75
|
export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
|
|
55
76
|
id: z.ZodString;
|
|
@@ -69,11 +90,21 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
|
|
|
69
90
|
design: "design";
|
|
70
91
|
other: "other";
|
|
71
92
|
}>;
|
|
93
|
+
shelf_life: z.ZodEnum<{
|
|
94
|
+
news: "news";
|
|
95
|
+
evergreen: "evergreen";
|
|
96
|
+
}>;
|
|
72
97
|
tldr: z.ZodString;
|
|
73
98
|
summary: z.ZodString;
|
|
74
99
|
text: z.ZodString;
|
|
75
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>>;
|
|
76
106
|
}, z.core.$strict>, z.ZodTransform<{
|
|
107
|
+
shelfLife: "news" | "evergreen";
|
|
77
108
|
postedAt: Date;
|
|
78
109
|
savedAt: Date;
|
|
79
110
|
keyPoints: string[];
|
|
@@ -87,6 +118,11 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
|
|
|
87
118
|
tldr: string;
|
|
88
119
|
summary: string;
|
|
89
120
|
text: string;
|
|
121
|
+
action: string;
|
|
122
|
+
links: {
|
|
123
|
+
title: string;
|
|
124
|
+
url: string;
|
|
125
|
+
}[];
|
|
90
126
|
}, {
|
|
91
127
|
id: string;
|
|
92
128
|
url: string;
|
|
@@ -97,10 +133,16 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
|
|
|
97
133
|
posted_at: string;
|
|
98
134
|
saved_at: string;
|
|
99
135
|
topic: "ai-agents" | "models-releases" | "dev-tools" | "startups-business" | "japan-tech" | "design" | "other";
|
|
136
|
+
shelf_life: "news" | "evergreen";
|
|
100
137
|
tldr: string;
|
|
101
138
|
summary: string;
|
|
102
139
|
text: string;
|
|
103
140
|
key_points: string[];
|
|
141
|
+
action: string;
|
|
142
|
+
links: {
|
|
143
|
+
title: string;
|
|
144
|
+
url: string;
|
|
145
|
+
}[];
|
|
104
146
|
}>>>;
|
|
105
147
|
export declare const stateFileSchema: z.ZodArray<z.ZodString>;
|
|
106
148
|
export type BookmarkFileEntry = z.input<typeof bookmarkSchema>;
|
package/dist/lib/bookmarks.js
CHANGED
|
@@ -5,6 +5,7 @@ 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
7
|
const instant = z.iso.datetime({ offset: true });
|
|
8
|
+
const SHELF_LIVES = ["news", "evergreen"];
|
|
8
9
|
export const bookmarkSchema = z
|
|
9
10
|
.strictObject({
|
|
10
11
|
id: nonEmpty,
|
|
@@ -20,13 +21,17 @@ export const bookmarkSchema = z
|
|
|
20
21
|
posted_at: z.union([z.iso.date(), instant]),
|
|
21
22
|
saved_at: instant,
|
|
22
23
|
topic: z.enum(TOPICS),
|
|
24
|
+
shelf_life: z.enum(SHELF_LIVES),
|
|
23
25
|
tldr: nonEmpty,
|
|
24
26
|
summary: nonEmpty,
|
|
25
27
|
text: z.string().trim(),
|
|
26
28
|
key_points: z.array(nonEmpty),
|
|
29
|
+
action: z.string().trim(),
|
|
30
|
+
links: z.array(z.strictObject({ title: nonEmpty, url: z.url({ protocol: /^https$/ }) })),
|
|
27
31
|
})
|
|
28
|
-
.transform(({ posted_at, saved_at, key_points, ...rest }) => ({
|
|
32
|
+
.transform(({ posted_at, saved_at, shelf_life, key_points, ...rest }) => ({
|
|
29
33
|
...rest,
|
|
34
|
+
shelfLife: shelf_life,
|
|
30
35
|
postedAt: new Date(posted_at),
|
|
31
36
|
savedAt: new Date(saved_at),
|
|
32
37
|
keyPoints: key_points,
|
package/dist/lib/copy.js
ADDED
|
@@ -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,11 +25,14 @@ export function topicFromParam(value) {
|
|
|
25
25
|
return value;
|
|
26
26
|
}
|
|
27
27
|
function feedItem(bookmark, page) {
|
|
28
|
-
const { author, tldr, summary, text, keyPoints, topic, url, savedAt, 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
37
|
title: tldr,
|
|
35
38
|
link: link.href,
|
|
@@ -37,7 +40,9 @@ function feedItem(bookmark, page) {
|
|
|
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
|
]
|
package/dist/lib/filter.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/lib/filter.js
CHANGED
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
import { TOPICS } from "./topics.js";
|
|
2
2
|
export const VIEWS = ["today", "inbox", "done", "all"];
|
|
3
|
-
|
|
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 :
|
|
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 !==
|
|
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,9 @@ 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
|
+
}[];
|
|
15
21
|
}[];
|
package/dist/lib/review.d.ts
CHANGED
|
@@ -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, }: {
|
package/dist/lib/review.js
CHANGED
|
@@ -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
|
-
|
|
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 }));
|
package/dist/lib/search.d.ts
CHANGED
package/dist/lib/search.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
package/dist/pages/index.astro
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
+
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" } });
|
package/dist/styles/global.css
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|