wiki-formant
Provides optional integration for uploads via @vercel/blob, allowing the wiki server to store and retrieve blobs using Vercel Blob storage.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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-formantAn 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 409HttpError.The bump is the diff's: added, removed or moved blocks are
major, changed prose or titleminor;changeoverrides 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,recentandbyIdsdropcontent.Search is four tiers: title prefix, title, literal prose, then full text on
search_tsvwith Postgres' headline as the snippet. Hidden paths, and paths under them, are left out unless named.branchis an agent's walk of a path and everything under it;countsis pages per tag path.Copy the models with
npx prisma-models wiki(--tenantfor a tenant-keyed copy), then build the full-text column withnpx 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,mainArticleandhiddeninherit, 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.
crumbsmakes 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, itsid(what apageListblock takes), andcharswhere the text was read. A pasted URL resolves, withpathPrefixstripped; a miss names the nearest pages.get_pagewithblocks: truereturns the stored blocks,id, metadata andversion: what an edit starts from.forEditadds the app's own edit fields;authorsnames revision authors.textshould be the twin's serializer andresolvethe page's live resolver, so a page reads the same over MCP as at its twin.get_full_corpusis asizeOnlypreflight, then page-aligned slices undermaxChars(CORPUS_BUDGET) withtruncated,nextSkipandclippedPage.corpusreturns the app's ownCorpusSections.privateHiddencloses the back office to every tool.metadata,label,noMatchandskillsare 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 thresholdEach 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 inhref. The reset control leads the row, flaggedreset.alphaControlsis[]belowneedsAlphaIndex(40 pages unlessalphaIndexMinPages);resolveLetterdrops a letter no page starts with.metadataRowsreturns a page's populated keys as rows withhrefon the facet ones;rankRelatedranks 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 }]injectHeadingIdsis idempotent;slugis a parameter, since a heading id is a published URL.Pass one
usedset across a page's blocks, seeded with ids the template renders, or a repeated heading mints a duplicate id.articleHtmlis the render-path chain: heading ids,scrollTables, and withlinksthe link pass: an internal link losestargetandrel, 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. ThrowMcpToolError(message, details)from a handler for the same.Capabilities, and the
-32601method list, advertise only what the config populates.A notification-only POST is a bare
202; malformed JSON is-32700with a400;GETis an explicit 405 with CORS; the preflight allow-list andAccess-Control-Expose-Headerscover 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/protocolVersioninparams._metais served as2026-07-28(server/discover, per-request version checks,resultTypeand cache hints); everything else is the legacy era, which echoes a supported client version. Every response carriesMCP-Protocol-Version.The A2A call an agent card invites (
message/send,SendMessage) is answered from the toolanswernames.rateLimit: { capacity, refillPerSec }is enforced before the body is parsed, withRateLimit-*headers on every answer;mcpRateLimited(sec)is the JSON-RPC 429.Every object a handler returns comes back as
structuredContentbeside the text;outputSchemais the stronger contract when declared.inputSchema.requireOneOfnames a set of which one must be present.A handler's second argument is its context:
ctx.meta(the request'sparams._meta) andctx.setMeta(key, value)(ontoresult._meta).config.gateis envelope-level middleware, for a payment gate whose demand replaces the call.readArgsreads raw arguments as typed, clamped values and records every override;withAdjustmentsputs them on the result asadjustments.feedbackTool(record, noun)issend_feedback. Listed,initializeappendsFEEDBACK_INVITEand an emptylistEnvelopepage or anMcpToolErrorcarries 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 }.
payloadBudgetweighs every listed call and every read-only tool with no required arguments, and asserts a JSON answer carriesstructuredContent.conditionalGetCheckswants a 304 onIf-None-Match(alone and in a list) and aLast-Modified;descriptorCheckswants an ETag and amax-ageon a JSON descriptor.t.rpcAt(endpoint, …)andt.callAtdrive 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.jsonistools/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_pagewithblocks: truereturns blocks the site'svalidateaccepts, 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.
noindexis 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 forllms.txt, a search index and a sitemap.Next drops a rewrite destination's query string, so a twin rewrite carries the
.mdextension 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,notModifiedandtextHeadersare the parts;corpusRouteskips the build on a 304.A URL answering JSON or markdown by
AcceptaskswantsMarkdown(request)and sendsVARY_ACCEPTon both branches.descriptorResponseserves a JSON descriptor with its own ETag andAccess-Control-Allow-Origin: *(descriptorHandlerinwiki-formant/well-knownis the route).Every error body is
{ error, ...extra }. A thrownHttpErroranswers 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)statesRetry-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:
securityHeaderson every route,cspenforced in production only (Next's dev runtime is inline andeval); an origin with a per-request nonce passes none./openapi.jsonrewritten to/.well-known/openapi.jsonfirst inbeforeFiles, merged with the app's ownrewrites()in either form.AVIF before WebP,
turbopackFileSystemCacheForBuild: false(Vercel restores a stale build cache), anddistDirfromSTUDIO_DIST_DIRfor 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);listEnvelopeis the shape every list tool answers with, itsnoteon an empty page.rateLimitis an in-memory token bucket;blockMsblocks an abusive key outright across every bucket naming it.clientIpreadsx-real-ipfirst, then the leftmostx-forwarded-for.MCP_RATE_LIMITis 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/useSidebarhold the rail's collapse state, remembered across loads and defaulted from the viewport only when the reader never chose. Pair it withsidebarBootScript(wiki-formant/sidebar) in<head>, or a remembered-closed rail paints open first.RailShellrenders the rail's landmark, scroll wrapper and collapse states, and closes a mobile drawer on a followed link; mark the active link withisRailLinkActive. WithRailShell prefix="wiki-sidebar"in a.wiki-layoutbeside.wiki-layout__main,base.csslays it out: a sticky column from 1024px, a drawer above the article below it. The site supplies--rail-top,--rail-borderand the surface.The breakpoint is known twice, in
matchMediaand in CSS.base.csssets--rail-floating: 1inside 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.railBreakpointMismatchandreadRailFloating(wiki-formant/sidebar) are the mechanism.TableOfContentsis the "on this page" list with scroll-spy.useLinkPreviewis the hover card: one delegated listener, an intent delay, a grace period into the card, viewport clamping and a per-href cache.LinkPreviewCardis its markup (link-preview-card,-banner,-body,-title,-excerpt); a caller that must move the card passes its ownstyle.useTypeaheadis the search field's state machine: a request-id guard, keyboard navigation, abort and a per-mount cache.fetchmust be referentially stable. Itscombobox(listKey)hands outinputProps,listPropsandoptionProps(i), witharia-activedescendantomitted rather than emptied;listKeykeeps two rendered lists' ids apart. The arithmetic iswiki-formant/combobox, which imports nothing.useSnapRowis a carousel as a scroll-snapped row:step,go(i)andat, no markup.useClickOutsideignores a container hidden at the current breakpoint (offsetParent).useUnsavedChanges(dirty)asks before leaving and returnsconfirmLeave()for a client-side navigation;useSaveShortcut(save)binds ⌘S / Ctrl+S;useSearchShortcut(open)is "/" to search, withfocusShown(input).SortHeaderrendersuseTableSort'sheaderProps(key)as a<th aria-sort>holding a<button>, the markupsortTableswrites.useBlockOperations(blocks, setBlocks, { duplicate })andBlockActionsare a block list's five edits and their buttons;useRevisionRestore(restore, { onRestored })confirms and restores,restoreViaPost(endpoint)being the usualrestore.useArticlePasses(ref, [blocks])runswiki-formant/dom's passes andrenderMath(KaTeX, an optional peer, loaded only when a page has$$…$$,\[…\]or\(…\)).<Beacon />sends page views and outbound clicks towiki-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" />FacetBarrendersfacetControlsandalphaControls;letterRow,letterControl,textandletterCounts="title"separate the A–Z row and let a label truncate while its count stays.Breadcrumbsrenders the trail and itsBreadcrumbListJSON-LD together;basemakes the URLs absolute.JsonLdescapes every<, so an authored</script>cannot close the tag.Pagersteps by links (href) or buttons (onPage,wrap); a step with nowhere to go stays in place, inert.SnapDotsare a snap row's dots.FacetSummaryis the count and Clear line under the facets;RelatedPagesthe see-also aside;InfoboxAsidethe facts landmark with its series line;InfoboxFactsrendersmetadataRowswithformatFactValueas its default cell.RevisionChangesrenderswithChangesrows;MaintenanceQueuesrendersmaintenanceQueues;Citeis 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,pageListandlist.CoreLeafBlockis their union (less the list and containers) andCoreLeafTypeits types; a wiki's atomic union isCoreLeafBlock | ListBlock | its native types, and a leaf added here fails to compile in every wiki until wired.coreAtomicToMarkdownmakes a resolved page'shrefabsolute withsiteUrl, since a twin is read off-site.A
codeTabstab'scodeis source text: escaped by its view, fenced verbatim by the twin. Passhighlightedto the view for server-highlighted markup.CODE_LANGSandDEFAULT_LANGare the language list.recentPages,pageListandlistresolve toResolvedPageRefs built withpageRef(page, now).ListBlockis the pages under a section (source: 'under'), the newest ('newest',count) or chosen ones ('chosen',pages), shown as links or cards.An
eventsitem is{ id, date, end?, text }, dated to the year, month or day (EVENT_DATE);splitEvents,eventWhen,eventBegunandeventEndedsplit upcoming from past by aday, which the browser re-splits by the visitor's own.A
testimonialis{ quote, author, role?, avatarUrl? }. Alocationsitem carries an address,hoursrows of{ days, opens, closes }and atimeZone.
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 blockA check for a core type narrows the package's (
coreis passed in).coreAtomicValidatorandcreateBlockValidatorare the parts.MAX_BLOCK_ROWScapsrecentPages.limitandpageIds, since both end in a databasetake:;blockLimitis the read side.safeLinkHrefandokUrlare 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} />;ColumnsViewstates its gap and alignment as data attributes and an inactive code tab ishidden, so no view needs Tailwind to scan the package.TestimonialViewisfigure.testimonial; passphotofor sized images.EventsViewsets upcoming soonest first and past latest first, with "Now on" beside one begun;dayfills the server's split.LocationsViewdraws 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/hoursholds 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, andDEFAULT_ZONEisEurope/London.BannerViewshowsBANNER_MESSAGES(wiki-formant/text) unlessmessageoverrides 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
openGraphandtwitterper segment rather than merging, sopageMetadatasets both;sitePageMetadata({ siteName, handle })binds the fields every page's card needs.articleLdrequiresimage.collectionLdis an index over anItemList.siteGraphLdis the Organization, WebSite andWebAPI@graphfor the root layout, andsiteRefs(url)hands out the same@ids for a page'spublisherandisPartOf.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 withinDESCRIPTION_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.
bannerFieldfetches only from the hosts named, crops, inlines PNG and turns the scrim on, or returns null. It loadssharp.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'shashStrandseededRandomare 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 likeroot.1.columns.0.blocks.2is part of the contract; passcontainersbeyond the core two.withChangesdiffs 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 }),
}),feedResponseanswers a conditional GET;lastBuildDateis 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
Licensegoes tolicenseBlock,agentCard({ license, licenseScope })andopenApiLicenseforinfo.license.X.urlis thesameAs,X.handlethe 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 withrel="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/);aiAllowadds the origin's own.Search engines get the HTML site and no twins (
/*.md$, plussearchDisallow). The?_rsc=prefetch payload is refused in every group.trackAiBotcounts a rostered crawler anywhere, and anything but a search engine asking for what only agents read.loadis 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.
sortTablessorts 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.activateTabGroupsmakes stored[data-tabs]markup a working tab group; without script every tab shows in order.onTweetResizebelieves only the embed host and hands over the posting window, sosizeTweetEmbedsizes that iframe alone.scrollTables(html)(wiki-formant/headings) wraps each table in a.table-scrollbox on the string, before layout.track(name, props)sends a browser event to the analytics beacon.
Search
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 });freshnessBanneris theoutdatedbanner once a page goes unchecked formaxAgeDays(180), or null.maintenanceQueuesreturns the outdated, orphaned, unsourced and missing-facts queues, empty ones dropped. Pass every page but the home page and the back office.MaintenanceQueuesdraws them on anoindexpage.citation({ publisher, title, date, url })(wiki-formant/react-server) is the lineCiteshows.
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. Extendtags,attributes,classesandschemesByTagfrom your stored HTML, never from memory.A YouTube iframe comes out as the
YouTubenode's markup, whoever wrote it, andbase.cssdraws 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 aWikiLinestores: its marks andwikiTextStyle's spans.sanitizeCoreLeafruns 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), whichblock-viewsdraws 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,rateLimitandstore(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-mapA 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 })];embedNodesturns a pasted YouTube, X or map link into its player.Iframeand the map node draw only an https src onDEFAULT_IFRAME_HOSTS(embedSrc), since the editor draws stored HTML where its author is signed in.The node views draw in default classes
base.cssstyles;classNamesandiconsreplace them.createTabsreturns both nodes, since one is unsatisfiable without the other. A short map link inserts at once and swaps itssrcwhen it resolves.createHeadingIdsgives 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" />WikiToolbarshows 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.useUrlPromptis the link and embed field: it opens on the current link, unlinks on an emptied one, and closes when its editor changes.WikiLineis a one-line field with its ownmarks, undo andonFocus, so one toolbar can drive the field last touched. Itsstylemark iswikiTextStyle: a font, colour and size on a run of words, which an article editor takes throughnodesand an app's own controls set withsetTextStyle.A link is stored as its
hrefand thelinkclass only; whether it is external is decided at render byarticleHtml'slinkspass.uploadImageTo('/api/upload')is theuploadImagean 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
groupheads it.The core fields are
CodeTabsFields,StatsFields,TestimonialFields,EventsFields,LocationsFields,ReferencesFields,LinkGridFields,BannerFields,PageListFields,ListFields,ColumnsFieldsandInfoboxFields.Field,ChoiceandLimitFieldare 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();createTrackerrecords only on a Vercel production deployment unlessenabledsays otherwise.trackMcpCallfitsMcpServerConfig.onCall;trackSearchnormalises a query and drops an empty one;recordEventis the undeferred write.A proxy reaches the tracker through a dynamic
import()insideevent.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.ownAgentsleaves the site's own out. A multi-tenant app passessite.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-inviteprints. A passkey is bound to its host. Deleting aPasskeyrow signs out every session it opened. Tokens are kept only as hashes (prisma/passkey.prisma).gate.signedIn(cookie)is a page's check andgate.allows(request)a write route's.statsQueryreads?days=(1, 7, 30, 90, 365 or all),?agents=apartand 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 overViewDaytotals starts atcounted_fromand 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.
rolaLoginToolsgivesget_challenge(the exact message to sign, naming the dApp definition address and the originrolaOriginChecklooks for) andlogin, whose refusal says to fetch a new challenge.notes.challenge,notes.signandnotes.addressadd 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 />seriesis 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.
aggregateislastfor a price,sumfor a count,meanfor a rate. A stepped range starts on a whole week or month.Points with
open,highandlowget a candles switch; withvolume, a volume pane. A zero-volume point leaves its slot empty.formatgets the tick spacing too;stepDecimalsandformatChartValuekeep ticks at one precision.markersare tooltips on the last point at or before each event.chartStepsis 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-modelscopies the shipped models intoprisma/schema/(Prisma reads a folder of files; a generatoroutputgains a../), and--checkfails on a missing or drifted copy.--incompares models in one file, and--tenantletssiteIddrop its default and gain a relation.fts-ddlruns before the column is declared inschema.prisma; a PascalCase table goes in quoted.indexnowposts the sitemap rows modified in the window to IndexNow, after checking the origin serves the key file inpublic/. The sitemap must render per request.mark-verifiedstampslastVerifiedAtsofreshnessBannerstops calling a page outdated.maintenance-logis the state of a sweep routine kept in one page'smetadata.state: every mode holds the row lock from read to write,backlog-addfiles an item another routine raised and a stalewritemerges it back, and awritewhosetotalRunsis below the stored one is refused.--sitenames 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.
This server cannot be deployed
Maintenance
Related MCP Connectors
Remote MCP server exposing SMI Aware tools, resources, and skills over Streamable HTTP.
Query, browse, and automate OmegaAI workspaces from any MCP client. Streamable HTTP with OAuth 2.0.
Streamable HTTP MCP gateway for NV oOS fleets — one API key, namespaced tools, quotas and policy.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceExposes LLM Wiki desktop capabilities via Streamable HTTP transport for MCP clients, enabling project listing, file reading, search, and knowledge graph queries through a secure API.MIT
- AlicenseNot gradedqualityBmaintenanceEnables MCP clients to connect, list tools, and invoke the echo tool over HTTP.228 npmMIT
- AlicenseNot gradedqualityCmaintenanceEnables 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
- AlicenseNot gradedqualityAmaintenanceEnables LLM clients to read, search, create, edit, manage, and upload content across any MediaWiki wiki, including extension-backed querying and OAuth-authenticated writes.8 npmMIT