wiki-formant 0.85.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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, and nothing else: 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. 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.
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 only: no colour, no type scale, no design token. Every value that
4
- is a look rather than a mechanism is left to the importing stylesheet, which
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,141 @@ 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
+ .section-card:hover { border-color: var(--ui-accent); }
583
+ .facet-summary { margin: 0; font-size: var(--ui-small); color: var(--ui-muted); }
584
+
585
+ /* The hover card over a link to another page (useLinkPreview). */
586
+ .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); }
587
+ .link-preview-body { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.75rem; }
588
+ .link-preview-title { font-weight: 600; }
589
+ .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; }
590
+
591
+ /* The rail's frame and its table of contents. */
592
+ .wiki-sidebar { --rail-border: 1px solid var(--ui-border); }
593
+ .wiki-rail__group, .wiki-rail__toc-list { display: flex; flex-direction: column; gap: 1px; }
594
+ .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); }
595
+ :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; }
596
+ .wiki-rail__toc-btn { padding: 0 0.5rem 0.25rem; }
597
+ .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; }
598
+ .wiki-rail__toc-item:hover, .wiki-rail__toc-item--active { color: var(--ui-accent); border-inline-start-color: var(--ui-accent); }
599
+ .wiki-rail__toc-item--active { font-weight: 600; }
600
+
601
+ /* Work queues (MaintenanceQueues). */
602
+ .maintenance-queue { display: flex; flex-direction: column; gap: 0.5rem; padding-top: 1.5rem; border-top: 1px solid var(--ui-border); }
603
+ .maintenance-count { margin-left: 0.5rem; font-weight: 400; font-variant-numeric: tabular-nums; color: var(--ui-muted); }
604
+ .maintenance-description, .maintenance-detail { color: var(--ui-muted); }
605
+ .maintenance-detail { font-size: var(--ui-small); }
606
+ .maintenance-list { display: grid; gap: 0.375rem; margin: 0; padding: 0; list-style: none; }
607
+ .maintenance-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; }
608
+ .maintenance-item > a { color: var(--ui-accent); }
609
+
610
+ /* A revision's changes and its inline diff. */
611
+ .revision-change-action { font-weight: 600; color: var(--ui-muted); }
612
+ .revision-change[data-action='added'] .revision-change-action { color: var(--ui-up); }
613
+ .revision-change[data-action='removed'] .revision-change-action { color: var(--ui-down); }
614
+ .revision-diff { line-height: 1.6; color: var(--ui-muted); }
615
+ .revision-diff del { color: var(--ui-down); }
616
+ .revision-diff ins { color: var(--ui-up); text-decoration: none; background: color-mix(in srgb, var(--ui-up) 12%, transparent); border-radius: 2px; }
617
+
618
+ /* Sortable headers: muted until hovered or sorted by. */
619
+ .sort-header { color: var(--ui-muted); transition: color 0.15s; }
620
+ .sort-header:hover,
621
+ :is([aria-sort='ascending'], [aria-sort='descending']) > .sort-header { color: inherit; }
622
+
623
+ /* Charts. */
624
+ .chart { background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); }
625
+ .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); }
626
+ .chart-status { font-size: var(--ui-small); color: var(--ui-muted); }
627
+ .chart-controls :is(button, a) { font-size: 0.6875rem; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); opacity: 1; }
628
+ .chart-controls :is(button, a):hover { background: var(--ui-surface-2); }
629
+ .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[]>>): 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
- // Zero imports, so a config pays for nothing it does not use. `withWikiDefaults`
15
- // is here for the same reason: next.config.ts is its only caller.
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
- return Object.entries({ ...BASE_CSP, ...directives })
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
@@ -0,0 +1,8 @@
1
+ declare const _default: {
2
+ entry: string[];
3
+ project: string[];
4
+ ignore: string[];
5
+ ignoreDependencies: string[];
6
+ ignoreBinaries: string[];
7
+ };
8
+ export default _default;
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
+ };
@@ -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;
@@ -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: what a
4
- // checker does with the verdicts — which pages to walk, what counts as an
5
- // internal path, how to report — stays with the caller, because that is the
6
- // half that is genuinely per-wiki.
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: async (value) => !!value &&
60
- (await sql(`SELECT 1 FROM "PasskeyToken" t JOIN "Passkey" p ON p.id = t."passkeyId"
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.headers
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:');
package/dist/revisions.js CHANGED
@@ -33,8 +33,14 @@ export function extractBlocks(blocks, containers, basePath = 'root') {
33
33
  });
34
34
  return out;
35
35
  }
36
+ /** A value with every object's keys in one order: jsonb hands a stored block's keys back in its own. */
37
+ const canonical = (v) => Array.isArray(v)
38
+ ? v.map(canonical)
39
+ : v && typeof v === 'object'
40
+ ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])]))
41
+ : v;
36
42
  /**
37
- * Which scalar fields differ. Nested block arrays are skipped: they are walked
43
+ * Which scalar fields differ, whatever order their keys are in. Nested block arrays are skipped: they are walked
38
44
  * as their own entries, and comparing them here would report a container as
39
45
  * modified every time anything inside it moved.
40
46
  */
@@ -57,7 +63,7 @@ function diffAttributes(oldBlock, newBlock, containers) {
57
63
  continue;
58
64
  const from = oldBlock[key];
59
65
  const to = newBlock[key];
60
- if (JSON.stringify(from) !== JSON.stringify(to))
66
+ if (JSON.stringify(canonical(from)) !== JSON.stringify(canonical(to)))
61
67
  diffs[key] = { from, to };
62
68
  }
63
69
  return Object.keys(diffs).length > 0 ? diffs : undefined;
@@ -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 {};
@@ -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
+ }
@@ -1,5 +1,6 @@
1
+ import { type BlockTreeShape } from './blocks.js';
1
2
  /**
2
- * An href acceptable to persist: http, https, mailto, or a relative path or
3
+ * An href acceptable to persist: http, https, mailto, tel, or a relative path or
3
4
  * fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
4
5
  * protocol-relative `//host` — comes back null.
5
6
  *
@@ -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 {};
@@ -8,9 +8,9 @@
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
- * An href acceptable to persist: http, https, mailto, or a relative path or
13
+ * An href acceptable to persist: http, https, mailto, tel, or a relative path or
14
14
  * fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
15
15
  * protocol-relative `//host` — comes back null.
16
16
  *
@@ -36,7 +36,7 @@ export function safeLinkHref(raw) {
36
36
  const scheme = href.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
37
37
  if (scheme) {
38
38
  const s = scheme[1].toLowerCase();
39
- if (s !== 'http' && s !== 'https' && s !== 'mailto')
39
+ if (s !== 'http' && s !== 'https' && s !== 'mailto' && s !== 'tel')
40
40
  return null;
41
41
  }
42
42
  return href;
@@ -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
+ }
@@ -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>;
@@ -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 MCP registry's domain proof.
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.85.0",
3
+ "version": "0.86.0",
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
- "katex": "^0.16.47",
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
  }