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": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-plugin": "1.3.6",
|
"@cloudflare/vitest-plugin": "1.3.6",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"microformats-parser": "^2.0.6",
|
||||||
"typescript": "^5.9.0",
|
"typescript": "^5.9.0",
|
||||||
"vitest": "4.1.11",
|
"vitest": "4.1.11",
|
||||||
"wrangler": "^4.148.0"
|
"wrangler": "^4.148.0"
|
||||||
|
|||||||
Generated
+24
@@ -18,6 +18,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22
|
specifier: ^22
|
||||||
version: 22.20.5
|
version: 22.20.5
|
||||||
|
microformats-parser:
|
||||||
|
specifier: ^2.0.6
|
||||||
|
version: 2.0.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.0
|
specifier: ^5.9.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -613,6 +616,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
entities@6.0.1:
|
||||||
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
error-stack-parser-es@1.0.5:
|
error-stack-parser-es@1.0.5:
|
||||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||||
|
|
||||||
@@ -722,6 +729,10 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
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:
|
miniflare@5.20261001.0-alpha:
|
||||||
resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==}
|
resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==}
|
||||||
engines: {node: '>=22.0.0'}
|
engines: {node: '>=22.0.0'}
|
||||||
@@ -739,6 +750,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
|
parse5@7.3.0:
|
||||||
|
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||||
|
|
||||||
path-to-regexp@6.3.0:
|
path-to-regexp@6.3.0:
|
||||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||||
|
|
||||||
@@ -1366,6 +1380,8 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
entities@6.0.1: {}
|
||||||
|
|
||||||
error-stack-parser-es@1.0.5: {}
|
error-stack-parser-es@1.0.5: {}
|
||||||
|
|
||||||
es-module-lexer@2.3.2: {}
|
es-module-lexer@2.3.2: {}
|
||||||
@@ -1467,6 +1483,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.6.0
|
'@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):
|
miniflare@5.20261001.0-alpha(@types/node@22.20.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cspotcode/source-map-support': 0.8.1
|
'@cspotcode/source-map-support': 0.8.1
|
||||||
@@ -1497,6 +1517,10 @@ snapshots:
|
|||||||
|
|
||||||
obug@2.2.1: {}
|
obug@2.2.1: {}
|
||||||
|
|
||||||
|
parse5@7.3.0:
|
||||||
|
dependencies:
|
||||||
|
entities: 6.0.1
|
||||||
|
|
||||||
path-to-regexp@6.3.0: {}
|
path-to-regexp@6.3.0: {}
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
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>`;
|
notice = `<p><em>no page ${esc(String(raw))}: pages run 1–${pages}</em></p>`;
|
||||||
}
|
}
|
||||||
const nav = (n: number, label: string) =>
|
const nav = (n: number, label: string) =>
|
||||||
n >= 1 && n <= pages ? `<a href="${here}?page=${n}">${label}</a>` : `<span>${label}</span>`;
|
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");
|
const list = notice ? "" : itemList(items, "updated", here);
|
||||||
const body = publicFrame(
|
const body = publicFrame(
|
||||||
username,
|
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>
|
`<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 kept = [...params].filter(([k, v]) => k !== "page" && v !== "");
|
||||||
const href = (n: number) => `${here}?${new URLSearchParams([...kept, ["page", String(n)]])}`;
|
const href = (n: number) => `${here}?${new URLSearchParams([...kept, ["page", String(n)]])}`;
|
||||||
const nav = (n: number, label: string) =>
|
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
|
const status = found.matched
|
||||||
? `${found.matched} match${found.matched === 1 ? "" : "es"}, page ${pageN} of ${pages}, newest ${query.by} first`
|
? `${found.matched} match${found.matched === 1 ? "" : "es"}, page ${pageN} of ${pages}, newest ${query.by} first`
|
||||||
: "no matches";
|
: "no matches";
|
||||||
@@ -566,7 +566,7 @@ function filteredPage(
|
|||||||
${fill ? fillLine(fill) : ""}
|
${fill ? fillLine(fill) : ""}
|
||||||
${filterForm(here, params)}
|
${filterForm(here, params)}
|
||||||
<p>${nav(pageN - 1, "← prev")} ${nav(pageN + 1, "next →")} <a href="${here}">all, by diigo page</a></p>
|
<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" } });
|
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>`;
|
</form>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** bookmarks as a list, each led by its `by` date */
|
/**
|
||||||
function itemList(items: Item[], by: Query["by"]): string {
|
* bookmarks as a list, each led by its `by` date, marked up as
|
||||||
return `<ol>
|
* 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
|
${items
|
||||||
.map((it) => {
|
.map((it) => {
|
||||||
const url = typeof it.url === "string" ? it.url : "";
|
const url = typeof it.url === "string" ? it.url : "";
|
||||||
const title = typeof it.title === "string" && it.title ? it.title : 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 href = /^https?:\/\//i.test(url) ? url : "#";
|
||||||
const secs = dateOf(it, by);
|
const tags = (typeof it.tags === "string" ? it.tags.split(",") : [])
|
||||||
const when = secs === undefined ? "" : new Date(secs * 1000).toISOString();
|
.map((t) => t.trim())
|
||||||
const date = when ? `<time datetime="${when}">${when}</time>` : "(no date)";
|
.filter(Boolean)
|
||||||
return `<li>${date} <a href="${esc(href)}">${esc(title)}</a>${desc}</li>`;
|
.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")}
|
.join("\n")}
|
||||||
</ol>`;
|
</ol>`;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import worker from "../src/index.js";
|
import worker from "../src/index.js";
|
||||||
import { idFromWebkey, toWebkey } from "../src/webkeys.js";
|
import { idFromWebkey, toWebkey } from "../src/webkeys.js";
|
||||||
import { PAGE_SIZE } from "../src/cache.js";
|
import { PAGE_SIZE } from "../src/cache.js";
|
||||||
|
import { mf2 } from "microformats-parser";
|
||||||
|
|
||||||
// matches tests/fake-webapp.js: 30 items, every third private
|
// matches tests/fake-webapp.js: 30 items, every third private
|
||||||
const WEBAPP_ID = "fake-webapp-id";
|
const WEBAPP_ID = "fake-webapp-id";
|
||||||
@@ -249,7 +250,7 @@ describe("worker routes", () => {
|
|||||||
// PAGE_SIZE 100: page 2 is bookmarks 101..200
|
// PAGE_SIZE 100: page 2 is bookmarks 101..200
|
||||||
expect(html).toContain(">bookmark 130<");
|
expect(html).toContain(">bookmark 130<");
|
||||||
// the date comes first, in toISOString() form
|
// 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)
|
expect(html).not.toContain(">bookmark 129<"); // private (129 % 3 === 0)
|
||||||
|
|
||||||
const json = (await (
|
const json = (await (
|
||||||
@@ -389,6 +390,34 @@ describe("public page filters (goal 1d)", () => {
|
|||||||
expect(await json()).toMatchObject({ matched: 270, complete: true });
|
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 () => {
|
it("JSON answers carry the canonical filter, so devs see what was understood", async () => {
|
||||||
const page = await wireDated();
|
const page = await wireDated();
|
||||||
const res = await call(`${page}?days=7&q=${encodeURIComponent("#Weekly")}`, { headers: { accept: "application/json" } });
|
const res = await call(`${page}?days=7&q=${encodeURIComponent("#Weekly")}`, { headers: { accept: "application/json" } });
|
||||||
|
|||||||
Reference in new issue
Block a user