From be796d9d0de7e3f825c4ac8057ff1b9ee545ce3b Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Fri, 9 Oct 2026 01:49:40 -0500 Subject: [PATCH] diigo-cache: the public page is an h-feed of h-entry bookmarks Microformats are the public page's API for other sites (potluck first): each bookmark is an h-entry with u-bookmark-of, p-name, p-summary, a p-category per tag (linked to a tag search), dt-published (bookmarked) and dt-updated (last edited). Prev/next links carry rel=prev/next, so a reader can follow a filter's pages. Tests parse the page with microformats-parser to pin the contract. Generated-by: Claude Opus 5.5 --- diigo-cache/package.json | 1 + diigo-cache/pnpm-lock.yaml | 24 +++++++++++++++++ diigo-cache/src/index.ts | 44 +++++++++++++++++++++++--------- diigo-cache/tests/caches.test.ts | 31 +++++++++++++++++++++- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/diigo-cache/package.json b/diigo-cache/package.json index fa69e9a..340400b 100644 --- a/diigo-cache/package.json +++ b/diigo-cache/package.json @@ -11,6 +11,7 @@ "devDependencies": { "@cloudflare/vitest-plugin": "1.3.6", "@types/node": "^22", + "microformats-parser": "^2.0.6", "typescript": "^5.9.0", "vitest": "4.1.11", "wrangler": "^4.148.0" diff --git a/diigo-cache/pnpm-lock.yaml b/diigo-cache/pnpm-lock.yaml index f11d5a6..020b31b 100644 --- a/diigo-cache/pnpm-lock.yaml +++ b/diigo-cache/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@types/node': specifier: ^22 version: 22.20.5 + microformats-parser: + specifier: ^2.0.6 + version: 2.0.6 typescript: specifier: ^5.9.0 version: 5.9.3 @@ -613,6 +616,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -722,6 +729,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + microformats-parser@2.0.6: + resolution: {integrity: sha512-VTnLImF17yd+ZfBH40sddHMJp5Rrker4ge+oPjgnBQ3jluQO0YmsNVA4UcEfHRBV8zrikBSMkAzAub+nTRX85g==} + engines: {node: '>=18'} + miniflare@5.20261001.0-alpha: resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==} engines: {node: '>=22.0.0'} @@ -739,6 +750,9 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -1366,6 +1380,8 @@ snapshots: detect-libc@2.1.2: {} + entities@6.0.1: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@2.3.2: {} @@ -1467,6 +1483,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + microformats-parser@2.0.6: + dependencies: + parse5: 7.3.0 + miniflare@5.20261001.0-alpha(@types/node@22.20.5): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -1497,6 +1517,10 @@ snapshots: obug@2.2.1: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} diff --git a/diigo-cache/src/index.ts b/diigo-cache/src/index.ts index febd4cc..7c01c0b 100644 --- a/diigo-cache/src/index.ts +++ b/diigo-cache/src/index.ts @@ -515,8 +515,8 @@ async function publicPage(request: Request, env: Env, username: string): Promise notice = `

no page ${esc(String(raw))}: pages run 1–${pages}

`; } const nav = (n: number, label: string) => - n >= 1 && n <= pages ? `${label}` : `${label}`; - const list = notice ? "" : itemList(items, "updated"); + n >= 1 && n <= pages ? `${label}` : `${label}`; + const list = notice ? "" : itemList(items, "updated", here); const body = publicFrame( username, `

${pages} page${pages === 1 ? "" : "s"} (diigo's, ${PUBLIC_PER_PAGE} bookmarks each; private ones are left out, so a page may show fewer).

@@ -555,7 +555,7 @@ function filteredPage( const kept = [...params].filter(([k, v]) => k !== "page" && v !== ""); const href = (n: number) => `${here}?${new URLSearchParams([...kept, ["page", String(n)]])}`; const nav = (n: number, label: string) => - n >= 1 && n <= pages ? `${label}` : `${label}`; + n >= 1 && n <= pages ? `${label}` : `${label}`; const status = found.matched ? `${found.matched} match${found.matched === 1 ? "" : "es"}, page ${pageN} of ${pages}, newest ${query.by} first` : "no matches"; @@ -566,7 +566,7 @@ function filteredPage( ${fill ? fillLine(fill) : ""} ${filterForm(here, params)}

${nav(pageN - 1, "← prev")} ${nav(pageN + 1, "next →")} all, by diigo page

-${itemList(items, query.by)}`, +${itemList(items, query.by, here)}`, ); return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } }); } @@ -602,19 +602,39 @@ words, "phrases", #tags, -negation, and after:, before:, newer_than:7d, during:2 `; } -/** bookmarks as a list, each led by its `by` date */ -function itemList(items: Item[], by: Query["by"]): string { - return `
    +/** + * bookmarks as a list, each led by its `by` date, marked up as + * microformats: an `h-feed` of `h-entry` bookmarks (`u-bookmark-of`, + * `p-name`, `p-summary`, `p-category` per tag, `dt-published` when + * bookmarked, `dt-updated` when last edited). This markup is the public + * page's API for other sites (tests/caches.test.ts pins it); tag links + * search this page (`here`) by tag. + * + * @see https://microformats.org/wiki/h-entry + * @see https://indieweb.org/bookmark + */ +function itemList(items: Item[], by: Query["by"], here: string): string { + const time = (it: Item, which: Query["by"], hidden: boolean) => { + const secs = dateOf(it, which); + if (secs === undefined) return hidden ? "" : "(no date)"; + const when = new Date(secs * 1000).toISOString(); + const cls = which === "created" ? "dt-published" : "dt-updated"; + return hidden ? `` : ``; + }; + return `
      ${items .map((it) => { const url = typeof it.url === "string" ? it.url : ""; const title = typeof it.title === "string" && it.title ? it.title : url; - const desc = typeof it.description === "string" && it.description ? `
      ${esc(it.description)}` : ""; + const desc = typeof it.description === "string" && it.description ? `
      ${esc(it.description)}` : ""; const href = /^https?:\/\//i.test(url) ? url : "#"; - const secs = dateOf(it, by); - const when = secs === undefined ? "" : new Date(secs * 1000).toISOString(); - const date = when ? `` : "(no date)"; - return `
    1. ${date} ${esc(title)}${desc}
    2. `; + const tags = (typeof it.tags === "string" ? it.tags.split(",") : []) + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => ` ${esc(t)}`) + .join(""); + const other = by === "created" ? "updated" : "created"; + return `
    3. ${time(it, by, false)} ${esc(title)}${tags}${desc}${time(it, other, true)}
    4. `; }) .join("\n")}
    `; diff --git a/diigo-cache/tests/caches.test.ts b/diigo-cache/tests/caches.test.ts index 79d0184..e10ab06 100644 --- a/diigo-cache/tests/caches.test.ts +++ b/diigo-cache/tests/caches.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import worker from "../src/index.js"; import { idFromWebkey, toWebkey } from "../src/webkeys.js"; import { PAGE_SIZE } from "../src/cache.js"; +import { mf2 } from "microformats-parser"; // matches tests/fake-webapp.js: 30 items, every third private const WEBAPP_ID = "fake-webapp-id"; @@ -249,7 +250,7 @@ describe("worker routes", () => { // PAGE_SIZE 100: page 2 is bookmarks 101..200 expect(html).toContain(">bookmark 130<"); // the date comes first, in toISOString() form - expect(html).toMatch(/