wiki-formant 0.85.1 → 0.86.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 +38 -3
- package/dist/base.css +144 -2
- package/dist/blocks.d.ts +2 -0
- package/dist/headers.d.ts +13 -1
- package/dist/headers.js +16 -4
- package/dist/knip.d.ts +8 -0
- package/dist/knip.js +31 -0
- package/dist/link-check.d.ts +48 -0
- package/dist/link-check.js +69 -4
- package/dist/passkey.d.ts +2 -0
- package/dist/passkey.js +7 -8
- package/dist/scripts.d.ts +189 -0
- package/dist/scripts.js +138 -0
- package/dist/validation.d.ts +43 -0
- package/dist/validation.js +28 -1
- package/dist/well-known.d.ts +27 -0
- package/dist/well-known.js +27 -1
- package/package.json +15 -7
package/README.md
CHANGED
|
@@ -321,6 +321,13 @@ export async function GET(request: Request) {
|
|
|
321
321
|
|
|
322
322
|
`descriptorResponse` and `descriptorHandler` serve a JSON descriptor (an agent card, a manifest, an OpenAPI document) with its own ETag and `Access-Control-Allow-Origin: *`, since every descriptor is public and browser agents read them; six of nine routes had passed the header by hand.
|
|
323
323
|
|
|
324
|
+
`wellKnownHandler({ agentCard, mcpManifest, openapi, registryAuth? })` is the whole `/.well-known/[doc]` route: the card at `agent.json` and `agent-card.json`, the manifest at `mcp.json`, the spec at `openapi.json` and the registry's proof at `mcp-registry-auth`, each through `descriptorResponse`, and a 404 for any other name. A rewrite puts `/openapi.json` on it. `registryAuth: null` serves no proof, for a tenant of a host whose namespace is the host's.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
// src/app/.well-known/[doc]/route.ts
|
|
328
|
+
export const GET = wellKnownHandler({ agentCard: AGENT_CARD, mcpManifest: MCP_MANIFEST, openapi: SPEC });
|
|
329
|
+
```
|
|
330
|
+
|
|
324
331
|
`corpusValidatorsFrom(seed, stamps)` is that pair from an aggregate: the newest of `stamps` (the epoch when every one is null), appended to `seed` for the tag. `corpusRoute(validators, build)` is the whole handler, and skips the build on a 304.
|
|
325
332
|
|
|
326
333
|
```ts
|
|
@@ -369,7 +376,7 @@ Four configs had restated these by hand on the grounds that a config could not i
|
|
|
369
376
|
export default withWikiDefaults<NextConfig>({ async redirects() { /* … */ } }, { csp });
|
|
370
377
|
```
|
|
371
378
|
|
|
372
|
-
`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.
|
|
379
|
+
`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. A second argument, `{ nonce, inlineScripts }`, makes `script-src` strict where there is a nonce: `'self'`, the nonce and `scriptHash` of each inline script, with no `'unsafe-inline'`. The hash admits a static boot script without the layout reading the nonce, and moves with the script when a bump changes it.
|
|
373
380
|
|
|
374
381
|
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`.
|
|
375
382
|
|
|
@@ -757,6 +764,15 @@ if (issues.length) return badRequest(`Invalid content: ${describeBlockIssues(iss
|
|
|
757
764
|
// Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
|
|
758
765
|
```
|
|
759
766
|
|
|
767
|
+
`defineBlocks<Block>({ defaults, checks?, pageId? })` is a wiki's whole block model from its type set. `defaults` is what inserting each type makes, and its keys are the types the wiki knows; `checks` is the leaf check for each native type, and for a core type the wiki narrows, which gets the core check as its second argument. It returns `types`, `atomicTypes` (all but the two containers), `shape`, `createBlock`, `duplicateBlock`, `validateBlocks` and `blockIssues`. `BlockTree<AtomicBlock>` from `wiki-formant/blocks` is the union it models.
|
|
768
|
+
|
|
769
|
+
```ts
|
|
770
|
+
export const { shape: BLOCK_SHAPE, createBlock, duplicateBlock, validateBlocks, blockIssues } = defineBlocks<Block>({
|
|
771
|
+
defaults: { ...coreBlockDefaults({ stats }), corpusStats: () => ({ type: 'corpusStats', view: 'sourceTiers' }) },
|
|
772
|
+
checks: { corpusStats: b => isCorpusView(b.view), list: (b, core) => core(b) && isSection(b.under) },
|
|
773
|
+
});
|
|
774
|
+
```
|
|
775
|
+
|
|
760
776
|
A `codeTabs` tab's `code` is source text in every wiki here, and every view treats it that way: `CodeTabsView` escapes it into a `<pre><code>`, the sanitiser leaves it alone, the markdown twin fences it verbatim. A wiki that escapes and highlights on the server passes `highlighted` to the view. The package used to assume editors stored highlighted markup; none ever had, and every twin printed `Vec<u8>` as `Vec`.
|
|
761
777
|
|
|
762
778
|
`recentPages`, `pageList` and a `list` resolve to `ResolvedPageRef`s — build each with `pageRef(page, now)`, which carries the ISO date a `<time>` needs — and `PageRefsView` renders them as a list, with a `renderItem` slot for a wiki that has its own row.
|
|
@@ -960,7 +976,7 @@ its readers asked for. The queries that return zero rows are the valuable ones
|
|
|
960
976
|
`wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
|
|
961
977
|
nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
|
|
962
978
|
returns the whole of it. `gate.route` is the sign-in route, `gate.signOut` the
|
|
963
|
-
sign-out route, `gate.signedIn(cookieValue)` is the check a page makes, and
|
|
979
|
+
sign-out route, `gate.signedIn(cookieValue)` is the check a page makes, `gate.allows(request)` the same check over a write route's request, and
|
|
964
980
|
`gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
|
|
965
981
|
models in `prisma/passkey.prisma`, and every token is kept only as its hash.
|
|
966
982
|
|
|
@@ -1092,7 +1108,7 @@ the site's stylesheet gives them and the box a look.
|
|
|
1092
1108
|
|
|
1093
1109
|
## Base stylesheet
|
|
1094
1110
|
|
|
1095
|
-
`wiki-formant/base.css` is the layout the package's markup does not work without
|
|
1111
|
+
`wiki-formant/base.css` is first the layout the package's markup does not work without: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the stats block's grid, the code-tab strip, the banner's row and the infobox's wrapping, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. That part has no colour and no scale, so a design system's own rules override it at equal specificity. The one set of tokens it declares is the page frame every site lays out by: `--content-width` (72rem, gutters inside), `--sidebar-width` (18rem) and `--page-pad` (16 / 24 / 32px at base / 640 / 1024). They sit in the components layer, so a site never restates them in `@theme`, which would lose; an element that must differ sets its own.
|
|
1096
1112
|
|
|
1097
1113
|
```css
|
|
1098
1114
|
@import "wiki-formant/base.css" layer(components);
|
|
@@ -1100,6 +1116,19 @@ the site's stylesheet gives them and the box a look.
|
|
|
1100
1116
|
|
|
1101
1117
|
No view emits a Tailwind utility any more — `ColumnsView` states its gap and alignment as data attributes, and an inactive code tab is `hidden` — so a site no longer needs its Tailwind to scan this package for the markup to work.
|
|
1102
1118
|
|
|
1119
|
+
After the structure comes the look: every component the package's markup names (heading anchors, code tabs and the copy button, notices, references, the infobox, stats cards and deltas, link grids, see-also cards, section cards, the link preview, the rail's table of contents, the work queues, revision diffs, sortable headers, charts) drawn from twelve tokens a site maps once, unlayered, from its own palette. Four sites had each drawn the same components in their own tokens. A site's own rule after the import still wins.
|
|
1120
|
+
|
|
1121
|
+
```css
|
|
1122
|
+
:root {
|
|
1123
|
+
--ui-surface: var(--color-surface-1); --ui-muted: var(--color-text-muted);
|
|
1124
|
+
--ui-surface-2: var(--color-surface-2); --ui-accent: var(--color-accent);
|
|
1125
|
+
--ui-border: var(--color-border); --ui-on-accent: var(--color-surface-0);
|
|
1126
|
+
--ui-radius: var(--radius); --ui-up: var(--color-success);
|
|
1127
|
+
--ui-pad: 1rem; --ui-down: var(--color-error);
|
|
1128
|
+
--ui-small: var(--text-small); --ui-shadow: var(--shadow);
|
|
1129
|
+
}
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1103
1132
|
## API
|
|
1104
1133
|
|
|
1105
1134
|
Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on —
|
|
@@ -1141,6 +1170,12 @@ and overwrites any inbound copy — and the leftmost `x-forwarded-for` only wher
|
|
|
1141
1170
|
`limitClient(prefix, headers, opts)` is the per-address gate, `rateLimit` over
|
|
1142
1171
|
`clientKey`, which a dozen call sites had written out.
|
|
1143
1172
|
|
|
1173
|
+
`wiki-formant/scripts` is what a wiki's direct-database scripts share: `withClient(fn, { tag })` (a pg client that rolls back and fails the run on a throw, and invalidates the cache `tag` through the Vercel CLI when it wrote), `storeFor(client)` and `readPage` over the app's own store, `writeRevision(client, page, blocks, opts)` (one revision, refused if the page moved since it was read), `upsertWikiPage(client, page)` (a revision only when something changed), the block builders `uid`, `content`, `banner` and `infobox`, and `assertLinkShapes`, which throws on the mis-shaped link a script would otherwise store unseen. Its `ScriptClient` is the slice of a pg client they take; Prisma fits it with `$queryRawUnsafe`.
|
|
1174
|
+
|
|
1175
|
+
`wiki-formant/link-check`'s `auditLinks(pages, { livePaths, probe?, internal? })` is a checker's whole walk: every link on every page probed once, internals checked against `livePaths` (a page's `/history` and `/edit` resolve with it, and a query rides on its path), metadata facts included, embeds collected for the caller to probe. `printLinkAudit(audit, { scope, pagesScanned })` prints what broke.
|
|
1176
|
+
|
|
1177
|
+
`wiki-formant/knip` is the dead-code config every app spreads into its own `knip.ts`: `export default { ...base, ignoreBinaries: [...base.ignoreBinaries, 'ffmpeg'] }`.
|
|
1178
|
+
|
|
1144
1179
|
## A bin
|
|
1145
1180
|
|
|
1146
1181
|
`check-classes` fails a build on a `className` token that resolves to nothing, and counts the inverse fault — 3+ Tailwind utilities inline, which is a component class that was never named. Both sets are derived from the emitted stylesheets rather than listed, so nothing has to be maintained per project. Run it from a repo root after a build:
|
package/dist/base.css
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* wiki-formant/base.css — the layout the package's markup does not work without.
|
|
2
2
|
|
|
3
|
-
Structure
|
|
4
|
-
|
|
3
|
+
Structure first: the layout the markup needs, no colour and no type scale.
|
|
4
|
+
Then, at the end, the look: every component drawn from twelve `--ui-*`
|
|
5
|
+
tokens the importing stylesheet maps from its own palette. That stylesheet
|
|
5
6
|
loads this first and overrides anything here with an equally specific rule.
|
|
6
7
|
|
|
7
8
|
@import "wiki-formant/base.css" layer(components);
|
|
@@ -488,3 +489,144 @@ pre:hover > .code-copy-btn,
|
|
|
488
489
|
max-width: 100%;
|
|
489
490
|
min-height: 200px;
|
|
490
491
|
}
|
|
492
|
+
|
|
493
|
+
/* ==== the look ================================================================
|
|
494
|
+
What the components above look like, drawn from twelve tokens a site maps
|
|
495
|
+
once from its own palette, unlayered so the mapping wins:
|
|
496
|
+
|
|
497
|
+
--ui-surface a card's ground --ui-muted secondary text
|
|
498
|
+
--ui-surface-2 a strip, a label cell --ui-accent links, the current item
|
|
499
|
+
--ui-border every rule and outline --ui-on-accent text on the accent
|
|
500
|
+
--ui-radius every corner --ui-up a good change, an insertion
|
|
501
|
+
--ui-pad a card's padding --ui-down a bad change, a deletion
|
|
502
|
+
--ui-small the small type size --ui-shadow a floating card's shadow
|
|
503
|
+
|
|
504
|
+
Four sites had each drawn these same components in their own tokens, the
|
|
505
|
+
same rules restated four ways, and the one that forgot a rule shipped it
|
|
506
|
+
unstyled. A site still overrides any of it with its own rule after this. */
|
|
507
|
+
|
|
508
|
+
/* Permalink beside a heading: the glyph comes from here, so a table of contents
|
|
509
|
+
reading the heading's text never picks it up. */
|
|
510
|
+
:is(h2, h3, h4, h5, h6):has(> .heading-anchor) { position: relative; }
|
|
511
|
+
.heading-anchor { position: absolute; top: 0; left: -1.25rem; font-weight: 400; color: var(--ui-muted); opacity: 0; transition: opacity 0.15s; }
|
|
512
|
+
.heading-anchor::before { content: '#'; }
|
|
513
|
+
.heading-anchor:hover { color: var(--ui-accent); }
|
|
514
|
+
:is(h2, h3, h4, h5, h6):hover > .heading-anchor,
|
|
515
|
+
.heading-anchor:focus-visible { opacity: 1; }
|
|
516
|
+
.link { color: var(--ui-accent); text-decoration: none; cursor: pointer; }
|
|
517
|
+
.link:hover { text-decoration: underline; }
|
|
518
|
+
|
|
519
|
+
/* Code tabs and the copy button. */
|
|
520
|
+
.code-tabs { margin-block: 1rem; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }
|
|
521
|
+
.code-tabs-list { overflow-x: auto; background: var(--ui-surface-2); border-bottom: 1px solid var(--ui-border); }
|
|
522
|
+
.code-tabs-btn { padding: 0.5rem 1rem; font: inherit; font-size: var(--ui-small); color: var(--ui-muted); background: transparent; border: 0; border-bottom: 2px solid transparent; transition: color 0.15s; }
|
|
523
|
+
.code-tabs-btn:hover { color: inherit; }
|
|
524
|
+
/* The box clips, so the focus ring goes inside the button. */
|
|
525
|
+
.code-tabs-btn:focus-visible { outline-offset: -2px; }
|
|
526
|
+
.code-tabs-btn-active { color: var(--ui-accent); border-bottom-color: var(--ui-accent); }
|
|
527
|
+
.code-tabs pre { margin: 0; border: 0; border-radius: 0; }
|
|
528
|
+
.code-copy-btn { padding: 0.375rem; color: var(--ui-muted); background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); z-index: 1; }
|
|
529
|
+
.code-copy-btn:hover { color: var(--ui-accent); }
|
|
530
|
+
|
|
531
|
+
/* Notices. A notice is a claim about the page, so it reads as an aside. */
|
|
532
|
+
.editorial-banner { align-items: flex-start; gap: 0.75rem; margin-block: 0.75rem; padding: 0.75rem; font-size: var(--ui-small); background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }
|
|
533
|
+
.editorial-banner-icon { flex-shrink: 0; margin-top: 0.125rem; }
|
|
534
|
+
.editorial-banner-body { flex: 1; min-width: 0; margin: 0; }
|
|
535
|
+
:is(.editorial-banner-unsourced, .editorial-banner-outdated, .editorial-banner-promotional, .editorial-banner-coi) { border-color: color-mix(in srgb, var(--ui-down) 35%, transparent); background: color-mix(in srgb, var(--ui-down) 8%, var(--ui-surface)); }
|
|
536
|
+
:is(.editorial-banner-unsourced, .editorial-banner-outdated, .editorial-banner-promotional, .editorial-banner-coi) .editorial-banner-icon { color: var(--ui-down); }
|
|
537
|
+
|
|
538
|
+
/* References. */
|
|
539
|
+
.references-block { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--ui-border); }
|
|
540
|
+
.references-block h2 { margin: 0 0 0.5rem; }
|
|
541
|
+
.references-list { margin: 0; padding-left: 1.5rem; list-style: decimal; font-size: var(--ui-small); color: var(--ui-muted); }
|
|
542
|
+
.reference-item { margin-block: 0.25rem; padding-left: 0.25rem; scroll-margin-top: 5rem; }
|
|
543
|
+
.reference-item:target { background: color-mix(in srgb, var(--ui-accent) 12%, transparent); border-radius: var(--ui-radius); }
|
|
544
|
+
:is(.reference-item a, .ref-backlink, .reference-link) { color: var(--ui-accent); }
|
|
545
|
+
:is(.reference-item a, .ref-backlink, .reference-link):hover { text-decoration: underline; }
|
|
546
|
+
|
|
547
|
+
/* The infobox and its facts. Labels are short and values are the reading, so
|
|
548
|
+
the label column takes 42%, the floor that still fits "Published:". */
|
|
549
|
+
.page-with-infobox { gap: 1.5rem; }
|
|
550
|
+
.infobox { padding: var(--ui-pad); background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }
|
|
551
|
+
.infobox table { table-layout: fixed; width: 100%; border-collapse: collapse; }
|
|
552
|
+
.infobox :is(th, td) { padding: 0.375rem 0.5rem; font-size: var(--ui-small); text-align: left; vertical-align: top; border: 0; border-bottom: 1px solid var(--ui-border); }
|
|
553
|
+
.infobox th { width: 42%; font-weight: 600; color: var(--ui-muted); }
|
|
554
|
+
.infobox tr:last-child > :is(th, td) { border-bottom: 0; }
|
|
555
|
+
.infobox-facts { margin: 0; }
|
|
556
|
+
.infobox-series { align-items: center; gap: 0.125rem; margin-bottom: 0.75rem; padding-bottom: 0.75rem; text-align: center; font-size: var(--ui-small); color: var(--ui-muted); border-bottom: 1px solid var(--ui-border); }
|
|
557
|
+
|
|
558
|
+
/* Stats blocks and the figures' change from the window before. */
|
|
559
|
+
.stat-grid { gap: 0.75rem; }
|
|
560
|
+
.stat-card { gap: 0.25rem; padding: var(--ui-pad); background: var(--ui-surface); border-radius: var(--ui-radius); }
|
|
561
|
+
.stat-value { font-weight: 700; line-height: 1.15; }
|
|
562
|
+
.stat-label { font-size: var(--ui-small); color: var(--ui-muted); }
|
|
563
|
+
.stats-delta { font-size: 0.6em; font-weight: 600; }
|
|
564
|
+
.stats-delta[data-better='true'] { color: var(--ui-up); }
|
|
565
|
+
.stats-delta[data-better='false'] { color: var(--ui-down); }
|
|
566
|
+
|
|
567
|
+
/* Columns, link grids, related pages. */
|
|
568
|
+
.column-view { gap: 1rem; }
|
|
569
|
+
.link-grid { display: flex; flex-direction: column; gap: 1rem; margin-block: 1rem; }
|
|
570
|
+
.link-grid-group-description { margin: 0 0 0.75rem; color: var(--ui-muted); }
|
|
571
|
+
.link-grid-group-description p { margin: 0; }
|
|
572
|
+
.link-grid-pills { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 0.5rem; }
|
|
573
|
+
.link-grid-pills > a { display: block; padding: 0.625rem 0.875rem; font-size: var(--ui-small); text-align: center; text-decoration: none; color: inherit; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); transition: background 0.15s, border-color 0.15s, color 0.15s; }
|
|
574
|
+
.link-grid-pills > a:hover { color: var(--ui-accent); background: var(--ui-surface-2); border-color: var(--ui-accent); }
|
|
575
|
+
.see-also-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 0.75rem; margin: 0; padding: 0; list-style: none; }
|
|
576
|
+
.see-also-item { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; padding: 0.75rem; color: inherit; text-decoration: none; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); transition: border-color 0.15s; }
|
|
577
|
+
.see-also-item:hover { border-color: var(--ui-accent); }
|
|
578
|
+
.see-also-title { font-weight: 600; }
|
|
579
|
+
.see-also-item:hover .see-also-title { color: var(--ui-accent); }
|
|
580
|
+
.see-also-detail { font-size: var(--ui-small); color: var(--ui-muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
581
|
+
.section-card { display: flex; flex-direction: column; gap: 0.5rem; padding: var(--ui-pad); background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); transition: border-color 0.15s; }
|
|
582
|
+
/* A page list as rows (PageRefsView): one panel, a rule between rows. radix-wiki dropped its own rule for this on 0.86 and the package drew none. */
|
|
583
|
+
.page-refs { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; overflow: hidden; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }
|
|
584
|
+
.page-refs > li + li { border-top: 1px solid var(--ui-border); }
|
|
585
|
+
.section-card:hover { border-color: var(--ui-accent); }
|
|
586
|
+
.facet-summary { margin: 0; font-size: var(--ui-small); color: var(--ui-muted); }
|
|
587
|
+
|
|
588
|
+
/* The hover card over a link to another page (useLinkPreview). */
|
|
589
|
+
.link-preview-card { position: fixed; z-index: 50; width: 20rem; max-width: calc(100vw - 1rem); overflow: hidden; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); box-shadow: var(--ui-shadow); }
|
|
590
|
+
.link-preview-body { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.75rem; }
|
|
591
|
+
.link-preview-title { font-weight: 600; }
|
|
592
|
+
.link-preview-excerpt { margin: 0; font-size: var(--ui-small); color: var(--ui-muted); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
593
|
+
|
|
594
|
+
/* The rail's frame and its table of contents. */
|
|
595
|
+
.wiki-sidebar { --rail-border: 1px solid var(--ui-border); }
|
|
596
|
+
.wiki-rail__group, .wiki-rail__toc-list { display: flex; flex-direction: column; gap: 1px; }
|
|
597
|
+
.wiki-rail__label { padding: 0 0.5rem 0.25rem; font-size: var(--ui-small); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ui-muted); }
|
|
598
|
+
:is(.wiki-rail__toc-btn, .wiki-rail__toc-item) { display: flex; align-items: center; gap: 0.25rem; width: 100%; font: inherit; text-align: left; color: var(--ui-muted); background: none; border: 0; cursor: pointer; }
|
|
599
|
+
.wiki-rail__toc-btn { padding: 0 0.5rem 0.25rem; }
|
|
600
|
+
.wiki-rail__toc-item { padding: 0.25rem 0.75rem; font-size: var(--ui-small); line-height: 1.4; text-decoration: none; border-inline-start: 2px solid var(--ui-border); transition: color 0.15s, border-color 0.15s; }
|
|
601
|
+
.wiki-rail__toc-item:hover, .wiki-rail__toc-item--active { color: var(--ui-accent); border-inline-start-color: var(--ui-accent); }
|
|
602
|
+
.wiki-rail__toc-item--active { font-weight: 600; }
|
|
603
|
+
|
|
604
|
+
/* Work queues (MaintenanceQueues). */
|
|
605
|
+
.maintenance-queue { display: flex; flex-direction: column; gap: 0.5rem; padding-top: 1.5rem; border-top: 1px solid var(--ui-border); }
|
|
606
|
+
.maintenance-count { margin-left: 0.5rem; font-weight: 400; font-variant-numeric: tabular-nums; color: var(--ui-muted); }
|
|
607
|
+
.maintenance-description, .maintenance-detail { color: var(--ui-muted); }
|
|
608
|
+
.maintenance-detail { font-size: var(--ui-small); }
|
|
609
|
+
.maintenance-list { display: grid; gap: 0.375rem; margin: 0; padding: 0; list-style: none; }
|
|
610
|
+
.maintenance-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; }
|
|
611
|
+
.maintenance-item > a { color: var(--ui-accent); }
|
|
612
|
+
|
|
613
|
+
/* A revision's changes and its inline diff. */
|
|
614
|
+
.revision-change-action { font-weight: 600; color: var(--ui-muted); }
|
|
615
|
+
.revision-change[data-action='added'] .revision-change-action { color: var(--ui-up); }
|
|
616
|
+
.revision-change[data-action='removed'] .revision-change-action { color: var(--ui-down); }
|
|
617
|
+
.revision-diff { line-height: 1.6; color: var(--ui-muted); }
|
|
618
|
+
.revision-diff del { color: var(--ui-down); }
|
|
619
|
+
.revision-diff ins { color: var(--ui-up); text-decoration: none; background: color-mix(in srgb, var(--ui-up) 12%, transparent); border-radius: 2px; }
|
|
620
|
+
|
|
621
|
+
/* Sortable headers: muted until hovered or sorted by. */
|
|
622
|
+
.sort-header { color: var(--ui-muted); transition: color 0.15s; }
|
|
623
|
+
.sort-header:hover,
|
|
624
|
+
:is([aria-sort='ascending'], [aria-sort='descending']) > .sort-header { color: inherit; }
|
|
625
|
+
|
|
626
|
+
/* Charts. */
|
|
627
|
+
.chart { background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }
|
|
628
|
+
.chart-canvas { font-size: 0.6875rem; color: var(--ui-accent); --chart-axis: var(--ui-muted); --chart-up: var(--ui-up); --chart-down: var(--ui-down); }
|
|
629
|
+
.chart-status { font-size: var(--ui-small); color: var(--ui-muted); }
|
|
630
|
+
.chart-controls :is(button, a) { font-size: 0.6875rem; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); opacity: 1; }
|
|
631
|
+
.chart-controls :is(button, a):hover { background: var(--ui-surface-2); }
|
|
632
|
+
.chart-controls :is(button, a):is([aria-pressed='true'], [aria-current]) { color: var(--ui-on-accent); background: var(--ui-accent); border-color: var(--ui-accent); }
|
package/dist/blocks.d.ts
CHANGED
|
@@ -126,6 +126,8 @@ export interface InfoboxBlock<A> {
|
|
|
126
126
|
type: 'infobox';
|
|
127
127
|
blocks: A[];
|
|
128
128
|
}
|
|
129
|
+
/** A wiki's whole block union from its atomic one: the leaves and the two containers holding them. */
|
|
130
|
+
export type BlockTree<A> = A | InfoboxBlock<A> | ColumnsBlock<A>;
|
|
129
131
|
/** Where a list's pages come from: those in a section, the newest, or ones picked by hand. */
|
|
130
132
|
export declare const LIST_SOURCES: readonly ["under", "newest", "chosen"];
|
|
131
133
|
export type ListSource = (typeof LIST_SOURCES)[number];
|
package/dist/headers.d.ts
CHANGED
|
@@ -11,11 +11,23 @@ export interface HeaderEntry {
|
|
|
11
11
|
export declare const DEFAULT_IFRAME_HOSTS: readonly string[];
|
|
12
12
|
/** CSP directive names this builder writes, in the order it writes them. */
|
|
13
13
|
export type CspDirective = 'default-src' | 'base-uri' | 'object-src' | 'frame-ancestors' | 'form-action' | 'script-src' | 'style-src' | 'font-src' | 'img-src' | 'media-src' | 'connect-src' | 'frame-src' | 'worker-src' | 'manifest-src';
|
|
14
|
+
/** A `script-src` source admitting exactly this inline script body. */
|
|
15
|
+
export declare const scriptHash: (body: string) => string;
|
|
14
16
|
/**
|
|
15
17
|
* The policy string: `BASE_CSP` with each directive in `directives` replacing
|
|
16
18
|
* its default. An empty list drops the directive.
|
|
19
|
+
*
|
|
20
|
+
* A `nonce` swaps `script-src` for the strict form, `'self'`, the nonce and a
|
|
21
|
+
* hash of each of `inlineScripts`, with no `'unsafe-inline'`. The hashes admit
|
|
22
|
+
* a static boot script without the layout reading the nonce, which would make
|
|
23
|
+
* every route render per request; hashing here means a package bump that
|
|
24
|
+
* changes the script moves its hash too, where a hand-copied one went stale
|
|
25
|
+
* and blanked every nonce route. Without a nonce the baseline stands.
|
|
17
26
|
*/
|
|
18
|
-
export declare function contentSecurityPolicy(directives?: Partial<Record<CspDirective, readonly string[]
|
|
27
|
+
export declare function contentSecurityPolicy(directives?: Partial<Record<CspDirective, readonly string[]>>, { nonce, inlineScripts }?: {
|
|
28
|
+
nonce?: string | null;
|
|
29
|
+
inlineScripts?: readonly string[];
|
|
30
|
+
}): string;
|
|
19
31
|
/**
|
|
20
32
|
* The six headers every route sends, plus the policy when one is given.
|
|
21
33
|
*
|
package/dist/headers.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// `youtube-nocookie.com` its sanitiser kept, so those embeds survived cleaning
|
|
12
12
|
// and then rendered blank.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
15
|
-
// is here for the same reason: next.config.ts is its
|
|
14
|
+
// One import, node:crypto for a script's hash, so a config pays for nothing
|
|
15
|
+
// else. `withWikiDefaults` is here for the same reason: next.config.ts is its
|
|
16
|
+
// only caller.
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
16
18
|
/**
|
|
17
19
|
* The embed hosts the editor's iframe, YouTube, tweet and map nodes produce:
|
|
18
20
|
* the sanitiser's default `iframeHosts` and the default `frame-src`, so an
|
|
@@ -50,12 +52,22 @@ export const BASE_CSP = {
|
|
|
50
52
|
'connect-src': ["'self'"],
|
|
51
53
|
'frame-src': frameSources(),
|
|
52
54
|
};
|
|
55
|
+
/** A `script-src` source admitting exactly this inline script body. */
|
|
56
|
+
export const scriptHash = (body) => `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`;
|
|
53
57
|
/**
|
|
54
58
|
* The policy string: `BASE_CSP` with each directive in `directives` replacing
|
|
55
59
|
* its default. An empty list drops the directive.
|
|
60
|
+
*
|
|
61
|
+
* A `nonce` swaps `script-src` for the strict form, `'self'`, the nonce and a
|
|
62
|
+
* hash of each of `inlineScripts`, with no `'unsafe-inline'`. The hashes admit
|
|
63
|
+
* a static boot script without the layout reading the nonce, which would make
|
|
64
|
+
* every route render per request; hashing here means a package bump that
|
|
65
|
+
* changes the script moves its hash too, where a hand-copied one went stale
|
|
66
|
+
* and blanked every nonce route. Without a nonce the baseline stands.
|
|
56
67
|
*/
|
|
57
|
-
export function contentSecurityPolicy(directives = {}) {
|
|
58
|
-
|
|
68
|
+
export function contentSecurityPolicy(directives = {}, { nonce = null, inlineScripts = [] } = {}) {
|
|
69
|
+
const strict = nonce ? { 'script-src': ["'self'", `'nonce-${nonce}'`, ...inlineScripts.map(scriptHash)] } : {};
|
|
70
|
+
return Object.entries({ ...BASE_CSP, ...directives, ...strict })
|
|
59
71
|
.filter(([, sources]) => sources?.length)
|
|
60
72
|
.map(([name, sources]) => `${name} ${sources.join(' ')}`)
|
|
61
73
|
.join('; ');
|
package/dist/knip.d.ts
ADDED
package/dist/knip.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// knip.ts — the dead-code config every app here shares. An app's own knip.ts
|
|
2
|
+
// spreads it and appends what only it has: `{ ...base, ignoreBinaries:
|
|
3
|
+
// [...base.ignoreBinaries, 'ffmpeg'] }`. Each had kept a copy, and the copies
|
|
4
|
+
// drifted on the one line that matters most: a glob that does not name
|
|
5
|
+
// `.well-known` skips a dotted directory and every agent-surface route in it.
|
|
6
|
+
const entry = [
|
|
7
|
+
// What the App Router loads by convention and nothing imports.
|
|
8
|
+
'src/app/**/page.tsx',
|
|
9
|
+
'src/app/**/layout.tsx',
|
|
10
|
+
'src/app/**/route.{ts,tsx}',
|
|
11
|
+
'src/app/**/{error,not-found,loading,global-error}.tsx',
|
|
12
|
+
'src/app/.well-known/**/route.ts',
|
|
13
|
+
'src/app/**/{sitemap,robots,manifest,opengraph-image,icon,apple-icon}.{ts,tsx}',
|
|
14
|
+
'src/proxy.ts',
|
|
15
|
+
// A module exporting for its test alone reads as dead without the test.
|
|
16
|
+
'src/**/*.test.ts',
|
|
17
|
+
// The routines run these from the command line, and they reach into `src`.
|
|
18
|
+
'scripts/**/*.{ts,mts,mjs}',
|
|
19
|
+
'studio/**/*.mjs',
|
|
20
|
+
];
|
|
21
|
+
export default {
|
|
22
|
+
entry,
|
|
23
|
+
project: ['src/**/*.{ts,tsx}', 'scripts/**/*.{ts,mts,mjs}', 'studio/**/*.mjs'],
|
|
24
|
+
ignore: ['src/generated/**'],
|
|
25
|
+
// The generated Prisma client imports the first; the second is reached through
|
|
26
|
+
// `@tailwindcss/postcss` and a stylesheet `@import`, neither an import edge.
|
|
27
|
+
ignoreDependencies: ['@prisma/client', 'tailwindcss'],
|
|
28
|
+
// The global CLI the scripts' cache invalidation shells out to, kept out of the
|
|
29
|
+
// dependencies because it put ~330 packages into every build.
|
|
30
|
+
ignoreBinaries: ['vercel'],
|
|
31
|
+
};
|
package/dist/link-check.d.ts
CHANGED
|
@@ -76,3 +76,51 @@ export declare function collectBlockLinks<B extends {
|
|
|
76
76
|
}>(blocks: readonly B[], containers?: (block: B) => B[][] | null): BlockLinks;
|
|
77
77
|
/** `Promise.all` with a ceiling, preserving input order in the results. */
|
|
78
78
|
export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
79
|
+
/** A page as `auditLinks` reads it: where it lives, its blocks, its metadata. */
|
|
80
|
+
export interface AuditPage {
|
|
81
|
+
path: string;
|
|
82
|
+
content: unknown;
|
|
83
|
+
metadata?: unknown;
|
|
84
|
+
}
|
|
85
|
+
export interface AuditOptions {
|
|
86
|
+
/** Every internal path that resolves: pages, category hubs, routes, assets. */
|
|
87
|
+
livePaths: ReadonlySet<string>;
|
|
88
|
+
/** Default `probeExternal`. A wiki with its own special cases wraps it. */
|
|
89
|
+
probe?: (url: string) => Promise<Probe>;
|
|
90
|
+
/**
|
|
91
|
+
* Whether an internal path is the wiki's to check. One it is not (an app
|
|
92
|
+
* route, say) is kept in `skipped` and never reported broken. Default all.
|
|
93
|
+
*/
|
|
94
|
+
internal?: (path: string) => boolean;
|
|
95
|
+
/** Default 12. */
|
|
96
|
+
concurrency?: number;
|
|
97
|
+
}
|
|
98
|
+
export type Referenced<T> = T & {
|
|
99
|
+
pages: string[];
|
|
100
|
+
};
|
|
101
|
+
export interface LinkAudit {
|
|
102
|
+
externalChecked: number;
|
|
103
|
+
brokenExternal: Referenced<Probe>[];
|
|
104
|
+
brokenInternal: Referenced<{
|
|
105
|
+
url: string;
|
|
106
|
+
}>[];
|
|
107
|
+
/** Internal paths `internal` turned away, each with the pages citing it. */
|
|
108
|
+
skipped: Map<string, string[]>;
|
|
109
|
+
/** Every embed, unprobed: a status check on one proves little on its own. */
|
|
110
|
+
embeds: Map<string, {
|
|
111
|
+
kind: string;
|
|
112
|
+
pages: string[];
|
|
113
|
+
}>;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Every link on every page, probed once per URL: externals by `probe`,
|
|
117
|
+
* internals against `livePaths`. A page's own `/history` and `/edit` resolve
|
|
118
|
+
* with it, and a query is a view of the path it rides on. The two checkers had
|
|
119
|
+
* each written this walk, and each had one of those rules and not the other.
|
|
120
|
+
*/
|
|
121
|
+
export declare function auditLinks(pages: readonly AuditPage[], opts: AuditOptions): Promise<LinkAudit>;
|
|
122
|
+
/** The human summary of an audit's broken links, under the scope it covered. */
|
|
123
|
+
export declare function printLinkAudit(audit: Pick<LinkAudit, 'externalChecked' | 'brokenExternal' | 'brokenInternal'>, { scope, pagesScanned }: {
|
|
124
|
+
scope: string;
|
|
125
|
+
pagesScanned: number;
|
|
126
|
+
}): void;
|
package/dist/link-check.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// link-check.ts — the dead-link probe wiki sweep scripts share.
|
|
2
2
|
//
|
|
3
|
-
// Node-only (fetch, AbortSignal, URL). No framework, no database:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Node-only (fetch, AbortSignal, URL). No framework, no database: which pages
|
|
4
|
+
// to walk, which paths are live and what else to report stay with the caller,
|
|
5
|
+
// because that is the half that is genuinely per-wiki. The walk over them and
|
|
6
|
+
// the summary of what broke are `auditLinks` and `printLinkAudit`.
|
|
7
7
|
//
|
|
8
8
|
// EVERY GUARD BELOW WAS PAID FOR BY A FALSE POSITIVE. npmjs.com 403s scripted
|
|
9
9
|
// requests, an expired certificate is not a dead host, a YouTube /embed/ URL
|
|
@@ -321,3 +321,68 @@ export async function mapLimit(items, limit, fn) {
|
|
|
321
321
|
await Promise.all(workers);
|
|
322
322
|
return results;
|
|
323
323
|
}
|
|
324
|
+
// A metadata fact (a website, an X handle's URL) renders as a live link and
|
|
325
|
+
// rots like a citation, but lives outside the blocks; a bare domain counts.
|
|
326
|
+
const metadataUrl = (v) => {
|
|
327
|
+
const s = typeof v === 'string' ? v.trim() : '';
|
|
328
|
+
if (/^https?:\/\//i.test(s))
|
|
329
|
+
return s;
|
|
330
|
+
return /^www\./i.test(s) || /^[a-z0-9.-]+\.[a-z]{2,}(\/|$)/i.test(s) ? `https://${s}` : null;
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* Every link on every page, probed once per URL: externals by `probe`,
|
|
334
|
+
* internals against `livePaths`. A page's own `/history` and `/edit` resolve
|
|
335
|
+
* with it, and a query is a view of the path it rides on. The two checkers had
|
|
336
|
+
* each written this walk, and each had one of those rules and not the other.
|
|
337
|
+
*/
|
|
338
|
+
export async function auditLinks(pages, opts) {
|
|
339
|
+
const { livePaths, probe = probeExternal, internal = () => true, concurrency = 12 } = opts;
|
|
340
|
+
const external = new Map();
|
|
341
|
+
const internals = new Map();
|
|
342
|
+
const skipped = new Map();
|
|
343
|
+
const embeds = new Map();
|
|
344
|
+
const cite = (map, key, page) => {
|
|
345
|
+
const refs = map.get(key);
|
|
346
|
+
if (!refs)
|
|
347
|
+
map.set(key, [page]);
|
|
348
|
+
else if (!refs.includes(page))
|
|
349
|
+
refs.push(page);
|
|
350
|
+
};
|
|
351
|
+
for (const page of pages) {
|
|
352
|
+
const links = collectBlockLinks(Array.isArray(page.content) ? page.content : []);
|
|
353
|
+
const meta = page.metadata && typeof page.metadata === 'object' ? Object.values(page.metadata) : [];
|
|
354
|
+
for (const url of [...links.external, ...meta.map(metadataUrl).filter((u) => !!u)])
|
|
355
|
+
cite(external, url, page.path);
|
|
356
|
+
for (const path of links.internal)
|
|
357
|
+
cite(internal(path) ? internals : skipped, path, page.path);
|
|
358
|
+
for (const { kind, url } of links.embeds) {
|
|
359
|
+
const entry = embeds.get(url) ?? { kind, pages: [] };
|
|
360
|
+
if (!entry.pages.includes(page.path))
|
|
361
|
+
entry.pages.push(page.path);
|
|
362
|
+
embeds.set(url, entry);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const results = await mapLimit([...external.keys()], concurrency, url => probe(url));
|
|
366
|
+
const resolves = (url) => {
|
|
367
|
+
const path = url.split('?')[0];
|
|
368
|
+
return livePaths.has(path) || livePaths.has(path.replace(/\/(history|edit)$/, ''));
|
|
369
|
+
};
|
|
370
|
+
return {
|
|
371
|
+
externalChecked: external.size,
|
|
372
|
+
brokenExternal: results.filter(r => !r.ok).map(r => ({ ...r, pages: external.get(r.url) })),
|
|
373
|
+
brokenInternal: [...internals].filter(([url]) => !resolves(url)).map(([url, refs]) => ({ url, pages: refs })),
|
|
374
|
+
skipped,
|
|
375
|
+
embeds,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/** The human summary of an audit's broken links, under the scope it covered. */
|
|
379
|
+
export function printLinkAudit(audit, { scope, pagesScanned }) {
|
|
380
|
+
console.log(`\nLink check — scope: ${scope}`);
|
|
381
|
+
console.log(`Pages scanned: ${pagesScanned} | external links checked: ${audit.externalChecked}`);
|
|
382
|
+
console.log(`\nBroken external (${audit.brokenExternal.length}):`);
|
|
383
|
+
for (const b of audit.brokenExternal)
|
|
384
|
+
console.log(` [${b.status || b.code || b.error}] ${b.url}\n ← ${b.pages.join(', ')}`);
|
|
385
|
+
console.log(`\nBroken internal (${audit.brokenInternal.length}):`);
|
|
386
|
+
for (const b of audit.brokenInternal)
|
|
387
|
+
console.log(` ${b.url}\n ← ${b.pages.join(', ')}`);
|
|
388
|
+
}
|
package/dist/passkey.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface PasskeyGate {
|
|
|
14
14
|
cookie: string;
|
|
15
15
|
/** Whether a session cookie's value is a live session whose passkey is still saved. */
|
|
16
16
|
signedIn(session: string | undefined): Promise<boolean>;
|
|
17
|
+
/** Whether a request carries a live session: `signedIn` over its cookie, for a write route. */
|
|
18
|
+
allows(request: Request): Promise<boolean>;
|
|
17
19
|
/** A single-use invite token, good for `days`. The bin turns it into a link. */
|
|
18
20
|
invite(days?: number): Promise<string>;
|
|
19
21
|
/**
|
package/dist/passkey.js
CHANGED
|
@@ -54,18 +54,17 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
|
|
|
54
54
|
const value = await issue(sql, 'session', sessionDays * DAY, undefined, passkeyId);
|
|
55
55
|
return setCookie(value, sessionDays * 86_400, secure);
|
|
56
56
|
}
|
|
57
|
+
const sessionOf = (request) => request.headers.get('cookie')?.split(/;\s*/).find(c => c.startsWith(`${cookie}=`))?.slice(cookie.length + 1);
|
|
58
|
+
const signedIn = async (value) => !!value &&
|
|
59
|
+
(await sql(`SELECT 1 FROM "PasskeyToken" t JOIN "Passkey" p ON p.id = t."passkeyId"
|
|
60
|
+
WHERE t.hash = $1 AND t.kind = 'session' AND t."expiresAt" > now()`, await hash(value))).length > 0;
|
|
57
61
|
return {
|
|
58
62
|
cookie,
|
|
59
|
-
signedIn
|
|
60
|
-
|
|
61
|
-
WHERE t.hash = $1 AND t.kind = 'session' AND t."expiresAt" > now()`, await hash(value))).length > 0,
|
|
63
|
+
signedIn,
|
|
64
|
+
allows: request => signedIn(sessionOf(request)),
|
|
62
65
|
invite: (days = 1) => issue(sql, 'invite', days * DAY),
|
|
63
66
|
async signOut(request) {
|
|
64
|
-
const value = request
|
|
65
|
-
.get('cookie')
|
|
66
|
-
?.split(/;\s*/)
|
|
67
|
-
.find(c => c.startsWith(`${cookie}=`))
|
|
68
|
-
?.slice(cookie.length + 1);
|
|
67
|
+
const value = sessionOf(request);
|
|
69
68
|
if (value)
|
|
70
69
|
await spend(sql, 'session', value);
|
|
71
70
|
return setCookie('', 0, new URL(request.url).protocol === 'https:');
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { BannerVariant } from './text.js';
|
|
2
|
+
import { type ChangeType } from './versioning.js';
|
|
3
|
+
import { type PageMetadata, type WikiPageRow } from './store.js';
|
|
4
|
+
/** The slice of a `pg` client every helper here takes. Prisma fits it with `$queryRawUnsafe`. */
|
|
5
|
+
export interface ScriptClient {
|
|
6
|
+
query(text: string, values?: unknown[]): Promise<{
|
|
7
|
+
rows: unknown[];
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
type Block = {
|
|
11
|
+
id: string;
|
|
12
|
+
type: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const uid: () => string;
|
|
15
|
+
export declare const content: (html: string) => {
|
|
16
|
+
id: string;
|
|
17
|
+
type: "content";
|
|
18
|
+
text: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A notice. Always pass `text`: the variant's standard message lives in the
|
|
22
|
+
* renderer, not the row, so a banner without it is invisible to search.
|
|
23
|
+
*/
|
|
24
|
+
export declare const banner: (variant: BannerVariant, text: string) => {
|
|
25
|
+
id: string;
|
|
26
|
+
type: "banner";
|
|
27
|
+
variant: BannerVariant;
|
|
28
|
+
text: string;
|
|
29
|
+
};
|
|
30
|
+
/** A facts panel leading an article: `table` is the inner `<table>` HTML. */
|
|
31
|
+
export declare const infobox: (table: string) => {
|
|
32
|
+
id: string;
|
|
33
|
+
type: "infobox";
|
|
34
|
+
blocks: {
|
|
35
|
+
id: string;
|
|
36
|
+
type: "content";
|
|
37
|
+
text: string;
|
|
38
|
+
}[];
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Throw on a link-grid link or reference list whose URL fields are mis-shaped.
|
|
42
|
+
*
|
|
43
|
+
* The app's validator rejects these, and scripts never reach it. The views drop
|
|
44
|
+
* an entry whose URL fails rather than rendering it inert, so a `{ label, url }`
|
|
45
|
+
* link (the shape is `{ label, href }`) shows as a heading with no pills and
|
|
46
|
+
* nothing reports it. A throw inside `withClient` is a rollback and a failed run.
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertLinkShapes(blocks: unknown, where?: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Mark everything cached under `tag` stale on the project `cwd` is linked to.
|
|
51
|
+
* A script's write reaches no route that would bust it, and pages hold a day.
|
|
52
|
+
*/
|
|
53
|
+
export declare function invalidate(tag: string, cwd?: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* Run `fn` on a connected client, then release it and its pool. A throw rolls
|
|
56
|
+
* back the open transaction and fails the process: every script writes to the
|
|
57
|
+
* live database, and a half-applied edit must never pass for a success. A run
|
|
58
|
+
* that wrote anything ends by invalidating `tag`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function withClient<T>(fn: (client: ScriptClient) => Promise<T>, { tag, cwd }?: {
|
|
61
|
+
tag?: string;
|
|
62
|
+
cwd?: string;
|
|
63
|
+
}): Promise<T | undefined>;
|
|
64
|
+
/** The app's own store over a script's client, so a script writes what the editor writes. */
|
|
65
|
+
export declare const storeFor: (client: ScriptClient, siteId?: string) => {
|
|
66
|
+
siteId: string;
|
|
67
|
+
hidden: string[];
|
|
68
|
+
isHidden: (tagPath: string) => boolean;
|
|
69
|
+
get: (tagPath: string, slug: string) => Promise<NonNullable<WikiPageRow<Block> & {
|
|
70
|
+
revisionCount: number;
|
|
71
|
+
}> | null>;
|
|
72
|
+
getById: (id: string) => Promise<NonNullable<WikiPageRow<Block> & {
|
|
73
|
+
revisionCount: number;
|
|
74
|
+
}> | null>;
|
|
75
|
+
list: (tagPath: string, { sort, limit, offset }?: {
|
|
76
|
+
sort?: import("./store.js").ListSort;
|
|
77
|
+
limit?: number;
|
|
78
|
+
offset?: number;
|
|
79
|
+
}) => Promise<import("./store.js").WikiPageListing[]>;
|
|
80
|
+
articles: (tagPath: string, { sort, limit, offset }?: {
|
|
81
|
+
sort?: import("./store.js").ListSort;
|
|
82
|
+
limit?: number;
|
|
83
|
+
offset?: number;
|
|
84
|
+
}) => Promise<WikiPageRow<Block>[]>;
|
|
85
|
+
subtree: (tagPath: string) => Promise<import("./store.js").WikiPageRef[]>;
|
|
86
|
+
recent: ({ tagPath, limit }?: {
|
|
87
|
+
tagPath?: string;
|
|
88
|
+
limit?: number;
|
|
89
|
+
}) => Promise<import("./store.js").WikiPageListing[]>;
|
|
90
|
+
byIds: (ids: readonly string[]) => Promise<import("./store.js").WikiPageListing[]>;
|
|
91
|
+
refs: () => Promise<Omit<WikiPageRow<unknown>, "content">[]>;
|
|
92
|
+
corpus: () => Promise<WikiPageRow<Block>[]>;
|
|
93
|
+
branch: ({ tagPath, sort, limit, offset, since }?: {
|
|
94
|
+
tagPath?: string;
|
|
95
|
+
sort?: import("./store.js").ListSort;
|
|
96
|
+
limit?: number;
|
|
97
|
+
offset?: number;
|
|
98
|
+
since?: Date;
|
|
99
|
+
}) => Promise<{
|
|
100
|
+
items: {
|
|
101
|
+
content: Block[];
|
|
102
|
+
title: string;
|
|
103
|
+
id: string;
|
|
104
|
+
metadata: PageMetadata;
|
|
105
|
+
tagPath: string;
|
|
106
|
+
slug: string;
|
|
107
|
+
siteId: string;
|
|
108
|
+
version: string;
|
|
109
|
+
authorId: string | null;
|
|
110
|
+
lastVerifiedAt: Date | null;
|
|
111
|
+
createdAt: Date;
|
|
112
|
+
updatedAt: Date;
|
|
113
|
+
snippet: string;
|
|
114
|
+
}[];
|
|
115
|
+
total: number;
|
|
116
|
+
}>;
|
|
117
|
+
counts: () => Promise<{
|
|
118
|
+
tagPath: string;
|
|
119
|
+
pages: number;
|
|
120
|
+
}[]>;
|
|
121
|
+
stats: () => Promise<{
|
|
122
|
+
pages: number;
|
|
123
|
+
updated: Date | null;
|
|
124
|
+
}>;
|
|
125
|
+
search: (query: string, { tagPath, skip, take, content: withContent }?: {
|
|
126
|
+
tagPath?: string | null;
|
|
127
|
+
skip?: number;
|
|
128
|
+
take?: number;
|
|
129
|
+
content?: boolean;
|
|
130
|
+
}) => Promise<{
|
|
131
|
+
items: (Omit<WikiPageRow<unknown>, "content"> & {
|
|
132
|
+
snippet: string;
|
|
133
|
+
} & {
|
|
134
|
+
score: number;
|
|
135
|
+
content?: Block[] | undefined;
|
|
136
|
+
})[];
|
|
137
|
+
total: number;
|
|
138
|
+
}>;
|
|
139
|
+
history: (pageId: string, { limit, offset }?: {
|
|
140
|
+
limit?: number;
|
|
141
|
+
offset?: number;
|
|
142
|
+
}) => Promise<import("./store.js").WikiRevisionEntry[]>;
|
|
143
|
+
revision: (pageId: string, revisionId: string) => Promise<import("./store.js").WikiRevisionRow<Block> | null>;
|
|
144
|
+
save: (input: import("./store.js").SaveInput<Block>) => Promise<import("./store.js").SaveResult<Block>>;
|
|
145
|
+
restore: (tagPath: string, slug: string, revisionId: string, by?: {
|
|
146
|
+
authorId?: string | null;
|
|
147
|
+
}) => Promise<import("./store.js").SaveResult<Block>>;
|
|
148
|
+
remove: (tagPath: string, slug: string) => Promise<boolean>;
|
|
149
|
+
verify: (tagPath: string, slug: string) => Promise<boolean>;
|
|
150
|
+
};
|
|
151
|
+
/** A page row by path, every column: what `writeRevision` takes. Null when there is none. */
|
|
152
|
+
export declare const readPage: (client: ScriptClient, tagPath: string, slug: string, siteId?: string) => Promise<NonNullable<WikiPageRow<Block> & {
|
|
153
|
+
revisionCount: number;
|
|
154
|
+
}> | null>;
|
|
155
|
+
export interface RevisionOptions {
|
|
156
|
+
/** The bump, and the revision's change type. Default minor. */
|
|
157
|
+
change?: Exclude<ChangeType, 'none'>;
|
|
158
|
+
message?: string;
|
|
159
|
+
/** Stamp `lastVerifiedAt`: the edit re-checked the page's facts. */
|
|
160
|
+
verified?: boolean;
|
|
161
|
+
/** Write nothing; return the version it would have been. */
|
|
162
|
+
dry?: boolean;
|
|
163
|
+
metadata?: PageMetadata;
|
|
164
|
+
authorId?: string | null;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Write edited blocks back to a page row from `readPage` as one revision. The
|
|
168
|
+
* save names the version it read, so an edit landing in between is refused
|
|
169
|
+
* (409) rather than overwritten. Returns the new version.
|
|
170
|
+
*/
|
|
171
|
+
export declare function writeRevision(client: ScriptClient, page: Pick<WikiPageRow, 'tagPath' | 'slug' | 'title' | 'version' | 'siteId'>, blocks: Block[], { change, message, verified, dry, metadata, authorId }?: RevisionOptions): Promise<string>;
|
|
172
|
+
/**
|
|
173
|
+
* Create a page or update it in place, keyed by site, tag path and slug. A
|
|
174
|
+
* change to its title or blocks writes a revision; a re-run that changes
|
|
175
|
+
* nothing writes none. `change` records that bump instead of the diff's, and
|
|
176
|
+
* always writes one.
|
|
177
|
+
*/
|
|
178
|
+
export declare function upsertWikiPage(client: ScriptClient, input: {
|
|
179
|
+
tagPath: string;
|
|
180
|
+
slug: string;
|
|
181
|
+
title: string;
|
|
182
|
+
content: Block[];
|
|
183
|
+
metadata?: PageMetadata;
|
|
184
|
+
message?: string;
|
|
185
|
+
change?: Exclude<ChangeType, 'none'>;
|
|
186
|
+
authorId?: string | null;
|
|
187
|
+
siteId?: string;
|
|
188
|
+
}): Promise<'created' | 'updated'>;
|
|
189
|
+
export {};
|
package/dist/scripts.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// scripts.ts — what a wiki's direct-database scripts share: the client, the
|
|
2
|
+
// store over it, the block builders, the shape check and the revision write.
|
|
3
|
+
//
|
|
4
|
+
// Node-only. A script bypasses the app's write routes, so it bypasses their
|
|
5
|
+
// validation and their cache busting too; both are here so no script can skip
|
|
6
|
+
// them. radix-wiki's seed-utils.mjs and caper's wiki-utils.ts had each written
|
|
7
|
+
// this half, and each had a piece the other lacked: the shape check, the
|
|
8
|
+
// write-tracking pool and the rollback on one side, the upsert that records a
|
|
9
|
+
// revision only when something changed on the other.
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { bump } from './versioning.js';
|
|
13
|
+
import { wikiStore } from './store.js';
|
|
14
|
+
import { validateLinkGroups, validateReferenceItems } from './validation.js';
|
|
15
|
+
// ---- block builders ---------------------------------------------------------
|
|
16
|
+
export const uid = () => randomUUID();
|
|
17
|
+
export const content = (html) => ({ id: uid(), type: 'content', text: html });
|
|
18
|
+
/**
|
|
19
|
+
* A notice. Always pass `text`: the variant's standard message lives in the
|
|
20
|
+
* renderer, not the row, so a banner without it is invisible to search.
|
|
21
|
+
*/
|
|
22
|
+
export const banner = (variant, text) => ({ id: uid(), type: 'banner', variant, text });
|
|
23
|
+
/** A facts panel leading an article: `table` is the inner `<table>` HTML. */
|
|
24
|
+
export const infobox = (table) => ({ id: uid(), type: 'infobox', blocks: [content(table)] });
|
|
25
|
+
// ---- the shape check --------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Throw on a link-grid link or reference list whose URL fields are mis-shaped.
|
|
28
|
+
*
|
|
29
|
+
* The app's validator rejects these, and scripts never reach it. The views drop
|
|
30
|
+
* an entry whose URL fails rather than rendering it inert, so a `{ label, url }`
|
|
31
|
+
* link (the shape is `{ label, href }`) shows as a heading with no pills and
|
|
32
|
+
* nothing reports it. A throw inside `withClient` is a rollback and a failed run.
|
|
33
|
+
*/
|
|
34
|
+
export function assertLinkShapes(blocks, where = 'content') {
|
|
35
|
+
const bad = [];
|
|
36
|
+
const leaf = (b, path) => {
|
|
37
|
+
if (!b || typeof b !== 'object')
|
|
38
|
+
return;
|
|
39
|
+
if (b.type === 'linkGrid')
|
|
40
|
+
for (const [gi, g] of (b.groups ?? []).entries())
|
|
41
|
+
for (const [li, l] of (g?.links ?? []).entries())
|
|
42
|
+
if (!validateLinkGroups([{ id: '', heading: '', links: [l] }]))
|
|
43
|
+
bad.push(`${path}.groups[${gi}].links[${li}] "${l?.label ?? '(no label)'}" has fields {${Object.keys(l ?? {}).sort().join(', ')}} -- LinkGridLink is { label, href }`);
|
|
44
|
+
if (b.type === 'references' && !validateReferenceItems(b.items))
|
|
45
|
+
bad.push(`${path}.items -- ReferenceItem is { id, text, url? }`);
|
|
46
|
+
};
|
|
47
|
+
(Array.isArray(blocks) ? blocks : []).forEach((b, i) => {
|
|
48
|
+
leaf(b, `[${i}]`);
|
|
49
|
+
if (b?.type === 'infobox')
|
|
50
|
+
(b.blocks ?? []).forEach((n, j) => leaf(n, `[${i}].blocks[${j}]`));
|
|
51
|
+
if (b?.type === 'columns')
|
|
52
|
+
(b.columns ?? []).forEach((c, ci) => (c?.blocks ?? []).forEach((n, j) => leaf(n, `[${i}].columns[${ci}].blocks[${j}]`)));
|
|
53
|
+
});
|
|
54
|
+
if (bad.length)
|
|
55
|
+
throw new Error(`${where}: ${bad.length} mis-shaped link(s)\n ${bad.join('\n ')}`);
|
|
56
|
+
}
|
|
57
|
+
// ---- the client and the cache -----------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* Mark everything cached under `tag` stale on the project `cwd` is linked to.
|
|
60
|
+
* A script's write reaches no route that would bust it, and pages hold a day.
|
|
61
|
+
*/
|
|
62
|
+
export function invalidate(tag, cwd = process.cwd()) {
|
|
63
|
+
try {
|
|
64
|
+
execFileSync('vercel', ['cache', 'invalidate', '--tag', tag, '--yes'], { cwd, stdio: 'ignore' });
|
|
65
|
+
console.log(`cache: ${tag} tag invalidated`);
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
console.warn(`cache: ${tag} tag NOT invalidated (${e.message}); run \`vercel cache invalidate --tag ${tag}\` from ${cwd}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Run `fn` on a connected client, then release it and its pool. A throw rolls
|
|
73
|
+
* back the open transaction and fails the process: every script writes to the
|
|
74
|
+
* live database, and a half-applied edit must never pass for a success. A run
|
|
75
|
+
* that wrote anything ends by invalidating `tag`.
|
|
76
|
+
*/
|
|
77
|
+
export async function withClient(fn, { tag = 'wiki', cwd = process.cwd() } = {}) {
|
|
78
|
+
const { default: pg } = await import('pg');
|
|
79
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 1, ssl: { rejectUnauthorized: false } });
|
|
80
|
+
const conn = await pool.connect();
|
|
81
|
+
let wrote = false;
|
|
82
|
+
const client = {
|
|
83
|
+
query: (text, values) => {
|
|
84
|
+
// Anywhere in the text: the store's writes are data-modifying CTEs.
|
|
85
|
+
if (/\b(insert\s+into|update\s+\S+\s+set|delete\s+from)\b/i.test(text))
|
|
86
|
+
wrote = true;
|
|
87
|
+
return conn.query(text, values);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
return await fn(client);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
await conn.query('ROLLBACK').catch(() => { });
|
|
95
|
+
console.error('ERROR:', e.message);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
conn.release();
|
|
101
|
+
await pool.end();
|
|
102
|
+
// A run that throws part-way may have committed earlier writes.
|
|
103
|
+
if (wrote)
|
|
104
|
+
invalidate(tag, cwd);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ---- reads and writes through the app's store ---------------------------------
|
|
108
|
+
/** The app's own store over a script's client, so a script writes what the editor writes. */
|
|
109
|
+
export const storeFor = (client, siteId = '') => wikiStore({ sql: async (q, ...v) => (await client.query(q, v)).rows, siteId });
|
|
110
|
+
/** A page row by path, every column: what `writeRevision` takes. Null when there is none. */
|
|
111
|
+
export const readPage = (client, tagPath, slug, siteId = '') => storeFor(client, siteId).get(tagPath, slug);
|
|
112
|
+
/**
|
|
113
|
+
* Write edited blocks back to a page row from `readPage` as one revision. The
|
|
114
|
+
* save names the version it read, so an edit landing in between is refused
|
|
115
|
+
* (409) rather than overwritten. Returns the new version.
|
|
116
|
+
*/
|
|
117
|
+
export async function writeRevision(client, page, blocks, { change = 'minor', message, verified = false, dry = false, metadata, authorId = null } = {}) {
|
|
118
|
+
if (dry)
|
|
119
|
+
return bump(page.version, change);
|
|
120
|
+
assertLinkShapes(blocks, page.title);
|
|
121
|
+
const { page: saved } = await storeFor(client, page.siteId).save({
|
|
122
|
+
tagPath: page.tagPath, slug: page.slug, content: blocks, metadata,
|
|
123
|
+
change, message, verified, authorId, baseVersion: page.version,
|
|
124
|
+
});
|
|
125
|
+
return saved.version;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Create a page or update it in place, keyed by site, tag path and slug. A
|
|
129
|
+
* change to its title or blocks writes a revision; a re-run that changes
|
|
130
|
+
* nothing writes none. `change` records that bump instead of the diff's, and
|
|
131
|
+
* always writes one.
|
|
132
|
+
*/
|
|
133
|
+
export async function upsertWikiPage(client, input) {
|
|
134
|
+
const { siteId = '', ...save } = input;
|
|
135
|
+
assertLinkShapes(save.content, `${save.tagPath}/${save.slug}`);
|
|
136
|
+
const { created } = await storeFor(client, siteId).save(save);
|
|
137
|
+
return created ? 'created' : 'updated';
|
|
138
|
+
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type BlockTreeShape } from './blocks.js';
|
|
1
2
|
/**
|
|
2
3
|
* An href acceptable to persist: http, https, mailto, tel, or a relative path or
|
|
3
4
|
* fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
|
|
@@ -86,3 +87,45 @@ export declare function duplicateBlockIds<B extends {
|
|
|
86
87
|
type: string;
|
|
87
88
|
id: string;
|
|
88
89
|
}>(block: B, newId?: () => string): B;
|
|
90
|
+
type Checked = Record<string, unknown>;
|
|
91
|
+
export interface BlockModelOptions<B extends {
|
|
92
|
+
id: string;
|
|
93
|
+
type: string;
|
|
94
|
+
}> {
|
|
95
|
+
/** What inserting each type makes. Its keys are the types this wiki knows. */
|
|
96
|
+
defaults: Record<B['type'], () => Omit<B, 'id'>>;
|
|
97
|
+
/**
|
|
98
|
+
* The leaf check for each native type, and for a core type the wiki narrows.
|
|
99
|
+
* `core` is the core check, for a narrowing that adds to it rather than
|
|
100
|
+
* replacing it. A type with no entry gets the core check.
|
|
101
|
+
*/
|
|
102
|
+
checks?: Partial<Record<B['type'], (block: Checked, core: (block: Checked) => boolean) => boolean>>;
|
|
103
|
+
/** As `coreAtomicValidator`'s. */
|
|
104
|
+
pageId?: (id: string) => boolean;
|
|
105
|
+
maxRows?: number;
|
|
106
|
+
}
|
|
107
|
+
export interface BlockModel<B extends {
|
|
108
|
+
id: string;
|
|
109
|
+
type: string;
|
|
110
|
+
}> {
|
|
111
|
+
/** Every type, in the order `defaults` lists them. */
|
|
112
|
+
types: B['type'][];
|
|
113
|
+
/** Every type but the two containers: what may nest inside one. */
|
|
114
|
+
atomicTypes: B['type'][];
|
|
115
|
+
shape: BlockTreeShape<B>;
|
|
116
|
+
createBlock: (type: B['type']) => B;
|
|
117
|
+
duplicateBlock: (block: B) => B;
|
|
118
|
+
validateBlocks: (content: unknown) => content is B[];
|
|
119
|
+
blockIssues: (content: unknown) => BlockIssue[];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A wiki's block model from its type set: the new-block factories and the leaf
|
|
123
|
+
* checks are its own, and everything derived from them is this. Each wiki had
|
|
124
|
+
* derived the same seven things by hand, and two kept a list of nestable types
|
|
125
|
+
* that drifted from the types the write path accepted.
|
|
126
|
+
*/
|
|
127
|
+
export declare function defineBlocks<B extends {
|
|
128
|
+
id: string;
|
|
129
|
+
type: string;
|
|
130
|
+
}>(opts: BlockModelOptions<B>): BlockModel<B>;
|
|
131
|
+
export {};
|
package/dist/validation.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// As elsewhere in this package, the dispatch stays with the caller so a new
|
|
10
10
|
// block type is a compile error there until it is handled.
|
|
11
|
-
import { LIST_SOURCES } from './blocks.js';
|
|
11
|
+
import { coreBlockShape, LIST_SOURCES } from './blocks.js';
|
|
12
12
|
/**
|
|
13
13
|
* An href acceptable to persist: http, https, mailto, tel, or a relative path or
|
|
14
14
|
* fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
|
|
@@ -241,3 +241,30 @@ export function duplicateBlockIds(block, newId = () => crypto.randomUUID()) {
|
|
|
241
241
|
}
|
|
242
242
|
return { ...block, id: newId() };
|
|
243
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* A wiki's block model from its type set: the new-block factories and the leaf
|
|
246
|
+
* checks are its own, and everything derived from them is this. Each wiki had
|
|
247
|
+
* derived the same seven things by hand, and two kept a list of nestable types
|
|
248
|
+
* that drifted from the types the write path accepted.
|
|
249
|
+
*/
|
|
250
|
+
export function defineBlocks(opts) {
|
|
251
|
+
const { defaults, checks = {}, pageId, maxRows } = opts;
|
|
252
|
+
const types = Object.keys(defaults);
|
|
253
|
+
const atomicTypes = types.filter(t => t !== 'columns' && t !== 'infobox');
|
|
254
|
+
const core = coreAtomicValidator({ pageId, maxRows });
|
|
255
|
+
const own = checks;
|
|
256
|
+
const { validateBlocks, blockIssues } = createBlockValidator({
|
|
257
|
+
isKnownType: t => Object.hasOwn(defaults, t),
|
|
258
|
+
isAtomicType: t => atomicTypes.includes(t),
|
|
259
|
+
validateAtomic: b => (own[b.type] ?? core)(b, core),
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
types,
|
|
263
|
+
atomicTypes,
|
|
264
|
+
shape: coreBlockShape(),
|
|
265
|
+
createBlock: type => ({ id: crypto.randomUUID(), ...defaults[type]() }),
|
|
266
|
+
duplicateBlock: block => duplicateBlockIds(block),
|
|
267
|
+
validateBlocks: (content) => validateBlocks(content),
|
|
268
|
+
blockIssues,
|
|
269
|
+
};
|
|
270
|
+
}
|
package/dist/well-known.d.ts
CHANGED
|
@@ -149,3 +149,30 @@ export declare function descriptorHandler(body: unknown, opts?: {
|
|
|
149
149
|
}): (request: Request) => Response;
|
|
150
150
|
/** The whole `<mcp-url>/server-card` route, projected from server.json. */
|
|
151
151
|
export declare const serverCardHandler: (manifest: ServerManifest) => ((request: Request) => Response);
|
|
152
|
+
export interface WellKnownDocs {
|
|
153
|
+
agentCard: unknown;
|
|
154
|
+
mcpManifest: unknown;
|
|
155
|
+
openapi: unknown;
|
|
156
|
+
/**
|
|
157
|
+
* The registry's key material, read from the environment by default. `null`
|
|
158
|
+
* serves no proof: a tenant of a host whose namespace is the host's own.
|
|
159
|
+
*/
|
|
160
|
+
registryAuth?: {
|
|
161
|
+
publicKey?: string;
|
|
162
|
+
keyType?: string;
|
|
163
|
+
} | null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The `/.well-known/[doc]` route: the agent card at `agent.json` and
|
|
167
|
+
* `agent-card.json` (A2A v0.3 renamed it; older clients ask for the first), the
|
|
168
|
+
* MCP manifest at `mcp.json`, the OpenAPI spec at `openapi.json` and the
|
|
169
|
+
* registry's domain proof at `mcp-registry-auth`. Every other name is a 404.
|
|
170
|
+
*
|
|
171
|
+
* Each app served these as five or six one-line routes re-exporting each other;
|
|
172
|
+
* one dynamic segment holds them all, and a rewrite puts `/openapi.json` on it.
|
|
173
|
+
*/
|
|
174
|
+
export declare function wellKnownHandler(docs: WellKnownDocs): (request: Request, context: {
|
|
175
|
+
params: Promise<{
|
|
176
|
+
doc: string;
|
|
177
|
+
}>;
|
|
178
|
+
}) => Promise<Response>;
|
package/dist/well-known.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// well-known.ts — the descriptors an agent finds before it knows anything else
|
|
2
|
-
// about an origin: the A2A Agent Card and the
|
|
2
|
+
// about an origin: the A2A Agent Card, the MCP manifest and the registry's
|
|
3
|
+
// domain proof, and the one route that serves them.
|
|
3
4
|
//
|
|
4
5
|
// All three apps here serve both, and all three had written them out. The
|
|
5
6
|
// registry-auth handler was identical bar a domain named in a comment; the
|
|
@@ -218,3 +219,28 @@ export function descriptorHandler(body, opts = {}) {
|
|
|
218
219
|
}
|
|
219
220
|
/** The whole `<mcp-url>/server-card` route, projected from server.json. */
|
|
220
221
|
export const serverCardHandler = (manifest) => descriptorHandler(serverCard(manifest, MCP_PROTOCOL_VERSIONS));
|
|
222
|
+
/**
|
|
223
|
+
* The `/.well-known/[doc]` route: the agent card at `agent.json` and
|
|
224
|
+
* `agent-card.json` (A2A v0.3 renamed it; older clients ask for the first), the
|
|
225
|
+
* MCP manifest at `mcp.json`, the OpenAPI spec at `openapi.json` and the
|
|
226
|
+
* registry's domain proof at `mcp-registry-auth`. Every other name is a 404.
|
|
227
|
+
*
|
|
228
|
+
* Each app served these as five or six one-line routes re-exporting each other;
|
|
229
|
+
* one dynamic segment holds them all, and a rewrite puts `/openapi.json` on it.
|
|
230
|
+
*/
|
|
231
|
+
export function wellKnownHandler(docs) {
|
|
232
|
+
const { registryAuth = {} } = docs;
|
|
233
|
+
const bodies = {
|
|
234
|
+
'agent.json': docs.agentCard,
|
|
235
|
+
'agent-card.json': docs.agentCard,
|
|
236
|
+
'mcp.json': docs.mcpManifest,
|
|
237
|
+
'openapi.json': docs.openapi,
|
|
238
|
+
};
|
|
239
|
+
return async (request, { params }) => {
|
|
240
|
+
const { doc } = await params;
|
|
241
|
+
if (doc === 'mcp-registry-auth' && registryAuth) {
|
|
242
|
+
return registryAuthHandler(registryAuth.publicKey ?? process.env.MCP_REGISTRY_PUBLIC_KEY, registryAuth.keyType ?? process.env.MCP_REGISTRY_KEY_TYPE)();
|
|
243
|
+
}
|
|
244
|
+
return Object.hasOwn(bodies, doc) ? descriptorResponse(request, bodies[doc]) : new Response('Not found', { status: 404 });
|
|
245
|
+
};
|
|
246
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.86.1",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -283,6 +283,16 @@
|
|
|
283
283
|
"import": "./dist/headers.js",
|
|
284
284
|
"default": "./dist/headers.js"
|
|
285
285
|
},
|
|
286
|
+
"./scripts": {
|
|
287
|
+
"types": "./dist/scripts.d.ts",
|
|
288
|
+
"import": "./dist/scripts.js",
|
|
289
|
+
"default": "./dist/scripts.js"
|
|
290
|
+
},
|
|
291
|
+
"./knip": {
|
|
292
|
+
"types": "./dist/knip.d.ts",
|
|
293
|
+
"import": "./dist/knip.js",
|
|
294
|
+
"default": "./dist/knip.js"
|
|
295
|
+
},
|
|
286
296
|
"./tsconfig.base.json": "./tsconfig.base.json"
|
|
287
297
|
},
|
|
288
298
|
"bin": {
|
|
@@ -327,6 +337,7 @@
|
|
|
327
337
|
"@tiptap/react": "^2.27.3",
|
|
328
338
|
"@tiptap/starter-kit": "^2.27.3",
|
|
329
339
|
"fast-diff": "^1.3.0",
|
|
340
|
+
"katex": "^0.16.47",
|
|
330
341
|
"lightweight-charts": "^5.2.1",
|
|
331
342
|
"sanitize-html": "^2.17.7"
|
|
332
343
|
},
|
|
@@ -335,12 +346,13 @@
|
|
|
335
346
|
"@radixdlt/rola": "^2.1.0",
|
|
336
347
|
"@types/katex": "^0.16.8",
|
|
337
348
|
"@types/node": "^22.0.0",
|
|
349
|
+
"@types/pg": "^8.23.1",
|
|
338
350
|
"@types/react": "^19.0.0",
|
|
339
351
|
"@types/sanitize-html": "^2.16.1",
|
|
340
352
|
"@vercel/blob": "^2.8.2",
|
|
341
353
|
"jose": "^6.2.12",
|
|
342
354
|
"jsdom": "^30.1.0",
|
|
343
|
-
"
|
|
355
|
+
"pg": "^8.23.1",
|
|
344
356
|
"react": "^19.2.4",
|
|
345
357
|
"sharp": "^0.35.5",
|
|
346
358
|
"typescript": "^5.9.0"
|
|
@@ -351,8 +363,7 @@
|
|
|
351
363
|
"jose": ">=6",
|
|
352
364
|
"pg": ">=8",
|
|
353
365
|
"react": ">=18",
|
|
354
|
-
"sharp": ">=0.33"
|
|
355
|
-
"katex": "^0.16.0"
|
|
366
|
+
"sharp": ">=0.33"
|
|
356
367
|
},
|
|
357
368
|
"peerDependenciesMeta": {
|
|
358
369
|
"react": {
|
|
@@ -372,9 +383,6 @@
|
|
|
372
383
|
},
|
|
373
384
|
"@vercel/blob": {
|
|
374
385
|
"optional": true
|
|
375
|
-
},
|
|
376
|
-
"katex": {
|
|
377
|
-
"optional": true
|
|
378
386
|
}
|
|
379
387
|
}
|
|
380
388
|
}
|