wiki-formant 0.60.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,12 +10,57 @@ npm install wiki-formant
10
10
 
11
11
  ## What this is, and what it deliberately is not
12
12
 
13
- Every wiki needs a page store, a design system and a set of block types. Those are the parts you must own — they encode your schema, your brand and your content model, and a package that tried to own them would fight you.
13
+ Every wiki needs a design system and a set of block types beyond the core. Those are the parts you must own: they encode your brand and your content model, and a package that tried to own them would fight you.
14
+
15
+ The page store is no longer one of them. Three wikis kept their own pages and revisions on three schemas that had drifted apart (cuid ids against int ids, a version column on one, no author on another, a tenant key on the third), and each wrote its page and its revision its own way. The tables are now the package's (`prisma/wiki.prisma`), and so is the SQL that reads and writes them (`wiki-formant/store`), so one wiki engine serves a standalone wiki and a multi-tenant host alike. The connection, the cache and the auth stay the app's.
14
16
 
15
17
  What every wiki *also* needs, rebuilds by hand, and then lets drift, is the layer above: the second browsing axis derived from metadata you are already storing, the HTML→markdown converter behind every `.md` twin, the JSON-RPC edges that decide whether an MCP client can talk to you at all, and the ETag arithmetic that turns a recrawl into a 304.
16
18
 
17
19
  This package was extracted from three production wikis that had each grown their own copy. By the time it was lifted, the copies had already diverged — one had lost the alphabetical index and the related-page ranking; one had `ToolAnnotations` the others lacked; one had MCP prompts the others lacked. Everything here is the union, with tests pinning the specific bugs that shipped.
18
20
 
21
+ ## Store
22
+
23
+ `wikiStore` reads and writes `WikiPage` and `WikiRevision` through one `Sql` function the app binds to its own client, scoped to one site.
24
+
25
+ ```ts
26
+ import { wikiStore } from 'wiki-formant/store';
27
+
28
+ const store = wikiStore({
29
+ sql: (query, ...values) => prisma.$queryRawUnsafe(query, ...values),
30
+ siteId: '', // a multi-tenant app passes the site's key
31
+ hidden: tree.hiddenPaths, // back office: never listed unasked
32
+ });
33
+
34
+ const page = await store.get('contents/tech', 'radix-engine');
35
+ const cards = await store.list('contents/tech', { sort: 'recent' });
36
+ const { items, total } = await store.search('validators', { take: 20 });
37
+ await store.save({ tagPath, slug, content, authorId, message, baseVersion: page.version });
38
+ ```
39
+
40
+ - **A save is one statement.** The page update and its revision are a data-modifying CTE guarded on the version the save read. A save that lost a race writes nothing and throws a 409 `HttpError`; pass `baseVersion` and a save opened on an old version is refused the same way, instead of erasing the edit in between.
41
+ - **The bump is the diff's.** Added, removed or moved blocks are `major`, changed prose or title `minor`; `change` overrides it for a script that knows better. A metadata-only save moves no version and writes no revision.
42
+ - **History is diffed on read.** A revision stores the page as it was saved, never a diff, so a script's revision and the editor's carry the same history. `history(pageId, { limit })` reads one row more than it returns, so the oldest entry shown is diffed against its real predecessor.
43
+ - **Listings carry a snippet, not the article.** `list`, `recent` and `byIds` drop `content` for one line of it; a category of 200 cards otherwise ships 200 block arrays.
44
+ - **Search is four tiers**: title prefix, title, literal prose, then full text on `search_tsv` with Postgres' own headline as the snippet. Hidden paths are left out unless the search names one.
45
+ - Ids and timestamps default in the database, so the store's SQL and a Prisma client write the same rows. Copy the models with `npx prisma-models wiki` (a tenant-keyed app checks its own copy with `--tenant`), then build the full-text column with `npx fts-ddl '"WikiPage"'`.
46
+
47
+ ## Tree
48
+
49
+ `createTree` is the category tree every wiki declares, and the walks each had written by hand.
50
+
51
+ ```ts
52
+ import { createTree, parseWikiPath } from 'wiki-formant/tree';
53
+
54
+ const tree = createTree(NODES, { globalKeys: [QUALITY] });
55
+ tree.resolve('contents/tech'); // { node, trail, valid, hidden, sort, mainArticle, metadataKeys }
56
+ parseWikiPath(tree, ['contents', 'tech', 'radix-engine', 'history']);
57
+ // { type: 'page', tagPath: 'contents/tech', slug: 'radix-engine', mode: 'history' }
58
+ ```
59
+
60
+ - Metadata keys accumulate down the trail; `sort`, `mainArticle` and `hidden` are inherited, the nearest declaration winning. A field only one wiki reads (an XRD threshold, a band) rides on the node, and the app folds it from `trail`.
61
+ - A path that is itself a category wins over a mode suffix, so a category named `history` stays reachable. The empty slug is a category's hub article, and `/<category>/edit` edits it.
62
+ - `crumbs` makes one crumb per URL segment, linked only where the tree knows the segment, so a trail can never be shorter than the address bar.
63
+
19
64
  ## Taxonomy
20
65
 
21
66
  `tagPath` puts a page in exactly one place in a tree. The `select`-typed metadata it already carries is a *cross-cutting* axis — and it is almost always stored, rendered once in an infobox as dead text, and never made pressable. The failure mode is a 141-page category rendered as one flat grid while the data to split it sits unread in a JSON column.
@@ -741,6 +786,10 @@ const SCHEMA: BlockSchema<Block> = {
741
786
 
742
787
  The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
743
788
 
789
+ An editor that draws each block in place, with a list of its own (miow's), hosts the same fields in `<BlockFieldsKit kit={KIT}>`, without `<BlockEditor>`. Only the two container fields need the full editor, since they hold a block list.
790
+
791
+ A banner with no text of its own shows `BANNER_MESSAGES` from `wiki-formant/text`, a sentence per variant that names no wiki; `BannerView` takes it unless `message` overrides it.
792
+
744
793
  The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
745
794
 
746
795
  ## Analytics
@@ -1037,17 +1086,16 @@ npx fts-ddl pages
1037
1086
  npx fts-ddl '"WikiPage"'
1038
1087
  ```
1039
1088
 
1040
- `mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
1089
+ `mark-verified` stamps `lastVerifiedAt` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Both work on the package's own `WikiPage` and `WikiRevision`. Run them from the app's root, like `passkey-invite`:
1041
1090
 
1042
1091
  ```sh
1043
1092
  npx mark-verified contents/tech/radix-engine scrypto # one page
1044
1093
  npx mark-verified --all ecosystem --invalidate wiki # a tag path, then the cache tag
1045
1094
  npx maintenance-log read --at contents/tech/operations/wiki-maintenance-log --routine wiki-sweep --author <id>
1046
- npx maintenance-log backlog-add '[{"item": "…"}]' --at operations/wiki-maintenance-log --routine caper-wiki-sweep \
1047
- --pages wiki_pages --revisions wiki_revisions --page-key wiki_page_id --scope caper_id
1095
+ npx maintenance-log backlog-add '[{"item": "…"}]' --at operations/wiki-maintenance-log --routine caper-wiki-sweep
1048
1096
  ```
1049
1097
 
1050
- `--scope column` keeps to rows where the column is NULL (`=value` for one value), `--table`/`--pages`/`--revisions` default to `pages` and `revisions`, and `--invalidate <tag>` runs `vercel cache invalidate` after a write.
1098
+ `--site <siteId>` names the wiki in a multi-tenant app (default `""`), and `--invalidate <tag>` runs `vercel cache invalidate` after a write.
1051
1099
 
1052
1100
  ## A reusable workflow
1053
1101
 
@@ -4,23 +4,21 @@
4
4
  // The routine's memory between runs lives in one wiki page's metadata.state,
5
5
  // and the page shows it, re-rendered and capped on every write (the rules are
6
6
  // `src/maintenance.ts`). radix-wiki and caper each carried this script with the
7
- // same rules against a different table, so the tables are arguments. Run it
8
- // from the app's root, where `pg` is installed and `.env` holds DATABASE_URL.
7
+ // same rules against a different table; the tables are now the package's own
8
+ // (`prisma/wiki.prisma`). Run it from the app's root, where `pg` is installed
9
+ // and `.env` holds DATABASE_URL.
9
10
  //
10
11
  // npx maintenance-log read # print the state as JSON
11
12
  // npx maintenance-log write '<json>' # replace it (or `write -` from stdin)
12
13
  // npx maintenance-log compact [--dry-run] # re-apply the caps to what is stored
13
14
  // npx maintenance-log backlog-add '<json>' # append items under a row lock
14
15
  //
15
- // with the page and its tables named every time:
16
+ // with the page named every time:
16
17
  //
17
18
  // --at <tagPath>/<slug> the log page, e.g. contents/tech/operations/wiki-maintenance-log
18
19
  // --routine <name> named on the page and in the revision message
19
- // --pages <table> default pages
20
- // --revisions <table> default revisions
21
- // --page-key <column> the revisions column naming the page, default page_id
22
- // --scope <column>[=<value>] only rows where the column is NULL, or equals the value
23
- // --author <id> revisions.author_id, default NULL
20
+ // --site <siteId> the wiki the page belongs to, default "" (a one-wiki app)
21
+ // --author <id> the revision's authorId, default NULL
24
22
  // --invalidate <tag> `vercel cache invalidate --tag` after a write
25
23
  //
26
24
  // `write` takes the whole new state: read, change, write back. A revision is
@@ -30,11 +28,9 @@
30
28
  // below the stored one: that state was read before another run wrote.
31
29
  // `backlog-add` is how another routine files an
32
30
  // item: `write` replaces the whole state, so an append through it could lose a
33
- // concurrent run's, and the row lock serialises the two. A revision's version
34
- // is the page's `version` column bumped a minor where the table has one, else
35
- // the latest revision's.
36
- import { randomUUID } from 'node:crypto';
37
- import { PINNED, addBacklog, compact, ident, render, scopeSql, staleWrite } from '../dist/maintenance.js';
31
+ // concurrent run's, and the row lock serialises the two. A revision bumps the
32
+ // page's version a minor.
33
+ import { PINNED, addBacklog, compact, render, staleWrite } from '../dist/maintenance.js';
38
34
  import { bump } from '../dist/versioning.js';
39
35
  import { arg, connect, invalidate } from './_cli.mjs';
40
36
 
@@ -46,15 +42,13 @@ const routine = arg('--routine');
46
42
  const slugAt = at.lastIndexOf('/');
47
43
 
48
44
  if (!['read', 'write', 'compact', 'backlog-add'].includes(mode) || slugAt < 1 || !routine) {
49
- console.error('usage: maintenance-log <read|write|compact|backlog-add> [json|-] --at <tagPath>/<slug> --routine <name> [--pages t] [--revisions t] [--page-key c] [--scope c[=v]] [--author id] [--invalidate tag] [--dry-run]');
45
+ console.error('usage: maintenance-log <read|write|compact|backlog-add> [json|-] --at <tagPath>/<slug> --routine <name> [--site id] [--author id] [--invalidate tag] [--dry-run]');
50
46
  process.exit(2);
51
47
  }
52
- const pages = ident(arg('--pages') ?? 'pages', '--pages');
53
- const revisions = ident(arg('--revisions') ?? 'revisions', '--revisions');
54
- const pageKey = ident(arg('--page-key') ?? 'page_id', '--page-key');
55
- const scope = scopeSql(arg('--scope'), 3);
56
- const where = `tag_path = $1 AND slug = $2 AND ${scope.sql}`;
57
- const address = [at.slice(0, slugAt), at.slice(slugAt + 1), ...scope.values];
48
+ const pages = '"WikiPage"';
49
+ const revisions = '"WikiRevision"';
50
+ const where = `"tagPath" = $1 AND slug = $2 AND "siteId" = $3`;
51
+ const address = [at.slice(0, slugAt), at.slice(slugAt + 1), arg('--site') ?? ''];
58
52
 
59
53
  const readStdin = async () => {
60
54
  let data = '';
@@ -90,7 +84,7 @@ try {
90
84
  for (const a of added) console.log(` ${a.item}`);
91
85
  if (dry) await q('ROLLBACK');
92
86
  else {
93
- await q(`UPDATE ${pages} SET content = $1, metadata = $2, updated_at = now() WHERE id = $3`, [
87
+ await q(`UPDATE ${pages} SET content = $1, metadata = $2, "updatedAt" = now() WHERE id = $3`, [
94
88
  JSON.stringify(render(next, routine)),
95
89
  JSON.stringify({ state: next, lastVerified: today }),
96
90
  page.id,
@@ -114,12 +108,11 @@ try {
114
108
  const content = render(state, routine);
115
109
 
116
110
  const [last] = await q(
117
- `SELECT version, created_at FROM ${revisions} WHERE ${pageKey} = $1 ORDER BY created_at DESC LIMIT 1`,
111
+ `SELECT "createdAt" FROM ${revisions} WHERE "pageId" = $1 ORDER BY "createdAt" DESC LIMIT 1`,
118
112
  [page.id],
119
113
  );
120
- const revise = mode === 'compact' || !last || last.created_at.toISOString().slice(0, 10) !== today;
121
- const was = 'version' in page ? page.version : last?.version;
122
- const version = revise ? bump(was, 'minor') : was;
114
+ const revise = mode === 'compact' || !last || last.createdAt.toISOString().slice(0, 10) !== today;
115
+ const version = revise ? bump(page.version, 'minor') : page.version;
123
116
 
124
117
  for (const key of ['backlog', 'runHistory', 'feedback']) {
125
118
  const [before, after] = [(input[key] ?? []).length, state[key].length];
@@ -140,32 +133,18 @@ try {
140
133
  if (!dry) {
141
134
  await q('BEGIN');
142
135
  await q(
143
- `UPDATE ${pages} SET content = $1, metadata = $2, updated_at = now()${'version' in page ? ', version = $4' : ''} WHERE id = $3`,
144
- [JSON.stringify(content), JSON.stringify({ state, lastVerified: today }), page.id, ...('version' in page ? [version] : [])],
136
+ `UPDATE ${pages} SET content = $1, metadata = $2, "updatedAt" = now(), version = $4 WHERE id = $3`,
137
+ [JSON.stringify(content), JSON.stringify({ state, lastVerified: today }), page.id, version],
145
138
  );
146
139
  if (revise) {
147
- // An id with no database default (a Prisma cuid) is supplied here.
148
- const [{ has_default }] = await q(
149
- `SELECT column_default IS NOT NULL AS has_default FROM information_schema.columns
150
- WHERE table_schema = current_schema() AND table_name = $1 AND column_name = 'id'`,
151
- [revisions.slice(1, -1)],
152
- );
153
- const row = {
154
- ...(has_default ? {} : { id: randomUUID() }),
155
- [pageKey.slice(1, -1)]: page.id,
156
- title: page.title,
157
- content: JSON.stringify(content),
158
- version,
159
- change_type: 'minor',
160
- author_id: arg('--author') ?? null,
161
- message: mode === 'compact'
162
- ? 'Compact the log: drop closed and duplicated backlog items, cap the append-only lists'
163
- : `${routine} run ${state.lastRun ?? today}`,
164
- };
165
- const columns = Object.keys(row);
166
140
  await q(
167
- `INSERT INTO ${revisions} (${columns.map(c => `"${c}"`).join(', ')}) VALUES (${columns.map((_, i) => `$${i + 1}`).join(', ')})`,
168
- Object.values(row),
141
+ `INSERT INTO ${revisions} ("pageId", title, content, version, "changeType", "authorId", message) VALUES ($1, $2, $3, $4, 'minor', $5, $6)`,
142
+ [
143
+ page.id, page.title, JSON.stringify(content), version, arg('--author') ?? null,
144
+ mode === 'compact'
145
+ ? 'Compact the log: drop closed and duplicated backlog items, cap the append-only lists'
146
+ : `${routine} run ${state.lastRun ?? today}`,
147
+ ],
169
148
  );
170
149
  }
171
150
  await q('COMMIT');
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // bin/mark-verified.mjs – stamp last_verified_at on pages a sweep has checked
2
+ // bin/mark-verified.mjs – stamp lastVerifiedAt on pages a sweep has checked
3
3
  // against their sources and found current, so `freshnessBanner`
4
4
  // (`wiki-formant/freshness`) stops calling them outdated. A page that needed an
5
5
  // edit gets its revision instead; this is for one that did not.
@@ -10,36 +10,33 @@
10
10
  // npx mark-verified <tagPath> <slug> one page ('' is a category hub)
11
11
  // npx mark-verified --all <tagPath> every page at or under a tag path
12
12
  //
13
- // --table <table> default pages
14
- // --scope <column>[=<value>] only rows where the column is NULL, or equals the value
13
+ // --site <siteId> the wiki the pages belong to, default "" (a one-wiki app)
15
14
  // --invalidate <tag> `vercel cache invalidate --tag` after a write
16
- import { ident, scopeSql } from '../dist/maintenance.js';
17
15
  import { arg, connect, invalidate } from './_cli.mjs';
18
16
 
19
17
  const args = process.argv.slice(2);
20
18
  const all = args.includes('--all');
21
- const [tagPath, slug] = args.filter((a, i) => !a.startsWith('--') && !['--table', '--scope', '--invalidate'].includes(args[i - 1]));
19
+ const [tagPath, slug] = args.filter((a, i) => !a.startsWith('--') && !['--site', '--invalidate'].includes(args[i - 1]));
22
20
 
23
21
  if (!tagPath || (!all && slug === undefined)) {
24
- console.error('usage: mark-verified <tagPath> <slug> | --all <tagPath> [--table t] [--scope c[=v]] [--invalidate tag]');
22
+ console.error('usage: mark-verified <tagPath> <slug> | --all <tagPath> [--site id] [--invalidate tag]');
25
23
  process.exit(2);
26
24
  }
27
- const table = ident(arg('--table') ?? 'pages', '--table');
28
- const scope = scopeSql(arg('--scope'), 3);
25
+ const site = arg('--site') ?? '';
29
26
 
30
27
  let client;
31
28
  try {
32
29
  client = await connect();
33
30
  const { rowCount } = all
34
31
  ? await client.query(
35
- `UPDATE ${table} SET last_verified_at = now() WHERE (tag_path = $1 OR tag_path LIKE $2) AND ${scope.sql}`,
36
- [tagPath, `${tagPath}/%`, ...scope.values],
32
+ `UPDATE "WikiPage" SET "lastVerifiedAt" = now() WHERE "siteId" = $3 AND ("tagPath" = $1 OR "tagPath" LIKE $2)`,
33
+ [tagPath, `${tagPath}/%`, site],
37
34
  )
38
35
  : await client.query(
39
- `UPDATE ${table} SET last_verified_at = now() WHERE tag_path = $1 AND slug = $2 AND ${scope.sql}`,
40
- [tagPath, slug, ...scope.values],
36
+ `UPDATE "WikiPage" SET "lastVerifiedAt" = now() WHERE "siteId" = $3 AND "tagPath" = $1 AND slug = $2`,
37
+ [tagPath, slug, site],
41
38
  );
42
- console.log(`Stamped last_verified_at on ${rowCount} page(s).`);
39
+ console.log(`Stamped lastVerifiedAt on ${rowCount} page(s).`);
43
40
  const tag = arg('--invalidate');
44
41
  if (rowCount && tag) await invalidate(tag);
45
42
  } catch (e) {
@@ -2,18 +2,18 @@
2
2
  // bin/prisma-models.mjs – copy the Prisma models wiki-formant's SQL reads into an
3
3
  // app's schema folder, or check that the copies there have not drifted.
4
4
  //
5
- // `wiki-formant/analytics` and `wiki-formant/passkey` own their SQL and name
5
+ // `wiki-formant/analytics`, `wiki-formant/passkey` and `wiki-formant/store` own their SQL and name
6
6
  // their tables as written, so the models are the package's. Three apps carried
7
7
  // them byte for byte, and the package kept a fourth copy as a comment.
8
8
  //
9
9
  // Prisma reads a schema split across the .prisma files of one folder. The app
10
10
  // points prisma.config.ts at the folder (`schema: 'prisma/schema'`), keeps its
11
11
  // generator, datasource and own models in prisma/schema/schema.prisma, and this
12
- // writes analytics.prisma and passkey.prisma beside them. Run it from the app's
12
+ // writes analytics.prisma, passkey.prisma and wiki.prisma beside them. Run it from the app's
13
13
  // root:
14
14
  //
15
- // npx prisma-models # write both into prisma/schema
16
- // npx prisma-models --check # exit 1 if either is missing or differs
15
+ // npx prisma-models # write all three into prisma/schema
16
+ // npx prisma-models --check # exit 1 if any is missing or differs
17
17
  // npx prisma-models analytics # only the ones named
18
18
  // npx prisma-models --dir db/schema # another folder
19
19
  //
@@ -26,7 +26,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import { arg } from './_cli.mjs';
28
28
 
29
- const MODELS = ['analytics', 'passkey'];
29
+ const MODELS = ['analytics', 'passkey', 'wiki'];
30
30
  const args = process.argv.slice(2);
31
31
  const check = args.includes('--check');
32
32
  const dir = arg('--dir') ?? 'prisma/schema';
@@ -120,6 +120,15 @@ export interface BlockSchema<B extends EditableBlock> {
120
120
  /** Types a list holds at most one of, such as an infobox an article lifts into its rail. */
121
121
  single?: readonly B['type'][];
122
122
  }
123
+ /**
124
+ * The core blocks' fields outside a `<BlockEditor>`, in `kit`'s style: for an
125
+ * editor that draws each block in place and has a list of its own, as miow's
126
+ * does. The two container fields hold a block list and still need the editor.
127
+ */
128
+ export declare function BlockFieldsKit({ kit, children }: {
129
+ kit: BlockEditorKit;
130
+ children: ReactNode;
131
+ }): import("react").JSX.Element;
123
132
  export interface BlockEditorProps<B extends EditableBlock> {
124
133
  blocks: B[];
125
134
  onChange: (blocks: B[]) => void;
@@ -34,6 +34,25 @@ function useEditorContext() {
34
34
  throw new Error('A block field editor must render inside <BlockEditor>.');
35
35
  return ctx;
36
36
  }
37
+ const NO_LIST = {
38
+ meta: () => undefined,
39
+ create: () => {
40
+ throw new Error('ColumnsFields and InfoboxFields hold a block list: render them inside <BlockEditor>.');
41
+ },
42
+ duplicate: block => block,
43
+ renderBody: () => null,
44
+ types: [],
45
+ nestedTypes: [],
46
+ };
47
+ /**
48
+ * The core blocks' fields outside a `<BlockEditor>`, in `kit`'s style: for an
49
+ * editor that draws each block in place and has a list of its own, as miow's
50
+ * does. The two container fields hold a block list and still need the editor.
51
+ */
52
+ export function BlockFieldsKit({ kit, children }) {
53
+ const value = useMemo(() => ({ kit, schema: NO_LIST }), [kit]);
54
+ return _jsx(Context.Provider, { value: value, children: children });
55
+ }
37
56
  /**
38
57
  * A page's blocks, edited: each in a card with its add-below, move, duplicate
39
58
  * and delete controls, and an add menu at the foot. Columns and infoboxes hold
@@ -119,7 +119,8 @@ export declare function StatsView({ items, columns, }: {
119
119
  export declare function BannerView({ variant, text, message, icon, policy, }: {
120
120
  variant: string;
121
121
  text?: string | null;
122
- message: string;
122
+ /** The sentence when the block has none. Defaults to `BANNER_MESSAGES`. */
123
+ message?: string;
123
124
  icon?: ReactNode;
124
125
  policy?: string;
125
126
  }): import("react").JSX.Element;
@@ -21,7 +21,7 @@ import { Fragment, useState } from 'react';
21
21
  import { Anchor } from './react-server.js';
22
22
  import { safeLinkHref } from './validation.js';
23
23
  import { cx } from './html.js';
24
- import { BANNER_LABELS, bannerVariant } from './text.js';
24
+ import { BANNER_LABELS, BANNER_MESSAGES, bannerVariant } from './text.js';
25
25
  /**
26
26
  * Tabbed code samples. Every tab's body stays mounted and the inactive ones are
27
27
  * hidden rather than unmounted: remounting a highlighted panel would re-run
@@ -129,6 +129,7 @@ export function StatsView({ items, columns = 4, }) {
129
129
  */
130
130
  export function BannerView({ variant, text, message, icon, policy, }) {
131
131
  const known = bannerVariant(variant);
132
+ message ??= BANNER_MESSAGES[known];
132
133
  const label = BANNER_LABELS[known];
133
134
  return (_jsxs("div", { className: cx('editorial-banner', `editorial-banner-${known}`), role: "note", children: [icon, _jsxs("p", { className: "editorial-banner-body", children: [_jsxs("strong", { children: [policy ? _jsx("a", { href: policy, children: label }) : label, "."] }), " ", text?.trim() || message] })] }));
134
135
  }
@@ -55,14 +55,3 @@ export declare function render(state: LogState, routine: string, newId?: () => s
55
55
  type: "content";
56
56
  text: string;
57
57
  }[];
58
- /** A table or column name from the command line, quoted, or a thrown usage error. */
59
- export declare function ident(name: string | undefined, what: string): string;
60
- /**
61
- * `--scope column` limits a bin to rows whose column is NULL, `--scope
62
- * column=value` to rows where it equals the value: caper's platform pages
63
- * have no caper, and its company pages share their tag paths.
64
- */
65
- export declare function scopeSql(scope: string | undefined, param: number): {
66
- sql: string;
67
- values: unknown[];
68
- };
@@ -132,23 +132,3 @@ export function render(state, routine, newId = () => crypto.randomUUID()) {
132
132
  block(`<h2>Recent runs</h2>${table(['Date', 'Category', 'Summary', 'Tweet'], runRows)}`),
133
133
  ];
134
134
  }
135
- const IDENT = /^[a-z_][a-z0-9_]*$/;
136
- /** A table or column name from the command line, quoted, or a thrown usage error. */
137
- export function ident(name, what) {
138
- if (!name || !IDENT.test(name))
139
- throw new Error(`${what} must be a lower-case SQL identifier, got ${JSON.stringify(name)}`);
140
- return `"${name}"`;
141
- }
142
- /**
143
- * `--scope column` limits a bin to rows whose column is NULL, `--scope
144
- * column=value` to rows where it equals the value: caper's platform pages
145
- * have no caper, and its company pages share their tag paths.
146
- */
147
- export function scopeSql(scope, param) {
148
- if (!scope)
149
- return { sql: 'TRUE', values: [] };
150
- const [column, value] = scope.split('=');
151
- return value === undefined
152
- ? { sql: `${ident(column, '--scope')} IS NULL`, values: [] }
153
- : { sql: `${ident(column, '--scope')} = $${param}`, values: [value] };
154
- }
package/dist/mcp.d.ts CHANGED
@@ -39,7 +39,7 @@ export type ToolParam = {
39
39
  description: string;
40
40
  enum?: string[];
41
41
  items?: {
42
- type: 'string' | 'number' | 'object';
42
+ type?: 'string' | 'number' | 'object';
43
43
  };
44
44
  };
45
45
  export type ToolSchema = {
package/dist/mcp.js CHANGED
@@ -203,11 +203,14 @@ function validateArgs(tool, args) {
203
203
  }
204
204
  }
205
205
  else if (spec.type === 'array') {
206
- const itemType = spec.items?.type ?? 'string';
206
+ // No `items` means strings. Items with no one type (a `oneOf` of shapes,
207
+ // as a zod union emits) are the tool's own to check: read as strings, every
208
+ // well-formed call was refused.
209
+ const itemType = spec.items ? spec.items.type : 'string';
207
210
  if (actual !== 'array') {
208
- problems.push(`Parameter "${key}" must be an array of ${itemType}s; received ${actual} (${JSON.stringify(value)}).`);
211
+ problems.push(`Parameter "${key}" must be an array${itemType ? ` of ${itemType}s` : ''}; received ${actual} (${JSON.stringify(value)}).`);
209
212
  }
210
- else if (value.some(item => typeOf(item) !== itemType)) {
213
+ else if (itemType && value.some(item => typeOf(item) !== itemType)) {
211
214
  problems.push(`Parameter "${key}" must contain only ${itemType}s.`);
212
215
  }
213
216
  }
@@ -0,0 +1,166 @@
1
+ import type { Sql } from './analytics.js';
2
+ import type { BlockGroup, DiffBlock } from './revisions.js';
3
+ import { type ChangeType } from './versioning.js';
4
+ import { type HistoryChange } from './history.js';
5
+ export type { Sql };
6
+ export type PageMetadata = Record<string, unknown>;
7
+ /** A page as stored. */
8
+ export interface WikiPageRow<B = unknown> {
9
+ id: string;
10
+ siteId: string;
11
+ tagPath: string;
12
+ slug: string;
13
+ title: string;
14
+ content: B[];
15
+ metadata: PageMetadata;
16
+ version: string;
17
+ authorId: string | null;
18
+ lastVerifiedAt: Date | null;
19
+ createdAt: Date;
20
+ updatedAt: Date;
21
+ }
22
+ /**
23
+ * A page as a listing carries it: one line of the article instead of the
24
+ * article. A category of 200 cards otherwise ships 200 block arrays to the
25
+ * browser.
26
+ */
27
+ export type WikiPageListing = Omit<WikiPageRow, 'content'> & {
28
+ snippet: string;
29
+ };
30
+ export interface WikiPageRef {
31
+ tagPath: string;
32
+ slug: string;
33
+ title: string;
34
+ }
35
+ export interface WikiRevisionRow<B = unknown> {
36
+ id: string;
37
+ pageId: string;
38
+ title: string;
39
+ content: B[];
40
+ version: string;
41
+ changeType: string;
42
+ message: string | null;
43
+ authorId: string | null;
44
+ createdAt: Date;
45
+ }
46
+ export type WikiRevisionEntry = Omit<WikiRevisionRow, 'content'> & {
47
+ changes: HistoryChange[];
48
+ };
49
+ export type ListSort = 'title' | 'recent' | 'date';
50
+ export interface WikiStoreOptions<B extends DiffBlock> {
51
+ sql: Sql;
52
+ /** The site every read and write is scoped to. "" in an app that serves one wiki. */
53
+ siteId?: string;
54
+ /**
55
+ * Tag paths a listing leaves out unless it names them: a wiki's back office,
56
+ * such as the maintenance log a routine rewrites every few hours. A hidden
57
+ * page keeps its URL, its twin and its history; it never arrives unasked.
58
+ */
59
+ hidden?: readonly string[];
60
+ /** The block tree's containers, for diffs. Defaults to the core model's (`coreBlockGroups`). */
61
+ containers?: (block: B) => BlockGroup<B>[] | null;
62
+ /**
63
+ * A listing's line for a page when nothing better is found: a search hit
64
+ * whose match is in the title alone, and every card. Defaults to the
65
+ * article's opening; a wiki that keeps a written summary in metadata says so.
66
+ */
67
+ snippet?: (page: Pick<WikiPageRow<B>, 'content' | 'metadata'>) => string;
68
+ }
69
+ export interface SaveInput<B> {
70
+ tagPath: string;
71
+ slug: string;
72
+ title?: string;
73
+ content?: B[];
74
+ /** Replaces the page's metadata whole. Omit to keep it. */
75
+ metadata?: PageMetadata;
76
+ authorId?: string | null;
77
+ /** The history's line for this save. Defaults to what the diff found ("1 block modified"). */
78
+ message?: string | null;
79
+ /**
80
+ * The version the writer opened. A save that finds the page has moved on is
81
+ * refused with 409 rather than quietly overwriting the edit in between.
82
+ */
83
+ baseVersion?: string;
84
+ /**
85
+ * The bump to record instead of the one the diff picks, for a writer that
86
+ * knows. Stated, it always writes a revision, even for a save the diff finds
87
+ * empty: a routine recording a reading in metadata wants it in the history.
88
+ */
89
+ change?: Exclude<ChangeType, 'none'>;
90
+ /** The writer's word that the page's facts were checked: stamps `lastVerifiedAt`. */
91
+ verified?: boolean;
92
+ /** Moves the page to another slug in the same tag path. */
93
+ newSlug?: string;
94
+ }
95
+ export interface SaveResult<B> {
96
+ page: WikiPageRow<B>;
97
+ created: boolean;
98
+ /** `none` when the save changed nothing a revision records, so none was written. */
99
+ changeType: ChangeType;
100
+ summary: string;
101
+ }
102
+ /** A row as a listing carries it, for an app that runs a listing query of its own. */
103
+ export declare function toListing<R extends {
104
+ content?: unknown;
105
+ }>(row: R): Omit<R, 'content'> & {
106
+ snippet: string;
107
+ };
108
+ /** Re-order rows to match an id list, dropping ids that no longer resolve. */
109
+ export declare function orderByIds<T extends {
110
+ id: string;
111
+ }>(rows: readonly T[], ids: readonly string[]): T[];
112
+ export declare function wikiStore<B extends DiffBlock = DiffBlock>(o: WikiStoreOptions<B>): {
113
+ siteId: string;
114
+ hidden: string[];
115
+ isHidden: (tagPath: string) => boolean;
116
+ get: (tagPath: string, slug: string) => Promise<NonNullable<WikiPageRow<B> & {
117
+ revisionCount: number;
118
+ }> | null>;
119
+ getById: (id: string) => Promise<NonNullable<WikiPageRow<B> & {
120
+ revisionCount: number;
121
+ }> | null>;
122
+ list: (tagPath: string, { sort, limit, offset }?: {
123
+ sort?: ListSort;
124
+ limit?: number;
125
+ offset?: number;
126
+ }) => Promise<WikiPageListing[]>;
127
+ articles: (tagPath: string, { sort, limit, offset }?: {
128
+ sort?: ListSort;
129
+ limit?: number;
130
+ offset?: number;
131
+ }) => Promise<WikiPageRow<B>[]>;
132
+ subtree: (tagPath: string) => Promise<WikiPageRef[]>;
133
+ recent: ({ tagPath, limit }?: {
134
+ tagPath?: string;
135
+ limit?: number;
136
+ }) => Promise<WikiPageListing[]>;
137
+ byIds: (ids: readonly string[]) => Promise<WikiPageListing[]>;
138
+ refs: () => Promise<Omit<WikiPageRow<unknown>, "content">[]>;
139
+ corpus: () => Promise<WikiPageRow<B>[]>;
140
+ stats: () => Promise<{
141
+ pages: number;
142
+ updated: Date | null;
143
+ }>;
144
+ search: (query: string, { tagPath, skip, take }?: {
145
+ tagPath?: string | null;
146
+ skip?: number;
147
+ take?: number;
148
+ }) => Promise<{
149
+ items: (WikiPageListing & {
150
+ score: number;
151
+ })[];
152
+ total: number;
153
+ }>;
154
+ history: (pageId: string, { limit, offset }?: {
155
+ limit?: number;
156
+ offset?: number;
157
+ }) => Promise<WikiRevisionEntry[]>;
158
+ revision: (pageId: string, revisionId: string) => Promise<WikiRevisionRow<B> | null>;
159
+ save: (input: SaveInput<B>) => Promise<SaveResult<B>>;
160
+ restore: (tagPath: string, slug: string, revisionId: string, by?: {
161
+ authorId?: string | null;
162
+ }) => Promise<SaveResult<B>>;
163
+ remove: (tagPath: string, slug: string) => Promise<boolean>;
164
+ verify: (tagPath: string, slug: string) => Promise<boolean>;
165
+ };
166
+ export type WikiStore<B extends DiffBlock = DiffBlock> = ReturnType<typeof wikiStore<B>>;