diigo-week: draft a potluck issue from a week of diigo bookmarks
An EmDash plugin (native, content:write only) with one admin page: a diigo username and an ISO week. It reads diigo-cache's public page (?in=<week>&by=created) as microformats, follows rel=next, and creates a draft in issues shaped like seed/issues.json. Transport is a DIIGO_CACHE service binding rather than ctx.http.fetch: EmDash's SSRF guard refuses localhost, and the binding names one worker. Logic lives in src/week.ts with the fetcher passed in; tests read fixtures in diigo-cache's markup. The checklist's goal 2 now names potluck; DiigoBridge waits. Generated-by: Claude Opus 5.5
This commit is contained in:
1 parent
be796d9d0d
commit
94a059af25
17 files changed
+6705
-22
No files matched your search
+8
-21
@@ -89,27 +89,14 @@ narrative.
|
||||
[the issues sketch](potluck/issues-sketch.md))? If the policy
|
||||
changes, stripping stored rows is a durable-state migration.
|
||||
|
||||
- [ ] **2. madmode reads the public cache**: public data, so no
|
||||
capability, no cookie, no plugin sync job, no cron on madmode's
|
||||
side. madmode fetches `GET /public/dckc-madmode` with
|
||||
`Accept: application/json` (plus 1d's `in=`/`days=`/`q=`).
|
||||
- [ ] 2-i. Transport: a service binding to diigo-cache when both
|
||||
are deployed (no public hop, so no rate-limit exposure);
|
||||
in dev, a base URL (`http://localhost:8787`).
|
||||
- [ ] 2-ii. The JSON shape of the public page is a contract
|
||||
between two workers: pin it in a diigo-cache test (and
|
||||
madmode's reader tests against the same fixture).
|
||||
- [ ] 2-iii. Decide `DiigoBridge`'s fate: madmode doesn't need it
|
||||
(public reads need no cache id), so its "cache-id source"
|
||||
TODO (`src/index.ts`, README "RPC use") is either dropped
|
||||
with the entrypoint or kept for a private-reading caller.
|
||||
- [ ] **3. `/bookmarks` renders real data** — titles, tags, dates at
|
||||
http://localhost:4321/bookmarks, newest first.
|
||||
- [ ] **4. The weekly page**: `/bookmarks/2026-W41` → the cache's
|
||||
`in=2026-W41` (needs 1d and 1f). Decide (user's call): a raw
|
||||
week list, or a draft post the user edits before publishing
|
||||
(the PESOS practice is editorial; a tag like `#weekly` in `q`
|
||||
is one cheap form of discretion).
|
||||
- [ ] **2. potluck reads the public cache**: public data, so no capability, no cookie, no secret on potluck's side. The [diigo-week](diigo-week/README.md) plugin reads `GET /public/dckc-madmode?in=<week>&by=created` as microformats (an `h-feed` of `h-entry` bookmarks). Built 2026-10-09; not yet run in dev.
|
||||
- [x] 2-i. Transport: the `DIIGO_CACHE` service binding in potluck's `wrangler.jsonc`, in dev too: the plugin's `ctx.http.fetch` refuses localhost (EmDash's SSRF guard), and a binding names one worker, not a host.
|
||||
- [x] 2-ii. The page's microformats are the contract: diigo-cache's tests parse it with microformats-parser; diigo-week's tests read fixtures in the same markup.
|
||||
- [ ] 2-iii. Decide `DiigoBridge`'s fate (postponed: nothing on this path uses it; it matters only for a caller that reads private items).
|
||||
- [ ] 2-iv. Run it in dev: diigo-cache and potluck both up; Plugins → Draft from diigo → a draft issue. Open question: whether the service binding resolves across the two dev servers (the dev registry).
|
||||
- [ ] ~~**3. `/bookmarks` renders real data**~~: superseded by potluck's issues (the madmode starter is gone).
|
||||
- [ ] **4. The weekly draft**: decided (2026-10-09): a draft issue you edit before publishing, made by diigo-week. The slug comes from the title (`week-2026-w40`).
|
||||
|
||||
- [ ] **5. Deployed end-to-end** (order matters):
|
||||
- [ ] 5-i. webapp: `cd webapp && npx wrangler deploy`
|
||||
- [ ] 5-ii. diigo-cache: rate-limit `/api` (and `/public/*`) first
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -0,0 +1,21 @@
|
||||
# diigo-week
|
||||
|
||||
An EmDash plugin that drafts a potluck issue from a week of your shared diigo bookmarks. It reads them as microformats from diigo-cache's public page, so you edit a draft instead of copy-pasting links.
|
||||
|
||||
## Getting started
|
||||
|
||||
Run diigo-cache (with a filled public cache) and potluck, each in its own terminal:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
(cd ../diigo-cache && pnpm dev) # → http://localhost:8787/
|
||||
(cd ../potluck && pnpm dev) # → http://localhost:4321/_emdash/admin
|
||||
```
|
||||
|
||||
In potluck's admin: Plugins → **Draft from diigo** → **Draft issue**. The draft shows up under Issues.
|
||||
|
||||
## How it reads the week
|
||||
|
||||
It asks diigo-cache for `/public/<user>?in=<week>&by=created` over the `DIIGO_CACHE` service binding (potluck's `wrangler.jsonc`), reads the page's `h-feed` of `h-entry` bookmarks, follows `rel=next`, and creates a draft in `issues`: title, week, dek, and a paragraph per bookmark with its description and tag links, as in potluck's `seed/issues.json`. The binding is its only reach outside the site; it declares `content:write` and `taxonomies:write` (the issue gets its bookmarks' tags, as the sample issues do), nothing else.
|
||||
|
||||
Checks: `pnpm lint && pnpm test`.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "diigo-week",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"emdash": "1.2.0",
|
||||
"microformats-parser": "^2.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emdash-cms/blocks": "^1.2.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
Generated
+6223
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- "."
|
||||
|
||||
strictDepBuilds: true
|
||||
dangerouslyAllowAllBuilds: false
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
workerd: true
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/** emdash's virtual module for the site worker's bindings (declared for site
|
||||
* builds in emdash/src/virtual-modules.d.ts), so this package typechecks
|
||||
* standalone: `env` from `cloudflare:workers` under @astrojs/cloudflare,
|
||||
* undefined on other adapters */
|
||||
declare module "virtual:emdash/env" {
|
||||
export const env: Record<string, unknown> | undefined;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @file diigo-week: an EmDash plugin that drafts a potluck issue from a
|
||||
* week of public diigo bookmarks.
|
||||
*/
|
||||
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginDefinition, RouteContext } from "emdash";
|
||||
import type { Block, BlockResponse } from "@emdash-cms/blocks";
|
||||
import { draftIssue, issueTags, lastWeek, readWeek } from "./week.js";
|
||||
|
||||
/** the diigo username drafts read from, until the admin page saves another */
|
||||
const DEFAULT_USER = "dckc-madmode";
|
||||
|
||||
/** something that answers HTTP requests: a service binding's `fetch` */
|
||||
type Fetcher = { fetch(request: Request): Promise<Response> };
|
||||
|
||||
/**
|
||||
* the `DIIGO_CACHE` service binding to diigo-cache, from the site worker's
|
||||
* env. The binding is the plugin's only reach outside the site:
|
||||
* `ctx.http.fetch` can't reach a dev server (it refuses localhost), and a
|
||||
* binding names one worker rather than a host.
|
||||
*/
|
||||
async function diigoCache(): Promise<Fetcher> {
|
||||
const { env } = await import("virtual:emdash/env");
|
||||
const binding = env?.DIIGO_CACHE as Fetcher | undefined;
|
||||
if (typeof binding?.fetch !== "function") {
|
||||
throw new Error('no DIIGO_CACHE service binding: add { "binding": "DIIGO_CACHE", "service": "diigo-cache" } to services in wrangler.jsonc');
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
/** GET a diigo-cache path as HTML; anything but a 200 is an error */
|
||||
async function getter(cache: Fetcher): Promise<(path: string) => Promise<string>> {
|
||||
return async (path) => {
|
||||
const res = await cache.fetch(new Request(`https://diigo-cache${path}`, { headers: { accept: "text/html" } }));
|
||||
const text = await res.text();
|
||||
if (res.status !== 200) throw new Error(`diigo-cache ${path.split("?")[0]}: ${res.status} ${text.slice(0, 200)}`);
|
||||
return text;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* assign `tags` to an issue in the `tag` taxonomy (as seed/issues.json
|
||||
* does), creating terms that don't exist yet; returns the tags that
|
||||
* couldn't be assigned, with why
|
||||
*/
|
||||
async function tagIssue(ctx: RouteContext, id: string, tags: string[]): Promise<string[]> {
|
||||
const tax = ctx.taxonomies;
|
||||
if (typeof tax?.createTerm !== "function" || typeof tax.addEntryTerms !== "function") throw new Error("this plugin lacks taxonomies:write");
|
||||
const known = new Map((await tax.getTerms("tag")).map((t) => [t.slug, t.id]));
|
||||
const ids: string[] = [];
|
||||
const failed: string[] = [];
|
||||
for (const tag of tags) {
|
||||
try {
|
||||
ids.push(known.get(tag) ?? (await tax.createTerm("tag", { label: tag, slug: tag })).id);
|
||||
} catch (error) {
|
||||
failed.push(`${tag} (${error instanceof Error ? error.message : String(error)})`);
|
||||
}
|
||||
}
|
||||
if (ids.length) await tax.addEntryTerms("issues", id, "tag", ids);
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** the admin page: a form for the diigo username and the week */
|
||||
async function page(ctx: RouteContext, note: Block[] = [], toast?: BlockResponse["toast"]): Promise<BlockResponse> {
|
||||
const user = (await ctx.kv.get<string>("user")) ?? DEFAULT_USER;
|
||||
const blocks: Block[] = [
|
||||
{ type: "header", text: "Draft an issue from diigo" },
|
||||
{
|
||||
type: "context",
|
||||
text: "Reads a week of shared bookmarks from diigo-cache (by when they were bookmarked) and saves them as a draft issue for you to edit.",
|
||||
},
|
||||
{
|
||||
type: "form",
|
||||
block_id: "draft",
|
||||
fields: [
|
||||
{ type: "text_input", action_id: "user", label: "diigo username", initial_value: user },
|
||||
{ type: "text_input", action_id: "week", label: "ISO week (UTC)", initial_value: lastWeek(new Date()) },
|
||||
],
|
||||
submit: { label: "Draft issue", action_id: "draft" },
|
||||
},
|
||||
];
|
||||
blocks.push(...note);
|
||||
return { blocks, toast };
|
||||
}
|
||||
|
||||
export function createPlugin(): PluginDefinition {
|
||||
return definePlugin({
|
||||
id: "diigo-week",
|
||||
version: "0.1.0",
|
||||
capabilities: ["content:write", "taxonomies:write"],
|
||||
admin: {
|
||||
pages: [{ path: "/draft", label: "Draft from diigo", icon: "bookmark" }],
|
||||
},
|
||||
routes: {
|
||||
admin: {
|
||||
permission: "plugins:manage",
|
||||
handler: async (ctx) => {
|
||||
const input = ctx.input as { type?: string; action_id?: string; values?: Record<string, unknown> } | undefined;
|
||||
if (input?.type !== "form_submit" || input.action_id !== "draft") return page(ctx);
|
||||
const user = String(input.values?.user ?? "").trim();
|
||||
const week = String(input.values?.week ?? "").trim();
|
||||
try {
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(user)) throw new Error("diigo username: letters, digits, _ and - only");
|
||||
await ctx.kv.set("user", user);
|
||||
const bookmarks = await readWeek(await getter(await diigoCache()), user, week);
|
||||
const issue = draftIssue(week, bookmarks);
|
||||
const content = ctx.content;
|
||||
if (typeof content?.create !== "function") throw new Error("this plugin lacks content:write");
|
||||
const created = await content.create("issues", issue);
|
||||
const untagged = await tagIssue(ctx, created.id, issueTags(bookmarks));
|
||||
if (untagged.length) ctx.log.warn("tags not assigned", { id: created.id, untagged });
|
||||
ctx.log.info("drafted issue", { week, bookmarks: bookmarks.length, id: created.id });
|
||||
return page(
|
||||
ctx,
|
||||
[
|
||||
{ type: "banner", variant: "default", title: `Drafted "${issue.title}"`, description: `${bookmarks.length} bookmarks, as a draft at /issues/${created.slug ?? "(no slug)"}. Cut, comment, and publish.${untagged.length ? ` Tags not assigned: ${untagged.join(", ")}.` : ""}` },
|
||||
{
|
||||
type: "actions",
|
||||
elements: [{ type: "link", label: "Open the draft", target: { kind: "content", collection: "issues", id: created.id }, appearance: "primary" }],
|
||||
},
|
||||
],
|
||||
{ message: `Drafted ${issue.title}`, type: "success" },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.log.error("draft failed", { week, message });
|
||||
return page(ctx, [{ type: "banner", variant: "alert", title: "Couldn't draft the issue", description: message }], { message, type: "error" });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file a week of bookmarks, read as microformats from diigo-cache's public
|
||||
* page, drafted as a potluck issue.
|
||||
*/
|
||||
|
||||
import { mf2 } from "microformats-parser";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** pages of matches one draft will read (100 bookmarks each) */
|
||||
export const MAX_PAGES = 10;
|
||||
|
||||
/** one bookmark, as its h-entry says */
|
||||
export type Bookmark = {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
/** when it was bookmarked, ISO */
|
||||
published: string;
|
||||
};
|
||||
|
||||
/** `yyyy-Www` for the ISO 8601 week holding `d` (UTC) */
|
||||
export function isoWeek(d: Date): string {
|
||||
const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
const day = t.getUTCDay() || 7;
|
||||
t.setUTCDate(t.getUTCDate() + 4 - day); // the week's Thursday names its year
|
||||
const year = t.getUTCFullYear();
|
||||
const week = Math.ceil(((t.getTime() - Date.UTC(year, 0, 1)) / DAY_MS + 1) / 7);
|
||||
return `${year}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** the last full ISO week before `now` */
|
||||
export function lastWeek(now: Date): string {
|
||||
return isoWeek(new Date(now.getTime() - 7 * DAY_MS));
|
||||
}
|
||||
|
||||
/** Monday 00:00Z of ISO week `week` (`2026-W41`), or undefined if malformed */
|
||||
export function weekStart(week: string): Date | undefined {
|
||||
const m = /^(\d{4})-W(\d{2})$/.exec(week);
|
||||
if (!m) return undefined;
|
||||
const [year, w] = [Number(m[1]), Number(m[2])];
|
||||
const jan4 = Date.UTC(year, 0, 4);
|
||||
const monday = jan4 - ((new Date(jan4).getUTCDay() || 7) - 1) * DAY_MS + (w - 1) * 7 * DAY_MS;
|
||||
const start = new Date(monday);
|
||||
return w >= 1 && isoWeek(start) === week ? start : undefined;
|
||||
}
|
||||
|
||||
/** the first string value of an mf2 property, or "" */
|
||||
const first = (props: Record<string, unknown[]>, name: string): string => {
|
||||
const v = props[name]?.[0];
|
||||
return typeof v === "string" ? v : "";
|
||||
};
|
||||
|
||||
/** the bookmarks in a page's `h-feed`, and its `rel=next` link if any */
|
||||
export function readPage(html: string, url: string): { bookmarks: Bookmark[]; next?: string } {
|
||||
const { items, rels } = mf2(html, { baseUrl: url });
|
||||
const feed = items.find((i) => i.type?.includes("h-feed"));
|
||||
const bookmarks = (feed?.children ?? [])
|
||||
.filter((e) => e.type?.includes("h-entry"))
|
||||
.map((e) => {
|
||||
const props = e.properties as Record<string, unknown[]>;
|
||||
const tags = (props.category ?? []).filter((t): t is string => typeof t === "string");
|
||||
return {
|
||||
url: first(props, "bookmark-of"),
|
||||
title: first(props, "name"),
|
||||
description: first(props, "summary"),
|
||||
tags,
|
||||
published: first(props, "published"),
|
||||
};
|
||||
})
|
||||
.filter((b) => b.url !== "");
|
||||
return { bookmarks, next: rels.next?.[0] };
|
||||
}
|
||||
|
||||
/**
|
||||
* a user's bookmarks from an ISO week, oldest first, by when they were
|
||||
* bookmarked: diigo-cache's `/public/<user>?in=<week>&by=created`,
|
||||
* following `rel=next` up to {@link MAX_PAGES} pages.
|
||||
*
|
||||
* @param get fetches a path on diigo-cache, answering its HTML; it throws
|
||||
* on anything but a 200
|
||||
*/
|
||||
export async function readWeek(get: (path: string) => Promise<string>, user: string, week: string): Promise<Bookmark[]> {
|
||||
const base = "https://diigo-cache.invalid";
|
||||
let path: string | undefined = `/public/${encodeURIComponent(user)}?in=${encodeURIComponent(week)}&by=created`;
|
||||
const bookmarks: Bookmark[] = [];
|
||||
for (let n = 0; path && n < MAX_PAGES; n += 1) {
|
||||
const page = readPage(await get(path), base + path);
|
||||
bookmarks.push(...page.bookmarks);
|
||||
const next: URL | undefined = page.next ? new URL(page.next) : undefined;
|
||||
path = next && next.origin === base ? next.pathname + next.search : undefined;
|
||||
}
|
||||
return bookmarks.reverse();
|
||||
}
|
||||
|
||||
/** a Portable Text span */
|
||||
type Span = { _type: "span"; _key: string; text: string; marks: string[] };
|
||||
/** a Portable Text block */
|
||||
type Block = { _type: "block"; _key: string; style: string; markDefs: { _type: "link"; _key: string; href: string }[]; children: Span[] };
|
||||
|
||||
/** a bookmark as one paragraph: the link, the description, then tag links (as in seed/issues.json) */
|
||||
function bookmarkBlock(b: Bookmark, i: number): Block {
|
||||
const k = `b${i}`;
|
||||
const markDefs: Block["markDefs"] = [{ _type: "link", _key: `${k}-a`, href: b.url }];
|
||||
const children: Span[] = [{ _type: "span", _key: `${k}-t`, text: b.title || b.url, marks: [`${k}-a`] }];
|
||||
if (b.description) children.push({ _type: "span", _key: `${k}-d`, text: `: ${b.description}`, marks: [] });
|
||||
b.tags.forEach((tag, j) => {
|
||||
markDefs.push({ _type: "link", _key: `${k}-g${j}`, href: `/tag/${encodeURIComponent(tag)}` });
|
||||
children.push({ _type: "span", _key: `${k}-s${j}`, text: " ", marks: [] });
|
||||
children.push({ _type: "span", _key: `${k}-n${j}`, text: tag, marks: [`${k}-g${j}`] });
|
||||
});
|
||||
return { _type: "block", _key: k, style: "normal", markDefs, children };
|
||||
}
|
||||
|
||||
/** the issue's tags: every bookmark's, deduplicated, in order of first use */
|
||||
export function issueTags(bookmarks: Bookmark[]): string[] {
|
||||
return [...new Set(bookmarks.flatMap((b) => b.tags))];
|
||||
}
|
||||
|
||||
/** `Oct 5–11` (or `Sep 28–Oct 4`) for the week starting `monday` */
|
||||
function range(monday: Date): string {
|
||||
const sunday = new Date(monday.getTime() + 6 * DAY_MS);
|
||||
const fmt = (d: Date, withMonth: boolean) =>
|
||||
withMonth ? d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }) : String(d.getUTCDate());
|
||||
return `${fmt(monday, true)}–${fmt(sunday, sunday.getUTCMonth() !== monday.getUTCMonth())}`;
|
||||
}
|
||||
|
||||
/** the fields of a draft `issues` entry for `week`: title, week, dek, body */
|
||||
export function draftIssue(week: string, bookmarks: Bookmark[]): Record<string, unknown> {
|
||||
const monday = weekStart(week);
|
||||
if (!monday) throw new Error(`week: expected an ISO week like 2026-W41, got ${JSON.stringify(week)}`);
|
||||
const intro: Block = {
|
||||
_type: "block",
|
||||
_key: "intro",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", _key: "intro-t", text: "(draft) A line or two about the week goes here.", marks: [] }],
|
||||
};
|
||||
return {
|
||||
title: `Week ${week}`,
|
||||
week,
|
||||
dek: `${range(monday)}: ${bookmarks.length} bookmark${bookmarks.length === 1 ? "" : "s"}. (draft)`,
|
||||
body: [intro, ...bookmarks.map(bookmarkBlock)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { draftIssue, issueTags, lastWeek, readWeek, weekStart } from "../src/week.js";
|
||||
|
||||
/** an h-entry as diigo-cache's public page marks it up (by=created) */
|
||||
const entry = (n: number, created: string, extra = "") =>
|
||||
`<li class="h-entry"><time class="dt-published" datetime="${created}">${created}</time> <a class="u-bookmark-of p-name" href="https://ex.example/${n}">bookmark ${n}</a>${extra}<time class="dt-updated" datetime="${created}" hidden></time></li>`;
|
||||
|
||||
const page = (entries: string[], next?: string) => `<!doctype html><html><body>
|
||||
<p>${next ? `<a rel="next" href="${next}">next →</a>` : "<span>next →</span>"}</p>
|
||||
<ol class="h-feed">
|
||||
${entries.join("\n")}
|
||||
</ol></body></html>`;
|
||||
|
||||
describe("readWeek", () => {
|
||||
it("reads h-entry bookmarks across rel=next pages, oldest first", async () => {
|
||||
const asked: string[] = [];
|
||||
const pages: Record<string, string> = {
|
||||
"/public/dckc-madmode?in=2026-W40&by=created": page(
|
||||
[
|
||||
entry(
|
||||
3,
|
||||
"2026-10-03T12:00:00.000Z",
|
||||
` <a class="p-category" href="/public/dckc-madmode?q=%23ocap">ocap</a> <a class="p-category" href="/public/dckc-madmode?q=%23weekly">weekly</a><br><small class="p-summary">a week of fragments</small>`,
|
||||
),
|
||||
entry(2, "2026-10-01T12:00:00.000Z"),
|
||||
],
|
||||
"/public/dckc-madmode?in=2026-W40&by=created&page=2",
|
||||
),
|
||||
"/public/dckc-madmode?in=2026-W40&by=created&page=2": page([entry(1, "2026-09-28T12:00:00.000Z")]),
|
||||
};
|
||||
const get = async (path: string) => {
|
||||
asked.push(path);
|
||||
const html = pages[path];
|
||||
if (!html) throw new Error(`no page ${path}`);
|
||||
return html;
|
||||
};
|
||||
const bookmarks = await readWeek(get, "dckc-madmode", "2026-W40");
|
||||
expect(asked).toHaveLength(2);
|
||||
expect(bookmarks.map((b) => b.title)).toEqual(["bookmark 1", "bookmark 2", "bookmark 3"]);
|
||||
expect(bookmarks[2]).toEqual({
|
||||
url: "https://ex.example/3",
|
||||
title: "bookmark 3",
|
||||
description: "a week of fragments",
|
||||
tags: ["ocap", "weekly"],
|
||||
published: "2026-10-03T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't follow a rel=next to another site", async () => {
|
||||
const asked: string[] = [];
|
||||
const get = async (path: string) => {
|
||||
asked.push(path);
|
||||
return page([entry(1, "2026-10-01T12:00:00.000Z")], "https://elsewhere.example/public/x");
|
||||
};
|
||||
await readWeek(get, "dckc-madmode", "2026-W40");
|
||||
expect(asked).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weeks", () => {
|
||||
it("lastWeek is the full ISO week before now", () => {
|
||||
expect(lastWeek(new Date("2026-10-09T01:00:00Z"))).toBe("2026-W40");
|
||||
// Monday 2026-01-05 starts W02; W01 began Monday 2025-12-29
|
||||
expect(lastWeek(new Date("2026-01-05T00:00:00Z"))).toBe("2026-W01");
|
||||
});
|
||||
|
||||
it("weekStart is Monday 00:00Z, and refuses a week that doesn't exist", () => {
|
||||
expect(weekStart("2026-W40")?.toISOString()).toBe("2026-09-28T00:00:00.000Z");
|
||||
expect(weekStart("2025-W01")?.toISOString()).toBe("2024-12-30T00:00:00.000Z");
|
||||
expect(weekStart("2025-W53")).toBeUndefined();
|
||||
expect(weekStart("2026-40")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftIssue", () => {
|
||||
it("drafts title, week, dek, and a body: an intro, then a paragraph per bookmark with tag links", () => {
|
||||
const issue = draftIssue("2026-W40", [
|
||||
{ url: "https://ex.example/1", title: "one", description: "nice", tags: ["ocap"], published: "2026-09-28T12:00:00.000Z" },
|
||||
]);
|
||||
expect(issue.title).toBe("Week 2026-W40");
|
||||
expect(issue.week).toBe("2026-W40");
|
||||
expect(issue.dek).toBe("Sep 28–Oct 4: 1 bookmark. (draft)");
|
||||
const [intro, b] = issue.body as { markDefs: { href: string }[]; children: { text: string }[] }[];
|
||||
expect(intro.children[0].text).toMatch(/draft/);
|
||||
expect(b.markDefs.map((m) => m.href)).toEqual(["https://ex.example/1", "/tag/ocap"]);
|
||||
expect(b.children.map((c) => c.text).join("")).toBe("one: nice ocap");
|
||||
});
|
||||
|
||||
it("refuses a malformed week", () => {
|
||||
expect(() => draftIssue("last week", [])).toThrow(/ISO week/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issueTags", () => {
|
||||
it("is every bookmark's tags, once each, in order of first use", () => {
|
||||
const b = (tags: string[]) => ({ url: "https://ex.example/", title: "", description: "", tags, published: "" });
|
||||
expect(issueTags([b(["governance", "economics"]), b([]), b(["governance", "scaling"])])).toEqual(["governance", "economics", "scaling"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"tests/**/*",
|
||||
"vitest.config.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,10 @@ potluck started as EmDash's `cloudflare:blog` template (`pnpm create emdash@late
|
||||
- **Microformats:** an issue list is an `h-feed` of `h-entry`s; an issue is an `h-entry` (`p-name`, `dt-published`, `p-summary`, `e-content`). In issue bodies, [CiteLink.astro](src/components/CiteLink.astro) marks off-site links as `h-cite` and `/tag/<tag>` links as rel-tag `p-category`.
|
||||
- **Look:** plain HTML, after kmjn.org's [Plain HTML with a few bureaucratic gestures](https://www.kmjn.org/notes/modern_plain_html.html): system fonts (no webfonts), one ~800px column, links in the text color with a dotted underline. Restyle in [src/styles/theme.css](src/styles/theme.css), which overrides the defaults in `tokens.css` (unlayered, so it always wins); don't edit `tokens.css` or `Base.astro` for visual changes. Colors use `light-dark(<light>, <dark>)`.
|
||||
|
||||
## Plugins
|
||||
|
||||
[diigo-week](../diigo-week/README.md) (Plugins → Draft from diigo in the admin) drafts an issue from a week of diigo bookmarks. It reads diigo-cache's public page as microformats over the `DIIGO_CACHE` service binding in [wrangler.jsonc](wrangler.jsonc); in dev, run diigo-cache's `pnpm dev` alongside. It's linked as `link:../diigo-week`, and registered in [astro.config.mjs](astro.config.mjs). After changing it, restart the dev server.
|
||||
|
||||
## Seeds
|
||||
|
||||
[seed/seed.json](seed/seed.json) (site settings, menus, widgets, the template's collections) is applied on the first request to an empty database. [seed/issues.json](seed/issues.json) is separate: `pnpm seed:issues` applies it to the local D1 database, which exists after the dev server's first request. Run it with the dev server stopped.
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ pnpm dev # → http://localhost:4321/issues
|
||||
|
||||
## Bookmarks in, a zine out
|
||||
|
||||
- **Your web, your site:** the bookmarks come from your own diigo account, so the web you read becomes something you publish ([PESOS](https://indieweb.org/PESOS)). For now issues are put together by hand. Pulling a week from diigo-cache is next.
|
||||
- **Your web, your site:** the bookmarks come from your own diigo account, so the web you read becomes something you publish ([PESOS](https://indieweb.org/PESOS)). The [diigo-week](../diigo-week/README.md) plugin drafts an issue from a week of them; you cut and comment.
|
||||
- **A week per issue:** `/issues/2026-w40`, by ISO week, UTC.
|
||||
- **Microformats:** feed readers and IndieWeb tools see each issue as an [h-entry](https://microformats.org/wiki/h-entry) and each bookmark as an [h-cite](https://microformats.org/wiki/h-cite).
|
||||
- **Plain HTML** that's easy to read: system fonts, one column.
|
||||
|
||||
@@ -16,6 +16,8 @@ export default defineConfig({
|
||||
emdash({
|
||||
database: d1({ binding: "DB", session: "auto" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
// drafts an issue from a week of diigo bookmarks (../diigo-week)
|
||||
plugins: [{ id: "diigo-week", version: "0.1.0", entrypoint: "diigo-week" }],
|
||||
}),
|
||||
],
|
||||
devToolbar: { enabled: false },
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@astrojs/react": "^6.0.5",
|
||||
"@emdash-cms/cloudflare": "^1.0.1",
|
||||
"astro": "^7.3.2",
|
||||
"diigo-week": "link:../diigo-week",
|
||||
"emdash": "^1.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
|
||||
Generated
+3
@@ -20,6 +20,9 @@ importers:
|
||||
astro:
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.6(@types/node@26.6.4)(yaml@2.9.1)
|
||||
diigo-week:
|
||||
specifier: link:../diigo-week
|
||||
version: link:../diigo-week
|
||||
emdash:
|
||||
specifier: ^1.0.1
|
||||
version: 1.2.0(@astrojs/react@6.0.6(@types/node@26.6.4)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@6.0.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.4(@tiptap/core@3.31.4(@tiptap/pm@3.31.4))(@tiptap/pm@3.31.4))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.6(@types/node@26.6.4)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.3)
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"bucket_name": "my-emdash-media",
|
||||
},
|
||||
],
|
||||
// diigo-cache's public pages, for the diigo-week plugin (reads its
|
||||
// microformats; in dev, diigo-cache's own `pnpm dev` serves it)
|
||||
"services": [{ "binding": "DIIGO_CACHE", "service": "diigo-cache" }],
|
||||
// Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable:
|
||||
// "worker_loaders": [{ "binding": "LOADER" }],
|
||||
// General maintenance cron trigger
|
||||
|
||||
Reference in new issue
Block a user