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
This commit is contained in:
dckc committed 2026-10-09 01:49:40 -05:00
1 parent c66aaea366
commit be796d9d0d
4 files changed
+87 -13

No files matched your search

+1
View File
@@ -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"
+24
View File
@@ -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: {}
+32 -12
View File
@@ -515,8 +515,8 @@ async function publicPage(request: Request, env: Env, username: string): Promise
notice = `<p><em>no page ${esc(String(raw))}: pages run 1–${pages}</em></p>`;
}
const nav = (n: number, label: string) =>
n >= 1 && n <= pages ? `<a href="${here}?page=${n}">${label}</a>` : `<span>${label}</span>`;
const list = notice ? "" : itemList(items, "updated");
n >= 1 && n <= pages ? `<a rel="${n < pageN ? "prev" : "next"}" href="${here}?page=${n}">${label}</a>` : `<span>${label}</span>`;
const list = notice ? "" : itemList(items, "updated", here);
const body = publicFrame(
username,
`<p>${pages} page${pages === 1 ? "" : "s"} (diigo's, ${PUBLIC_PER_PAGE} bookmarks each; private ones are left out, so a page may show fewer).</p>
@@ -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 ? `<a href="${esc(href(n))}">${label}</a>` : `<span>${label}</span>`;
n >= 1 && n <= pages ? `<a rel="${n < pageN ? "prev" : "next"}" href="${esc(href(n))}">${label}</a>` : `<span>${label}</span>`;
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)}
<p>${nav(pageN - 1, "← prev")} ${nav(pageN + 1, "next →")} <a href="${here}">all, by diigo page</a></p>
${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
</form>`;
}
/** bookmarks as a list, each led by its `by` date */
function itemList(items: Item[], by: Query["by"]): string {
return `<ol>
/**
* 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 ? `<time class="${cls}" datetime="${when}" hidden></time>` : `<time class="${cls}" datetime="${when}">${when}</time>`;
};
return `<ol class="h-feed">
${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 ? `<br><small>${esc(it.description)}</small>` : "";
const desc = typeof it.description === "string" && it.description ? `<br><small class="p-summary">${esc(it.description)}</small>` : "";
const href = /^https?:\/\//i.test(url) ? url : "#";
const secs = dateOf(it, by);
const when = secs === undefined ? "" : new Date(secs * 1000).toISOString();
const date = when ? `<time datetime="${when}">${when}</time>` : "(no date)";
return `<li>${date} <a href="${esc(href)}">${esc(title)}</a>${desc}</li>`;
const tags = (typeof it.tags === "string" ? it.tags.split(",") : [])
.map((t) => t.trim())
.filter(Boolean)
.map((t) => ` <a class="p-category" href="${esc(`${here}?q=${encodeURIComponent(`#${t}`)}`)}">${esc(t)}</a>`)
.join("");
const other = by === "created" ? "updated" : "created";
return `<li class="h-entry">${time(it, by, false)} <a class="u-bookmark-of p-name" href="${esc(href)}">${esc(title)}</a>${tags}${desc}${time(it, other, true)}</li>`;
})
.join("\n")}
</ol>`;
+30 -1
View File
@@ -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(/<li><time datetime="\d{4}-\d\d-\d\dT[\d:.]+Z">[^<]+<\/time> <a /);
expect(html).toMatch(/<li class="h-entry"><time class="dt-updated" datetime="\d{4}-\d\d-\d\dT[\d:.]+Z">[^<]+<\/time> <a /);
expect(html).not.toContain(">bookmark 129<"); // private (129 % 3 === 0)
const json = (await (
@@ -389,6 +390,34 @@ describe("public page filters (goal 1d)", () => {
expect(await json()).toMatchObject({ matched: 270, complete: true });
});
it("the page is an h-feed of h-entry bookmarks: microformats are its API for other sites", async () => {
const page = await wireDated();
const html = await (await call(`${page}?days=7&by=created`)).text();
const base = `https://diigo-cache.test${page}`;
const { items, rels } = mf2(html, { baseUrl: base });
const feed = items.find((i) => i.type?.includes("h-feed"));
const entries = feed?.children ?? [];
expect(entries.map((e) => e.properties.name?.[0])).toEqual(dated(1, 2, 3, 4, 6, 7));
const seven = entries[5].properties;
expect(seven["bookmark-of"]).toEqual(["https://dated.example/7"]);
expect(seven.summary).toEqual(["a week of fragments"]);
expect(seven.category).toEqual(["ocap", "weekly"]);
const published = new Date(String(seven.published?.[0])).getTime();
const updated = new Date(String(seven.updated?.[0])).getTime();
// 7 isn't a 5: created when updated, 6 days + 1h ago
expect(published).toBe(updated);
expect(Date.now() - published).toBeGreaterThan(6 * 24 * 60 * 60 * 1000);
// one page of matches: no rel=next
expect(rels.next).toBeUndefined();
});
it("filtered pages link the next page with rel=next, keeping the filter", async () => {
const { page } = await wireStored("fake-deep-webapp", 3);
const html = await (await call(`${page}?days=300`)).text();
const { rels } = mf2(html, { baseUrl: `https://diigo-cache.test${page}?days=300` });
expect(rels.next).toEqual([`https://diigo-cache.test${page}?days=300&page=2`]);
});
it("JSON answers carry the canonical filter, so devs see what was understood", async () => {
const page = await wireDated();
const res = await call(`${page}?days=7&q=${encodeURIComponent("#Weekly")}`, { headers: { accept: "application/json" } });