wiki-formant 0.51.0 → 0.52.1
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 +45 -17
- package/bin/_cli.mjs +50 -0
- package/bin/analytics-digest.mjs +4 -23
- package/bin/check-classes.mjs +23 -15
- 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-views.js +2 -3
- 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 +3 -0
- package/dist/editor.js +2 -0
- package/dist/headers.d.ts +28 -0
- package/dist/headers.js +33 -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 +13 -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<NextConfig>({ 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({
|
|
@@ -790,7 +798,6 @@ returns the whole of it. `gate.route` is the sign-in route,
|
|
|
790
798
|
`gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
|
|
791
799
|
while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
|
|
792
800
|
models in `prisma/passkey.prisma`, and every token is kept only as its hash.
|
|
793
|
-
`@simplewebauthn/server` is an optional peer.
|
|
794
801
|
|
|
795
802
|
There is no sign-up. While no passkey is saved, the page saves the first one
|
|
796
803
|
from whoever opens it, so the owner should do that as soon as the door ships.
|
|
@@ -806,14 +813,18 @@ export const gate = createPasskeyGate({ sql, rpName: 'Caper' });
|
|
|
806
813
|
export const POST = gate.route;
|
|
807
814
|
|
|
808
815
|
// src/app/stats/page.tsx
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
+
);
|
|
813
822
|
```
|
|
814
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
|
+
|
|
815
826
|
`PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
|
|
816
|
-
own subpath, because it imports
|
|
827
|
+
own subpath, because it imports `@simplewebauthn/browser`.
|
|
817
828
|
`Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
|
|
818
829
|
headline figures, each with its change from the window before
|
|
819
830
|
(`.stats-delta[data-better]`, a share or for the bounce rate points, for the
|
|
@@ -836,8 +847,7 @@ events only from their visitors. Agent tool calls and missed searches are
|
|
|
836
847
|
events no visit reaches, so their rows are not links. `ViewDay` keeps totals,
|
|
837
848
|
not visits, so a narrowed page starts at `counted_from`, the first day the site
|
|
838
849
|
counted itself, says so, and drops the comparison with the window before. The chart is `TimeChart` with a count
|
|
839
|
-
axis from zero
|
|
840
|
-
needs installed.
|
|
850
|
+
axis from zero.
|
|
841
851
|
|
|
842
852
|
## ROLA sign-in
|
|
843
853
|
|
|
@@ -932,7 +942,19 @@ package's tests still reach it, and the `.d.ts` leaves it out.
|
|
|
932
942
|
|
|
933
943
|
`wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
|
|
934
944
|
`new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
|
|
935
|
-
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
|
+
```
|
|
936
958
|
|
|
937
959
|
`rateLimit` takes `blockMs` (and a `blockKey`, default the bucket's own) for a
|
|
938
960
|
caller already shown to be abusive: running dry blocks the key outright for that
|
|
@@ -961,7 +983,7 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
|
|
|
961
983
|
npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
|
|
962
984
|
```
|
|
963
985
|
|
|
964
|
-
`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:
|
|
965
987
|
|
|
966
988
|
```sh
|
|
967
989
|
npx passkey-invite https://caper.network/stats
|
|
@@ -974,6 +996,12 @@ npx prisma-models # write both into prisma/schema
|
|
|
974
996
|
npx prisma-models --check # in the build, before prisma generate
|
|
975
997
|
```
|
|
976
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
|
+
|
|
977
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.
|
|
978
1006
|
|
|
979
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-classes.mjs
CHANGED
|
@@ -66,22 +66,28 @@ for (const f of cssFiles) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// Classes this project
|
|
70
|
-
// Tailwind utility, which is what makes
|
|
71
|
-
// than a maintained prefix list. Read
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const css = fs.readFileSync(
|
|
69
|
+
// Classes defined by this project's stylesheets or by wiki-formant's base.css.
|
|
70
|
+
// Everything else the build emitted is a Tailwind utility, which is what makes
|
|
71
|
+
// the composition count derivable rather than a maintained prefix list. Read
|
|
72
|
+
// from rule preludes only, so a decimal in a value or a class named in a
|
|
73
|
+
// comment is not taken for a selector.
|
|
74
|
+
const classesIn = file => {
|
|
75
|
+
const out = new Set();
|
|
76
|
+
const css = fs.readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
77
77
|
for (const [, prelude] of css.matchAll(/([^{};]*)\{/g)) {
|
|
78
78
|
if (prelude.trim().startsWith('@')) continue;
|
|
79
79
|
for (const m of prelude.matchAll(/\.((?:\\.|[-\w])+)/g)) {
|
|
80
80
|
const name = m[1].replace(/\\/g, '');
|
|
81
|
-
if (/^[A-Za-z_-]/.test(name))
|
|
81
|
+
if (/^[A-Za-z_-]/.test(name)) out.add(name);
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
-
|
|
84
|
+
return out;
|
|
85
|
+
};
|
|
86
|
+
const ownCss = walk(SRC_DIR, '.css');
|
|
87
|
+
const globalsFile = ownCss.find(f => f.endsWith('globals.css'));
|
|
88
|
+
const own = new Set(ownCss.flatMap(f => [...classesIn(f)]));
|
|
89
|
+
const baseCss = new URL('../dist/base.css', import.meta.url);
|
|
90
|
+
const named = new Set([...own, ...(fs.existsSync(baseCss) ? classesIn(baseCss) : [])]);
|
|
85
91
|
|
|
86
92
|
// Only bare literals: anything interpolated is beyond a static check.
|
|
87
93
|
const dead = new Map();
|
|
@@ -120,20 +126,22 @@ if (compositions.length) {
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
// Every token in the JavaScript the build ships, server and client. A selector
|
|
123
|
-
// is referenced when its name appears whole,
|
|
124
|
-
// or `_` does, which is how `editorial-banner-${variant}` is written
|
|
129
|
+
// is referenced when its name appears whole, when a prefix of it ending in `-`
|
|
130
|
+
// or `_` does, which is how `editorial-banner-${variant}` is written, or when it
|
|
131
|
+
// splits at a `-` or `_` into two shipped tokens, which is how `RailShell`'s
|
|
132
|
+
// `${prefix}--open` is written.
|
|
125
133
|
const shipped = new Set();
|
|
126
134
|
for (const f of [...walk(STATIC_DIR, '.js'), ...walk('.next/server', '.js')]) {
|
|
127
135
|
for (const [tok] of fs.readFileSync(f, 'utf8').matchAll(/[\w-]+/g)) shipped.add(tok);
|
|
128
136
|
}
|
|
129
137
|
const referenced = name =>
|
|
130
138
|
shipped.has(name) ||
|
|
131
|
-
[...name].some((c, i) => i >= 3 && (c === '-' || c === '_') && shipped.has(name.slice(0, i + 1)));
|
|
132
|
-
const unused = [...
|
|
139
|
+
[...name].some((c, i) => i >= 3 && (c === '-' || c === '_') && (shipped.has(name.slice(0, i + 1)) || (shipped.has(name.slice(0, i)) && shipped.has(name.slice(i)))));
|
|
140
|
+
const unused = [...own].filter(n => !referenced(n)).sort();
|
|
133
141
|
|
|
134
142
|
if (unused.length) {
|
|
135
143
|
const verb = CHECK_UNUSED ? 'must be deleted' : 'advisory';
|
|
136
|
-
console.error(`check-classes: ${unused.length} selector(s) in ${
|
|
144
|
+
console.error(`check-classes: ${unused.length} selector(s) in ${ownCss.join(', ')} referenced by nothing the build ships (${verb}):\n`);
|
|
137
145
|
console.error(` ${unused.join(', ')}\n`);
|
|
138
146
|
console.error(' Class names inside HTML stored in a database do not count, so check stored content before deleting one.\n');
|
|
139
147
|
}
|
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
|
}
|