Skip to main content
Glama

wiki-formant

The portable half of a wiki: a page store, derived taxonomy, a spec-correct MCP transport, markdown twins, block rendering and editing, and the conditional-GET plumbing an agent surface needs.

The editor, history, sanitiser, chart and passkey modules bring their libraries as dependencies (@tiptap/*, fast-diff, sanitize-html, lightweight-charts, @simplewebauthn/*). What only some apps use is an optional peer: pg, react, jose and @radixdlt/rola for ROLA, sharp and @vercel/blob for uploads. Web-standard Request/Response throughout, so it runs on Next route handlers, Hono, Bun, Deno and workers.

npm install wiki-formant

An agent can start a wiki for you, hosted on miow.me or in your own app, with the skill in skills/wiki-formant: npx skills add tutmoses/wiki-formant.

What stays the app's: its design system, its block types beyond the core, its connection, cache and auth. Everything else here is shared.

API

Every module has a subpath (wiki-formant/taxonomy, wiki-formant/mcp, …) and there is no root export: a barrel loads every module for the one symbol a route wanted. The emitted .d.ts files are the reference; this README shows one use of each subpath and the rules a signature cannot say. A function a module uses only itself is marked @internal and left out of the .d.ts.

wiki-formant/tsconfig.base.json is the compiler floor: an app's tsconfig.json extends it and keeps only paths, include, exclude and any licensed extra, since tsconfig resolves those against the file that declares them.

{ "extends": "wiki-formant/tsconfig.base.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/app/.well-known/**/*.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], "exclude": ["node_modules"] }

Related MCP server: census-mcp

Store

wiki-formant/store: wikiStore reads and writes WikiPage and WikiRevision through one Sql function the app binds to its own client, scoped to one site.

import { wikiStore } from 'wiki-formant/store';

const store = wikiStore({
  sql: (query, ...values) => prisma.$queryRawUnsafe(query, ...values),
  siteId: '',                 // a multi-tenant app passes the site's key
  hidden: tree.hiddenPaths,   // back office: never listed unasked
});

const page = await store.get('contents/tech', 'radix-engine');
const { items, total } = await store.search('validators', { take: 20 });
await store.save({ tagPath, slug, content, authorId, message, baseVersion: page.version });
  • A save is one statement: the page update and its revision in a CTE guarded on the version read. A lost race, a stale baseVersion, a page deleted since or a rename onto a taken slug writes nothing and throws a 409 HttpError.

  • The bump is the diff's: added, removed or moved blocks are major, changed prose or title minor; change overrides it. A metadata-only save moves no version and writes no revision.

  • History is diffed on read. A revision stores the page as saved. history(pageId, { limit }) reads one row more than it returns, so the oldest entry shown has its real predecessor.

  • Listings carry a snippet, not the article: list, recent and byIds drop content.

  • Search is four tiers: title prefix, title, literal prose, then full text on search_tsv with Postgres' headline as the snippet. Hidden paths, and paths under them, are left out unless named.

  • branch is an agent's walk of a path and everything under it; counts is pages per tag path.

  • Copy the models with npx prisma-models wiki (--tenant for a tenant-keyed copy), then build the full-text column with npx fts-ddl '"WikiPage"'.

Tree

wiki-formant/tree: the category tree a wiki declares, and its walks.

import { createTree, parseWikiPath } from 'wiki-formant/tree';

const tree = createTree(NODES, { globalKeys: [QUALITY] });
tree.resolve('contents/tech');   // { node, trail, valid, hidden, sort, mainArticle, metadataKeys }
parseWikiPath(tree, ['contents', 'tech', 'radix-engine', 'history']);
// { type: 'page', tagPath: 'contents/tech', slug: 'radix-engine', mode: 'history' }
  • Metadata keys accumulate down the trail; sort, mainArticle and hidden inherit, the nearest declaration winning. A field only one wiki reads rides on the node.

  • A path that is itself a category wins over a mode suffix. The empty slug is a category's hub article.

  • crumbs makes one crumb per URL segment, linked only where the tree knows it.

Category view

wiki-formant/category: a category page from the tree, the taxonomy and anything that reads like the store.

import { categoryView } from 'wiki-formant/category';

const view = await categoryView({ tree, taxonomy, pages: store }, 'ecosystem', searchParams);
// { sort, total, pages, all, filters, letter, facets, letters, sortable, clearHref, sortHref, sections }

A link carries sort only when it is not the category's default, so the bare category and its default are one address.

Wiki read tools

wiki-formant/wiki-tools: the read half of a wiki's MCP server: search_wiki, get_page, get_pages, list_pages, get_categories, get_recent_changes, get_full_corpus and get_page_history. searchWiki is the same rows for a plain-GET ?q= and a search page.

import { wikiReadTools, searchWiki, type CorpusSection } from 'wiki-formant/wiki-tools';

const reading = { store, tree, url: (tagPath, slug) => `${SITE}/${tagPath}/${slug}`, text: extractText, noun: 'the Radix Wiki', corpusTitle: 'Radix Wiki' };
const tools = [...wikiReadTools(reading), ...myOwnTools];
const { items, total } = await searchWiki(reading, 'validators', { page: 1, size: 20 });
  • Every row carries path, which every read tool takes back, its id (what a pageList block takes), and chars where the text was read. A pasted URL resolves, with pathPrefix stripped; a miss names the nearest pages.

  • get_page with blocks: true returns the stored blocks, id, metadata and version: what an edit starts from. forEdit adds the app's own edit fields; authors names revision authors.

  • text should be the twin's serializer and resolve the page's live resolver, so a page reads the same over MCP as at its twin.

  • get_full_corpus is a sizeOnly preflight, then page-aligned slices under maxChars (CORPUS_BUDGET) with truncated, nextSkip and clippedPage. corpus returns the app's own CorpusSections.

  • privateHidden closes the back office to every tool. metadata, label, noMatch and skills are the app's.

  • Writes stay with each app.

Taxonomy

wiki-formant/taxonomy: the cross-cutting browsing axis derived from the select metadata pages already carry. The tree is yours; supply getMetadataKeys and one href builder.

import { createTaxonomy } from 'wiki-formant/taxonomy';

const taxonomy = createTaxonomy({
  getMetadataKeys: tagPath => TAGS[tagPath]?.metadataKeys ?? [],
  href: (tagPath, { sort, filters, letter }) => /* your URL contract */,
});
const state   = { sort, filters, letter: taxonomy.resolveLetter(all, filters, query.letter) };
const groups  = taxonomy.facetControls('ecosystem', all, state);  // [{ key, label, options }]
const letters = taxonomy.alphaControls('ecosystem', all, state);  // [] below the threshold
  • Each facet is counted over the set narrowed by every other filter, so its options stay switchable.

  • Values come from the data, not the declared options. A single-valued facet hides unless it is active.

  • Every control is { value, label, count, href, active } with every other axis carried in href. The reset control leads the row, flagged reset.

  • alphaControls is [] below needsAlphaIndex (40 pages unless alphaIndexMinPages); resolveLetter drops a letter no page starts with.

  • metadataRows returns a page's populated keys as rows with href on the facet ones; rankRelated ranks by shared facet values and returns the shared axis for the See also heading.

Headings

wiki-formant/headings: heading ids, permalink anchors, and the list an "on this page" rail renders.

import { articleHtml, headingsFrom } from 'wiki-formant/headings';

const used = new Set([slug(page.title)]);
const blocks = page.blocks.map(b => ({ ...b, text: articleHtml(b.text, { used, links: { selfHost: 'radix.wiki' } }) }));
const toc = headingsFrom(blocks.map(b => b.text).join(''));   // [{ id, text, level }]
  • injectHeadingIds is idempotent; slug is a parameter, since a heading id is a published URL.

  • Pass one used set across a page's blocks, seeded with ids the template renders, or a repeated heading mints a duplicate id.

  • articleHtml is the render-path chain: heading ids, scrollTables, and with links the link pass: an internal link loses target and rel, an external one gets them, and a bare-URL anchor gets readable text.

MCP

wiki-formant/mcp: a Model Context Protocol server over Streamable HTTP.

import { mcpResponse, mcpGet, mcpOptions, McpToolError } from 'wiki-formant/mcp';
import { MCP_RATE_LIMIT } from 'wiki-formant/rate-limit';

const config = {
  serverInfo: { name: 'my-wiki', version: '1.4.0' },
  instructions: 'Call search_pages first; get_page needs a full path.',
  docsUrl: 'https://example.com/llms.txt',
  tools: [{
    name: 'search_pages',
    description: 'Full-text search across the wiki.',
    inputSchema: { type: 'object', properties: { q: { type: 'string', description: 'query' } }, required: ['q'] },
    annotations: { readOnlyHint: true },
    handler: async ({ q }) => search(String(q)),
  }],
  rateLimit: MCP_RATE_LIMIT,
  onCall: (req, body) => track(req, body),
};

export const POST = (req: Request) => mcpResponse(req, config);
export const GET  = () => mcpGet(config.docsUrl);
export const OPTIONS = () => mcpOptions();
  • A caller-fixable mistake is a tool result with isError, never -32603: bad arguments name every bad field, quote legal values and append the schema. Throw McpToolError(message, details) from a handler for the same.

  • Capabilities, and the -32601 method list, advertise only what the config populates.

  • A notification-only POST is a bare 202; malformed JSON is -32700 with a 400; GET is an explicit 405 with CORS; the preflight allow-list and Access-Control-Expose-Headers cover what MCP clients send and read.

  • Batches are capped (DEFAULT_MAX_BATCH, 20) and refused from 2025-06-18 on.

  • Both protocol eras on one endpoint: a request with io.modelcontextprotocol/protocolVersion in params._meta is served as 2026-07-28 (server/discover, per-request version checks, resultType and cache hints); everything else is the legacy era, which echoes a supported client version. Every response carries MCP-Protocol-Version.

  • The A2A call an agent card invites (message/send, SendMessage) is answered from the tool answer names.

  • rateLimit: { capacity, refillPerSec } is enforced before the body is parsed, with RateLimit-* headers on every answer; mcpRateLimited(sec) is the JSON-RPC 429.

  • Every object a handler returns comes back as structuredContent beside the text; outputSchema is the stronger contract when declared. inputSchema.requireOneOf names a set of which one must be present.

  • A handler's second argument is its context: ctx.meta (the request's params._meta) and ctx.setMeta(key, value) (onto result._meta). config.gate is envelope-level middleware, for a payment gate whose demand replaces the call.

  • readArgs reads raw arguments as typed, clamped values and records every override; withAdjustments puts them on the result as adjustments.

  • feedbackTool(record, noun) is send_feedback. Listed, initialize appends FEEDBACK_INVITE and an empty listEnvelope page or an McpToolError carries a one-line pointer to it.

Conformance

wiki-formant/conformance: the half of an MCP conformance run that is not about one server's tools. Every value it compares against is imported from wiki-formant/mcp.

import { createTester, standardSuite, wikiReadChecks } from 'wiki-formant/conformance';

const t = createTester({ base: 'https://example.com', clientName: 'my-mcp-test' });
const init = await standardSuite(t, {
  version: serverManifest.version,
  openapi: ['/openapi.json', '/.well-known/openapi.json'],
  writes: ['create_page', 'send_feedback'],
  manifest: { writeAuth: true },
  budget: [{ name: 'search_wiki', args: { query: 'x' } }, { name: 'get_full_corpus', maxBytes: 260_000 }],
  conditionalGet: ['llms.txt'],
  robots: ['/api/mcp', '/llms.txt', '/.well-known/agent-card.json'],
});
const { first } = await wikiReadChecks(t, { query: 'radix', address: r => ({ tagPath: r.tagPath, slug: r.slug }), twin: r => `${r.path}.md` });
process.exit(t.summary());

standardSuite(t, cfg) runs the suites every surface runs, in order: transportChecks (CORS preflight, GET→405, 202 for a notification, -32700, the batch cap, honest capabilities, version negotiation), versionCoherence over standardVersionSources, agentCardParity, annotationChecks, manifestParity, payloadBudget, conditionalGetChecks, descriptorChecks (default S10_DESCRIPTORS) and robotsChecks. budget, conditionalGet and robots run only when given, for a surface whose fixtures exist only later in its run. It returns the initialize response. Each suite is exported for a surface that needs it alone.

wikiReadChecks(t, opts) checks the wiki-tools read tools: categories, a search and the page it finds (by address, default { path }), its twin (given twin), a listing, recent changes, a corpus preflight and a slice that is cut and resumes when the corpus exceeds corpusChars, and the teaching answers to no hits, a missing argument, an unknown tool and a missing page. endpoint and label run it against a second server. It returns { rows, first, page }.

  • payloadBudget weighs every listed call and every read-only tool with no required arguments, and asserts a JSON answer carries structuredContent.

  • conditionalGetChecks wants a 304 on If-None-Match (alone and in a list) and a Last-Modified; descriptorChecks wants an ETag and a max-age on a JSON descriptor.

  • t.rpcAt(endpoint, …) and t.callAt drive another endpoint with the same backoff, User-Agent and tally.

The drift checks hold a server to what its own site does:

  • manifestParity(t, { writeAuth? }): /.well-known/mcp.json is tools/list, names the endpoint, and states write auth only beside a write tool.

  • rowParity(t, label, mcp, rest): a tool's rows and the GET API's are identical and not empty.

  • twinParity(t, label, twinUrl, text): a tool's text is the page's markdown twin, past frontmatter and title.

  • editReadChecks(t, { path, rows?, validate?, endpoint?, label? }): rows carry ids, get_page with blocks: true returns blocks the site's validate accepts, and the history's newest revision is that version.

  • roundTrip(t, label, { read, write }): a write takes its read back unchanged.

  • openapiWriteParity(t, [[path, method, tool], …], specUrl?): every field an OpenAPI write documents, the tool takes.

  • rolaOriginCheck(t): the ROLA recipe names the origin under test.

  • freshAfterWriteChecks(t, urls, write, { shows?, gone? }): each URL shows the write within five seconds. Run it against a deployment, since with no CDN it passes trivially.

Markdown twins

wiki-formant/markdown: htmlToMarkdown keeps the structure an agent cites by (headings, lists, tables, code, emphasis); markdownDocument adds the frontmatter.

import { htmlToMarkdown, markdownDocument } from 'wiki-formant/markdown';
import { markdownHeaders, cacheTagHeaders, VARY_ACCEPT } from 'wiki-formant/http';

return notModified(request, etag, lastModified) ?? new Response(
  markdownDocument(
    { title: page.title, url, updated: page.updatedAt, lastVerified: page.lastVerifiedAt, license },
    blocksToMarkdown(page.content),
  ),
  { headers: markdownHeaders(lastModified, { etag, noindex: true, extra: { ...VARY_ACCEPT, ...cacheTagHeaders(pageTag(path)) } }) },
);
  • Tables convert first, ordered lists number per list, cell pipes are escaped, and a headerless table gets a header.

  • noindex is for a twin of an HTML page; a document with no HTML page (AGENTS.md) leaves it off.

  • cacheTagHeaders(tags) with the tags the save path revalidates, or the CDN holds a route handler's twin past every save. The same goes for llms.txt, a search index and a sitemap.

  • Next drops a rewrite destination's query string, so a twin rewrite carries the .md extension through to its destination.

Conditional GET and route errors

wiki-formant/http: validators, the headers of every text surface, and the answers a route gives when it is not answering with data.

import { corpusValidatorsFrom, corpusRoute, errors, handleRoute, HttpError } from 'wiki-formant/http';

export const GET = corpusRoute(async () => {
  const agg = await prisma.page.aggregate({ _count: true, _max: { updatedAt: true } });
  return corpusValidatorsFrom([agg._count], [agg._max.updatedAt]);
}, buildCorpus);

export const POST = (req: Request) => handleRoute(async () => {
  const q = new URL(req.url).searchParams.get('q');
  if (!q) return errors.badRequest('q is required');
  if (q.length > 200) throw new HttpError(400, 'q is too long', { code: 'INVALID_PARAMS' });
  return Response.json(await search(q));
}, 'Search failed');
  • corpusEtag, notModified and textHeaders are the parts; corpusRoute skips the build on a 304.

  • A URL answering JSON or markdown by Accept asks wantsMarkdown(request) and sends VARY_ACCEPT on both branches.

  • descriptorResponse serves a JSON descriptor with its own ETag and Access-Control-Allow-Origin: * (descriptorHandler in wiki-formant/well-known is the route).

  • Every error body is { error, ...extra }. A thrown HttpError answers with its status; anything else is logged and answered with a 500 carrying only the handler's message ('Search failed'), so no exception text reaches a caller. errors.tooManyRequests(sec) states Retry-After.

Response headers and the Next config

wiki-formant/headers: the headers every route sends, the shared CSP, and the Next config defaults. It imports nothing, so next.config.ts pays for nothing it does not use.

import type { NextConfig } from 'next';
import { contentSecurityPolicy, withWikiDefaults } from 'wiki-formant/headers';

const csp = contentSecurityPolicy({ 'connect-src': ["'self'", 'https://*.radixdlt.com'] });
export default withWikiDefaults({ async redirects() { /* … */ } } satisfies NextConfig, { csp });

withWikiDefaults(config, { csp }) adds:

  • securityHeaders on every route, csp enforced in production only (Next's dev runtime is inline and eval); an origin with a per-request nonce passes none.

  • /openapi.json rewritten to /.well-known/openapi.json first in beforeFiles, merged with the app's own rewrites() in either form.

  • AVIF before WebP, turbopackFileSystemCacheForBuild: false (Vercel restores a stale build cache), and distDir from STUDIO_DIST_DIR for radix-studio's second dev server.

What the app sets wins, and its headers() follow these. contentSecurityPolicy starts from the shared base and each directive passed replaces its default; an empty list drops one. The default frame-src is DEFAULT_IFRAME_HOSTS, the sanitiser's default iframeHosts, so an embed that clears one clears the other; a site with its own hosts passes the same list to both.

Pagination, versioning and rate limits

import { parsePagination, paginatedResponse, listEnvelope } from 'wiki-formant/pagination';
import { bump, parseVersion } from 'wiki-formant/versioning';
import { limitClient, rateLimitedResponse } from 'wiki-formant/rate-limit';

const { page, pageSize } = parsePagination(searchParams);            // page ≥ 1, pageSize 1–100
return Response.json(paginatedResponse(items, total, page, pageSize)); // always carries totalPages

bump(page.version, 'minor');                                           // tolerant of null or junk

const limit = limitClient('search', request.headers, { capacity: 30, refillPerSec: 0.5 });
if (!limit.ok) return rateLimitedResponse(limit.retryAfterSec);
  • listEnvelope is the shape every list tool answers with, its note on an empty page.

  • rateLimit is an in-memory token bucket; blockMs blocks an abusive key outright across every bucket naming it. clientIp reads x-real-ip first, then the leftmost x-forwarded-for. MCP_RATE_LIMIT is the MCP servers' budget.

React

wiki-formant/react carries 'use client'. What is shared is behaviour; class names and icons are passed in.

import { LinkPreviewCard, SidebarProvider, RailShell, TableOfContents, useLinkPreview } from 'wiki-formant/react';

function LinkPreview() {
  const { preview, cardProps } = useLinkPreview<Preview>({ eligible, fetch: fetchPreview, cardHeight: 200 });
  return preview && <LinkPreviewCard {...cardProps} title={preview.title} excerpt={preview.excerpt} bannerImage={preview.bannerImage} />;
}
  • SidebarProvider / useSidebar hold the rail's collapse state, remembered across loads and defaulted from the viewport only when the reader never chose. Pair it with sidebarBootScript (wiki-formant/sidebar) in <head>, or a remembered-closed rail paints open first.

  • RailShell renders the rail's landmark, scroll wrapper and collapse states, and closes a mobile drawer on a followed link; mark the active link with isRailLinkActive. With RailShell prefix="wiki-sidebar" in a .wiki-layout beside .wiki-layout__main, base.css lays it out: a sticky column from 1024px, a drawer above the article below it. The site supplies --rail-top, --rail-border and the surface.

  • The breakpoint is known twice, in matchMedia and in CSS. base.css sets --rail-floating: 1 inside its media query and the hook warns in development when the two disagree; a rail laid out by its own CSS sets the property the same way. railBreakpointMismatch and readRailFloating (wiki-formant/sidebar) are the mechanism.

  • TableOfContents is the "on this page" list with scroll-spy.

  • useLinkPreview is the hover card: one delegated listener, an intent delay, a grace period into the card, viewport clamping and a per-href cache. LinkPreviewCard is its markup (link-preview-card, -banner, -body, -title, -excerpt); a caller that must move the card passes its own style.

  • useTypeahead is the search field's state machine: a request-id guard, keyboard navigation, abort and a per-mount cache. fetch must be referentially stable. Its combobox(listKey) hands out inputProps, listProps and optionProps(i), with aria-activedescendant omitted rather than emptied; listKey keeps two rendered lists' ids apart. The arithmetic is wiki-formant/combobox, which imports nothing.

  • useSnapRow is a carousel as a scroll-snapped row: step, go(i) and at, no markup.

  • useClickOutside ignores a container hidden at the current breakpoint (offsetParent).

  • useUnsavedChanges(dirty) asks before leaving and returns confirmLeave() for a client-side navigation; useSaveShortcut(save) binds ⌘S / Ctrl+S; useSearchShortcut(open) is "/" to search, with focusShown(input).

  • SortHeader renders useTableSort's headerProps(key) as a <th aria-sort> holding a <button>, the markup sortTables writes.

  • useBlockOperations(blocks, setBlocks, { duplicate }) and BlockActions are a block list's five edits and their buttons; useRevisionRestore(restore, { onRestored }) confirms and restores, restoreViaPost(endpoint) being the usual restore.

  • useArticlePasses(ref, [blocks]) runs wiki-formant/dom's passes and renderMath (KaTeX, an optional peer, loaded only when a page has $$…$$, \[…\] or \(…\)).

  • <Beacon /> sends page views and outbound clicks to wiki-formant/analytics.

useClickOutside, useSnapRow and the editor hooks ship no class names.

Server components

wiki-formant/react-server: the same React without the directive, for parts that are pure functions of their props. Each takes the router's link component, since a bare <a> turns every press into a full load.

import Link from 'next/link';
import { Breadcrumbs, FacetBar, FacetSummary, InfoboxFacts, JsonLd, Pager } from 'wiki-formant/react-server';

<Breadcrumbs link={Link} items={tree.crumbs(path)} base={SITE_URL} />
<FacetBar link={Link} facets={groups} letters={letters} alphaFirst />
<Pager href={p => `?page=${p}`} page={page} total={totalPages} noun="Page" />
  • FacetBar renders facetControls and alphaControls; letterRow, letterControl, text and letterCounts="title" separate the A–Z row and let a label truncate while its count stays.

  • Breadcrumbs renders the trail and its BreadcrumbList JSON-LD together; base makes the URLs absolute.

  • JsonLd escapes every <, so an authored </script> cannot close the tag.

  • Pager steps by links (href) or buttons (onPage, wrap); a step with nowhere to go stays in place, inert. SnapDots are a snap row's dots.

  • FacetSummary is the count and Clear line under the facets; RelatedPages the see-also aside; InfoboxAside the facts landmark with its series line; InfoboxFacts renders metadataRows with formatFactValue as its default cell.

  • RevisionChanges renders withChanges rows; MaintenanceQueues renders maintenanceQueues; Cite is an article's citation in a <details>.

Block trees

wiki-formant/blocks: the two core containers (columns[i].blocks, an infobox's blocks), the walks over them, and the core leaves' text, markdown and defaults.

import { coreBlockShape, mapBlockTree, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';

export const BLOCK_SHAPE = coreBlockShape<Block>();
mapBlockTree(blocks, processLeaf, BLOCK_SHAPE);

switch (block.type) {
  case 'content': case 'codeTabs': case 'banner': case 'references':
  case 'stats': case 'linkGrid': case 'recentPages': case 'pageList':
    return coreAtomicToMarkdown(block, { siteUrl: SITE_URL, referencesTitle: 'Sources' });
  case 'corpusStats':
    return block.resolvedHtml ? htmlToMarkdown(block.resolvedHtml) : '';
}
  • The dispatch over a repo's own union stays in that repo, so a new type is a compile error there; the core bodies come from here (coreAtomicText, coreAtomicToMarkdown, coreBlockDefaults).

  • The core leaves are content, codeTabs, banner, references, stats, linkGrid, testimonial, events, locations, recentPages, pageList and list. CoreLeafBlock is their union (less the list and containers) and CoreLeafType its types; a wiki's atomic union is CoreLeafBlock | ListBlock | its native types, and a leaf added here fails to compile in every wiki until wired.

  • coreAtomicToMarkdown makes a resolved page's href absolute with siteUrl, since a twin is read off-site.

  • A codeTabs tab's code is source text: escaped by its view, fenced verbatim by the twin. Pass highlighted to the view for server-highlighted markup. CODE_LANGS and DEFAULT_LANG are the language list.

  • recentPages, pageList and list resolve to ResolvedPageRefs built with pageRef(page, now).

  • ListBlock is the pages under a section (source: 'under'), the newest ('newest', count) or chosen ones ('chosen', pages), shown as links or cards.

  • An events item is { id, date, end?, text }, dated to the year, month or day (EVENT_DATE); splitEvents, eventWhen, eventBegun and eventEnded split upcoming from past by a day, which the browser re-splits by the visitor's own.

  • A testimonial is { quote, author, role?, avatarUrl? }. A locations item carries an address, hours rows of { days, opens, closes } and a timeZone.

Validation

wiki-formant/validation: the block model in one declaration, and the checks under it.

import { defineBlocks, describeBlockIssues } from 'wiki-formant/validation';
import { coreBlockDefaults } from 'wiki-formant/blocks';

const model = defineBlocks<Block>({
  defaults: { ...coreBlockDefaults(), corpusStats: () => ({ type: 'corpusStats', view: 'sourceTiers' }) },
  checks: { corpusStats: b => isCorpusView(b.view), list: (b, core) => core(b) && isSection(b.under) },
});
export const { shape: BLOCK_SHAPE, createBlock, duplicateBlock, validateBlocks, blockIssues } = model;

const issues = blockIssues(body.content);
if (issues.length) return errors.badRequest(`Invalid content: ${describeBlockIssues(issues)}`);
// Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
  • A check for a core type narrows the package's (core is passed in). coreAtomicValidator and createBlockValidator are the parts.

  • MAX_BLOCK_ROWS caps recentPages.limit and pageIds, since both end in a database take:; blockLimit is the read side.

  • safeLinkHref and okUrl are the link checks the views rely on.

Block views

wiki-formant/block-views: the views of the core leaves, as client components that render server-side.

import { CodeTabsView, ColumnsView, LinkGridView, PageRefsView, StatsView, EventsView, LocationsView, BannerView } from 'wiki-formant/block-views';

case 'codeTabs': return <CodeTabsView tabs={block.tabs} />;
case 'columns':  return <ColumnsView columns={block.columns} gap={block.gap} render={renderBlock} />;
case 'pageList': return <PageRefsView refs={block.items} link={Link} renderItem={row} />;
  • ColumnsView states its gap and alignment as data attributes and an inactive code tab is hidden, so no view needs Tailwind to scan the package.

  • TestimonialView is figure.testimonial; pass photo for sized images.

  • EventsView sets upcoming soonest first and past latest first, with "Now on" beside one begun; day fills the server's split.

  • LocationsView draws each place's address, week (PlaceHours), note and map; the browser adds whether it is open by the place's clock, once a minute. wiki-formant/hours holds those rules (hoursRows, openState, clock, hoursLd, mapHref, mapEmbed) for a twin or structured data; a closing time at or before the opening one is the next morning, and DEFAULT_ZONE is Europe/London.

  • BannerView shows BANNER_MESSAGES (wiki-formant/text) unless message overrides it.

Text

wiki-formant/text: a block tree as plain prose, for an LLM export or a description.

import { openingText, plainText, stripHtml } from 'wiki-formant/text';
import { describe } from 'wiki-formant/metadata';

const description = describe(page.metadata?.excerpt || openingText(page.content));

openingText is the first top-level content block with prose once headings are out, so a page opening on an infobox is not described by its table. matchSnippet is a search hit's line.

Page metadata

wiki-formant/metadata: a page's canonical, markdown alternate, Open Graph and Twitter card from one input, and the schema.org nodes beside them.

export const generateMetadata = () => ({
  title,
  ...pageMetadata({ title, description, url, type: 'article', image: ogImageUrl(title), siteName, handle, markdownTwin: true }),
});
<JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: SITE.publisher, citation: citationsFromReferences(refs) })} />
  • Next replaces openGraph and twitter per segment rather than merging, so pageMetadata sets both; sitePageMetadata({ siteName, handle }) binds the fields every page's card needs.

  • articleLd requires image. collectionLd is an index over an ItemList. siteGraphLd is the Organization, WebSite and WebAPI @graph for the root layout, and siteRefs(url) hands out the same @ids for a page's publisher and isPartOf.

  • questionPairs(html) reads a FAQ from headings ending in ?; faqPageLd(pairs, url) is null under two pairs.

  • describe(text) is a meta description from any prose within DESCRIPTION_LIMIT (160): whole sentences where they fill half, else cut at a word with an ellipsis.

Social cards

wiki-formant/og: the /og card, as the element next/og's ImageResponse takes.

import { SocialCard, bannerField, bubbles, cardRandom, OG_SIZE, OG_HEADERS } from 'wiki-formant/og';

const field = (await bannerField(searchParams.get('banner'), /\.public\.blob\.vercel-storage\.com$/))
  ?? bubbles(cardRandom(title), { field: [BG, NAVY], colors: [GREEN, ORANGE] });
return new ImageResponse(<SocialCard {...{ title, eyebrow, description }} {...field} mark="example.org" colors={colors} fontFamily="Inter" />, { ...OG_SIZE, headers: OG_HEADERS, fonts });
  • A page's banner is its card; the generated field is for a page without one. bannerField fetches only from the hosts named, crops, inlines PNG and turns the scrim on, or returns null. It loads sharp.

  • Fonts stay with the site: Next traces a font file only from a literal path in the app, and satori reads TTF or OTF.

  • wiki-formant/seeded's hashStr and seededRandom are the deterministic randomness the art draws from, identical on server and browser.

Revisions and history

wiki-formant/revisions: what changed between two versions of a page, and which bump to ask for. wiki-formant/history: what each revision changed, as a history view shows it.

import { computeRevisionDiff } from 'wiki-formant/revisions';
import { withChanges } from 'wiki-formant/history';

const diff = computeRevisionDiff({ currentVersion: page.version, oldContent, newContent, oldTitle, newTitle });
const rows = withChanges(revisionsNewestFirst);   // each row needs its `content`; it leaves without it
<RevisionChanges changes={rows[0].changes} label={type => BLOCK_LABELS[type]} />
  • Blocks match by id, so a moved block is one moved. Container keys are walked as their own entries, never diffed as attributes. A path like root.1.columns.0.blocks.2 is part of the contract; pass containers beyond the core two.

  • withChanges diffs on the server, by words, leaves only. The oldest row is diffed against an empty page, so fetch one more than you show.

Feeds and sitemaps

import { feedResponse } from 'wiki-formant/feed';
import { treeSitemap, tagPrefixes } from 'wiki-formant/sitemap';

export const GET = async (request: Request) => feedResponse(request, channel, await items());

...treeSitemap(pages, {
  sections: tagPrefixes(pages.map(p => p.tagPath)),
  sectionRow: path => ({ url: `${SITE_URL}/wiki/${path}`, changeFrequency: 'weekly', priority: 0.6 }),
  pageRow: p => ({ url: pageUrl(p), changeFrequency: 'weekly', priority: 0.7 }),
}),
  • feedResponse answers a conditional GET; lastBuildDate is the newest item's date, and an empty feed carries none.

  • A section is dated by the newest page at or beneath it, or not at all. Every URL appears once: an empty-slug page dates its section and gets no row.

Licences and the X account

import { ccBy40, licenseBlock, openApiLicense } from 'wiki-formant/license';
import { xAccount, followLines, followNote, followLinkProps } from 'wiki-formant/social';

const license = ccBy40({ siteName: 'AcuiQ', siteUrl: SITE_URL });
const block = licenseBlock({ license, scope: 'The protocol compilation and prose', excludes });
export const X = xAccount({ siteName: 'AcuiQ', handle: '@acu_iq', posts: "a named trial's prescription with its citation" });
  • One License goes to licenseBlock, agentCard({ license, licenseScope }) and openApiLicense for info.license.

  • X.url is the sameAs, X.handle the card's, followLines(X) goes into llms.txt and AGENTS.md, followNote(X) into the MCP instructions, and <a {...followLinkProps(X)}> is the footer link with rel="me".

Agent descriptors

wiki-formant/well-known: the A2A agent card, the MCP manifest and server card, and the registry's domain proof.

import { agentCard, mcpManifest, skillsFromTools, wellKnownHandler, serverCardHandler } from 'wiki-formant/well-known';

const AGENT_CARD = agentCard({ name, description, url: `${SITE_URL}/api/mcp`, version, skills: skillsFromTools(TOOLS), license });
// src/app/.well-known/[doc]/route.ts
export const GET = wellKnownHandler({ agentCard: AGENT_CARD, mcpManifest: mcpManifest({ … }), openapi: SPEC });

The card is served identically at agent-card.json and agent.json, and its url is the MCP endpoint, which answers A2A. registryAuthHandler serves MCP_REGISTRY_PUBLIC_KEY at /.well-known/mcp-registry-auth, 404 when unset.

AI crawlers

wiki-formant/crawlers: one roster of AI crawlers, for the proxy that counts them and the robots.txt that addresses them.

import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';

trackAiBot(request, event, () => import('@/lib/track'));   // in proxy.ts
const rules = crawlerRules({ allow: '/', disallow, aiAllow });
  • Every agent group allows AGENT_SURFACE_PATHS (/api/mcp, /llms.txt, /openapi.json, /.well-known/); aiAllow adds the origin's own.

  • Search engines get the HTML site and no twins (/*.md$, plus searchDisallow). The ?_rsc= prefetch payload is refused in every group.

  • trackAiBot counts a rostered crawler anywhere, and anything but a search engine asking for what only agents read. load is a dynamic import so the proxy does not load a database client.

Rendered-article passes

wiki-formant/dom: what runs against an article element once it is in the document.

import { addCopyButtons, sortTables, activateTabGroups, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbed } from 'wiki-formant/dom';

addCopyButtons(el);
sortTables(el);
activateTabGroups(el);
hydrateTweetEmbeds(el);
const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
  • Each pass is idempotent.

  • sortTables sorts a column as dates, numbers or text (one stray value makes it text), restores the author's order on a third press, and leaves label/value and merged-cell tables alone.

  • activateTabGroups makes stored [data-tabs] markup a working tab group; without script every tab shows in order.

  • onTweetResize believes only the embed host and hands over the posting window, so sizeTweetEmbed sizes that iframe alone.

  • scrollTables(html) (wiki-formant/headings) wraps each table in a .table-scroll box on the string, before layout.

  • track(name, props) sends a browser event to the analytics beacon.

wiki-formant/search: what a literal tier and a full-text tier must agree on: proseSql, HEADLINE_OPTIONS, FTS_RANK_NORMALIZATION, and searchTsvDdl(table), the statements that drop and re-add the generated search_tsv column and its index in one transaction.

const { column, index } = searchTsvDdl('"WikiPage"');

Dates, freshness and the maintenance queues

wiki-formant/freshness and wiki-formant/queues.

import { relativeTime, formatDay, freshnessBanner } from 'wiki-formant/freshness';
import { maintenanceQueues } from 'wiki-formant/queues';

relativeTime(then, now, { style: 'short' });        // '3h ago'; `now` is always passed in
formatDay(day, { locale: 'en-GB', month: 'long' }); // '19 September 2026', always UTC
const banner = freshnessBanner(page, Date.now(), { against: 'current sources and the live ledger' });
const queues = maintenanceQueues(pages, { href, required, nowMs, verify, listedIds });
  • freshnessBanner is the outdated banner once a page goes unchecked for maxAgeDays (180), or null.

  • maintenanceQueues returns the outdated, orphaned, unsourced and missing-facts queues, empty ones dropped. Pass every page but the home page and the back office. MaintenanceQueues draws them on a noindex page.

  • citation({ publisher, title, date, url }) (wiki-formant/react-server) is the line Cite shows.

Sanitising stored HTML

wiki-formant/sanitize: the allowlist between saved HTML and a reader's browser. Run it server-side on the render path.

import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';

const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS });   // the same hosts in the CSP's frame-src
const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
  • The default list is what wiki-formant/tiptap's nodes store plus a presentational SVG subset. Extend tags, attributes, classes and schemesByTag from your stored HTML, never from memory.

  • A YouTube iframe comes out as the YouTube node's markup, whoever wrote it, and base.css draws it as a 16:9 player.

  • Classes pass by name only, since a site's own utilities can draw over the chrome.

  • sanitizeLine(html, { links }) cleans what a WikiLine stores: its marks and wikiTextStyle's spans. sanitizeCoreLeaf runs it over the core leaves' one-line fields (a figure, a quote and who said it, the events' and places' headings, a place's name), which block-views draws as HTML.

Image uploads

wiki-formant/upload: the route an editor's image upload posts to.

// src/app/api/upload/route.ts
export const runtime = 'nodejs';
export const POST = uploadHandler({
  authorize: async () => (await currentUser())?.id ?? errors.unauthorized('Sign in to upload.'),
  prefix: 'wiki/',
});
  • The file's own bytes say what it is; SVG is refused. Every image is re-encoded upright, so no source metadata (GPS included) is kept. Defaults: 4 MB, 40 megapixels.

  • Each uploader has a budget on authorize's id and address, and each address its own.

  • encode, maxBytes, maxPixels, rateLimit and store (Vercel Blob by default) are options. normalizeImage(bytes, opts) is the sniff, cap and re-encode without the route.

Maps

wiki-formant/maps: a pasted Google or Apple Maps URL as one an <iframe> accepts.

import { toMapEmbedUrl, resolveMapHandler } from 'wiki-formant/maps';

toMapEmbedUrl('https://maps.apple.com/?ll=51.5,-0.12');
export const GET = resolveMapHandler({ authorize: async () => !!(await currentUser()) });   // /api/resolve-map

A short link resolves through the wiki's own route (resolveMapUrl is the client half): exact shortener hosts, one hop, an allowlisted landing host, a timeout and your sign-in check.

Editor nodes

wiki-formant/tiptap: the custom nodes: Iframe, YouTube (with its paste rule), TwitterEmbed, createMapEmbed, createCodeBlock, createTabs and createHeadingIds.

import { embedNodes, createCodeBlock, createTabs, createHeadingIds } from 'wiki-formant/tiptap';

const { TabGroup, TabItem } = createTabs();
const nodes = [...embedNodes({ resolveMapUrl }), createCodeBlock({ classNames, icons }), TabGroup, TabItem, createHeadingIds({ slug })];
  • embedNodes turns a pasted YouTube, X or map link into its player. Iframe and the map node draw only an https src on DEFAULT_IFRAME_HOSTS (embedSrc), since the editor draws stored HTML where its author is signed in.

  • The node views draw in default classes base.css styles; classNames and icons replace them.

  • createTabs returns both nodes, since one is unsatisfiable without the other. A short map link inserts at once and swaps its src when it resolves.

  • createHeadingIds gives each heading the id its published copy will carry.

Editor

wiki-formant/editor: the rich-text editor's engine, minus its chrome.

import { useWikiEditor, useUrlPrompt, WikiToolbar, UrlPromptField, WikiLine, uploadImageTo } from 'wiki-formant/editor';

const url = useUrlPrompt(editor, { resolveMapUrl });
<WikiToolbar editor={editor} prompt={url} onUpload={triggerUpload}
  icons={{ ...ICONS, unlink: <Unlink />, upload: <Upload />, embed: <Globe /> }}
  classNames={{ toolbar: 'toolbar', button: 'toolbar-btn', pressed: 'toolbar-btn-on', divider: 'toolbar-divider' }} />
<UrlPromptField prompt={url} classNames={{ row: 'row', input: 'input', apply: 'btn', cancel: 'btn-quiet' }} />
<WikiLine as="h1" value={title} onChange={setTitle} placeholder="The page's title" />
  • WikiToolbar shows each command the editor's extensions support (supports) and nothing else, in fixed groups, with the table controls while the cursor is in a table.

  • useUrlPrompt is the link and embed field: it opens on the current link, unlinks on an emptied one, and closes when its editor changes.

  • WikiLine is a one-line field with its own marks, undo and onFocus, so one toolbar can drive the field last touched. Its style mark is wikiTextStyle: a font, colour and size on a run of words, which an article editor takes through nodes and an app's own controls set with setTextStyle.

  • A link is stored as its href and the link class only; whether it is external is decided at render by articleHtml's links pass. uploadImageTo('/api/upload') is the uploadImage an editor passes.

Block editor

wiki-formant/block-editor: the block list an author edits and the fields of the core types. The look arrives as a kit.

const KIT: BlockEditorKit = {
  classNames: { action: 'icon-btn', input: 'input', row: 'row', stack: 'stack-sm' },
  icons: { add: <Plus />, up: <ChevronUp />, down: <ChevronDown />, duplicate: <Copy />, remove: <Trash2 /> },
};
const SCHEMA: BlockSchema<Block> = {
  meta: type => BLOCK_META[type], create: createBlock, duplicate: duplicateBlock,
  types: PAGE_TYPES, nestedTypes: ATOMIC_TYPES, single: ['infobox'],
  renderBody: (block, onUpdate) => {
    switch (block.type) {
      case 'codeTabs': return <CodeTabsFields block={block} onUpdate={onUpdate} />;
      case 'columns': return <ColumnsFields block={block} onUpdate={onUpdate} />;
      case 'daoTimeline': return <DaoTimelineEdit block={block} onUpdate={onUpdate} />;
    }
  },
};
<BlockEditor blocks={blocks} onChange={setBlocks} schema={SCHEMA} kit={KIT} />
  • Each block is a card with a toolbar (name, add below, move, duplicate, delete); an add menu of more than eight types opens on a filter box. An option's group heads it.

  • The core fields are CodeTabsFields, StatsFields, TestimonialFields, EventsFields, LocationsFields, ReferencesFields, LinkGridFields, BannerFields, PageListFields, ListFields, ColumnsFields and InfoboxFields. Field, Choice and LimitField are for a wiki's own types.

  • A type the build does not know is shown by its raw type and carried through untouched.

  • Keep the kit and schema stable (a module constant or useMemo): both reach every card through context.

  • An editor that draws blocks in place hosts the fields in <BlockFieldsKit kit={KIT}> with <BlockToolbar> and <AddBlockMenu>.

Analytics

wiki-formant/analytics: page views and events in the site's own Postgres. No cookie and no stored address: a visitor is a hash of address and browser under a salt replaced every UTC day. Rows are kept 400 days. The tables ship in prisma/analytics.prisma; ViewDay holds days a site counted elsewhere first.

// src/lib/track.ts
const sql: Sql = (query, ...values) => prisma.$queryRawUnsafe(query, ...values);
export const { trackEvent, trackMcpCall, trackFeedback, trackSearch, viewRoute } = createTracker({ sql, defer: after, siteUrl: SITE_URL });

// src/app/api/view/route.ts
export const POST = viewRoute();
  • createTracker records only on a Vercel production deployment unless enabled says otherwise. trackMcpCall fits McpServerConfig.onCall; trackSearch normalises a query and drops an empty one; recordEvent is the undeferred write.

  • A proxy reaches the tracker through a dynamic import() inside event.waitUntil, since it imports the database client.

  • digest(sql, { days }) is the site as one JSON object: visitors, agents, views, bounce rate, visit length, sources, pages, countries, devices, by day, agents by name, MCP calls and searches that found nothing. A visit is a run of views with no gap over 30 minutes; a bounce is a one-view visit with no event of the reader's. ownAgents leaves the site's own out. A multi-tenant app passes site.

  • A browser under automation (navigator.webdriver) sends no beacon.

Passkeys and the stats page

wiki-formant/passkey: the door to a site's own admin pages, a passkey and nothing else. wiki-formant/stats: the stats page behind it.

// src/lib/admin.ts
export const gate = createPasskeyGate({ sql, rpName: 'Caper' });

// src/app/api/passkey/route.ts
export const POST = gate.route;
export const DELETE = gate.signOut;

// src/app/stats/page.tsx
const q = statsQuery(params);
return (
  <StatsGate gate={gate} session={(await cookies()).get(gate.cookie)?.value} invite={params.invite} host="caper.network">
    {async () => <Stats digest={await digest(sql, q)} {...q} link={Link} />}
  </StatsGate>
);
  • There is no sign-up: every passkey is saved from a single-use link npx passkey-invite prints. A passkey is bound to its host. Deleting a Passkey row signs out every session it opened. Tokens are kept only as hashes (prisma/passkey.prisma).

  • gate.signedIn(cookie) is a page's check and gate.allows(request) a write route's.

  • statsQuery reads ?days= (1, 7, 30, 90, 365 or all), ?agents=apart and the narrowings ?page=, ?entry=, ?source=, ?country=, ?device=, which stack and recount every figure from the visits that pass.

  • Each headline figure shows its change from the window before (.stats-delta[data-better]) and charts on press. A narrowed page over ViewDay totals starts at counted_from and says so.

ROLA sign-in

wiki-formant/rola: a Radix wallet sign-in up to the token: a single-use challenge, the wallet's proof, the HS256 session, and the two MCP tools that sign an agent in. How a site keeps the session stays with it.

import { createRola, sessionSecret, signSession, verifySession, bearerToken, rolaLoginTools } from 'wiki-formant/rola';

const rola = createRola({ expectedOrigin, dAppDefinitionAddress, networkId, applicationName, challenges: prisma.challenge });
const v = await rola.verifySignedChallenge(signed);        // { isValid, error? }
const secret = sessionSecret(process.env.JWT_SECRET);      // throws under 32 characters
const token = await signSession({ id: signed.address }, secret, 7 * 86400);
const claims = await verifySession<{ id: string }>(bearerToken(request.headers) ?? cookie, secret);

const tools = rolaLoginTools({
  site: 'the wiki', dAppDefinitionAddress, origin: SITE_URL, tokenTtlSec: 7 * 86400, writeTools: 'create_page / edit_page',
  generateChallenge: rola.generateChallenge,
  login: async signed => issueToken(signed),   // { token, expiresAt, ...shown }; throw McpToolError to refuse
  notes: { sign: { address: 'your virtual account; its key must be an on-ledger owner_keys entry' } },
});
  • A challenge is spent by one conditional delete whatever the outcome, so one signature cannot pass twice; making one purges the expired. Only an account proof signs in. Verification pins HS256.

  • rolaLoginTools gives get_challenge (the exact message to sign, naming the dApp definition address and the origin rolaOriginCheck looks for) and login, whose refusal says to fetch a new challenge. notes.challenge, notes.sign and notes.address add a surface's own lines.

Charts

wiki-formant/chart: TimeChart, every time-series chart: one area series in the box's color, range buttons (24H to All) and day, week and month steps.

<TimeChart series={days} label="Transactions" aggregate="sum" fromZero
  markers={[{ time: haltAt, label: 'Network halt' }]} step="month" onView={v => keep(v)} />
<TimeChart series={load} label="Price" format={formatPrice} range="30d" candles />
  • series is the points or a loader asked for 'hour' (24H), '4h' (7D) or 'day', once each.

  • The default step is the finest that makes 200 points or fewer; no step is finer than the points. aggregate is last for a price, sum for a count, mean for a rate. A stepped range starts on a whole week or month.

  • Points with open, high and low get a candles switch; with volume, a volume pane. A zero-volume point leaves its slot empty.

  • format gets the tick spacing too; stepDecimals and formatChartValue keep ticks at one precision. markers are tooltips on the last point at or before each event.

  • chartSteps is exported for a chart drawn elsewhere.

Base stylesheet

wiki-formant/base.css: the layout the package's markup needs and the package's components drawn on eleven --ui-* tokens (--ui-surface, --ui-surface-2, --ui-border, --ui-radius, --ui-pad, --ui-small, --ui-muted, --ui-accent, --ui-on-accent, --ui-up, --ui-down) a site maps from its palette in :root. No colour and no scale, so a design system's rules override it at equal specificity.

@import "wiki-formant/base.css" layer(components);

It declares the page frame (--content-width 72rem, --sidebar-width 18rem, --page-pad 16/24/32px) in the components layer, so a site never restates them in @theme; the primitives .spinner, .skeleton, .empty-state; and the reduced-motion guard. The split of a page beside its infobox and the rail's surface stay the site's.

Database and scripts

wiki-formant/db: pgPoolConfig(url), ten clients on the 6543 transaction pooler and three on the 5432 session pooler, and prismaConfig(root, schema?), which prefers DIRECT_URL, since the Prisma CLI hangs on the transaction pooler.

// prisma.config.ts
import { defineConfig } from 'prisma/config';
import { prismaConfig } from 'wiki-formant/db';
export default defineConfig(prismaConfig(import.meta.dirname));

wiki-formant/scripts: what a seed or sweep script shares: withClient (a pg client that rolls back on a throw and expires the cache tag after a write), readPage, writeRevision (refused if the page moved on), upsertWikiPage, the block builders content, banner and infobox, and assertLinkShapes.

import { withClient, readPage, writeRevision } from 'wiki-formant/scripts';
await withClient(async client => {
  const page = await readPage(client, 'contents/tech', 'radix-engine');
  await writeRevision(client, page!, blocks, { change: 'minor', message: 'Why, with the source.' });
}, { tag: 'wiki' });

wiki-formant/link-check: auditLinks, each link once with the pages that carry it, probed with probeExternal or the checker's own, and printLinkAudit.

wiki-formant/knip: the shared knip configuration, spread into each app's knip.ts.

Bins

Run each from the app's root, where .env holds DATABASE_URL.

npx check-classes [--warn] [--compositions]   # a className token no stylesheet defines; 3+ inline utilities
npx check-pages https://radix.wiki            # the served HTML of every sitemap page
npx analytics-digest --days 7 [--gap "Symptom Miss:term"]
npx passkey-invite https://caper.network/stats [--days n]
npx prisma-models [analytics|passkey|wiki] [--check] [--in prisma/schema.prisma --tenant]
npx fts-ddl '"WikiPage"'                      # rebuild search_tsv and its index over DIRECT_URL
npx indexnow https://radix.wiki [--hours 6 | --all]
npx mark-verified <tagPath> <slug> | --all <tagPath> [--invalidate wiki]
npx maintenance-log read|write|compact|backlog-add --at <path> --routine <name> [--author <id>]
  • prisma-models copies the shipped models into prisma/schema/ (Prisma reads a folder of files; a generator output gains a ../), and --check fails on a missing or drifted copy. --in compares models in one file, and --tenant lets siteId drop its default and gain a relation.

  • fts-ddl runs before the column is declared in schema.prisma; a PascalCase table goes in quoted.

  • indexnow posts the sitemap rows modified in the window to IndexNow, after checking the origin serves the key file in public/. The sitemap must render per request.

  • mark-verified stamps lastVerifiedAt so freshnessBanner stops calling a page outdated.

  • maintenance-log is the state of a sweep routine kept in one page's metadata.state: every mode holds the row lock from read to write, backlog-add files an item another routine raised and a stale write merges it back, and a write whose totalRuns is below the stored one is refused. --site names the wiki in a multi-tenant app.

A reusable workflow

.github/workflows/publish-mcp-registry.yml republishes an origin's server to the MCP registry when its server.json version moves.

on:
  push: { branches: [main], paths: ['server.json'] }
  workflow_dispatch:
jobs:
  publish:
    uses: tutmoses/wiki-formant/.github/workflows/publish-mcp-registry.yml@main
    with:
      domain: example.com
    secrets:
      MCP_REGISTRY_PRIVATE_KEY: ${{ secrets.MCP_REGISTRY_PRIVATE_KEY }}

Licence

MIT.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables MCP clients to connect over stateless Streamable HTTP and authenticate through an existing OAuth 2.1 / OpenID Connect authorization server, with JWT validation, per-tool scope enforcement, and step-up challenges. It ships example scoped tools for reading and creating per-user notes that developers replace with their own.
    MIT