wiki-formant 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The portable half of a wiki: derived taxonomy, a spec-correct MCP transport, markdown twins, and the conditional-GET plumbing an agent surface needs.
4
4
 
5
- Zero runtime dependencies. Web-standard `Request`/`Response`, so it runs unchanged on Next route handlers, Hono, Bun, Deno and workers.
5
+ The editor, history, sanitiser, chart and passkey modules bring their libraries as dependencies (`@tiptap/*`, `fast-diff`, `sanitize-html`, `lightweight-charts`, `@simplewebauthn/*`), since every wiki here uses all five. What only some apps use stays an optional peer: `pg`, `react`, `jose` and `@radixdlt/rola` for ROLA, `sharp` and `@vercel/blob` for uploads. Web-standard `Request`/`Response`, so it runs unchanged on Next route handlers, Hono, Bun, Deno and workers.
6
6
 
7
7
  ```bash
8
8
  npm install wiki-formant
@@ -193,10 +193,12 @@ return new Response(
193
193
  license: { spdx: 'CC-BY-4.0', url: 'https://creativecommons.org/licenses/by/4.0/' } },
194
194
  blocksToMarkdown(page.content), // your block types, your function
195
195
  ),
196
- { headers: markdownHeaders(lastModified, { etag }) },
196
+ { headers: markdownHeaders(lastModified, { etag, noindex: true }) },
197
197
  );
198
198
  ```
199
199
 
200
+ `noindex` sends `X-Robots-Tag: noindex`, for a twin of an HTML page: a search engine that reads it has the page already. A document with no HTML page, an AGENTS.md, leaves it off.
201
+
200
202
  Block trees stay in your app — every project owns its own type set. Give this module HTML and it gives you markdown.
201
203
 
202
204
  > A trap worth naming: if you serve twins via a rewrite, Next drops the destination query string. The rewrite must carry the `.md` extension through to the destination path, or the twin silently serves JSON.
@@ -263,6 +265,12 @@ const nextConfig = {
263
265
 
264
266
  Four configs had restated these by hand on the grounds that a config could not import an ESM-only package. It could not because no subpath had a `default` condition, and Next resolves a config's imports the way `require` does; every subpath has one now, and Node 24 loads the module there unchanged.
265
267
 
268
+ `withWikiDefaults(config, { csp })` wraps the whole config: the headers on every route with `csp` enforced in production, AVIF before WebP from the image optimiser, Turbopack's build cache off (Vercel restored a stale one and shipped a stylesheet two commits old), and `distDir` from `STUDIO_DIST_DIR` for radix-studio's second dev server. What the app sets wins, and its own `headers()` follow these.
269
+
270
+ ```ts
271
+ export default withWikiDefaults({ async redirects() { /* … */ } }, { csp });
272
+ ```
273
+
266
274
  `contentSecurityPolicy` starts from the policy all four origins shared line for line, and each directive passed replaces its default rather than appending, so the call site reads as the whole list. An empty list drops a directive.
267
275
 
268
276
  The default `frame-src` is `DEFAULT_IFRAME_HOSTS`, the same list the sanitiser's `iframeHosts` defaults to. The sanitiser's comment had asked every config to keep the two matched by hand, and one did not: its policy lacked the bare `youtube.com` and `youtube-nocookie.com` its sanitiser kept, so those embeds survived cleaning and then rendered blank. A site with its own host list passes it to the sanitiser and states the same hosts in its `frame-src`.
@@ -414,7 +422,7 @@ npx indexnow https://radix.wiki --hours 6
414
422
  npx indexnow https://radix.wiki --all # every row: a new origin or a new key
415
423
  ```
416
424
 
417
- Rows with no `lastModified` go only in a full submission. A 403 or 422 from the engine exits 1.
425
+ Rows with no `lastModified` go only in a full submission. A 403 or 422 from the engine exits 1. A sitemap index is read one level down, as `check-pages` reads it.
418
426
 
419
427
  ## Page metadata
420
428
 
@@ -485,7 +493,7 @@ was. That half is not here.
485
493
 
486
494
  What each revision changed, as a history view shows it. `revisions` stays free
487
495
  of dependencies because every save imports it; this is the reading half, and it
488
- needs `fast-diff`, an optional peer.
496
+ needs `fast-diff`.
489
497
 
490
498
  ```tsx
491
499
  import { withChanges } from 'wiki-formant/history';
@@ -560,7 +568,7 @@ In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetE
560
568
 
561
569
  `sortTables` makes the tables stored in article HTML sortable by their headers. They arrive as a string a `dangerouslySetInnerHTML` wrote, so React never sees their rows and cannot sort them. A column is dates if every filled cell starts with one, numbers if every one does, and text otherwise — one stray value makes the whole column text, which beats sorting half of it by one rule and half by another. A third press restores the author's order, which is often chronological or ranked and otherwise needs a reload. Label/value tables and tables with merged cells are left alone. The markup it writes — `aria-sort` on the cell, a `.sort-header` button inside it — is the markup a React sortable header should write too, so both kinds of table draw their arrows from one stylesheet rule.
562
570
 
563
- `scrollTables(html)` is the one table pass that runs on the string, before render: it wraps each stored table in a `.table-scroll` box, and `base.css` lets that box scroll sideways. A table box ignores `overflow`, so a table with an address or a URL in a cell is as wide as that token whatever its column, and a scroll box added by an effect would arrive after the table had already been laid out too wide. Call it on the render path beside `injectHeadingIds`. A table already in a box keeps it, so it is safe on HTML that has been through it once.
571
+ `scrollTables(html)` is the one table pass that runs on the string, before render: it wraps each stored table in a `.table-scroll` box, and `base.css` lets that box scroll sideways. A table box ignores `overflow`, so a table with an address or a URL in a cell is as wide as that token whatever its column, and a scroll box added by an effect would arrive after the table had already been laid out too wide. Call it on the render path beside `injectHeadingIds`. A table already in a box keeps it, so it is safe on HTML that has been through it once. `articleHtml(html, { used, slug, anchor, links })` (`wiki-formant/headings`) is that chain: heading ids, the scroll boxes, and, given `links`, `normaliseLinks`.
564
572
 
565
573
  The embed's origin is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed.
566
574
 
@@ -623,7 +631,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
623
631
 
624
632
  ## Sanitising stored HTML
625
633
 
626
- `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render two HTML fields themselves — `linkGrid` descriptions and `references` text — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. `sanitize-html` is an optional peer; run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
634
+ `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render two HTML fields themselves — `linkGrid` descriptions and `references` text — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. Run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
627
635
 
628
636
  ```ts
629
637
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
@@ -653,7 +661,7 @@ The bytes say what the file is, never the name or type, and SVG is refused. Ever
653
661
 
654
662
  ## Editor nodes
655
663
 
656
- `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. The four `@tiptap/*` packages are optional peers, so a consumer taking only the taxonomy still installs a package with no runtime dependencies.
664
+ `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`.
657
665
 
658
666
  ```ts
659
667
  const CodeBlock = createCodeBlock({
@@ -668,16 +676,19 @@ The language list defaults to `CODE_LANGS` and `DEFAULT_LANG` from `wiki-formant
668
676
 
669
677
  `createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
670
678
 
671
- `toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
672
-
673
- `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
679
+ `toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". `WikiToolbar` lays them out the way three wikis had each assembled them by hand: the format buttons, Remove link while the cursor is in a link, upload and embed, the insert buttons, and the table controls while the cursor is in a table. Every button is a `ToolbarButton` (named, and `aria-pressed`, since these are toggles); the link button opens the address field and reads as on while it is open. Which buttons, icons and classes are yours; a toolbar shaped differently builds from `ToolbarButton` and `runTableAction` directly.
674
680
 
675
681
  ```tsx
676
- const url = useUrlPrompt(editor, { resolveMapUrl });
677
- <ToolbarButton label="Link" pressed={url.kind === 'link'} onPress={() => url.toggle('link')}>…</ToolbarButton>
678
- {url.kind && <input autoFocus value={url.value} onChange={e => url.setValue(e.target.value)} onKeyDown={url.onKeyDown} />}
682
+ <WikiToolbar editor={editor} isActive={isActive} prompt={url} actions={FORMAT} trailing={INSERT} onUpload={triggerUpload} embed
683
+ icons={{ ...ICONS, unlink: <Unlink />, upload: <Upload />, embed: <Globe /> }}
684
+ classNames={{ toolbar: 'toolbar', button: 'toolbar-btn', pressed: 'toolbar-btn-on', divider: 'toolbar-divider' }} />
685
+ <UrlPromptField prompt={url} classNames={{ row: 'row', input: 'input', apply: 'btn', cancel: 'btn-quiet' }} />
679
686
  ```
680
687
 
688
+ `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
689
+
690
+ `UrlPromptField` is its markup: an address input on which Enter applies and Escape closes, and Apply and Cancel.
691
+
681
692
  A link is stored as its `href` and the `link` class, nothing else. Stock Tiptap writes `target="_blank" rel="noopener noreferrer nofollow"` onto every link, internal ones included, which on a site throws away its own link equity and opens in-site navigation in new tabs. Whether a link is external depends on the host serving the page, so `normaliseLinks` decides it at render time. A legacy link loses both attributes the next time its page is saved.
682
693
 
683
694
  The redirect resolves through the wiki's own route, because the editor cannot read it cross-origin. `resolveMapUrl` is the client half and `resolveMapHandler` the whole route: exact shortener hosts, one hop, an allowlisted landing host, a timeout, and your sign-in check as `authorize`. One of the two copies it replaced matched `goo.gl` as a substring and followed every redirect for anyone who asked.
@@ -686,6 +697,36 @@ The redirect resolves through the wiki's own route, because the editor cannot re
686
697
  export const GET = resolveMapHandler({ authorize: async () => !!(await currentUser()) });
687
698
  ```
688
699
 
700
+ ## Block editor
701
+
702
+ `wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once; the look arrives as a kit.
703
+
704
+ ```tsx
705
+ const KIT: BlockEditorKit = {
706
+ classNames: { list: 'stack', card: 'block-card', action: 'icon-btn', input: 'input', row: 'row', … },
707
+ icons: { add: <Plus />, up: <ChevronUp />, down: <ChevronDown />, duplicate: <Copy />, remove: <Trash2 /> },
708
+ Choice: ToggleGroup, // optional: a <select> otherwise
709
+ Menu: PopoverMenu, // optional: an inline panel otherwise
710
+ };
711
+ const SCHEMA: BlockSchema<Block> = {
712
+ meta: type => BLOCK_META[type], create: createBlock, duplicate: duplicateBlock,
713
+ types: PAGE_TYPES, nestedTypes: ATOMIC_TYPES, single: ['infobox'],
714
+ renderBody: (block, onUpdate) => {
715
+ switch (block.type) {
716
+ case 'codeTabs': return <CodeTabsFields block={block} onUpdate={onUpdate} />;
717
+ case 'columns': return <ColumnsFields block={block} onUpdate={onUpdate} />;
718
+ case 'daoTimeline': return <DaoTimelineEdit block={block} onUpdate={onUpdate} />;
719
+ …
720
+ }
721
+ },
722
+ };
723
+ <BlockEditor blocks={blocks} onChange={setBlocks} schema={SCHEMA} kit={KIT} />
724
+ ```
725
+
726
+ The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
727
+
728
+ The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
729
+
689
730
  ## Analytics
690
731
 
691
732
  Page views and events, counted in the site's own Postgres instead of a hosted
@@ -757,7 +798,6 @@ returns the whole of it. `gate.route` is the sign-in route,
757
798
  `gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
758
799
  while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
759
800
  models in `prisma/passkey.prisma`, and every token is kept only as its hash.
760
- `@simplewebauthn/server` is an optional peer.
761
801
 
762
802
  There is no sign-up. While no passkey is saved, the page saves the first one
763
803
  from whoever opens it, so the owner should do that as soon as the door ships.
@@ -773,14 +813,18 @@ export const gate = createPasskeyGate({ sql, rpName: 'Caper' });
773
813
  export const POST = gate.route;
774
814
 
775
815
  // src/app/stats/page.tsx
776
- if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
777
- return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
778
- const q = statsQuery(await searchParams); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
779
- return <Stats digest={await digest(sql, q)} {...q} />;
816
+ const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
817
+ return (
818
+ <StatsGate gate={gate} session={(await cookies()).get(gate.cookie)?.value} invite={params.invite} host="caper.network">
819
+ {async () => <Stats digest={await digest(sql, q)} {...q} />}
820
+ </StatsGate>
821
+ );
780
822
  ```
781
823
 
824
+ `StatsGate` (`wiki-formant/stats`) renders `children` for a signed-in reader and, for anyone else, the line saying what the prompt will do and the `PasskeyButton` that does it; `classNames` places the text, the button's box, the button and its error.
825
+
782
826
  `PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
783
- own subpath, because it imports the optional peer `@simplewebauthn/browser`.
827
+ own subpath, because it imports `@simplewebauthn/browser`.
784
828
  `Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
785
829
  headline figures, each with its change from the window before
786
830
  (`.stats-delta[data-better]`, a share or for the bounce rate points, for the
@@ -803,8 +847,7 @@ events only from their visitors. Agent tool calls and missed searches are
803
847
  events no visit reaches, so their rows are not links. `ViewDay` keeps totals,
804
848
  not visits, so a narrowed page starts at `counted_from`, the first day the site
805
849
  counted itself, says so, and drops the comparison with the window before. The chart is `TimeChart` with a count
806
- axis from zero, so `lightweight-charts` is an optional peer that a stats page
807
- needs installed.
850
+ axis from zero.
808
851
 
809
852
  ## ROLA sign-in
810
853
 
@@ -899,7 +942,19 @@ package's tests still reach it, and the `.d.ts` leaves it out.
899
942
 
900
943
  `wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
901
944
  `new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
902
- 5432 session pooler, whose fifteen-client cap a build's workers share.
945
+ 5432 session pooler, whose fifteen-client cap a build's workers share. It also
946
+ holds `prismaConfig(root, schema?)`, what an app's prisma.config.ts hands
947
+ `defineConfig`: `.env` loaded from `root`, the schema folder (or one file) and
948
+ migrations under it, and DIRECT_URL preferred, because the Prisma CLI's
949
+ connections hang on the transaction pooler.
950
+
951
+ ```ts
952
+ // prisma.config.ts
953
+ import { defineConfig } from 'prisma/config';
954
+ import { prismaConfig } from 'wiki-formant/db';
955
+
956
+ export default defineConfig(prismaConfig(import.meta.dirname));
957
+ ```
903
958
 
904
959
  `rateLimit` takes `blockMs` (and a `blockKey`, default the bucket's own) for a
905
960
  caller already shown to be abusive: running dry blocks the key outright for that
@@ -928,7 +983,7 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
928
983
  npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
929
984
  ```
930
985
 
931
- `passkey-invite` prints a single-use link that saves another passkey for the page it names, good for a day (`--days n` for longer). Run it the same way:
986
+ `passkey-invite` prints a single-use link that saves another passkey for the page it names, good for a day (`--days n` for longer). No routine calls it: the site's owner runs it by hand, from an app's checkout, when a second device or a second person needs into the stats page. Run it the same way:
932
987
 
933
988
  ```sh
934
989
  npx passkey-invite https://caper.network/stats
@@ -941,6 +996,12 @@ npx prisma-models # write both into prisma/schema
941
996
  npx prisma-models --check # in the build, before prisma generate
942
997
  ```
943
998
 
999
+ An app that keeps one schema file and writes the models into it by hand, because each needs a relation the shipped one cannot carry, checks them in place: `--in` names the file and compares model by model, ignoring comments and spacing, and `--tenant` lets each `siteId` drop its default and gain a `@relation` on it.
1000
+
1001
+ ```sh
1002
+ npx prisma-models analytics --check --in prisma/schema.prisma --tenant # miow
1003
+ ```
1004
+
944
1005
  `fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why.
945
1006
 
946
1007
  ```sh
package/bin/_cli.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // bin/_cli.mjs – what the bins share: reading a flag, and a client on the app's
2
+ // database. Five bins had each written the `.env` fallback and the `pg.Client`
3
+ // setup, and four the flag reader.
4
+
5
+ const argv = process.argv.slice(2);
6
+
7
+ /** The value after `name`, or undefined. */
8
+ export const arg = name => {
9
+ const i = argv.indexOf(name);
10
+ return i >= 0 ? argv[i + 1] : undefined;
11
+ };
12
+
13
+ /** Every value after each `name`, for a flag given more than once. */
14
+ export const all = name => argv.flatMap((a, i) => (a === name && argv[i + 1] ? [argv[i + 1]] : []));
15
+
16
+ /**
17
+ * A connected `pg.Client` on DATABASE_URL, read from the environment first and
18
+ * `./.env` second, so a bin runs from the app's root, where `pg` is installed.
19
+ * `direct` prefers DIRECT_URL and moves DATABASE_URL from the transaction
20
+ * pooler on 6543 to the session one on 5432: DDL hangs on the first. Throws
21
+ * when no URL is set.
22
+ */
23
+ export async function connect({ direct = false, ...options } = {}) {
24
+ const url = () => (direct ? process.env.DIRECT_URL || process.env.DATABASE_URL?.replace(':6543', ':5432').replace(/\?.*$/, '') : process.env.DATABASE_URL);
25
+ if (!url()) {
26
+ try {
27
+ process.loadEnvFile('.env');
28
+ } catch {
29
+ // reported below
30
+ }
31
+ }
32
+ const connectionString = url();
33
+ if (!connectionString) throw new Error(`no ${direct ? 'DIRECT_URL or DATABASE_URL' : 'DATABASE_URL'} in the environment or ./.env`);
34
+ // Imported once a URL is known: `pg` is the app's, not this package's.
35
+ const { default: pg } = await import('pg');
36
+ const client = new pg.Client({ connectionString, ...options });
37
+ await client.connect();
38
+ return client;
39
+ }
40
+
41
+ /** `vercel cache invalidate --tag`, reported either way. */
42
+ export async function invalidate(tag) {
43
+ const { execFileSync } = await import('node:child_process');
44
+ try {
45
+ execFileSync('vercel', ['cache', 'invalidate', '--tag', tag, '--yes'], { stdio: 'ignore' });
46
+ console.log(`cache: ${tag} tag invalidated`);
47
+ } catch (e) {
48
+ console.warn(`cache: ${tag} tag NOT invalidated (${e.message}); run \`vercel cache invalidate --tag ${tag}\``);
49
+ }
50
+ }
@@ -10,31 +10,12 @@
10
10
  // npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
11
11
  // npx analytics-digest --days 7 --site <id> # a multi-tenant app's site
12
12
  // npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds to the search gaps
13
- import pg from 'pg';
14
13
  import { digest, SEARCH_GAPS } from '../dist/analytics.js';
14
+ import { all, arg, connect } from './_cli.mjs';
15
15
 
16
- const args = process.argv.slice(2);
17
- const arg = name => {
18
- const i = args.indexOf(name);
19
- return i >= 0 ? args[i + 1] : undefined;
20
- };
21
- const all = name => args.flatMap((a, i) => (a === name && args[i + 1] ? [args[i + 1]] : []));
22
-
23
- if (!process.env.DATABASE_URL) {
24
- try {
25
- process.loadEnvFile('.env');
26
- } catch {
27
- // reported below
28
- }
29
- }
30
- if (!process.env.DATABASE_URL) {
31
- console.log(JSON.stringify({ error: 'no DATABASE_URL in the environment or ./.env' }));
32
- process.exit(0);
33
- }
34
-
35
- const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
16
+ let client;
36
17
  try {
37
- await client.connect();
18
+ client = await connect();
38
19
  const sql = (query, ...values) => client.query(query, values).then(r => r.rows);
39
20
  const result = await digest(sql, {
40
21
  site: arg('--site') ?? '',
@@ -53,5 +34,5 @@ try {
53
34
  } catch (e) {
54
35
  console.log(JSON.stringify({ error: e.message }));
55
36
  } finally {
56
- await client.end().catch(() => {});
37
+ await client?.end().catch(() => {});
57
38
  }
@@ -28,12 +28,11 @@
28
28
  // which is the right default, since an unlisted route is one nobody asked a
29
29
  // crawler to read.
30
30
  import { outlineIssues } from '../dist/headings.js';
31
+ import { readSitemap } from '../dist/indexnow.js';
32
+ import { decodeEntities } from '../dist/markdown.js';
31
33
  import { TITLE_BUDGET, TITLE_LIMIT } from '../dist/metadata.js';
34
+ import { arg } from './_cli.mjs';
32
35
 
33
- const arg = name => {
34
- const i = process.argv.indexOf(name);
35
- return i >= 0 ? process.argv[i + 1] : undefined;
36
- };
37
36
  const has = name => process.argv.includes(name);
38
37
 
39
38
  const site = (arg('--site') || '').replace(/\/+$/, '');
@@ -52,20 +51,9 @@ const get = async url => {
52
51
  return { status: res.status, body: res.ok ? await res.text() : '' };
53
52
  };
54
53
 
55
- // The sitemap is the URL list, and a sitemap index is one more fetch deep.
54
+ // The sitemap is the URL list.
56
55
  async function sitemapUrls(origin) {
57
- const seen = [];
58
- const read = async url => {
59
- const { status, body } = await get(url);
60
- if (status !== 200) return;
61
- const locs = [...body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map(m => m[1]);
62
- if (/<sitemapindex/i.test(body)) {
63
- for (const child of locs.slice(0, 20)) await read(child);
64
- } else {
65
- seen.push(...locs);
66
- }
67
- };
68
- await read(`${origin}/sitemap.xml`);
56
+ const seen = (await readSitemap(`${origin}/sitemap.xml`, url => fetch(url, { headers: { 'user-agent': UA } })).catch(() => [])).map(e => e.url);
69
57
  // A sitemap states absolute production URLs by definition, so pointing
70
58
  // --site at a dev server would otherwise crawl production and report the
71
59
  // build you were trying to replace. The path is what the sitemap contributes;
@@ -88,8 +76,6 @@ const descriptionOf = html => {
88
76
  const m = html.match(/<meta[^>]+name=["']description["'][^>]*>/i);
89
77
  return m ? (m[0].match(/content=["']([\s\S]*?)["']/i)?.[1] ?? '').trim() : null;
90
78
  };
91
- const decode = s =>
92
- s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'");
93
79
 
94
80
  const urls = await sitemapUrls(site);
95
81
  if (!urls.length) {
@@ -126,7 +112,7 @@ for (const url of checked) {
126
112
  });
127
113
  }
128
114
 
129
- const title = decode(titleOf(page.body));
115
+ const title = decodeEntities(titleOf(page.body));
130
116
  if (!title) faults.push({ url, fault: 'noTitle', detail: '' });
131
117
  else {
132
118
  if (title.length > TITLE_LIMIT) faults.push({ url, fault: 'titleTooLong', detail: `${title.length} chars: ${title}` });
package/bin/fts-ddl.mjs CHANGED
@@ -28,8 +28,8 @@
28
28
  // Connection: DIRECT_URL (the session pooler on 5432) when set, else
29
29
  // DATABASE_URL moved from 6543 to 5432. Both run DDL; the session port is the
30
30
  // one that never hangs on a long statement.
31
- import pg from 'pg';
32
31
  import { searchTsvDdl } from '../dist/search.js';
32
+ import { connect } from './_cli.mjs';
33
33
 
34
34
  const table = process.argv[2];
35
35
  if (!table || !/^[a-z_][a-z0-9_]*$/.test(table)) {
@@ -37,26 +37,11 @@ if (!table || !/^[a-z_][a-z0-9_]*$/.test(table)) {
37
37
  process.exit(1);
38
38
  }
39
39
 
40
- if (!process.env.DIRECT_URL && !process.env.DATABASE_URL) {
41
- try {
42
- process.loadEnvFile('.env');
43
- } catch {
44
- // reported below
45
- }
46
- }
47
- const url = process.env.DIRECT_URL || process.env.DATABASE_URL?.replace(':6543', ':5432');
48
- if (!url) {
49
- console.error('no DIRECT_URL or DATABASE_URL in the environment or ./.env');
50
- process.exit(1);
51
- }
52
-
53
40
  // A bad route hangs rather than erroring without a connect timeout.
54
- const client = new pg.Client({
55
- connectionString: url.replace(/\?.*$/, ''),
56
- ssl: { rejectUnauthorized: false },
57
- connectionTimeoutMillis: 15000,
41
+ const client = await connect({ direct: true, ssl: { rejectUnauthorized: false }, connectionTimeoutMillis: 15000 }).catch(e => {
42
+ console.error(e.message);
43
+ process.exit(1);
58
44
  });
59
- await client.connect();
60
45
  const q = s => client.query(s).then(r => r.rows);
61
46
  try {
62
47
  const ddl = searchTsvDdl(table);
package/bin/indexnow.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // npx indexnow https://radix.wiki --hours 6
10
10
  // npx indexnow https://radix.wiki --all # every row: a new origin or a new key
11
11
  import { readdirSync, readFileSync } from 'node:fs';
12
- import { sitemapEntries, changedUrls, submitIndexNow } from '../dist/indexnow.js';
12
+ import { readSitemap, changedUrls, submitIndexNow } from '../dist/indexnow.js';
13
13
 
14
14
  const args = process.argv.slice(2);
15
15
  const origin = args.find(a => /^https?:\/\//.test(a))?.replace(/\/$/, '');
@@ -40,12 +40,10 @@ if (live.trim() !== key) {
40
40
  process.exit(1);
41
41
  }
42
42
 
43
- const sitemap = await fetch(`${origin}/sitemap.xml`);
44
- if (!sitemap.ok) {
45
- console.error(`${origin}/sitemap.xml answered ${sitemap.status}`);
43
+ const entries = await readSitemap(`${origin}/sitemap.xml`).catch(e => {
44
+ console.error(e.message);
46
45
  process.exit(1);
47
- }
48
- const entries = sitemapEntries(await sitemap.text());
46
+ });
49
47
  const urls = args.includes('--all') ? entries.map(e => e.url) : changedUrls(entries, Date.now() - hours * 3_600_000);
50
48
  const statuses = await submitIndexNow(key, urls);
51
49
  console.log(JSON.stringify({ origin, submitted: urls.length, statuses }));
@@ -31,16 +31,12 @@
31
31
  // concurrent run's, and the row lock serialises the two. A revision's version
32
32
  // is the page's `version` column bumped a minor where the table has one, else
33
33
  // the latest revision's.
34
- import { execFileSync } from 'node:child_process';
35
34
  import { randomUUID } from 'node:crypto';
36
35
  import { addBacklog, compact, ident, render, scopeSql } from '../dist/maintenance.js';
37
36
  import { bump } from '../dist/versioning.js';
37
+ import { arg, connect, invalidate } from './_cli.mjs';
38
38
 
39
39
  const args = process.argv.slice(2);
40
- const arg = name => {
41
- const i = args.indexOf(name);
42
- return i >= 0 ? args[i + 1] : undefined;
43
- };
44
40
  const [mode, payload] = args.filter((a, i) => !a.startsWith('--') && !args[i - 1]?.match(/^--(?!dry-run)/));
45
41
  const dry = args.includes('--dry-run');
46
42
  const at = arg('--at') ?? '';
@@ -58,31 +54,17 @@ const scope = scopeSql(arg('--scope'), 3);
58
54
  const where = `tag_path = $1 AND slug = $2 AND ${scope.sql}`;
59
55
  const address = [at.slice(0, slugAt), at.slice(slugAt + 1), ...scope.values];
60
56
 
61
- if (!process.env.DATABASE_URL) {
62
- try {
63
- process.loadEnvFile('.env');
64
- } catch {
65
- // reported below
66
- }
67
- }
68
- if (!process.env.DATABASE_URL) {
69
- console.error('no DATABASE_URL in the environment or ./.env');
70
- process.exit(1);
71
- }
72
-
73
57
  const readStdin = async () => {
74
58
  let data = '';
75
59
  for await (const chunk of process.stdin) data += chunk;
76
60
  return data;
77
61
  };
78
62
 
79
- // Imported once the command is known good: `pg` is the app's, not this package's.
80
- const { default: pg } = await import('pg');
81
- const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
63
+ let client;
82
64
  const q = (text, values) => client.query(text, values).then(r => r.rows);
83
65
  let wrote = false;
84
66
  try {
85
- await client.connect();
67
+ client = await connect();
86
68
  const today = new Date().toISOString().slice(0, 10);
87
69
 
88
70
  if (mode === 'read') {
@@ -181,18 +163,11 @@ try {
181
163
  }
182
164
  }
183
165
  } catch (e) {
184
- await client.query('ROLLBACK').catch(() => {});
166
+ await client?.query('ROLLBACK').catch(() => {});
185
167
  console.error(e.message);
186
168
  process.exitCode = 1;
187
169
  } finally {
188
- await client.end().catch(() => {});
170
+ await client?.end().catch(() => {});
189
171
  const tag = arg('--invalidate');
190
- if (wrote && tag) {
191
- try {
192
- execFileSync('vercel', ['cache', 'invalidate', '--tag', tag, '--yes'], { stdio: 'ignore' });
193
- console.log(`cache: ${tag} tag invalidated`);
194
- } catch (e) {
195
- console.warn(`cache: ${tag} tag NOT invalidated (${e.message}); run \`vercel cache invalidate --tag ${tag}\``);
196
- }
197
- }
172
+ if (wrote && tag) await invalidate(tag);
198
173
  }
@@ -13,14 +13,10 @@
13
13
  // --table <table> default pages
14
14
  // --scope <column>[=<value>] only rows where the column is NULL, or equals the value
15
15
  // --invalidate <tag> `vercel cache invalidate --tag` after a write
16
- import { execFileSync } from 'node:child_process';
17
16
  import { ident, scopeSql } from '../dist/maintenance.js';
17
+ import { arg, connect, invalidate } from './_cli.mjs';
18
18
 
19
19
  const args = process.argv.slice(2);
20
- const arg = name => {
21
- const i = args.indexOf(name);
22
- return i >= 0 ? args[i + 1] : undefined;
23
- };
24
20
  const all = args.includes('--all');
25
21
  const [tagPath, slug] = args.filter((a, i) => !a.startsWith('--') && !['--table', '--scope', '--invalidate'].includes(args[i - 1]));
26
22
 
@@ -31,23 +27,9 @@ if (!tagPath || (!all && slug === undefined)) {
31
27
  const table = ident(arg('--table') ?? 'pages', '--table');
32
28
  const scope = scopeSql(arg('--scope'), 3);
33
29
 
34
- if (!process.env.DATABASE_URL) {
35
- try {
36
- process.loadEnvFile('.env');
37
- } catch {
38
- // reported below
39
- }
40
- }
41
- if (!process.env.DATABASE_URL) {
42
- console.error('no DATABASE_URL in the environment or ./.env');
43
- process.exit(1);
44
- }
45
-
46
- // Imported once the command is known good: `pg` is the app's, not this package's.
47
- const { default: pg } = await import('pg');
48
- const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
30
+ let client;
49
31
  try {
50
- await client.connect();
32
+ client = await connect();
51
33
  const { rowCount } = all
52
34
  ? await client.query(
53
35
  `UPDATE ${table} SET last_verified_at = now() WHERE (tag_path = $1 OR tag_path LIKE $2) AND ${scope.sql}`,
@@ -59,17 +41,10 @@ try {
59
41
  );
60
42
  console.log(`Stamped last_verified_at on ${rowCount} page(s).`);
61
43
  const tag = arg('--invalidate');
62
- if (rowCount && tag) {
63
- try {
64
- execFileSync('vercel', ['cache', 'invalidate', '--tag', tag, '--yes'], { stdio: 'ignore' });
65
- console.log(`cache: ${tag} tag invalidated`);
66
- } catch (e) {
67
- console.warn(`cache: ${tag} tag NOT invalidated (${e.message}); run \`vercel cache invalidate --tag ${tag}\``);
68
- }
69
- }
44
+ if (rowCount && tag) await invalidate(tag);
70
45
  } catch (e) {
71
46
  console.error(e.message);
72
47
  process.exitCode = 1;
73
48
  } finally {
74
- await client.end().catch(() => {});
49
+ await client?.end().catch(() => {});
75
50
  }
@@ -8,36 +8,26 @@
8
8
  //
9
9
  // npx passkey-invite https://caper.network/stats
10
10
  // npx passkey-invite http://localhost:3000/stats --days 7
11
- import pg from 'pg';
12
11
  import { createPasskeyGate } from '../dist/passkey.js';
12
+ import { arg, connect } from './_cli.mjs';
13
13
 
14
- const args = process.argv.slice(2);
15
- const page = args.find(a => /^https?:\/\//.test(a));
16
- const days = Number(args[args.indexOf('--days') + 1] ?? 1);
14
+ const page = process.argv.slice(2).find(a => /^https?:\/\//.test(a));
17
15
 
18
16
  if (!page) {
19
17
  console.error('usage: passkey-invite <page url> [--days n]');
20
18
  process.exit(1);
21
19
  }
22
- if (!process.env.DATABASE_URL) {
23
- try {
24
- process.loadEnvFile('.env');
25
- } catch {
26
- // reported below
27
- }
28
- }
29
- if (!process.env.DATABASE_URL) {
30
- console.error('no DATABASE_URL in the environment or ./.env');
31
- process.exit(1);
32
- }
33
20
 
34
- const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
21
+ let client;
35
22
  try {
36
- await client.connect();
23
+ client = await connect();
37
24
  const sql = (query, ...values) => client.query(query, values).then(r => r.rows);
38
25
  const url = new URL(page);
39
- url.searchParams.set('invite', await createPasskeyGate({ sql, rpName: '' }).invite(args.includes('--days') ? days : 1));
26
+ url.searchParams.set('invite', await createPasskeyGate({ sql, rpName: '' }).invite(Number(arg('--days') ?? 1)));
40
27
  console.log(url.href);
28
+ } catch (e) {
29
+ console.error(e.message);
30
+ process.exitCode = 1;
41
31
  } finally {
42
- await client.end().catch(() => {});
32
+ await client?.end().catch(() => {});
43
33
  }