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:
dckc committed 2026-10-09 02:12:41 -05:00
1 parent be796d9d0d
commit 94a059af25
17 files changed
+6705 -22

No files matched your search

+8 -21
View File
@@ -89,27 +89,14 @@ narrative.
[the issues sketch](potluck/issues-sketch.md))? If the policy [the issues sketch](potluck/issues-sketch.md))? If the policy
changes, stripping stored rows is a durable-state migration. changes, stripping stored rows is a durable-state migration.
- [ ] **2. madmode reads the public cache**: public data, so no - [ ] **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.
capability, no cookie, no plugin sync job, no cron on madmode's - [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.
side. madmode fetches `GET /public/dckc-madmode` with - [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.
`Accept: application/json` (plus 1d's `in=`/`days=`/`q=`). - [ ] 2-iii. Decide `DiigoBridge`'s fate (postponed: nothing on this path uses it; it matters only for a caller that reads private items).
- [ ] 2-i. Transport: a service binding to diigo-cache when both - [ ] 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).
are deployed (no public hop, so no rate-limit exposure); - [ ] ~~**3. `/bookmarks` renders real data**~~: superseded by potluck's issues (the madmode starter is gone).
in dev, a base URL (`http://localhost:8787`). - [ ] **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`).
- [ ] 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).
- [ ] **5. Deployed end-to-end** (order matters): - [ ] **5. Deployed end-to-end** (order matters):
- [ ] 5-i. webapp: `cd webapp && npx wrangler deploy` - [ ] 5-i. webapp: `cd webapp && npx wrangler deploy`
- [ ] 5-ii. diigo-cache: rate-limit `/api` (and `/public/*`) first - [ ] 5-ii. diigo-cache: rate-limit `/api` (and `/public/*`) first
+1
View File
@@ -0,0 +1 @@
node_modules
+21
View File
@@ -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`.
+24
View File
@@ -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"
}
}
+6223
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
packages:
- "."
strictDepBuilds: true
dangerouslyAllowAllBuilds: false
allowBuilds:
esbuild: true
workerd: true
+7
View File
@@ -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;
}
+134
View File
@@ -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" });
}
},
},
},
});
}
+146
View File
@@ -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)],
};
}
+99
View File
@@ -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"]);
});
});
+20
View File
@@ -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"
]
}
+4
View File
@@ -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`. - **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>)`. - **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 ## 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. [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
View File
@@ -13,7 +13,7 @@ pnpm dev # → http://localhost:4321/issues
## Bookmarks in, a zine out ## 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. - **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). - **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. - **Plain HTML** that's easy to read: system fonts, one column.
+2
View File
@@ -16,6 +16,8 @@ export default defineConfig({
emdash({ emdash({
database: d1({ binding: "DB", session: "auto" }), database: d1({ binding: "DB", session: "auto" }),
storage: r2({ binding: "MEDIA" }), 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 }, devToolbar: { enabled: false },
+1
View File
@@ -20,6 +20,7 @@
"@astrojs/react": "^6.0.5", "@astrojs/react": "^6.0.5",
"@emdash-cms/cloudflare": "^1.0.1", "@emdash-cms/cloudflare": "^1.0.1",
"astro": "^7.3.2", "astro": "^7.3.2",
"diigo-week": "link:../diigo-week",
"emdash": "^1.0.1", "emdash": "^1.0.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4"
+3
View File
@@ -20,6 +20,9 @@ importers:
astro: astro:
specifier: ^7.3.2 specifier: ^7.3.2
version: 7.3.6(@types/node@26.6.4)(yaml@2.9.1) version: 7.3.6(@types/node@26.6.4)(yaml@2.9.1)
diigo-week:
specifier: link:../diigo-week
version: link:../diigo-week
emdash: emdash:
specifier: ^1.0.1 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) 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)
+3
View File
@@ -16,6 +16,9 @@
"bucket_name": "my-emdash-media", "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: // Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable:
// "worker_loaders": [{ "binding": "LOADER" }], // "worker_loaders": [{ "binding": "LOADER" }],
// General maintenance cron trigger // General maintenance cron trigger