diigo-week: draft a potluck issue from a week of diigo bookmarks
An EmDash plugin (native, content:write only) with one admin page: a diigo username and an ISO week. It reads diigo-cache's public page (?in=<week>&by=created) as microformats, follows rel=next, and creates a draft in issues shaped like seed/issues.json. Transport is a DIIGO_CACHE service binding rather than ctx.http.fetch: EmDash's SSRF guard refuses localhost, and the binding names one worker. Logic lives in src/week.ts with the fetcher passed in; tests read fixtures in diigo-cache's markup. The checklist's goal 2 now names potluck; DiigoBridge waits. Generated-by: Claude Opus 5.5
This commit is contained in:
1 parent
be796d9d0d
commit
94a059af25
17 files changed
+6705
-22
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
In potluck's admin: Plugins → **Draft from diigo** → **Draft issue**. The draft shows up under Issues.
|
||||
|
||||
## 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,134 @@
|
||||
/**
|
||||
* @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 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 };
|
||||
}
|
||||
|
||||
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" }],
|
||||
},
|
||||
routes: {
|
||||
admin: {
|
||||
permission: "plugins:manage",
|
||||
handler: async (ctx) => {
|
||||
const input = ctx.input as { type?: string; action_id?: string; values?: Record<string, unknown> } | undefined;
|
||||
if (input?.type !== "form_submit" || input.action_id !== "draft") return page(ctx);
|
||||
const user = String(input.values?.user ?? "").trim();
|
||||
const week = String(input.values?.week ?? "").trim();
|
||||
try {
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(user)) throw new Error("diigo username: letters, digits, _ and - only");
|
||||
await ctx.kv.set("user", user);
|
||||
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 page(
|
||||
ctx,
|
||||
[
|
||||
{ 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(", ")}.` : ""}` },
|
||||
{
|
||||
type: "actions",
|
||||
elements: [{ type: "link", label: "Open the draft", target: { kind: "content", collection: "issues", id: created.id }, appearance: "primary" }],
|
||||
},
|
||||
],
|
||||
{ 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 page(ctx, [{ type: "banner", variant: "alert", title: "Couldn't draft the issue", description: message }], { message, type: "error" });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user