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 +84 -23
- package/bin/_cli.mjs +50 -0
- package/bin/analytics-digest.mjs +4 -23
- package/bin/check-pages.mjs +6 -20
- package/bin/fts-ddl.mjs +4 -19
- package/bin/indexnow.mjs +4 -6
- package/bin/maintenance-log.mjs +6 -31
- package/bin/mark-verified.mjs +5 -30
- package/bin/passkey-invite.mjs +9 -19
- package/bin/prisma-models.mjs +40 -5
- package/dist/base.css +25 -0
- package/dist/block-editor.d.ts +203 -0
- package/dist/block-editor.js +218 -0
- package/dist/block-views.js +2 -3
- package/dist/blocks.d.ts +67 -0
- package/dist/crawlers.d.ts +2 -2
- package/dist/db.d.ts +25 -0
- package/dist/db.js +32 -1
- package/dist/editor.d.ts +61 -0
- package/dist/editor.js +29 -1
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +32 -1
- package/dist/headings.d.ts +10 -0
- package/dist/headings.js +12 -0
- package/dist/history.js +1 -1
- package/dist/http.d.ts +7 -2
- package/dist/http.js +7 -2
- package/dist/indexnow.d.ts +7 -0
- package/dist/indexnow.js +22 -3
- package/dist/maintenance.js +2 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +3 -4
- package/dist/sanitize.js +1 -1
- package/dist/stats.d.ts +28 -0
- package/dist/stats.js +18 -1
- package/dist/tiptap.js +2 -4
- package/package.json +18 -83
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
|
-
|
|
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
|
|
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.
|
|
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`.
|
|
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".
|
|
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
|
-
|
|
677
|
-
<
|
|
678
|
-
{
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/bin/analytics-digest.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
37
|
+
await client?.end().catch(() => {});
|
|
57
38
|
}
|
package/bin/check-pages.mjs
CHANGED
|
@@ -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
|
|
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/�?39;|'/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 =
|
|
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 =
|
|
55
|
-
|
|
56
|
-
|
|
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 {
|
|
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
|
|
44
|
-
|
|
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 }));
|
package/bin/maintenance-log.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
166
|
+
await client?.query('ROLLBACK').catch(() => {});
|
|
185
167
|
console.error(e.message);
|
|
186
168
|
process.exitCode = 1;
|
|
187
169
|
} finally {
|
|
188
|
-
await client
|
|
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
|
}
|
package/bin/mark-verified.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
49
|
+
await client?.end().catch(() => {});
|
|
75
50
|
}
|
package/bin/passkey-invite.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
21
|
+
let client;
|
|
35
22
|
try {
|
|
36
|
-
await
|
|
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(
|
|
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
|
|
32
|
+
await client?.end().catch(() => {});
|
|
43
33
|
}
|