grok-digest 0.3.0 → 0.3.2

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 CHANGED
@@ -54,17 +54,24 @@ bun run validate
54
54
  "shelf_life": "evergreen",
55
55
  "tldr": "Stale workaround comments spread because coding agents copy them",
56
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.",
57
+ "text": "ban and delete most if not all code comments!\n\nagents love copying what already exists. https://t.co/Xq7mPLe0Ab",
58
58
  "key_points": [
59
59
  "Agents imitate existing code, so stale workaround comments spread like weeds.",
60
60
  "Keep only comments that must exist, such as browser-quirk explanations."
61
61
  ],
62
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" }]
63
+ "links": [{ "title": "pstack: the /no-comments skill", "url": "https://github.com/example/pstack" }],
64
+ "media": [
65
+ {
66
+ "url": "https://pbs.twimg.com/media/G3xAmPLeAAAbCdE.jpg",
67
+ "alt": "A diff that deletes a workaround comment",
68
+ "link": "https://t.co/Xq7mPLe0Ab"
69
+ }
70
+ ]
64
71
  }
65
72
  ```
66
73
 
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.
74
+ 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.
68
75
 
69
76
  ### id
70
77
 
@@ -125,7 +132,7 @@ One or two sentences on what the post says and why it matters. For a thread, sum
125
132
 
126
133
  ### text
127
134
 
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.
135
+ 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.
129
136
 
130
137
  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
138
 
@@ -151,6 +158,19 @@ Every link in the post and its thread, followed to where it really lands. Expand
151
158
  - Leave out links to the post itself, to the author's profile, and to the post's images or videos.
152
159
  - Write `[]` when there are none.
153
160
 
161
+ ### media
162
+
163
+ The images in the post and its thread, in thread order, so the card can show each one with the post it came from. One object per photo:
164
+
165
+ - `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.
166
+ - `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 `""`.
167
+ - `link`: the short link that stands for the image in `text`, copied exactly as it appears there, such as `https://t.co/Xq7mPLe0Ab` or `pic.x.com/Xq7mPLe0Ab`. X gives all the photos of one post a single link, so photos from the same post share it. The card shows the images where that link is and hides the link. The build rejects a `link` that is not in `text`. Leave `link` out only when `text` has no link for the image; the card then shows it after the post.
168
+ - 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.
169
+ - Don't add link-preview cards or the author's profile picture.
170
+ - Write `[]` when the post has no images.
171
+
172
+ Images never go in `links`, and `text` keeps the post's exact words, including any `t.co` lines that stood for the images.
173
+
154
174
  ## Change or remove a bookmark
155
175
 
156
176
  To change one, edit its fields in place. Keep `id` and `saved_at`. Commit with `Update bookmark (@handle)`.
package/README.md CHANGED
@@ -63,7 +63,7 @@ The site needs no Astro config. The package depends on the Astro version it was
63
63
  import { SCHEMA_VERSION, bookmarkSchema, parseBookmarks, TOPICS } from "grok-digest/schema";
64
64
  ```
65
65
 
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.
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 `[]`.
67
67
 
68
68
  | File | Version 1 shape |
69
69
  | --------------------- | -------------------------------------------------------------------- |
@@ -5,6 +5,7 @@ import { Button } from "./ui/button";
5
5
  import { Card } from "./ui/card";
6
6
  import type { Bookmark } from "../lib/bookmarks";
7
7
  import { copyText } from "../lib/copy";
8
+ import { postParts } from "../lib/media";
8
9
  import { TOPIC_LABELS } from "../lib/topics";
9
10
 
10
11
  type Props = {
@@ -12,13 +13,13 @@ type Props = {
12
13
  };
13
14
 
14
15
  const { bookmark } = Astro.props;
15
- const { id, url, author, postedAt, savedAt, topic, shelfLife, tldr, summary, text, keyPoints, action, links } =
16
- bookmark;
16
+ const { id, url, author, postedAt, savedAt, topic, shelfLife, tldr, summary, keyPoints, action, links } = bookmark;
17
17
 
18
18
  const dateFormat = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeZone: "UTC" });
19
19
  const isoDay = (date: Date) => date.toISOString().slice(0, 10);
20
20
  const postedOtherDay = isoDay(postedAt) !== isoDay(savedAt);
21
21
  const hostOf = (link: string) => new URL(link).hostname.replace(/^www\./, "");
22
+ const post = postParts(bookmark);
22
23
  ---
23
24
 
24
25
  <li id={`b-${id}`} data-bookmark-id={id} data-url={url} data-topic={topic} class="group/bookmark scroll-mt-6">
@@ -88,11 +89,43 @@ const hostOf = (link: string) => new URL(link).hostname.replace(/^www\./, "");
88
89
  ))}
89
90
  </ul>
90
91
  )}
91
- {text !== "" && (
92
+ {post.length > 0 && (
92
93
  <figure class="border-t border-border pt-4">
93
94
  <figcaption class="mb-2 text-xs font-medium text-muted-foreground">Original post</figcaption>
94
- <blockquote class="text-sm leading-relaxed whitespace-pre-wrap text-pretty text-foreground/80">
95
- {text}
95
+ <blockquote class="flex flex-col gap-3 text-sm leading-relaxed text-pretty text-foreground/80">
96
+ {post.map((part) =>
97
+ part.kind === "text" ? (
98
+ <p class="whitespace-pre-wrap">{part.text}</p>
99
+ ) : (
100
+ <ul
101
+ class:list={["grid max-w-md gap-2", { "grid-cols-2": part.images.length > 1 }]}
102
+ aria-label="Images in the post"
103
+ >
104
+ {part.images.map((image, index) => (
105
+ <li
106
+ class:list={{
107
+ "col-span-2": index === 0 && part.images.length > 1 && part.images.length % 2 === 1,
108
+ }}
109
+ >
110
+ <a
111
+ href={image.url}
112
+ target="_blank"
113
+ rel="noopener noreferrer"
114
+ class="block overflow-hidden rounded-lg border border-border bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
115
+ >
116
+ <img
117
+ src={image.url}
118
+ alt={image.alt}
119
+ loading="lazy"
120
+ decoding="async"
121
+ class="aspect-video w-full object-cover"
122
+ />
123
+ </a>
124
+ </li>
125
+ ))}
126
+ </ul>
127
+ ),
128
+ )}
96
129
  </blockquote>
97
130
  </figure>
98
131
  )}
@@ -31,6 +31,11 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
31
31
  title: z.ZodString;
32
32
  url: z.ZodURL;
33
33
  }, z.core.$strict>>;
34
+ media: z.ZodDefault<z.ZodArray<z.ZodObject<{
35
+ url: z.ZodURL;
36
+ alt: z.ZodOptional<z.ZodString>;
37
+ link: z.ZodOptional<z.ZodString>;
38
+ }, z.core.$strict>>>;
34
39
  }, z.core.$strict>, z.ZodTransform<{
35
40
  shelfLife: "news" | "evergreen";
36
41
  postedAt: Date;
@@ -51,6 +56,11 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
51
56
  title: string;
52
57
  url: string;
53
58
  }[];
59
+ media: {
60
+ url: string;
61
+ alt?: string | undefined;
62
+ link?: string | undefined;
63
+ }[];
54
64
  }, {
55
65
  id: string;
56
66
  url: string;
@@ -71,6 +81,11 @@ export declare const bookmarkSchema: z.ZodPipe<z.ZodObject<{
71
81
  title: string;
72
82
  url: string;
73
83
  }[];
84
+ media: {
85
+ url: string;
86
+ alt?: string | undefined;
87
+ link?: string | undefined;
88
+ }[];
74
89
  }>>;
75
90
  export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
76
91
  id: z.ZodString;
@@ -103,6 +118,11 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
103
118
  title: z.ZodString;
104
119
  url: z.ZodURL;
105
120
  }, z.core.$strict>>;
121
+ media: z.ZodDefault<z.ZodArray<z.ZodObject<{
122
+ url: z.ZodURL;
123
+ alt: z.ZodOptional<z.ZodString>;
124
+ link: z.ZodOptional<z.ZodString>;
125
+ }, z.core.$strict>>>;
106
126
  }, z.core.$strict>, z.ZodTransform<{
107
127
  shelfLife: "news" | "evergreen";
108
128
  postedAt: Date;
@@ -123,6 +143,11 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
123
143
  title: string;
124
144
  url: string;
125
145
  }[];
146
+ media: {
147
+ url: string;
148
+ alt?: string | undefined;
149
+ link?: string | undefined;
150
+ }[];
126
151
  }, {
127
152
  id: string;
128
153
  url: string;
@@ -143,6 +168,11 @@ export declare const bookmarksFileSchema: z.ZodArray<z.ZodPipe<z.ZodObject<{
143
168
  title: string;
144
169
  url: string;
145
170
  }[];
171
+ media: {
172
+ url: string;
173
+ alt?: string | undefined;
174
+ link?: string | undefined;
175
+ }[];
146
176
  }>>>;
147
177
  export declare const stateFileSchema: z.ZodArray<z.ZodString>;
148
178
  export type BookmarkFileEntry = z.input<typeof bookmarkSchema>;
@@ -3,6 +3,7 @@ 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 });
8
9
  const SHELF_LIVES = ["news", "evergreen"];
@@ -28,6 +29,20 @@ export const bookmarkSchema = z
28
29
  key_points: z.array(nonEmpty),
29
30
  action: z.string().trim(),
30
31
  links: z.array(z.strictObject({ title: nonEmpty, url: z.url({ protocol: /^https$/ }) })),
32
+ media: z
33
+ .array(z.strictObject({
34
+ url: z.url({ protocol: /^https$/, hostname: X_MEDIA_HOSTNAME }),
35
+ alt: nonEmpty.optional(),
36
+ link: nonEmpty.optional(),
37
+ }))
38
+ .default([]),
39
+ })
40
+ .superRefine(({ text, media }, ctx) => {
41
+ media.forEach(({ link }, index) => {
42
+ if (link !== undefined && !text.includes(link)) {
43
+ ctx.addIssue({ code: "custom", path: ["media", index, "link"], message: `"${link}" is not in text` });
44
+ }
45
+ });
31
46
  })
32
47
  .transform(({ posted_at, saved_at, shelf_life, key_points, ...rest }) => ({
33
48
  ...rest,
@@ -1,2 +1,2 @@
1
1
  import type { Bookmark } from "./bookmarks.ts";
2
- export declare function copyText({ url, author, postedAt, tldr, summary, text, keyPoints, action, links }: Bookmark): string;
2
+ export declare function copyText({ url, author, postedAt, tldr, summary, text, media, keyPoints, action, links, }: Bookmark): string;
package/dist/lib/copy.js CHANGED
@@ -8,11 +8,12 @@ function quote(text) {
8
8
  function list(heading, items) {
9
9
  return items.length === 0 ? "" : [`${heading}:`, ...items.map((item) => `- ${item}`)].join("\n");
10
10
  }
11
- export function copyText({ url, author, postedAt, tldr, summary, text, keyPoints, action, links }) {
11
+ export function copyText({ url, author, postedAt, tldr, summary, text, media, keyPoints, action, links, }) {
12
12
  return [
13
13
  "A post I saved from X:",
14
14
  `**${tldr}**\n${author.name} (@${author.handle}) · posted ${dateFormat.format(postedAt)} · ${url}`,
15
15
  text === "" ? "" : quote(text),
16
+ list("Images", media.map((image) => (image.alt === undefined ? image.url : `${image.alt}: ${image.url}`))),
16
17
  `Summary: ${summary}`,
17
18
  list("Key points", keyPoints),
18
19
  action === "" ? "" : `Try: ${action}`,
package/dist/lib/feed.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isTopic } from "./filter.js";
2
+ import { postParts } 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) {
@@ -29,7 +30,11 @@ function feedItem(bookmark, page) {
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
- const quote = text === "" ? "" : `<blockquote><p>${escapeHtml(text)}</p></blockquote>`;
33
+ const quoted = postParts(bookmark)
34
+ .flatMap((part) => part.kind === "text"
35
+ ? [`<p>${escapeHtml(part.text)}</p>`]
36
+ : part.images.map((image) => `<p><img src="${escapeHtml(image.url)}" alt="${escapeHtml(image.alt)}"></p>`))
37
+ .join("");
33
38
  const linkItems = links
34
39
  .map((outbound) => `<li><a href="${escapeHtml(outbound.url)}">${escapeHtml(outbound.title)}</a></li>`)
35
40
  .join("");
@@ -43,7 +48,7 @@ function feedItem(bookmark, page) {
43
48
  action === "" ? "" : `<p>Try: ${escapeHtml(action)}</p>`,
44
49
  points === "" ? "" : `<ul>${points}</ul>`,
45
50
  linkItems === "" ? "" : `<ul>${linkItems}</ul>`,
46
- quote,
51
+ quoted === "" ? "" : `<blockquote>${quoted}</blockquote>`,
47
52
  `<p><a href="${escapeHtml(url)}">Open on X</a></p>`,
48
53
  ]
49
54
  .filter((part) => part !== "")
@@ -18,4 +18,9 @@ export declare const bookmarks: {
18
18
  title: string;
19
19
  url: string;
20
20
  }[];
21
+ media: {
22
+ url: string;
23
+ alt?: string | undefined;
24
+ link?: string | undefined;
25
+ }[];
21
26
  }[];
@@ -0,0 +1,14 @@
1
+ import type { Bookmark } from "./bookmarks.ts";
2
+ type Image = {
3
+ url: string;
4
+ alt: string;
5
+ };
6
+ type PostPart = {
7
+ kind: "text";
8
+ text: string;
9
+ } | {
10
+ kind: "images";
11
+ images: Image[];
12
+ };
13
+ export declare function postParts({ author, text, media }: Bookmark): PostPart[];
14
+ export {};
@@ -0,0 +1,22 @@
1
+ export function postParts({ author, text, media }) {
2
+ const position = (index) => (media.length === 1 ? "" : ` ${index + 1} of ${media.length}`);
3
+ const images = media.map(({ url, alt, link }, index) => ({
4
+ url,
5
+ alt: alt ?? `Image${position(index)} from the post by @${author.handle}`,
6
+ link,
7
+ }));
8
+ const imagesFor = (link) => ({
9
+ kind: "images",
10
+ images: images.filter((image) => image.link === link).map(({ url, alt }) => ({ url, alt })),
11
+ });
12
+ const links = [...new Set(images.flatMap(({ link }) => (link === undefined ? [] : [link])))].toSorted((a, b) => text.indexOf(a) - text.indexOf(b));
13
+ const parts = [];
14
+ let rest = text;
15
+ for (const link of links) {
16
+ const at = rest.indexOf(link);
17
+ parts.push({ kind: "text", text: rest.slice(0, at).trim() }, imagesFor(link));
18
+ rest = rest.slice(at + link.length);
19
+ }
20
+ parts.push({ kind: "text", text: rest.trim() }, imagesFor(undefined));
21
+ return parts.filter((part) => (part.kind === "text" ? part.text !== "" : part.images.length > 0));
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-digest",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",