Compare commits
14
Commits
efb54ad4cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8a220bbc | ||
|
|
68132ff4b3 | ||
|
|
9c09e8c5a4 | ||
|
|
94a059af25 | ||
|
|
be796d9d0d | ||
|
|
c66aaea366 | ||
|
|
a25bd699e1 | ||
|
|
ea3e256bb8 | ||
|
|
0af7a3ea6c | ||
|
|
9719fd0aa8 | ||
|
|
bce2424cc0 | ||
|
|
7f1e19fe85 | ||
|
|
8a812d76e3 | ||
|
|
002f08b456 |
No files matched your search
+19
-34
@@ -23,7 +23,7 @@ narrative.
|
||||
goal 5.
|
||||
|
||||
- [x] **1c. diigo-dump evolves into a bookmark cache**
|
||||
([diigo-cache/README.md](diigo-cache/README.md); design in the
|
||||
([diigo-cache/CONTRIBUTING.md](diigo-cache/CONTRIBUTING.md); design in the
|
||||
JSDoc of `src/cache.ts`, `src/index.ts`, `src/pm-save.ts`):
|
||||
- [x] 1c-i. Two independent page caches, private and public
|
||||
(`PagedCache`, `DiigoCache`, `PublicCache`).
|
||||
@@ -42,20 +42,16 @@ narrative.
|
||||
2026-10-07, via `scripts/compare-export.ts`. Getting there
|
||||
found diigo's `page_num` is 0-based (schema v2 migration).
|
||||
|
||||
- [ ] **1d. Date windows and search on the public page**, toward the
|
||||
- [x] **1d. Date windows and search on the public page**, toward the
|
||||
weekly PESOS roundup of [#23](https://github.com/dckc/madmode-blog/issues/23)
|
||||
(a week's permalink: `in=2026-W41`). One filter, two spellings:
|
||||
devs pass `{ days: 7, q: "#weekly" }`, URLs the same fields;
|
||||
`q` takes gmail/Slack/GitHub operators. UTC, ISO weeks, half-open
|
||||
windows; a fetch budget per request. Design: the API in
|
||||
`diigo-cache/src/query.ts` and the skipped suites in
|
||||
`tests/query.test.ts` and `tests/caches.test.ts` ("public page
|
||||
filters"); done when they're unskipped and green.
|
||||
Reconcile with 1f first: `query.ts` plans to filter while walking
|
||||
diigo's pages (binary search by `updated`), but after 1c-vi a
|
||||
filled cache holds every item, so 1d can query `items` in sqlite
|
||||
and spend diigo fetches only on a head refresh (1f). Choose
|
||||
before implementing; the skipped suites may need adjusting.
|
||||
windows (`diigo-cache/src/query.ts`; `tests/query.test.ts`,
|
||||
`tests/caches.test.ts` "public page filters"). Filters read
|
||||
stored `items` only (the public page never fetches): results are
|
||||
as fresh as the cache, and say `complete` when every diigo page
|
||||
is stored. Keeping them fresh is 1f. 2026-10-09.
|
||||
|
||||
- [x] **1e. Fill job: a button that grabs every page** into a cache
|
||||
from the webkey page `/cache/#<key>`, which trades the key for a
|
||||
@@ -93,31 +89,20 @@ narrative.
|
||||
[the issues sketch](potluck/issues-sketch.md))? If the policy
|
||||
changes, stripping stored rows is a durable-state migration.
|
||||
|
||||
- [ ] **2. madmode reads the public cache**: public data, so no
|
||||
capability, no cookie, no plugin sync job, no cron on madmode's
|
||||
side. madmode fetches `GET /public/dckc-madmode` with
|
||||
`Accept: application/json` (plus 1d's `in=`/`days=`/`q=`).
|
||||
- [ ] 2-i. Transport: a service binding to diigo-cache when both
|
||||
are deployed (no public hop, so no rate-limit exposure);
|
||||
in dev, a base URL (`http://localhost:8787`).
|
||||
- [ ] 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).
|
||||
- [ ] **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). Working in dev 2026-10-09; 2-iii postponed.
|
||||
- [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.
|
||||
- [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.
|
||||
- [ ] 2-iii. Decide `DiigoBridge`'s fate (postponed: nothing on this path uses it; it matters only for a caller that reads private items).
|
||||
- [x] 2-iv. Run it in dev: diigo-cache and potluck both up; the dashboard card → a draft issue of 22 bookmarks for 2026-W40 (2026-10-09). The service binding resolves across the two dev servers.
|
||||
- [ ] ~~**3. `/bookmarks` renders real data**~~: superseded by potluck's issues (the madmode starter is gone).
|
||||
- [ ] **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`).
|
||||
|
||||
- [ ] **4b. Consider scruffier tags.** EmDash tags are taxonomy terms (rows that must exist before use); diigo-week auto-creates them, so drafting feels scruffy, but the model is neat. Alternative: the inline `/tag/<tag>` links in issue bodies *are* the tags (matching the microformats `p-category` reading), and potluck's `/tag/<tag>` page finds issues by scanning bodies. Cost: no EmDash tag field or index; a scan over a few dozen issues a year is cheap. Open: does the admin's tag field let you type a new tag inline?
|
||||
|
||||
- [ ] **5. Deployed end-to-end** (order matters):
|
||||
- [ ] 5-i. webapp: `cd webapp && npx wrangler deploy`
|
||||
- [ ] 5-ii. diigo-cache: rate-limit `/api` (and `/public/*`) first
|
||||
(diigo-cache README, "Deployment concern"); deploy; create
|
||||
(diigo-cache CONTRIBUTING, "Deployment concern"); deploy; create
|
||||
against prod (ids → password manager; no worker secret);
|
||||
run the fill job.
|
||||
- [ ] 5-iii. madmode: deploy with the service binding; the
|
||||
@@ -239,5 +224,5 @@ to every goal in this tree.
|
||||
Oct 6 2026)
|
||||
- [webapp/README.md](webapp/README.md) — HTTP + RPC surfaces of the
|
||||
factory worker; open TODO on the mint-form placement
|
||||
- [diigo-cache/README.md](diigo-cache/README.md) — the cache worker:
|
||||
- [diigo-cache/CONTRIBUTING.md](diigo-cache/CONTRIBUTING.md) — the cache worker:
|
||||
create flow, the two DOs, run/deploy details
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
CONTRIBUTING.md
|
||||
@@ -0,0 +1,72 @@
|
||||
# Contributing to diigo-cache
|
||||
|
||||
diigo-cache is a Cloudflare Worker cache over [the webapp factory](../webapp/README.md): session-cookie reads of diigo bookmarks, cached in Durable-Object SQLite storage. Evolved from the diigo-dump worker (provenance: [mm-diigo `projects/diigo-bak/`](https://github.com/dckc/mm-diigo)); the static API-key path is dead (see [the capsite checklist](../capsite-checklist.md), goal 2 preamble).
|
||||
|
||||
## What it holds
|
||||
|
||||
- **The create flow, on one port**: `/_connect` renders webapp's own mint form (diigo-prefilled, cookie blank) — wrangler multi-config dev doesn't serve aux workers' HTTP, so `/_mint` relays the form opaquely into the WEBAPP binding (the cookie crosses; nothing on this side opens or logs it). Mint → `/_wire` (webapp id pre-filled, mode public|private) → the created page, where the user saves the ids.
|
||||
- **Two independent page caches** (`src/cache.ts`), one behavior: a page hit is served from storage with no diigo call; a miss asks the webapp instance in the cache's wiring and etches the page.
|
||||
- `DiigoCache` (binding `CACHE_DO`): every item. Addressed by its cache id.
|
||||
- `PublicCache` (binding `PUBLIC_DO`): shared items only — private rows are dropped *before* etching (`publicOnly`), so its storage holds nothing private. Same cookie webapp as the private cache. Public page n is diigo page n, filtered (may show fewer than `PAGE_SIZE`, 100: diigo's cap). `/public/<username>` resolves through the `idFromName("index")` registry, and reads through `viewer()`, a read-only facet: it shows stored pages and fill progress, and can't fetch from diigo or write.
|
||||
- **Storage**: SQL lives in `src/cache-read.sql` (selects only) and `src/cache-write.sql`; `src/store.ts` turns them into a reader and a writer facet, so the cache logic holds no SQL text.
|
||||
- **Pages**: home's dump form (cache id + page number, POST) shows raw dump rows; `/public/<username>?page=n` shows an HTML list with a page form (JSON for `Accept: application/json`). Both say `cache: HIT` or `MISS`; every page read also logs `{event: "cache", kind, page, outcome}`.
|
||||
- **Filters on the public page** (`src/query.ts`): `days`, `after`, `before`, `in` (`2026`, `2026-03`, `2026-Q1`, `2026-W41`), `by=updated|created`, and `q` (words, `"phrases"`, `#tag`, `-` negation, gmail/Slack/GitHub date operators). UTC, half-open windows. The same fields work as a dev-side spec (`toQuery({ days: 7 })`). A filter queries the stored `items` table through `viewer()`, never diigo: results are as fresh as the cache, and JSON says `complete` when every diigo page is stored.
|
||||
- **Publish later**: `POST /_publish` (cache id in the body) wires a public companion to a private-only cache.
|
||||
- **The webkey page and Cap'n Web**: `/cache/#<cache id>` is a skeleton page, the same for every key; its script reads the key from the fragment and trades it in-band for a capability over [Cap'n Web](https://github.com/cloudflare/capnweb) at `/api` (`CacheApi.cache(key)` → a `CacheCap` facet: fill, status, the public companion; `src/api.ts`). The worker serves capnweb's browser build itself at `/_capnweb.js`.
|
||||
- **Fill job**: walks every diigo page into a cache in the background — DO alarms, a few pages per step, progress stored in the DO (`startFill`/`fillStatus`/`stopFill`), driven from the webkey page; the public page shows progress. "Invalidate the last N days" there drops the cached pages holding anything updated that recently (`invalidateRecent`), so the next fill refetches them.
|
||||
- The `DiigoBridge` entrypoint for other workers (its cache-id source is the goal-2 wiring TODO).
|
||||
|
||||
## State surface
|
||||
|
||||
Ids are **webkeys** ([the pattern](../docs/webkeys.md)): 256-bit bearer secrets that never ride a query string or path. The webkey page carries the cache id in the URL fragment (never sent to the server) and passes it in-band over Cap'n Web; forms carry ids in POST bodies; logs and pages show only a fingerprint (a prefix of the id's one-way hash).
|
||||
|
||||
- **The cache id**: the cache's read capability (bearer; not logged). Held by the user — saved to the password manager under a petname they choose (the username field; never sent to the server) — and passed per POST.
|
||||
- **The webapp instance id**: the diigo-read capability; lives in each cache DO's wiring, arrives from the mint step.
|
||||
- **The cookie**: lives **only in webapp's DO storage**; crosses the wire once, at mint time, as an opaque relay.
|
||||
- **The public cache**: no capability — `/public/<username>` is public by design (a guessable URL; hiding the link doesn't hide the page).
|
||||
|
||||
Stored state is meant to be durable: see the breaking-change rule in [capsite AGENTS.md](../AGENTS.md).
|
||||
|
||||
## Deployment concern: rate-limit `/api`
|
||||
|
||||
Public pages only read storage, so strangers can't make the cache spend your diigo session. `/api` is keyed (a cache id is needed), but Cap'n Web pipelining makes calls cheap to send. Before exposing a deployment:
|
||||
|
||||
- rate-limit `/api` (and `/public/*`, which reads storage) — on Cloudflare, the [Workers rate-limit binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) (keyed by client and/or username); on other workerd hosts, a rate limit in the proxy in front;
|
||||
- run the fill job (goal 1e): public pages show only what's stored.
|
||||
|
||||
## RPC use (from another worker, same account)
|
||||
|
||||
```jsonc
|
||||
// wrangler.jsonc of the caller
|
||||
"services": [{ "binding": "DIIGO", "service": "diigo-cache", "entrypoint": "DiigoBridge" }]
|
||||
```
|
||||
|
||||
```ts
|
||||
// cache ids ride goal-2's wiring; see the capsite checklist
|
||||
const ndjson = await env.DIIGO.dumpNdjson(4); // 4 pages of PAGE_SIZE
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
pnpm types # regenerate worker-configuration.d.ts (after wrangler.jsonc changes)
|
||||
pnpm lint # fails if those types are stale; tsc over src/ and tests/
|
||||
pnpm test # vitest
|
||||
|
||||
Tests run the real worker and both DOs in the vitest pool, with `WEBAPP` bound to [a fake webapp](tests/fake-webapp.js) serving diigo-shaped items (`wrangler.tests.jsonc`).
|
||||
|
||||
## Run
|
||||
|
||||
[The README](README.md) gets a cache going in dev. The same create flow from a terminal:
|
||||
|
||||
wid=$(curl -s -X POST http://localhost:8787/_mint --data-urlencode \
|
||||
'url=https://www.diigo.com/interact_api/' --data-urlencode \
|
||||
'headers={"user-agent":"Mozilla/5.0","x-requested-with":"XMLHttpRequest","cookie":"<paste>"}' \
|
||||
--data-urlencode 'msPerCall=500' -D - -o /dev/null | grep -i x-webapp-id)
|
||||
curl -s -X POST http://localhost:8787/_wire -H 'content-type: application/json' \
|
||||
-d "{\"webappId\":\"<from $wid>\",\"mode\":\"private\"}"
|
||||
|
||||
Check a filled cache against a diigo RSS export: `node scripts/compare-export.ts <cache.sqlite> <export.xml> <out-dir>`.
|
||||
|
||||
## Deploy
|
||||
|
||||
Not deployed yet; the order matters (goal 5 in [the capsite checklist](../capsite-checklist.md)): webapp first (`cd ../webapp && npx wrangler deploy`), then diigo-cache, after rate-limiting `/api` (above).
|
||||
+16
-110
@@ -1,120 +1,26 @@
|
||||
# diigo-cache
|
||||
|
||||
Cloudflare Worker cache over [the webapp factory](../webapp/README.md):
|
||||
session-cookie reads of diigo bookmarks, cached in Durable-Object SQLite
|
||||
storage. Evolved from the diigo-dump worker (provenance:
|
||||
[mm-diigo `projects/diigo-bak/`](https://github.com/dckc/mm-diigo)); the
|
||||
static API-key path is dead (see [the capsite checklist](../capsite-checklist.md),
|
||||
goal 2 preamble).
|
||||
Your diigo bookmarks, cached in a [workerd](https://github.com/cloudflare/workerd) worker, with a public page you can filter by week: `/public/<you>?in=2026-W41`. Your diigo session cookie is stored in one Durable Object and nowhere else.
|
||||
|
||||
## What it holds
|
||||
## Getting started
|
||||
|
||||
- **The create flow, on one port**: `/_connect` renders webapp's own
|
||||
mint form (diigo-prefilled, cookie blank) — wrangler multi-config dev
|
||||
doesn't serve aux workers' HTTP, so `/_mint` relays the form opaquely
|
||||
into the WEBAPP binding (the cookie crosses; nothing on this side opens
|
||||
or logs it). Mint → `/_wire` (webapp id pre-filled, mode
|
||||
public|private) → the created page, where the user saves the ids.
|
||||
- **Two independent page caches** (`src/cache.ts`), one behavior: a
|
||||
page hit is served from storage with no diigo call; a miss asks the
|
||||
webapp instance in the cache's wiring and etches the page.
|
||||
- `DiigoCache` (binding `CACHE_DO`): every item. Addressed by its
|
||||
cache id.
|
||||
- `PublicCache` (binding `PUBLIC_DO`): shared items only — private
|
||||
rows are dropped *before* etching (`publicOnly`), so its storage
|
||||
holds nothing private. Same cookie webapp as the private cache.
|
||||
Public page n is diigo page n, filtered (may show fewer than
|
||||
`PAGE_SIZE`, 100: diigo's cap).
|
||||
`/public/<username>` resolves through the `idFromName("index")`
|
||||
registry, and reads through `viewer()`, a read-only facet: it shows
|
||||
stored pages and fill progress, and can't fetch from diigo or write.
|
||||
- **Storage**: SQL lives in `src/cache-read.sql` (selects only) and
|
||||
`src/cache-write.sql`; `src/store.ts` turns them into a reader and a
|
||||
writer facet, so the cache logic holds no SQL text.
|
||||
- **Pages**: home's dump form (cache id + page number, POST) shows raw
|
||||
dump rows; `/public/<username>?page=n` shows an HTML list with a page
|
||||
form (JSON for `Accept: application/json`). Both say `cache: HIT` or
|
||||
`MISS`; every page read also logs `{event: "cache", kind, page,
|
||||
outcome}`.
|
||||
- **Publish later**: `POST /_publish` (cache id in the body) wires a
|
||||
public companion to a private-only cache.
|
||||
- **The webkey page and Cap'n Web**: `/cache/#<cache id>` is a skeleton
|
||||
page, the same for every key; its script reads the key from the
|
||||
fragment and trades it in-band for a capability over
|
||||
[Cap'n Web](https://github.com/cloudflare/capnweb) at `/api`
|
||||
(`CacheApi.cache(key)` → a `CacheCap` facet: fill, status, the public
|
||||
companion; `src/api.ts`). The worker serves capnweb's browser build
|
||||
itself at `/_capnweb.js`.
|
||||
- **Fill job**: walks every diigo page into a cache in the background —
|
||||
DO alarms, a few pages per step, progress stored in the DO
|
||||
(`startFill`/`fillStatus`/`stopFill`), driven from the webkey page;
|
||||
the public page shows progress. "Invalidate the last N days" there
|
||||
drops the cached pages holding anything updated that recently
|
||||
(`invalidateRecent`), so the next fill refetches them.
|
||||
- The `DiigoBridge` entrypoint for other workers (its cache-id source is
|
||||
the goal-2 wiring TODO).
|
||||
|
||||
## State surface
|
||||
|
||||
Ids are **webkeys** ([the pattern](../docs/webkeys.md)): 256-bit bearer
|
||||
secrets that never ride a query string or path. The webkey page carries
|
||||
the cache id in the URL fragment (never sent to the server) and passes
|
||||
it in-band over Cap'n Web; forms carry ids in POST bodies; logs and pages
|
||||
show only a fingerprint (a prefix of the id's one-way hash).
|
||||
|
||||
- **The cache id**: the cache's read capability (bearer; not logged).
|
||||
Held by the user — saved to the password manager under a petname they
|
||||
choose (the username field; never sent to the server) — and passed
|
||||
per POST.
|
||||
- **The webapp instance id**: the diigo-read capability; lives in each
|
||||
cache DO's wiring, arrives from the mint step.
|
||||
- **The cookie**: lives **only in webapp's DO storage**; crosses the
|
||||
wire once, at mint time, as an opaque relay.
|
||||
- **The public cache**: no capability — `/public/<username>` is public
|
||||
by design (a guessable URL; hiding the link doesn't hide the page).
|
||||
|
||||
Stored state is meant to be durable: see the breaking-change rule in
|
||||
[capsite AGENTS.md](../AGENTS.md).
|
||||
|
||||
## Deployment concern: rate-limit `/api`
|
||||
|
||||
Public pages only read storage, so strangers can't make the cache spend
|
||||
your diigo session. `/api` is keyed (a cache id is needed), but Cap'n
|
||||
Web pipelining makes calls cheap to send. Before exposing a deployment:
|
||||
|
||||
- rate-limit `/api` (and `/public/*`, which reads storage) — on
|
||||
Cloudflare, the
|
||||
[Workers rate-limit binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)
|
||||
(keyed by client and/or username); on other workerd hosts, a rate
|
||||
limit in the proxy in front;
|
||||
- run the fill job (goal 1e): public pages show only what's stored.
|
||||
|
||||
## RPC use (from another worker, same account)
|
||||
|
||||
```jsonc
|
||||
// wrangler.jsonc of the caller
|
||||
"services": [{ "binding": "DIIGO", "service": "diigo-cache", "entrypoint": "DiigoBridge" }]
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev # → http://localhost:8787/
|
||||
```
|
||||
|
||||
```ts
|
||||
// cache ids ride goal-2's wiring; see the capsite checklist
|
||||
const ndjson = await env.DIIGO.dumpNdjson(4); // 4 pages of PAGE_SIZE
|
||||
```
|
||||
Then, in the browser:
|
||||
|
||||
## Test
|
||||
1. **mint the diigo session at webapp**: paste the `Cookie` header from diigo.com (devtools, any logged-in request).
|
||||
2. **wire** it, mode **public**. Save the cache id it shows (the password-manager button does this).
|
||||
3. **open this cache** → **fill**, and watch it walk your bookmarks.
|
||||
4. Visit `/public/<your diigo username>?days=7`.
|
||||
|
||||
pnpm types # regenerate worker-configuration.d.ts (after wrangler.jsonc changes)
|
||||
pnpm lint # fails if those types are stale; tsc over src/ and tests/
|
||||
pnpm test # vitest
|
||||
## Bookmarks you hold, a page anyone can read
|
||||
|
||||
Tests run the real worker and both DOs in the vitest pool, with
|
||||
`WEBAPP` bound to [a fake webapp](tests/fake-webapp.js) serving
|
||||
diigo-shaped items (`wrangler.tests.jsonc`).
|
||||
- **Filter by date and search:** `?days=7`, `?in=2026-W41` (ISO week, UTC), `?after=2026-01-01&before=2026-02-01`, `?by=created`, and `?q=` with words, `"phrases"`, `#tags`, `-negation`, and gmail-style `newer_than:7d`. Add `Accept: application/json` for JSON.
|
||||
- **Private stays private:** the public cache drops private bookmarks before storing anything, so it never holds them.
|
||||
- **Ids are capabilities:** the cache id is a bearer key you keep in your password manager. It rides URL fragments and POST bodies, never a path or query string, so it stays out of logs and history.
|
||||
- **Cheap to read:** pages come from Durable Object SQLite storage, and a fill job in the background fetches each diigo page once.
|
||||
|
||||
## Run, deploy
|
||||
|
||||
[How to run / connect / deploy is maintained in the capsite checklist](../capsite-checklist.md)
|
||||
(single source; this README keeps only worker-level facts).
|
||||
|
||||
Status: create, dump, publish, and public pages exercised against live
|
||||
diigo in dev (2026-10-07); not yet deployed.
|
||||
Design, tests, and deploying: [CONTRIBUTING](CONTRIBUTING.md).
|
||||
@@ -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: {}
|
||||
|
||||
@@ -19,3 +19,14 @@ where cast(items.updated as integer) >= ?;
|
||||
|
||||
-- pagesThrough:
|
||||
select count(*) as n from pages where page_num <= ?;
|
||||
|
||||
-- itemsByUpdated:
|
||||
select json from items
|
||||
where cast(updated as integer) >= ? and cast(updated as integer) < ?
|
||||
order by cast(updated as integer) desc, item_id;
|
||||
|
||||
-- itemsByCreated:
|
||||
select json from items
|
||||
where cast(json_extract(json, '$.created_at') as integer) >= ?
|
||||
and cast(json_extract(json, '$.created_at') as integer) < ?
|
||||
order by cast(json_extract(json, '$.created_at') as integer) desc, item_id;
|
||||
@@ -6,6 +6,8 @@
|
||||
import { DurableObject, RpcTarget } from "cloudflare:workers";
|
||||
import type { PagedGet } from "./diigo.js";
|
||||
import { publicOnly, toRows } from "./items.js";
|
||||
import { matches } from "./query.js";
|
||||
import type { Query } from "./query.js";
|
||||
import { makeReader, makeWriter, SCHEMA_VERSION } from "./store.js";
|
||||
import type { Reader, Writer } from "./store.js";
|
||||
import type { Item, ItemRow } from "./items.js";
|
||||
@@ -398,7 +400,18 @@ abstract class PagedCache extends DurableObject<CacheEnv> {
|
||||
}
|
||||
}
|
||||
|
||||
/** what anyone may see of a cache: stored pages and fill progress */
|
||||
/** one page of a filter's matches over stored items */
|
||||
export type QueryPage = {
|
||||
/** the matches on this page, raw dump rows (JSON strings) */
|
||||
items: string[];
|
||||
/** matches in all */
|
||||
matched: number;
|
||||
/** true when every diigo page is stored, so no match can be missing */
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
/** what anyone may see of a cache: stored pages, stored items by filter,
|
||||
* and fill progress */
|
||||
export class PageViewer extends RpcTarget {
|
||||
#reader: Reader;
|
||||
#fillStatus: () => Promise<FillStatus | undefined>;
|
||||
@@ -415,6 +428,25 @@ export class PageViewer extends RpcTarget {
|
||||
return stored && { ...stored, cached: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* page `pageNum` (`count` per page) of the stored items matching
|
||||
* `query`, newest first by its date. The window narrows in SQL; terms
|
||||
* are checked per item. Reads storage only: items not yet stored can't
|
||||
* match, which `complete` reports.
|
||||
*/
|
||||
query(query: Query, pageNum: number, count: number): QueryPage {
|
||||
const from = query.after ? Math.ceil(query.after.getTime() / 1000) : Number.MIN_SAFE_INTEGER;
|
||||
const to = query.before ? Math.ceil(query.before.getTime() / 1000) : Number.MAX_SAFE_INTEGER;
|
||||
const hits = this.#reader.itemsIn(query.by, from, to).filter((json) => matches(JSON.parse(json) as Item, query));
|
||||
const total = this.#reader.knownTotal();
|
||||
const pages = total === null ? null : Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
return {
|
||||
items: hits.slice((pageNum - 1) * count, pageNum * count),
|
||||
matched: hits.length,
|
||||
complete: pages !== null && this.#reader.pagesThrough(pages) >= pages,
|
||||
};
|
||||
}
|
||||
|
||||
/** diigo's total, as last stored, or null if nothing is stored */
|
||||
knownTotal(): number | null {
|
||||
return this.#reader.knownTotal();
|
||||
|
||||
+150
-32
@@ -10,9 +10,11 @@ import { CAPNWEB_BROWSER_JS } from "./capnweb-browser.js";
|
||||
import { CACHE_PAGE_HTML } from "./cache-page.js";
|
||||
import { DiigoCacheLogic, PublicCacheLogic } from "./cache.js";
|
||||
import { PAGE_SIZE } from "./cache.js";
|
||||
import type { CacheEnv, Clock, FillStatus } from "./cache.js";
|
||||
import type { CacheEnv, Clock, FillStatus, QueryPage } from "./cache.js";
|
||||
import type { Item } from "./cache.js";
|
||||
import { updatedISO, usernameOf } from "./items.js";
|
||||
import { usernameOf } from "./items.js";
|
||||
import { dateOf, isEmpty, parseQuery } from "./query.js";
|
||||
import type { Query } from "./query.js";
|
||||
import { toWebkey, idFromWebkey, isWebkey, shashKey } from "./webkeys.js";
|
||||
import { pmSave } from "./pm-save.js";
|
||||
|
||||
@@ -443,10 +445,16 @@ async function dump(request: Request, env: Env): Promise<Response> {
|
||||
|
||||
const PUBLIC_PER_PAGE = PAGE_SIZE;
|
||||
|
||||
/** the public page's day presets */
|
||||
const DAY_PRESETS = [7, 30, 90, 365];
|
||||
|
||||
/**
|
||||
* the public page for a username: stored pages only, through the cache's
|
||||
* read-only `viewer()` (it can't fetch or write). HTML with a page-number
|
||||
* form, or JSON for `Accept: application/json`. `?page=` in the URL is
|
||||
* read-only `viewer()` (it can't fetch or write). HTML with a filter form
|
||||
* and a page-number form, or JSON for `Accept: application/json`. A
|
||||
* filter (goal 1d: `days`, `after`, `before`, `in`, `by`, `q`; see
|
||||
* {@link parseQuery}) lists the stored items that match, `page` counting
|
||||
* matches; with none, `page` is diigo's page as stored. Query params are
|
||||
* fine here: nothing on this page is secret.
|
||||
*/
|
||||
async function publicPage(request: Request, env: Env, username: string): Promise<Response> {
|
||||
@@ -456,14 +464,33 @@ async function publicPage(request: Request, env: Env, username: string): Promise
|
||||
const ns = env.PUBLIC_DO;
|
||||
const cache = ns.get(ns.idFromString(idFromWebkey(publicId)));
|
||||
|
||||
const raw = new URL(request.url).searchParams.get("page");
|
||||
const params = new URL(request.url).searchParams;
|
||||
const accept = request.headers.get("accept") ?? "";
|
||||
const wantsJson = accept.includes("application/json") && !accept.includes("text/html");
|
||||
const here = `/public/${encodeURIComponent(username)}`;
|
||||
const raw = params.get("page");
|
||||
const pageN = raw === null ? 1 : Number.parseInt(raw, 10);
|
||||
const valid = !Number.isNaN(pageN) && pageN >= 1 && pageN <= 10000;
|
||||
|
||||
const query = parseQuery(params, new Date());
|
||||
if ("error" in query) {
|
||||
console.log({ event: "notice", page: "public", text: "filter refused", error: query.error });
|
||||
if (wantsJson) return Response.json({ error: query.error }, { status: 400 });
|
||||
const body = publicFrame(username, `${filterForm(here, params)}
|
||||
<p><em>${esc(query.error)}</em></p>`);
|
||||
return new Response(body, { status: 400, headers: { "content-type": "text/html;charset=utf-8" } });
|
||||
}
|
||||
|
||||
const viewer = await cache.viewer();
|
||||
let page, total, fill;
|
||||
let page, fill, found;
|
||||
let total: number | null = null;
|
||||
try {
|
||||
if (isEmpty(query)) {
|
||||
page = await viewer.page(valid ? pageN : 1, PUBLIC_PER_PAGE);
|
||||
total = page ? page.total : await viewer.knownTotal();
|
||||
} else {
|
||||
found = await viewer.query(query, valid ? pageN : 1, PUBLIC_PER_PAGE);
|
||||
}
|
||||
fill = await viewer.fillStatus();
|
||||
} catch (err) {
|
||||
console.error({ event: "public-page", page: pageN, error: String(err) });
|
||||
@@ -471,13 +498,14 @@ async function publicPage(request: Request, env: Env, username: string): Promise
|
||||
} finally {
|
||||
viewer[Symbol.dispose]();
|
||||
}
|
||||
if (found) return filteredPage(username, here, params, query, found, valid ? pageN : 1, wantsJson, fill);
|
||||
|
||||
// total and pages count all of diigo's items: public page n is diigo
|
||||
// page n with the private rows dropped
|
||||
const pages = total === null ? 1 : Math.max(1, Math.ceil(total / PUBLIC_PER_PAGE));
|
||||
const items = page ? page.items.map((j) => JSON.parse(j) as Item) : [];
|
||||
|
||||
const accept = request.headers.get("accept") ?? "";
|
||||
if (accept.includes("application/json") && !accept.includes("text/html")) {
|
||||
if (wantsJson) {
|
||||
return Response.json({ username, page: pageN, pages, total, stored: page !== null, etchedAt: page?.etchedAt ?? null, items });
|
||||
}
|
||||
|
||||
@@ -486,42 +514,132 @@ async function publicPage(request: Request, env: Env, username: string): Promise
|
||||
console.log({ event: "notice", page: "public", text: "page out of range", pages });
|
||||
notice = `<p><em>no page ${esc(String(raw))}: pages run 1–${pages}</em></p>`;
|
||||
}
|
||||
const here = `/public/${encodeURIComponent(username)}`;
|
||||
const nav = (n: number, label: string) =>
|
||||
n >= 1 && n <= pages ? `<a href="${here}?page=${n}">${label}</a>` : `<span>${label}</span>`;
|
||||
const list = notice
|
||||
? ""
|
||||
: `<ol>
|
||||
${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 href = /^https?:\/\//i.test(url) ? url : "#";
|
||||
const when = updatedISO(it);
|
||||
const date = when ? `<time datetime="${when}">${when}</time>` : "(no date)";
|
||||
return `<li>${date} <a href="${esc(href)}">${esc(title)}</a>${desc}</li>`;
|
||||
})
|
||||
.join("\n")}
|
||||
</ol>`;
|
||||
const body = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>${esc(username)}'s shared bookmarks</title></head>
|
||||
<body>
|
||||
<h1>${esc(username)}'s shared bookmarks</h1>
|
||||
<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>
|
||||
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>
|
||||
<p>${page ? `<strong>stored ${esc(page.etchedAt ?? "")}</strong> — page ${valid ? pageN : 1}, ${items.length} shared` : `<strong>not fetched yet</strong> — page ${valid ? pageN : 1}; the cache's owner fills it`}</p>
|
||||
${fill ? fillLine(fill) : ""}
|
||||
${filterForm(here, params)}
|
||||
<form method="get" action="${here}">
|
||||
<p>${nav(pageN - 1, "← prev")}
|
||||
<label>page <input type="number" name="page" min="1" value="${Number.isNaN(pageN) ? 1 : pageN}"></label>
|
||||
<button type="submit">go</button>
|
||||
${nav(pageN + 1, "next →")}</p>
|
||||
</form>
|
||||
${notice}${list}
|
||||
</body></html>`;
|
||||
${notice}${list}`,
|
||||
);
|
||||
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
|
||||
}
|
||||
|
||||
/** a filter's matches, as the public page's HTML or JSON */
|
||||
function filteredPage(
|
||||
username: string,
|
||||
here: string,
|
||||
params: URLSearchParams,
|
||||
query: Query,
|
||||
found: QueryPage,
|
||||
pageN: number,
|
||||
wantsJson: boolean,
|
||||
fill: FillStatus | undefined,
|
||||
): Response {
|
||||
const pages = Math.max(1, Math.ceil(found.matched / PUBLIC_PER_PAGE));
|
||||
const items = found.items.map((j) => JSON.parse(j) as Item);
|
||||
if (wantsJson) {
|
||||
const { matched, complete } = found;
|
||||
return Response.json({ username, filter: query, page: pageN, pages, matched, complete, items });
|
||||
}
|
||||
// links keep the filter: the request's own non-empty params, in order
|
||||
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 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";
|
||||
const caveat = found.complete ? "" : " — not every diigo page is stored yet, so some matches may be missing";
|
||||
const body = publicFrame(
|
||||
username,
|
||||
`<p><strong>${esc(status)}</strong>${caveat}</p>
|
||||
${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, here)}`,
|
||||
);
|
||||
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
|
||||
}
|
||||
|
||||
/** the public page's frame: title and heading around `content` */
|
||||
function publicFrame(username: string, content: string): string {
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>${esc(username)}'s shared bookmarks</title></head>
|
||||
<body>
|
||||
<h1>${esc(username)}'s shared bookmarks</h1>
|
||||
${content}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
/** the filter form, echoing the request's filter */
|
||||
function filterForm(here: string, params: URLSearchParams): string {
|
||||
const field = (name: string) => esc(params.get(name) ?? "");
|
||||
const days = params.get("days") ?? "";
|
||||
const presets = DAY_PRESETS.map(String);
|
||||
if (days && !presets.includes(days)) presets.push(days);
|
||||
const option = (value: string, label: string, current: string) =>
|
||||
`<option value="${esc(value)}"${value === current ? " selected" : ""}>${esc(label)}</option>`;
|
||||
return `<form method="get" action="${here}">
|
||||
<p><label>last <select name="days">${option("", "any", days)}${presets.map((n) => option(n, `${n} days`, days)).join("")}</select></label>
|
||||
<label>or after <input type="date" name="after" value="${field("after")}"></label>
|
||||
<label>before <input type="date" name="before" value="${field("before")}"></label>
|
||||
<label>or in <input name="in" size="9" placeholder="2026-W41" value="${field("in")}"></label>
|
||||
<label>by <select name="by">${option("updated", "updated", params.get("by") ?? "")}${option("created", "created", params.get("by") ?? "")}</select></label></p>
|
||||
<p><label>search <input type="search" name="q" size="40" value="${field("q")}"></label>
|
||||
<button type="submit">filter</button></p>
|
||||
<p><small>Dates and periods (2026, 2026-03, 2026-Q1, 2026-W41) are UTC. Search takes
|
||||
words, "phrases", #tags, -negation, and after:, before:, newer_than:7d, during:2026-Q1, created:2026.</small></p>
|
||||
</form>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 class="p-summary">${esc(it.description)}</small>` : "";
|
||||
const href = /^https?:\/\//i.test(url) ? url : "#";
|
||||
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>`;
|
||||
}
|
||||
|
||||
/** home page: the connect link (webapp's cookie form, diigo-prefilled) +
|
||||
* the wire step for an already-minted id + the dump form. `home` renders
|
||||
* the error path too, so it takes env for the connect link. Notices (the
|
||||
|
||||
+188
-19
@@ -1,18 +1,10 @@
|
||||
/**
|
||||
* @file the public page's filters: date windows and search (goal 1d, **not
|
||||
* yet implemented**; the design is the skipped specs in tests/query.test.ts).
|
||||
* @file the public page's filters: date windows and search over a cache's
|
||||
* stored items (goal 1d; tests/query.test.ts).
|
||||
*/
|
||||
|
||||
import type { Item } from "./items.js";
|
||||
|
||||
/**
|
||||
* diigo fetches one request may spend (cache hits are free); under the
|
||||
* Workers free plan's 50 external subrequests per invocation
|
||||
*
|
||||
* @see https://developers.cloudflare.com/workers/platform/limits/
|
||||
*/
|
||||
export const FETCH_BUDGET = 40;
|
||||
|
||||
/**
|
||||
* a filter as devs write it (`{ days: 7, q: "#weekly" }`); URL params map
|
||||
* onto these fields one-to-one (strings, with "" meaning absent). Dates
|
||||
@@ -56,6 +48,76 @@ export type Query = {
|
||||
/** a refused filter: the message is safe to show and log */
|
||||
export type QueryError = { error: string };
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** thrown inside {@link toQuery}, returned as a {@link QueryError} */
|
||||
class Refused extends Error {}
|
||||
|
||||
/** `yyyy-mm-dd` or `yyyy/mm/dd` as 00:00Z of that UTC day */
|
||||
function parseDay(text: string, what: string): Date {
|
||||
const m = /^(\d{4})([-/])(\d{2})\2(\d{2})$/.exec(text);
|
||||
const d = m && new Date(Date.UTC(Number(m[1]), Number(m[3]) - 1, Number(m[4])));
|
||||
if (!m || !d || d.getUTCMonth() !== Number(m[3]) - 1 || d.getUTCDate() !== Number(m[4])) {
|
||||
throw new Refused(`${what}: expected a date as yyyy-mm-dd`);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Monday 00:00Z of ISO week 1 of `year` (the week holding January 4) */
|
||||
function isoWeekOne(year: number): number {
|
||||
const jan4 = Date.UTC(year, 0, 4);
|
||||
const weekday = new Date(jan4).getUTCDay() || 7;
|
||||
return jan4 - (weekday - 1) * DAY_MS;
|
||||
}
|
||||
|
||||
/** a named period as its half-open [after, before) window */
|
||||
function parsePeriod(text: string, what: string): [Date, Date] {
|
||||
const refuse = () => new Refused(`${what}: expected a period as 2025, 2025-03, 2025-Q1, or 2025-W01`);
|
||||
let m;
|
||||
if ((m = /^(\d{4})$/.exec(text))) {
|
||||
const y = Number(m[1]);
|
||||
return [new Date(Date.UTC(y, 0, 1)), new Date(Date.UTC(y + 1, 0, 1))];
|
||||
}
|
||||
if ((m = /^(\d{4})-(\d{2})$/.exec(text))) {
|
||||
const [y, mo] = [Number(m[1]), Number(m[2])];
|
||||
if (mo < 1 || mo > 12) throw refuse();
|
||||
return [new Date(Date.UTC(y, mo - 1, 1)), new Date(Date.UTC(y, mo, 1))];
|
||||
}
|
||||
if ((m = /^(\d{4})-Q([1-4])$/.exec(text))) {
|
||||
const [y, q] = [Number(m[1]), Number(m[2])];
|
||||
return [new Date(Date.UTC(y, 3 * (q - 1), 1)), new Date(Date.UTC(y, 3 * q, 1))];
|
||||
}
|
||||
if ((m = /^(\d{4})-W(\d{2})$/.exec(text))) {
|
||||
const [y, w] = [Number(m[1]), Number(m[2])];
|
||||
const weeks = (isoWeekOne(y + 1) - isoWeekOne(y)) / (7 * DAY_MS);
|
||||
if (w < 1 || w > weeks) throw refuse();
|
||||
const monday = isoWeekOne(y) + (w - 1) * 7 * DAY_MS;
|
||||
return [new Date(monday), new Date(monday + 7 * DAY_MS)];
|
||||
}
|
||||
throw refuse();
|
||||
}
|
||||
|
||||
/** gmail's `newer_than:` amount (`7d`, `2w`, `1m`, `1y`) counted back from
|
||||
* `now`; months and years are calendar steps */
|
||||
function parseNewerThan(text: string, now: Date): Date {
|
||||
const m = /^(\d{1,4})([dwmy])$/.exec(text);
|
||||
if (!m || Number(m[1]) < 1) throw new Refused("newer_than: expected an amount like 7d, 2w, 1m, or 1y");
|
||||
const n = Number(m[1]);
|
||||
const d = new Date(now.getTime());
|
||||
if (m[2] === "d") d.setTime(now.getTime() - n * DAY_MS);
|
||||
if (m[2] === "w") d.setTime(now.getTime() - 7 * n * DAY_MS);
|
||||
if (m[2] === "m") d.setUTCMonth(d.getUTCMonth() - n);
|
||||
if (m[2] === "y") d.setUTCFullYear(d.getUTCFullYear() - n);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** `days` as a whole number from 1 to 3650 */
|
||||
function parseDays(days: number | string): number {
|
||||
const n = typeof days === "number" ? days : /^\d+$/.test(days) ? Number(days) : NaN;
|
||||
if (!Number.isInteger(n) || n < 1 || n > 3650) throw new Refused("days: expected a whole number from 1 to 3650");
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve a filter: fields and `q` operators combine; a bound given twice
|
||||
* (by fields, operators, or both) is refused, as is an empty window.
|
||||
@@ -75,21 +137,128 @@ export type QueryError = { error: string };
|
||||
* not read ambiently, so tests pin it)
|
||||
*/
|
||||
export function toQuery(spec: FilterSpec, now: Date): Query | QueryError {
|
||||
void spec;
|
||||
void now;
|
||||
throw new Error("toQuery: not implemented (goal 1d)");
|
||||
try {
|
||||
return resolve(spec, now);
|
||||
} catch (err) {
|
||||
if (err instanceof Refused) return { error: err.message };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function resolve(spec: FilterSpec, now: Date): Query {
|
||||
const words: Term[] = [];
|
||||
const tags: Term[] = [];
|
||||
let by: { value: Query["by"]; from: string } | undefined;
|
||||
let after: { value: Date; from: string } | undefined;
|
||||
let before: { value: Date; from: string } | undefined;
|
||||
|
||||
const setBy = (value: Query["by"], from: string) => {
|
||||
if (by && by.value !== value) throw new Refused(`${by.from} and ${from} name different dates`);
|
||||
by = { value, from };
|
||||
};
|
||||
const setAfter = (value: Date, from: string) => {
|
||||
if (after) throw new Refused(`${after.from} and ${from} both set where the window starts`);
|
||||
after = { value, from };
|
||||
};
|
||||
const setBefore = (value: Date, from: string) => {
|
||||
if (before) throw new Refused(`${before.from} and ${from} both set where the window ends`);
|
||||
before = { value, from };
|
||||
};
|
||||
const setPeriod = ([lo, hi]: [Date, Date], from: string) => {
|
||||
setAfter(lo, from);
|
||||
setBefore(hi, from);
|
||||
};
|
||||
|
||||
const present = (v: string | number | undefined) => v !== undefined && v !== "";
|
||||
if (present(spec.by)) {
|
||||
if (spec.by !== "updated" && spec.by !== "created") throw new Refused("by: expected updated or created");
|
||||
setBy(spec.by, `by=${spec.by}`);
|
||||
}
|
||||
if (present(spec.days)) setAfter(new Date(now.getTime() - parseDays(spec.days!) * DAY_MS), "days");
|
||||
if (present(spec.after)) setAfter(parseDay(spec.after!, "after"), "after");
|
||||
if (present(spec.before)) setBefore(parseDay(spec.before!, "before"), "before");
|
||||
if (present(spec.in)) setPeriod(parsePeriod(spec.in!, "in"), "in");
|
||||
|
||||
for (const m of (spec.q ?? "").matchAll(/(-?)"([^"]*)"|(\S+)/g)) {
|
||||
if (m[2] !== undefined) {
|
||||
if (m[2].trim()) words.push({ text: m[2].trim().toLowerCase(), negated: m[1] === "-" });
|
||||
continue;
|
||||
}
|
||||
const token = m[3];
|
||||
const negated = token.length > 1 && token.startsWith("-");
|
||||
const body = negated ? token.slice(1) : token;
|
||||
const op = /^([a-z_]+):(.+)$/i.exec(body);
|
||||
const name = op?.[1].toLowerCase();
|
||||
const arg = op?.[2] ?? "";
|
||||
const windowOp = ["after", "before", "newer_than", "during", "in", "created", "updated"];
|
||||
if (name && windowOp.includes(name) && negated) throw new Refused(`${name}: can't be negated`);
|
||||
if (name === "after") setAfter(parseDay(arg, "after:"), "after:");
|
||||
else if (name === "before") setBefore(parseDay(arg, "before:"), "before:");
|
||||
else if (name === "newer_than") setAfter(parseNewerThan(arg, now), "newer_than:");
|
||||
else if (name === "during" || name === "in") setPeriod(parsePeriod(arg, `${name}:`), `${name}:`);
|
||||
else if (name === "created" || name === "updated") {
|
||||
setBy(name, `${name}:`);
|
||||
setPeriod(parsePeriod(arg, `${name}:`), `${name}:`);
|
||||
} else if (name === "tag") tags.push({ text: arg.toLowerCase(), negated });
|
||||
else if (body.length > 1 && body.startsWith("#")) tags.push({ text: body.slice(1).toLowerCase(), negated });
|
||||
else words.push({ text: body.toLowerCase(), negated });
|
||||
}
|
||||
|
||||
if (after && before && after.value.getTime() >= before.value.getTime()) {
|
||||
throw new Refused(`the window is empty: ${after.from} is not earlier than ${before.from}`);
|
||||
}
|
||||
const query: Query = { by: by?.value ?? "updated", words, tags };
|
||||
if (after) query.after = after.value;
|
||||
if (before) query.before = before.value;
|
||||
return query;
|
||||
}
|
||||
|
||||
/** {@link toQuery} over the public page's URL params */
|
||||
export function parseQuery(params: URLSearchParams, now: Date): Query | QueryError {
|
||||
void params;
|
||||
void now;
|
||||
throw new Error("parseQuery: not implemented (goal 1d)");
|
||||
const field = (name: string) => params.get(name) ?? "";
|
||||
const by = field("by");
|
||||
return toQuery(
|
||||
{
|
||||
days: field("days"),
|
||||
after: field("after"),
|
||||
before: field("before"),
|
||||
in: field("in"),
|
||||
by: by as FilterSpec["by"],
|
||||
q: field("q"),
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
/** does the query filter anything? (an empty one lists diigo's pages as stored) */
|
||||
export function isEmpty(query: Query): boolean {
|
||||
return !query.after && !query.before && !query.words.length && !query.tags.length && query.by === "updated";
|
||||
}
|
||||
|
||||
/** the item's date that `by` names, in epoch seconds (diigo's unit), or
|
||||
* undefined when it carries none */
|
||||
export function dateOf(item: Item, by: Query["by"]): number | undefined {
|
||||
const secs = by === "created" ? item.created_at : item.updated_at;
|
||||
return typeof secs === "number" && Number.isFinite(secs) ? secs : undefined;
|
||||
}
|
||||
|
||||
/** does `item` fall in the window and satisfy every term? */
|
||||
export function matches(item: Item, query: Query): boolean {
|
||||
void item;
|
||||
void query;
|
||||
throw new Error("matches: not implemented (goal 1d)");
|
||||
if (query.after || query.before) {
|
||||
const secs = dateOf(item, query.by);
|
||||
if (secs === undefined) return false;
|
||||
if (query.after && secs * 1000 < query.after.getTime()) return false;
|
||||
if (query.before && secs * 1000 >= query.before.getTime()) return false;
|
||||
}
|
||||
const text = (v: unknown) => (typeof v === "string" ? v : "");
|
||||
// fields joined by newline, so a phrase never spans two of them
|
||||
const haystack = [item.title, item.description, item.url].map(text).join("\n").toLowerCase();
|
||||
if (!query.words.every((w) => haystack.includes(w.text) !== w.negated)) return false;
|
||||
const itemTags = new Set(
|
||||
text(item.tags)
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
return query.tags.every((t) => itemTags.has(t.text) !== t.negated);
|
||||
}
|
||||
@@ -41,6 +41,12 @@ export function makeReader(sql: Sql) {
|
||||
deepestPageSince(cutoff: number): number | null {
|
||||
return run<{ last: number | null }>("deepestPageSince", cutoff)[0]?.last ?? null;
|
||||
},
|
||||
/** stored items whose `by` date (epoch seconds) is in [`from`, `to`),
|
||||
* newest first; the raw dump rows (JSON strings) */
|
||||
itemsIn(by: "updated" | "created", from: number, to: number): string[] {
|
||||
const name = by === "created" ? "itemsByCreated" : "itemsByUpdated";
|
||||
return run<{ json: string }>(name, from, to).map((r) => r.json);
|
||||
},
|
||||
/** how many cached pages are numbered `pageNum` or lower */
|
||||
pagesThrough(pageNum: number): number {
|
||||
return Number(run<{ n: number }>("pagesThrough", pageNum)[0]?.n ?? 0);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { evictDurableObject, runInDurableObject } from "cloudflare:test";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import worker from "../src/index.js";
|
||||
import { idFromWebkey, toWebkey } from "../src/webkeys.js";
|
||||
import { FETCH_BUDGET } from "../src/query.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";
|
||||
@@ -250,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 (
|
||||
@@ -283,30 +283,34 @@ function isoWeek(d: Date): string {
|
||||
return `${year}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
describe.skip("public page filters (goal 1d)", () => {
|
||||
describe("public page filters (goal 1d)", () => {
|
||||
// the fake's dated account: "dated n" updated n-1 days + 1h ago;
|
||||
// #weekly on every 7th; every 10th private; every 5th created 100
|
||||
// days before it was updated
|
||||
// days before it was updated. The public page only reads, so each
|
||||
// test stores the pages it needs on the owner's side first.
|
||||
const call = (path: string, init?: RequestInit) =>
|
||||
worker.fetch(new Request(`https://diigo-cache.test${path}`, init), env as never);
|
||||
const wireDated = async () => {
|
||||
/** wire a public cache to `webappId` and store diigo pages 1..`pages` */
|
||||
const wireStored = async (webappId: string, pages: number) => {
|
||||
const res = await call("/_wire", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ webappId: "fake-dated-webapp", mode: "public" }),
|
||||
body: JSON.stringify({ webappId, mode: "public" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return "/public/dated_dan";
|
||||
const { username, publicId } = (await res.json()) as { username: string; publicId: string };
|
||||
const pub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.idFromString(idFromWebkey(publicId)));
|
||||
for (let n = 1; n <= pages; n += 1) await pub.page(n, PAGE_SIZE);
|
||||
return { page: `/public/${username}`, pub };
|
||||
};
|
||||
const wireDated = async () => (await wireStored("fake-dated-webapp", 1)).page;
|
||||
const titlesIn = (html: string) => [...html.matchAll(/>(dated \d+)<\/a>/g)].map((m) => m[1]);
|
||||
const dated = (...ns: number[]) => ns.map((n) => `dated ${n}`);
|
||||
|
||||
it("?days=7 lists the last week, walking only the diigo pages it needs", async () => {
|
||||
it("?days=7 lists the last week", async () => {
|
||||
const page = await wireDated();
|
||||
const before = await testEnv.WEBAPP.calls();
|
||||
const html = await (await call(`${page}?days=7`)).text();
|
||||
expect(titlesIn(html)).toEqual(dated(1, 2, 3, 4, 5, 6, 7));
|
||||
expect((await testEnv.WEBAPP.calls()) - before).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("?by=created windows, lists, and dates by when it was bookmarked", async () => {
|
||||
@@ -327,13 +331,7 @@ describe.skip("public page filters (goal 1d)", () => {
|
||||
});
|
||||
|
||||
it("?days=300 pages the matches PAGE_SIZE at a time; links keep the filter", async () => {
|
||||
const res = await call("/_wire", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ webappId: "fake-deep-webapp", mode: "public" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const page = "/public/deep_dee";
|
||||
const { page } = await wireStored("fake-deep-webapp", 3);
|
||||
// deep_dee: one a day; days=300 holds 1..300, minus every 10th (private)
|
||||
const want = Array.from({ length: 300 }, (_, k) => k + 1).filter((n) => n % 10 !== 0);
|
||||
const first = await (await call(`${page}?days=300`)).text();
|
||||
@@ -354,6 +352,15 @@ describe.skip("public page filters (goal 1d)", () => {
|
||||
expect(titlesIn(html)).toEqual(dated(11, 12));
|
||||
});
|
||||
|
||||
it("?in= names an ISO week", async () => {
|
||||
const { page } = await wireStored("fake-deep-webapp", 3);
|
||||
const week = isoWeek(new Date(Date.now() - 100 * 24 * 60 * 60 * 1000));
|
||||
const html = await (await call(`${page}?in=${week}`)).text();
|
||||
// one a day; every 10th is private
|
||||
expect(titlesIn(html).length).toBeGreaterThanOrEqual(6);
|
||||
expect(titlesIn(html).length).toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it("?q=#weekly&days=60 filters by tag; private items stay out", async () => {
|
||||
const page = await wireDated();
|
||||
const html = await (await call(`${page}?days=60&q=${encodeURIComponent("#weekly")}`)).text();
|
||||
@@ -366,22 +373,49 @@ describe.skip("public page filters (goal 1d)", () => {
|
||||
expect(titlesIn(html)).toEqual(dated(7, 14, 21, 28, 35, 42, 49, 56));
|
||||
});
|
||||
|
||||
it("a request spends at most FETCH_BUDGET diigo fetches; incomplete results say so, and a reload continues", async () => {
|
||||
const res = await call("/_wire", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ webappId: "fake-deep-webapp", mode: "public" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// deep_dee: 60 diigo pages of 100; a search matching nothing must walk them all
|
||||
it("a filter reads only stored items, never diigo; results say whether storage is complete", async () => {
|
||||
const { page, pub } = await wireStored("fake-deep-webapp", 1);
|
||||
const json = async () =>
|
||||
(await call(`${page}?days=300`, { headers: { accept: "application/json" } })).json() as Promise<{
|
||||
matched: number;
|
||||
complete: boolean;
|
||||
}>;
|
||||
const before = await testEnv.WEBAPP.calls();
|
||||
const first = await call("/public/deep_dee?q=nomatch", { headers: { accept: "application/json" } });
|
||||
expect(await first.json()).toMatchObject({ complete: false, items: [] });
|
||||
expect((await testEnv.WEBAPP.calls()) - before).toBeLessThanOrEqual(FETCH_BUDGET + 1);
|
||||
const html = await (await call("/public/deep_dee?q=nomatch")).text();
|
||||
expect(html).toMatch(/complete|no matches/);
|
||||
const again = await call("/public/deep_dee?q=nomatch", { headers: { accept: "application/json" } });
|
||||
expect(await again.json()).toMatchObject({ complete: true, items: [] });
|
||||
// page 1 holds 1..100, minus 10 private
|
||||
expect(await json()).toMatchObject({ matched: 90, complete: false });
|
||||
expect(await (await call(`${page}?days=300`)).text()).toMatch(/some matches may be missing/);
|
||||
expect(await testEnv.WEBAPP.calls()).toBe(before);
|
||||
await pub.page(2, PAGE_SIZE);
|
||||
await pub.page(3, PAGE_SIZE);
|
||||
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 () => {
|
||||
@@ -393,25 +427,6 @@ describe.skip("public page filters (goal 1d)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("?in= an old week costs O(log pages) diigo fetches with by=updated, not a walk from page 1", async () => {
|
||||
const res = await call("/_wire", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ webappId: "fake-deep-webapp", mode: "public" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// deep_dee: 6000 bookmarks, one a day, 60 diigo pages of 100; a week
|
||||
// ~14 years back sits near page 51 — a walk from page 1 would cost ~51
|
||||
const longAgo = new Date(Date.now() - 5100 * 24 * 60 * 60 * 1000);
|
||||
const week = isoWeek(longAgo);
|
||||
const before = await testEnv.WEBAPP.calls();
|
||||
const html = await (await call(`/public/deep_dee?in=${week}`)).text();
|
||||
// one a day; every 10th is private
|
||||
expect(titlesIn(html).length).toBeGreaterThanOrEqual(6);
|
||||
expect(titlesIn(html).length).toBeLessThanOrEqual(7);
|
||||
expect((await testEnv.WEBAPP.calls()) - before).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it("a malformed filter is a 400 with a message, not an empty list", async () => {
|
||||
const page = await wireDated();
|
||||
const res = await call(`${page}?days=7x`);
|
||||
|
||||
@@ -18,9 +18,9 @@ const OVERSTATEMENT = 60;
|
||||
* 5th an old bookmark edited recently (created 100 days before updated) */
|
||||
const DATED_WEBAPP = "fake-dated-webapp";
|
||||
const DATED_TOTAL = 60;
|
||||
/** the same shape, 6000 deep (~16 years, 60 pages of 100): for walk cost */
|
||||
/** the same shape, 300 deep (3 pages of 100): for paging matches */
|
||||
const DEEP_WEBAPP = "fake-deep-webapp";
|
||||
const DEEP_TOTAL = 6000;
|
||||
const DEEP_TOTAL = 300;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
function datedItem(n, now, user = "dated_dan") {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { matches, parseQuery, toQuery } from "../src/query.js";
|
||||
import type { FilterSpec, Query, QueryError } from "../src/query.js";
|
||||
|
||||
// goal 1d (capsite checklist): the design as tests; unskip as it lands
|
||||
// goal 1d (capsite checklist)
|
||||
|
||||
const NOW = new Date("2026-10-07T12:00:00.000Z");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -21,7 +21,7 @@ const refused = (q: Query | QueryError) => {
|
||||
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`);
|
||||
const range = (q: Query) => [q.after?.toISOString().slice(0, 10), q.before?.toISOString().slice(0, 10)];
|
||||
|
||||
describe.skip("filters: one spec, two spellings (goal 1d)", () => {
|
||||
describe("filters: one spec, two spellings (goal 1d)", () => {
|
||||
it("devs write { days: 7 }; URLs write ?days=7; same query", () => {
|
||||
expect(spec({ days: 7 })).toEqual(url("days=7"));
|
||||
expect(spec({ days: 7 }).after).toEqual(new Date(NOW.getTime() - 7 * DAY));
|
||||
@@ -42,7 +42,7 @@ describe.skip("filters: one spec, two spellings (goal 1d)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("windows: UTC, half-open (goal 1d)", () => {
|
||||
describe("windows: UTC, half-open (goal 1d)", () => {
|
||||
it("after/before are UTC days: after inclusive from 00:00Z, before exclusive", () => {
|
||||
expect(range(url("after=2026-01-01&before=2026-02-01"))).toEqual(["2026-01-01", "2026-02-01"]);
|
||||
expect(url("after=2026/01/01").after).toEqual(day("2026-01-01"));
|
||||
@@ -88,7 +88,7 @@ describe.skip("windows: UTC, half-open (goal 1d)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("q: terms and operators (goal 1d)", () => {
|
||||
describe("q: terms and operators (goal 1d)", () => {
|
||||
const q = (text: string) => url(`q=${encodeURIComponent(text)}`);
|
||||
|
||||
it("words, phrases, #tags, tag:, and - negation; case-insensitive", () => {
|
||||
@@ -160,7 +160,7 @@ const item = (iso: string, extra: Record<string, unknown> = {}) => ({
|
||||
});
|
||||
const query = (q: Partial<Query>): Query => ({ by: "updated", words: [], tags: [], ...q });
|
||||
|
||||
describe.skip("matches (goal 1d)", () => {
|
||||
describe("matches (goal 1d)", () => {
|
||||
it("the window is half-open: after inclusive, before exclusive", () => {
|
||||
const q = query({ after: day("2026-01-01"), before: day("2026-02-01") });
|
||||
expect(matches(item("2026-01-01T00:00:00.000Z"), q)).toBe(true);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -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
|
||||
```
|
||||
|
||||
On potluck's admin dashboard, the **This week's issue** card has a **Draft 2026-Wnn from diigo** button for last week; it links to the draft once made. For any other week: Plugins → **Draft from diigo**. A week that already has an issue isn't drafted again.
|
||||
|
||||
## 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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+6223
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- "."
|
||||
|
||||
strictDepBuilds: true
|
||||
dangerouslyAllowAllBuilds: false
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
workerd: true
|
||||
Vendored
+7
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @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 issue already drafted or published for `week`, if any (issues
|
||||
* are few: a scan of the newest is enough) */
|
||||
async function issueFor(ctx: RouteContext, week: string): Promise<{ id: string; status: string; title: string } | undefined> {
|
||||
const found = (await ctx.content!.list("issues", { limit: 100 })).items.find((it) => it.data.week === week);
|
||||
return found && { id: found.id, status: found.status, title: String(found.data.title ?? week) };
|
||||
}
|
||||
|
||||
/** a button-row link to an issue in the editor */
|
||||
const openIssue = (id: string, label: string): Block => ({
|
||||
type: "actions",
|
||||
elements: [{ type: "link", label, target: { kind: "content", collection: "issues", id }, appearance: "primary" }],
|
||||
});
|
||||
|
||||
/** draft `week` from `user`'s bookmarks, unless the week has an issue
|
||||
* already: blocks that say what happened (with a link), and a toast */
|
||||
async function draft(ctx: RouteContext, user: string, week: string): Promise<{ note: Block[]; toast: BlockResponse["toast"] }> {
|
||||
try {
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(user)) throw new Error("diigo username: letters, digits, _ and - only");
|
||||
const existing = await issueFor(ctx, week);
|
||||
if (existing) {
|
||||
return {
|
||||
note: [
|
||||
{ type: "banner", variant: "default", title: `${week} already has an issue`, description: `"${existing.title}" (${existing.status}). Delete it to draft the week again.` },
|
||||
openIssue(existing.id, "Open it"),
|
||||
],
|
||||
toast: { message: `${week} already has an issue`, type: "info" },
|
||||
};
|
||||
}
|
||||
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 {
|
||||
note: [
|
||||
{ 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(", ")}.` : ""}` },
|
||||
openIssue(created.id, "Open the draft"),
|
||||
],
|
||||
toast: { 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 { note: [{ type: "banner", variant: "alert", title: "Couldn't draft the issue", description: message }], toast: { message, type: "error" } };
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
/** the dashboard widget: last week's issue if there is one, else a button to draft it */
|
||||
async function widget(ctx: RouteContext, note: Block[] = [], toast?: BlockResponse["toast"]): Promise<BlockResponse> {
|
||||
const week = lastWeek(new Date());
|
||||
if (note.length) return { blocks: note, toast };
|
||||
const existing = await issueFor(ctx, week);
|
||||
const blocks: Block[] = existing
|
||||
? [{ type: "section", text: `${week}: "${existing.title}" (${existing.status})` }, openIssue(existing.id, "Open it")]
|
||||
: [
|
||||
{ type: "section", text: `${week} has no issue yet.` },
|
||||
{ type: "actions", elements: [{ type: "button", action_id: "draft-last-week", label: `Draft ${week} from diigo`, style: "primary" }] },
|
||||
];
|
||||
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" }],
|
||||
widgets: [{ id: "last-week", title: "This week's issue", size: "half" }],
|
||||
},
|
||||
routes: {
|
||||
admin: {
|
||||
permission: "plugins:manage",
|
||||
handler: async (ctx) => {
|
||||
const input = ctx.input as { type?: string; page?: string; action_id?: string; values?: Record<string, unknown> } | undefined;
|
||||
const user = (await ctx.kv.get<string>("user")) ?? DEFAULT_USER;
|
||||
if (input?.type === "block_action" && input.action_id === "draft-last-week") {
|
||||
const { note, toast } = await draft(ctx, user, lastWeek(new Date()));
|
||||
return widget(ctx, note, toast);
|
||||
}
|
||||
if (input?.page?.startsWith("widget:")) return widget(ctx);
|
||||
if (input?.type !== "form_submit" || input.action_id !== "draft") return page(ctx);
|
||||
const formUser = String(input.values?.user ?? "").trim();
|
||||
if (/^[A-Za-z0-9_-]{1,64}$/.test(formUser)) await ctx.kv.set("user", formUser);
|
||||
const { note, toast } = await draft(ctx, formUser, String(input.values?.week ?? "").trim());
|
||||
return page(ctx, note, toast);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
This is an EmDash site -- a CMS built on Astro with a full admin UI.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev # Start the Astro dev server
|
||||
npx emdash types # Regenerate TypeScript types from a running site
|
||||
```
|
||||
|
||||
The admin UI is at `http://localhost:4321/_emdash/admin`.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `astro.config.mjs` | Astro config with `emdash()` integration, database, and storage |
|
||||
| `src/live.config.ts` | EmDash loader registration (boilerplate -- don't modify) |
|
||||
| `seed/seed.json` | Schema definition + demo content (collections, fields, taxonomies, menus, widgets) |
|
||||
| `emdash-env.d.ts` | Generated types for collections (auto-regenerated on dev server start) |
|
||||
| `src/layouts/Base.astro` | Base layout with EmDash wiring (menus, search, page contributions) |
|
||||
| `src/pages/` | Astro pages -- all server-rendered |
|
||||
|
||||
## Skills
|
||||
|
||||
Agent skills are in `.agents/skills/`. Load them when working on specific tasks:
|
||||
|
||||
- **building-emdash-site** -- Querying content, rendering Portable Text, schema design, seed files, site features (menus, widgets, search, SEO, comments, bylines). Start here.
|
||||
- **creating-plugins** -- Building EmDash plugins with hooks, storage, admin UI, API routes, and Portable Text block types.
|
||||
- **emdash-cli** -- CLI commands for content management, seeding, type generation, and visual editing flow.
|
||||
|
||||
## Documentation
|
||||
|
||||
The EmDash docs are available as an MCP server at `https://docs.emdashcms.com/mcp`. When you need to verify an API, hook, config option, field type, or pattern, call `search_docs` against the live documentation rather than relying on training-data recall. The docs reflect current behaviour; assumptions may not.
|
||||
|
||||
This template ships with `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json` so Claude Code, Cursor, and VS Code auto-discover the docs server. Other tools (OpenCode, Windsurf, etc.) need a manual one-time setup -- see [docs.emdashcms.com/docs-mcp](https://docs.emdashcms.com/docs-mcp).
|
||||
|
||||
## Rules
|
||||
|
||||
- All content pages must be server-rendered (`output: "server"`). No `getStaticPaths()` for CMS content.
|
||||
- Image fields are objects (`{ src, alt }`), not strings. Use `<Image image={...} />` from `"emdash/ui"`.
|
||||
- `entry.id` is the slug (for URLs). `entry.data.id` is the database ULID (for API calls like `getEntryTerms`).
|
||||
- When Astro's cache is enabled, pass content-query hints to `Astro.cache.set(cacheHint)`. Use the `WithCacheHint` variants for site settings, menus, taxonomies, and widget areas rendered by cached routes.
|
||||
- Taxonomy names in queries must match the seed's `"name"` field exactly (e.g., `"category"` not `"categories"`).
|
||||
|
||||
## This Template
|
||||
|
||||
A blog with posts, pages, categories, tags, full-text search, and RSS. Designed for personal writing, technical writing, indie newsletters, and anything where the writing is the product. Editorial-tech aesthetic: confident sans-serif, restrained accent, real article structure with bylines and reading time.
|
||||
|
||||
## Pages
|
||||
|
||||
| Page | Path | What it shows |
|
||||
| ----------- | ------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| Home | `/` | Featured post hero (large image + excerpt), latest posts grid |
|
||||
| All posts | `/posts` | Article count, full post list with excerpts and tag chips |
|
||||
| Post detail | `/posts/[slug]` | Featured image, title, body, left meta column (authors + date), right TOC + search + categories gutter |
|
||||
| Search | `/search` | Full-text search UI |
|
||||
| Page | `/pages/[slug]` | Static page content (Portable Text) |
|
||||
| Category | `/category/[slug]` | Posts filtered by category |
|
||||
| Tag | `/tag/[slug]` | Posts filtered by tag |
|
||||
| RSS | `/rss.xml` | Generated feed |
|
||||
|
||||
## Schema
|
||||
|
||||
- `posts` collection: `title`, `featured_image`, `content` (Portable Text), `excerpt` (text).
|
||||
- `pages` collection: `title`, `content` (Portable Text). Used for `/about` etc.
|
||||
- Taxonomies: `category`, `tag`.
|
||||
- Single `primary` menu (Home, About, Posts by default).
|
||||
|
||||
Site settings have `title` and `tagline` -- both render in the header / footer.
|
||||
|
||||
## Visual character
|
||||
|
||||
Single typeface: **Inter** on `--font-body`, used for everything including headings (`--font-heading` defaults to the body face; tighter letter-spacing on h1/h2). **JetBrains Mono** on `--font-mono` for inline code and code blocks. Body and headings share the same family; weight and size carry the hierarchy (`--font-weight-heading` 600, `--font-weight-display` 700 for h1/page titles).
|
||||
|
||||
The brand colour is `#0066cc` (`--color-brand`) -- used for links, the post-card title hover, and the search input focus ring. There's also a secondary text colour (`--color-text-secondary`) and a `--color-muted` for meta info. Don't add a second accent.
|
||||
|
||||
The article layout is the standout feature: a three-column reading view with a left meta column (author bylines, date), centred 680px body column, and a right gutter for search, table of contents, and categories. Don't flatten that into one column on desktop -- the layout signals "this is something to read".
|
||||
|
||||
## Customisation
|
||||
|
||||
Design tokens live in `src/styles/tokens.css` with their default values. To restyle the site, override tokens in `src/styles/theme.css` -- declarations there are unlayered, so they always beat the `@layer base` defaults. Don't edit `tokens.css` or `Base.astro` for visual changes.
|
||||
|
||||
Colours are defined with `light-dark(<light>, <dark>)`, so each token carries both modes. Overriding with a plain colour changes light and dark at once; use `light-dark()` in the override to keep them distinct. There is no separate dark palette to maintain.
|
||||
|
||||
Webfonts are configured in `astro.config.mjs` under `fonts:`. To swap the body face, change the `name:` for the entry bound to `cssVariable: "--font-body"`. Good alternatives: Geist, IBM Plex Sans, Söhne (if you have a licence), Public Sans. If you want a serif-bodied blog, swap to a humanist serif like Source Serif, Crimson Pro, or Lora -- but then also raise `--font-size-base` to `1.0625rem` for readability. To give headings their own face (or use a system font) without touching the font pipeline, override `--font-heading` or `--font-body` in `theme.css`.
|
||||
|
||||
CSS variables worth knowing (see `tokens.css` for the full list):
|
||||
|
||||
- `--color-brand`, `--color-brand-hover`, `--color-on-brand`, `--color-brand-ring`
|
||||
- `--color-bg`, `--color-bg-subtle`, `--color-surface`, `--color-text`, `--color-text-secondary`, `--color-muted`, `--color-border`, `--color-border-subtle`
|
||||
- `--font-body`, `--font-heading`, `--font-mono`
|
||||
- `--font-weight-heading` (600) / `--font-weight-display` (700) -- heading weights; lower them if you switch to a serif
|
||||
- `--tracking-tight` / `--tracking-snug` / `--tracking-wide` / `--tracking-wider` -- letter-spacing tokens used across headings and meta labels
|
||||
- `--content-width` (680px) -- article body column
|
||||
- `--wide-width` (1200px) -- max container
|
||||
- `--gutter-width` (200px) -- right sidebar (TOC) on article pages
|
||||
- `--meta-col-width` (180px) -- left meta column on article pages
|
||||
- `--avatar-size-{xs,sm,md,lg}` -- byline avatar sizes at different scales
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't add a second accent colour or coloured section backgrounds. The page should be black, white, and one blue.
|
||||
- Don't replace Inter with a display sans (Bebas, Anton, etc.). Headings rely on weight contrast, not novelty faces.
|
||||
- Don't collapse the article gutter on desktop -- it's part of the reading experience.
|
||||
- Don't use stock blog copy ("Welcome to my blog", "Stay tuned for more"). Write a real tagline that says what this blog is about.
|
||||
- Don't seed the home page with three identical placeholder posts. If you only have one real post, show one real post.
|
||||
- Comments are enabled on posts and rendered on the post detail page. Configure moderation before publishing the site, or remove `commentsEnabled` and the comments UI together.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
CONTRIBUTING.md
|
||||
@@ -0,0 +1,51 @@
|
||||
# Contributing to potluck
|
||||
|
||||
potluck started as EmDash's `cloudflare:blog` template (`pnpm create emdash@latest`); it's part of [the capsite goal tree](../capsite-checklist.md), where it is the site that publishes the weekly roundup.
|
||||
|
||||
## How it's put together
|
||||
|
||||
- **Issues** are the `issues` collection ([seed/issues.json](seed/issues.json)): `title`, `week` (an ISO week, `2026-W40`), `dek` (one-line summary), `body` (Portable Text). The slug is the week, lowercased: `/issues/2026-w40`. [issues-sketch.md](issues-sketch.md) is the dry run behind the three sample issues: real public bookmarks, editorial lines marked *draft*.
|
||||
- **Pages:** home and `/issues` list issues ([src/pages/issues/](src/pages/issues/)); `/tag/<tag>` lists issues by diigo tag; search and `/rss.xml` cover issues. The `posts` collection and its pages are the template's, still in place.
|
||||
- **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>)`.
|
||||
|
||||
## 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
|
||||
|
||||
[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.
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
pnpm typecheck # astro check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## EmDash rules
|
||||
|
||||
- All content pages are server-rendered (`output: "server"`); no `getStaticPaths()` for CMS content.
|
||||
- Image fields are objects (`{ src, alt }`), not strings; render with `<Image image={...} />` from `"emdash/ui"`.
|
||||
- `entry.id` is the slug (for URLs); `entry.data.id` is the database ULID (for API calls like `getEntryTerms`).
|
||||
- When Astro's cache is enabled, pass query hints to `Astro.cache.set(cacheHint)`.
|
||||
- Taxonomy names in queries match the seed's `"name"` exactly (`"tag"`, not `"tags"`).
|
||||
- `npx emdash types` regenerates `emdash-env.d.ts` from a running site (the dev server also regenerates it on start).
|
||||
|
||||
The EmDash docs are an MCP server at `https://docs.emdashcms.com/mcp` (wired up in `.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`). Check them rather than recalling APIs from memory.
|
||||
|
||||
## Deploying
|
||||
|
||||
```sh
|
||||
pnpm wrangler login
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
The first deploy provisions the D1 database and R2 bucket named in [wrangler.jsonc](wrangler.jsonc). See [Deploy to Cloudflare](https://docs.emdashcms.com/deployment/cloudflare/).
|
||||
|
||||
Other workerd hosts are untested: the site uses D1 and R2 through `@emdash-cms/cloudflare` and the Cloudflare adapter (locally, miniflare stands in for both), so a host has to supply those two bindings.
|
||||
|
||||
## For agents
|
||||
|
||||
`AGENTS.md` is a symlink to this file. capsite's [working rules](../AGENTS.md) apply here too (the dev server is the user's to start and stop). EmDash skills are in `.agents/skills/`: `building-emdash-site` (start here), `creating-plugins`, `emdash-cli`.
|
||||
+14
-60
@@ -1,68 +1,22 @@
|
||||
# EmDash Blog Template (Cloudflare)
|
||||
# potluck
|
||||
|
||||
A clean, minimal blog built with [EmDash](https://github.com/emdash-cms/emdash) and deployed on Cloudflare Workers with D1 and R2.
|
||||
A weekly zine of what you bookmarked elsewhere, published on your own site. Each issue is one week of links with a few words around them.
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/emdash-cms/templates/tree/main/blog-cloudflare)
|
||||
## Getting started
|
||||
|
||||

|
||||
|
||||
## What's Included
|
||||
|
||||
- Featured post hero on the homepage
|
||||
- Post archive with reading time estimates
|
||||
- Category and tag archives
|
||||
- Full-text search
|
||||
- RSS feed
|
||||
- SEO metadata and JSON-LD
|
||||
- Dark/light mode
|
||||
|
||||
## Pages
|
||||
|
||||
| Page | Route |
|
||||
|---|---|
|
||||
| Homepage | `/` |
|
||||
| All posts | `/posts` |
|
||||
| Single post | `/posts/:slug` |
|
||||
| Category archive | `/category/:slug` |
|
||||
| Tag archive | `/tag/:slug` |
|
||||
| Search | `/search` |
|
||||
| Static pages | `/pages/:slug` |
|
||||
| 404 | fallback |
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | Desktop | Mobile |
|
||||
|---|---|---|
|
||||
| Light |  |  |
|
||||
| Dark |  |  |
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **Runtime:** Cloudflare Workers
|
||||
- **Database:** D1
|
||||
- **Storage:** R2
|
||||
- **Framework:** Astro with `@astrojs/cloudflare`
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm dev # open http://localhost:4321/_emdash/admin, finish the setup wizard, then Ctrl-C
|
||||
pnpm seed:issues # three sample issues, W38–W40 of 2026
|
||||
pnpm dev # → http://localhost:4321/issues
|
||||
```
|
||||
|
||||
Open http://localhost:4321/_emdash/admin and complete the setup wizard. EmDash runs database migrations and applies the blog seed during setup. The site is available at http://localhost:4321.
|
||||
## Bookmarks in, a zine out
|
||||
|
||||
## Deploying
|
||||
- **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.
|
||||
- **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.
|
||||
- **A real editor:** it's an [EmDash](https://docs.emdashcms.com/) site, so you write issues in its admin UI. It runs in [workerd](https://github.com/cloudflare/workerd), on your machine or a host.
|
||||
|
||||
```bash
|
||||
pnpm wrangler login
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
The first deployment provisions the named D1 database and R2 bucket from `wrangler.jsonc`. See [Deploy to Cloudflare](https://docs.emdashcms.com/deployment/cloudflare/) for production setup, or use the deploy button above.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Node.js variant](../blog) -- same template using SQLite and local file storage
|
||||
- [All templates](../)
|
||||
- [EmDash documentation](https://docs.emdashcms.com/)
|
||||
How it's put together, checks, and deploying: [CONTRIBUTING](CONTRIBUTING.md).
|
||||
@@ -16,6 +16,8 @@ export default defineConfig({
|
||||
emdash({
|
||||
database: d1({ binding: "DB", session: "auto" }),
|
||||
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 },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"seed:issues": "emdash seed seed/issues.json -d \"$(find .wrangler/state/v3/d1 -name '*.sqlite' ! -name metadata.sqlite)\"",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"deploy": "astro build && wrangler deploy",
|
||||
@@ -19,6 +20,7 @@
|
||||
"@astrojs/react": "^6.0.5",
|
||||
"@emdash-cms/cloudflare": "^1.0.1",
|
||||
"astro": "^7.3.2",
|
||||
"diigo-week": "link:../diigo-week",
|
||||
"emdash": "^1.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
|
||||
Generated
+3
@@ -20,6 +20,9 @@ importers:
|
||||
astro:
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.6(@types/node@26.6.4)(yaml@2.9.1)
|
||||
diigo-week:
|
||||
specifier: link:../diigo-week
|
||||
version: link:../diigo-week
|
||||
emdash:
|
||||
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)
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"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:
|
||||
// "worker_loaders": [{ "binding": "LOADER" }],
|
||||
// General maintenance cron trigger
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
DO-backed fluent web client factory: site-agnostic. Deploy once; instances
|
||||
are attenuated handles whose **ids are bearer capabilities** — anyone
|
||||
holding the id string can drive the instance; nobody without it can.
|
||||
TODO: this proxy-page flow (see diigo-cache README) vs running webapp as
|
||||
TODO: this proxy-page flow (see [diigo-cache CONTRIBUTING](../diigo-cache/CONTRIBUTING.md)) vs running webapp as
|
||||
the primary worker (`wrangler dev -c ../webapp/wrangler.jsonc -c ../diigo-cache/wrangler.jsonc`)
|
||||
so its own port serves the mint form directly; reconsider when the UI
|
||||
grows past a form or two.
|
||||
|
||||
Reference in new issue
Block a user