wiki-formant 0.59.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 +55 -7
- package/bin/maintenance-log.mjs +27 -48
- package/bin/mark-verified.mjs +10 -13
- package/bin/prisma-models.mjs +5 -5
- package/dist/block-editor.d.ts +9 -0
- package/dist/block-editor.js +19 -0
- package/dist/block-views.d.ts +2 -1
- package/dist/block-views.js +2 -1
- package/dist/maintenance.d.ts +0 -11
- package/dist/maintenance.js +0 -20
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +6 -3
- package/dist/react-server.d.ts +26 -1
- package/dist/react-server.js +9 -12
- package/dist/store.d.ts +166 -0
- package/dist/store.js +237 -0
- package/dist/taxonomy.d.ts +5 -1
- package/dist/text.d.ts +6 -0
- package/dist/text.js +13 -0
- package/dist/tree.d.ts +95 -0
- package/dist/tree.js +122 -0
- package/package.json +16 -5
- package/prisma/wiki.prisma +62 -0
- package/prisma/wiki.sql +68 -0
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
|
|
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.
|
|
@@ -60,7 +105,7 @@ const letters = taxonomy.alphaControls('ecosystem', all, state); // [] below the
|
|
|
60
105
|
Every control arrives as `{value, label, count, href, active}`, its `href` already built through your one builder with **every other axis carried along** — the arithmetic that, done by hand, drops the reader's letter the first time they press a chip.
|
|
61
106
|
|
|
62
107
|
- **The reset control leads the row**, flagged `reset`, so a consumer that maps the array cannot ship a narrowed view with nothing to press to widen it.
|
|
63
|
-
- **`alphaControls` returns `[]` below the index threshold**, folding in `needsAlphaIndex` — one call is either the index you needed or nothing, rather than a decision each caller makes and one caller forgets.
|
|
108
|
+
- **`alphaControls` returns `[]` below the index threshold**, folding in `needsAlphaIndex` — one call is either the index you needed or nothing, rather than a decision each caller makes and one caller forgets. A hub's sort toggle reads the same predicate over the same unfiltered count (`needsAlphaIndex(all.length)`; 40 pages unless `alphaIndexMinPages` says otherwise), so a set small enough to need no index offers no sort either.
|
|
64
109
|
- **`resolveLetter` drops a letter no page starts with.** A stray `?letter=Q` would otherwise empty the grid with no control marked to explain why.
|
|
65
110
|
|
|
66
111
|
|
|
@@ -367,7 +412,7 @@ are unit-tested without a DOM.
|
|
|
367
412
|
`wiki-formant/react` carries `'use client'`, and that is a module-level boundary: anything exported from it hydrates in the consumer's tree whether or not it uses a hook. `wiki-formant/react-server` is the same React, without the directive — for the parts of a wiki that are pure functions of their props and should ship no JavaScript at all.
|
|
368
413
|
|
|
369
414
|
`FacetBar` renders the rows `createTaxonomy` already produces. This is why the taxonomy exports a rows model rather than markup: the rows could always cross the boundary and, until this subpath existed, the markup could not, so all three wikis hand-rendered it and two put `aria-pressed` on an `<a>`. `Breadcrumbs` renders the trail and its `BreadcrumbList` JSON-LD together, because a trail whose structured data is written somewhere else is a trail that will one day disagree with its own markup — which is the case Google penalises. It takes a `base` origin: structured-data URLs must be absolute and a package cannot know the site.
|
|
370
|
-
`FacetBar` takes a `count` class to render each count as its own element, and `alphaFirst` to lead with the A–Z row. Its JSON-LD goes out through `JsonLd`, which is exported for every other payload a page emits: it re-encodes each `<` as `\u003c`, because these payloads carry authored titles and an authored `</script>` would otherwise close the tag.
|
|
415
|
+
`FacetBar` takes a `count` class to render each count as its own element, and `alphaFirst` to lead with the A–Z row. A chip row above the grid and a panel beside it are the same markup laid out by different stylesheets, so the class names are what tell them apart: `letterRow`, `letterControl` and `letterControlActive` style the A–Z row apart from the facet rows (each falls back to `row`, `control` or `controlActive`); a `text` class wraps each label in its own element and gives a facet option its value as `title`, so a long value can truncate while its count stays in view; and `letterCounts="title"` keeps each letter a bare glyph with its count in the tooltip. With none of them set the markup is what it was. Its JSON-LD goes out through `JsonLd`, which is exported for every other payload a page emits: it re-encodes each `<` as `\u003c`, because these payloads carry authored titles and an authored `</script>` would otherwise close the tag.
|
|
371
416
|
|
|
372
417
|
`PageNav` is the previous/next pair at the foot of an article — the sequential read the infobox rail's lateral links do not cover. Ordering is the caller's, because it is the one part that is never portable: a wiki's sequence is its section's configured sort, a knowledge base's is a taxonomy walk. Pair it with `adjacentPages` from `wiki-formant/pagination` over a list you already hold — neither wiki needs a query for it, and the two indexed lookups the neighbours used to cost were the reason one of them dropped the control.
|
|
373
418
|
|
|
@@ -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 `
|
|
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
|
-
`--
|
|
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
|
|
package/bin/maintenance-log.mjs
CHANGED
|
@@ -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
|
|
8
|
-
// from the app's root, where `pg` is installed
|
|
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
|
|
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
|
-
// --
|
|
20
|
-
// --
|
|
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
|
|
34
|
-
//
|
|
35
|
-
|
|
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> [--
|
|
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 =
|
|
53
|
-
const revisions =
|
|
54
|
-
const
|
|
55
|
-
const
|
|
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,
|
|
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
|
|
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.
|
|
121
|
-
const
|
|
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,
|
|
144
|
-
[JSON.stringify(content), JSON.stringify({ state, lastVerified: today }), page.id,
|
|
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} (
|
|
168
|
-
|
|
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');
|
package/bin/mark-verified.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// bin/mark-verified.mjs – stamp
|
|
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
|
-
// --
|
|
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('--') && !['--
|
|
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> [--
|
|
22
|
+
console.error('usage: mark-verified <tagPath> <slug> | --all <tagPath> [--site id] [--invalidate tag]');
|
|
25
23
|
process.exit(2);
|
|
26
24
|
}
|
|
27
|
-
const
|
|
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
|
|
36
|
-
[tagPath, `${tagPath}/%`,
|
|
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
|
|
40
|
-
[tagPath, slug,
|
|
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
|
|
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) {
|
package/bin/prisma-models.mjs
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|
16
|
-
// npx prisma-models --check # exit 1 if
|
|
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';
|
package/dist/block-editor.d.ts
CHANGED
|
@@ -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;
|
package/dist/block-editor.js
CHANGED
|
@@ -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
|
package/dist/block-views.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/block-views.js
CHANGED
|
@@ -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
|
}
|
package/dist/maintenance.d.ts
CHANGED
|
@@ -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
|
-
};
|
package/dist/maintenance.js
CHANGED
|
@@ -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
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
|
-
|
|
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
|
}
|
package/dist/react-server.d.ts
CHANGED
|
@@ -31,15 +31,35 @@ export declare const Anchor: WikiLinkComponent;
|
|
|
31
31
|
* tree, which is a shared convention, these are each design system's own words
|
|
32
32
|
* for a chip and a row. Defaults are given so a consumer that shares them writes
|
|
33
33
|
* nothing.
|
|
34
|
+
*
|
|
35
|
+
* Whether the bar is a chip row above the grid or a panel beside it is decided
|
|
36
|
+
* here and nowhere else: the markup is the same, the stylesheet behind these
|
|
37
|
+
* names lays it out.
|
|
34
38
|
*/
|
|
35
39
|
export interface FacetBarClassNames {
|
|
36
40
|
root?: string;
|
|
41
|
+
/** Each facet's row, and the A–Z row unless `letterRow` is set. */
|
|
37
42
|
row?: string;
|
|
43
|
+
/** A row's heading: the facet's label, or `alphaLabel`. */
|
|
38
44
|
label?: string;
|
|
45
|
+
/** A facet option, and a letter unless `letterControl` is set. */
|
|
39
46
|
control?: string;
|
|
40
47
|
controlActive?: string;
|
|
41
48
|
/** Set it and each count is its own `<span>` with this class, not ` (n)` text. */
|
|
42
49
|
count?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Set it and each control's text is its own `<span>` with this class, and a
|
|
52
|
+
* facet option carries its value as `title`: the hook for truncating a long
|
|
53
|
+
* value while its count stays in view, with the full value one hover away.
|
|
54
|
+
*/
|
|
55
|
+
text?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The A–Z row and its letters, which a panel draws as a block of glyphs
|
|
58
|
+
* while its facet options run full width. Each falls back to its shared key.
|
|
59
|
+
*/
|
|
60
|
+
letterRow?: string;
|
|
61
|
+
letterControl?: string;
|
|
62
|
+
letterControlActive?: string;
|
|
43
63
|
}
|
|
44
64
|
export interface FacetBarProps {
|
|
45
65
|
link: WikiLinkComponent;
|
|
@@ -49,6 +69,11 @@ export interface FacetBarProps {
|
|
|
49
69
|
alphaLabel?: string;
|
|
50
70
|
/** Lead with the A–Z row: two lines against a facet block's twenty. */
|
|
51
71
|
alphaFirst?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Where a letter's count goes. `'inline'` prints it like an option's;
|
|
74
|
+
* `'title'` keeps the letter a bare glyph and puts its count in the tooltip.
|
|
75
|
+
*/
|
|
76
|
+
letterCounts?: 'inline' | 'title';
|
|
52
77
|
classNames?: FacetBarClassNames;
|
|
53
78
|
/** The landmark's accessible name. */
|
|
54
79
|
label?: string;
|
|
@@ -63,7 +88,7 @@ export interface FacetBarProps {
|
|
|
63
88
|
* `aria-current`, not `aria-pressed`: a link is not a toggle button and does not
|
|
64
89
|
* take that attribute.
|
|
65
90
|
*/
|
|
66
|
-
export declare function FacetBar({ link: Link, facets, letters, alphaLabel, alphaFirst, classNames, label: navLabel, }: FacetBarProps): import("react").JSX.Element | null;
|
|
91
|
+
export declare function FacetBar({ link: Link, facets, letters, alphaLabel, alphaFirst, letterCounts, classNames, label: navLabel, }: FacetBarProps): import("react").JSX.Element | null;
|
|
67
92
|
export interface FacetSummaryProps {
|
|
68
93
|
shown: number;
|
|
69
94
|
total: number;
|
package/dist/react-server.js
CHANGED
|
@@ -28,7 +28,8 @@ import { blockLocation } from './revisions.js';
|
|
|
28
28
|
* Exported because `block-views` renders links too and had declared the same
|
|
29
29
|
* three lines; a default that exists twice is a default that can diverge.
|
|
30
30
|
*/
|
|
31
|
-
export const Anchor = (
|
|
31
|
+
export const Anchor = (props) => _jsx("a", { ...props });
|
|
32
|
+
const pagesPhrase = (n) => `${n} page${n === 1 ? '' : 's'}`;
|
|
32
33
|
/**
|
|
33
34
|
* The section's second axis, as pressable chips.
|
|
34
35
|
*
|
|
@@ -39,20 +40,16 @@ export const Anchor = ({ href, className, children }) => _jsx("a", { href: href,
|
|
|
39
40
|
* `aria-current`, not `aria-pressed`: a link is not a toggle button and does not
|
|
40
41
|
* take that attribute.
|
|
41
42
|
*/
|
|
42
|
-
export function FacetBar({ link: Link, facets, letters, alphaLabel = 'A–Z', alphaFirst = false, classNames = {}, label: navLabel = 'Filter pages', }) {
|
|
43
|
+
export function FacetBar({ link: Link, facets, letters, alphaLabel = 'A–Z', alphaFirst = false, letterCounts = 'inline', classNames = {}, label: navLabel = 'Filter pages', }) {
|
|
43
44
|
if (!facets.length && !letters.length)
|
|
44
45
|
return null;
|
|
45
|
-
const { root = 'stack tight', row = 'cluster', label = 'form-label', control = 'tag', controlActive = 'tag tag-removable', count, } = classNames;
|
|
46
|
-
const chip = (c) => (_jsxs(Link, { href: c.href, className: c.active ?
|
|
47
|
-
const facetRows = facets.map(facet => (_jsxs("div", { className: row, children: [_jsx("span", { className: label, children: facet.label }), facet.options.map(o => chip({
|
|
46
|
+
const { root = 'stack tight', row = 'cluster', label = 'form-label', control = 'tag', controlActive = 'tag tag-removable', count, text, letterRow = row, letterControl = control, letterControlActive = controlActive, } = classNames;
|
|
47
|
+
const chip = (c, classes, opts) => (_jsxs(Link, { href: c.href, className: c.active ? classes.active : classes.idle, "aria-current": c.active ? 'true' : undefined, ...(opts.title ? { title: opts.title } : {}), children: [text ? _jsx("span", { className: text, children: c.label }) : c.label, opts.showCount && (count ? _jsx("span", { className: count, children: c.count }) : ` (${c.count})`)] }, c.value || 'all'));
|
|
48
|
+
const facetRows = facets.map(facet => (_jsxs("div", { className: row, children: [_jsx("span", { className: label, children: facet.label }), facet.options.map(o => chip(o, { idle: control, active: controlActive }, { title: text ? o.label : undefined, showCount: true }))] }, facet.key)));
|
|
48
49
|
// The reset control leads the letters and says what it resets to.
|
|
49
|
-
const alphaRow = letters.length > 0 && (_jsxs("div", { className:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
active: l.active,
|
|
53
|
-
text: l.label,
|
|
54
|
-
n: l.count,
|
|
55
|
-
...(l.reset ? { title: `All ${l.count} pages` } : {}),
|
|
50
|
+
const alphaRow = letters.length > 0 && (_jsxs("div", { className: letterRow, children: [_jsx("span", { className: label, children: alphaLabel }), letters.map(l => chip(l, { idle: letterControl, active: letterControlActive }, {
|
|
51
|
+
title: l.reset ? `All ${l.count} pages` : letterCounts === 'title' ? pagesPhrase(l.count) : undefined,
|
|
52
|
+
showCount: letterCounts === 'inline',
|
|
56
53
|
}))] }, "alpha"));
|
|
57
54
|
// A landmark: every control in it narrows the list below, and a screen
|
|
58
55
|
// reader user skipping between regions should be able to land on it.
|