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:
1 parent
c66aaea366
commit
be796d9d0d
4 files changed
+87
-13
No files matched your search
@@ -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"
|
||||
|
||||
Generated
+24
@@ -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
@@ -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>`;
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
Reference in new issue
Block a user