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/dist/tree.js ADDED
@@ -0,0 +1,122 @@
1
+ // tree.ts – a wiki's category tree, and what a URL path means against it.
2
+ //
3
+ // Three wikis declared their tree three ways: radix-wiki's `TAG_HIERARCHY`
4
+ // (slug + children, keys and sort inherited down the trail), caper's
5
+ // `WIKI_CATEGORIES` (each node naming its full `tagPath`, with a `categoryTrail`
6
+ // walk of its own) and acuiq's `WIKI_SECTIONS` (one flat level). Each then
7
+ // re-derived the same answers by hand: is this a real category, which metadata
8
+ // keys apply here, which article is this page "part of a series on", what sort
9
+ // does a listing default to, which paths are back office.
10
+ //
11
+ // The node holds what every wiki reads; anything only one wiki reads (an XRD
12
+ // threshold, a band, an icon set) rides on the node as an extra field, and the
13
+ // app folds it from `trail`. Pure: no data access, so a client component and
14
+ // the server route read paths through the same parser.
15
+ const segmentsOf = (tagPath) => typeof tagPath === 'string' ? tagPath.split('/').filter(Boolean) : tagPath.filter(Boolean);
16
+ export function createTree(nodes, opts = {}) {
17
+ const globalKeys = [...(opts.globalKeys ?? [])];
18
+ const cache = new Map();
19
+ function resolve(tagPath) {
20
+ const segments = segmentsOf(tagPath);
21
+ const key = segments.join('/');
22
+ const hit = cache.get(key);
23
+ if (hit)
24
+ return hit;
25
+ const trail = [];
26
+ const metadataKeys = [];
27
+ let level = nodes;
28
+ let sort = 'title';
29
+ let mainArticle;
30
+ let hidden = false;
31
+ for (const segment of segments) {
32
+ const node = level.find(n => n.slug === segment);
33
+ if (!node) {
34
+ const missing = { node: null, trail: [], valid: false, hidden: false, sort: 'title', metadataKeys: [] };
35
+ cache.set(key, missing);
36
+ return missing;
37
+ }
38
+ trail.push(node);
39
+ if (node.metadataKeys)
40
+ metadataKeys.push(...node.metadataKeys);
41
+ if (node.sort)
42
+ sort = node.sort;
43
+ if (node.mainArticle)
44
+ mainArticle = node.mainArticle;
45
+ hidden ||= !!node.hidden;
46
+ level = node.children ?? [];
47
+ }
48
+ metadataKeys.push(...globalKeys);
49
+ const resolved = { node: trail.at(-1) ?? null, trail, valid: trail.length > 0, hidden, sort, mainArticle, metadataKeys };
50
+ cache.set(key, resolved);
51
+ return resolved;
52
+ }
53
+ /** Every path in the tree, depth first, parents before children. */
54
+ function paths(level = nodes, parent = '', inherited = false, depth = 0) {
55
+ return level.flatMap(node => {
56
+ const path = parent ? `${parent}/${node.slug}` : node.slug;
57
+ const hidden = inherited || !!node.hidden;
58
+ return [{ path, node: node, hidden, depth }, ...paths(node.children ?? [], path, hidden, depth + 1)];
59
+ });
60
+ }
61
+ const all = paths();
62
+ return {
63
+ nodes,
64
+ resolve,
65
+ paths: () => all,
66
+ /** Back-office paths, which a listing leaves out unless it names one. Pass to `wikiStore({ hidden })`. */
67
+ hiddenPaths: all.filter(p => p.hidden && p.path).map(p => p.path),
68
+ isValid: (tagPath) => resolve(tagPath).valid,
69
+ node: (tagPath) => resolve(tagPath).node,
70
+ metadataKeys: (tagPath) => resolve(tagPath).metadataKeys,
71
+ /** A category's name, or its last segment unslugged when the tree does not know it. */
72
+ label: (tagPath) => resolve(tagPath).node?.name ?? (segmentsOf(tagPath).at(-1) ?? '').replace(/-/g, ' '),
73
+ /**
74
+ * One crumb per segment, so the trail can never be shorter than the URL. A
75
+ * segment the tree knows is a link to its category; one it does not is text.
76
+ */
77
+ crumbs: (tagPath) => {
78
+ const segments = segmentsOf(tagPath);
79
+ return segments.map((segment, i) => {
80
+ const path = segments.slice(0, i + 1).join('/');
81
+ const node = resolve(path).node;
82
+ return { path, label: node?.name ?? segment.replace(/-/g, ' '), linked: !!node };
83
+ });
84
+ },
85
+ };
86
+ }
87
+ /** The views a page or a category has beside itself, each a final path segment. */
88
+ export const WIKI_MODES = ['edit', 'history'];
89
+ /**
90
+ * What a path means: the home page, a category (its hub article is the empty
91
+ * slug), a page, or nothing, each optionally in one of `modes`. A path that is
92
+ * itself a category wins over a mode suffix, so a category named `history` is
93
+ * still reachable; `/<category>/edit` edits the category's hub.
94
+ *
95
+ * Static routes are the app's: check them before calling this.
96
+ */
97
+ export function parseWikiPath(tree, segments = [], { modes = WIKI_MODES } = {}) {
98
+ const parts = segments.filter(Boolean);
99
+ const last = parts.at(-1);
100
+ const mode = last && modes.includes(last) && !tree.isValid(parts) ? last : null;
101
+ const path = mode ? parts.slice(0, -1) : parts;
102
+ const invalid = { type: 'invalid', tagPath: '', slug: '', mode: null };
103
+ if (!path.length)
104
+ return { type: 'home', tagPath: '', slug: '', mode };
105
+ if (tree.isValid(path))
106
+ return { type: 'category', tagPath: path.join('/'), slug: '', mode };
107
+ if (path.length < 2 || !tree.isValid(path.slice(0, -1)))
108
+ return invalid;
109
+ return { type: 'page', tagPath: path.slice(0, -1).join('/'), slug: path.at(-1), mode };
110
+ }
111
+ /**
112
+ * A slug no page in `tagPath` can hold, because `parseWikiPath` reads that URL
113
+ * as something else: a mode, or a child category. Creating a page treats it as
114
+ * taken.
115
+ */
116
+ export function isReservedSlug(tree, tagPath, slug, modes = WIKI_MODES) {
117
+ return modes.includes(slug) || tree.isValid([...segmentsOf(tagPath), slug]);
118
+ }
119
+ /** A page's path: the category for its hub, `/` for the home page. */
120
+ export function wikiPath(tagPath, slug) {
121
+ return `/${[tagPath, slug].filter(Boolean).join('/')}`;
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -117,6 +117,16 @@
117
117
  "import": "./dist/search.js",
118
118
  "default": "./dist/search.js"
119
119
  },
120
+ "./store": {
121
+ "types": "./dist/store.d.ts",
122
+ "import": "./dist/store.js",
123
+ "default": "./dist/store.js"
124
+ },
125
+ "./tree": {
126
+ "types": "./dist/tree.d.ts",
127
+ "import": "./dist/tree.js",
128
+ "default": "./dist/tree.js"
129
+ },
120
130
  "./feed": {
121
131
  "types": "./dist/feed.d.ts",
122
132
  "import": "./dist/feed.js",
@@ -296,6 +306,7 @@
296
306
  "sanitize-html": "^2.17.7"
297
307
  },
298
308
  "devDependencies": {
309
+ "@electric-sql/pglite": "^0.3.9",
299
310
  "@radixdlt/rola": "^2.1.0",
300
311
  "@types/node": "^22.0.0",
301
312
  "@types/react": "^19.0.0",
@@ -308,12 +319,12 @@
308
319
  "typescript": "^5.9.0"
309
320
  },
310
321
  "peerDependencies": {
322
+ "@radixdlt/rola": ">=2",
323
+ "@vercel/blob": ">=2",
324
+ "jose": ">=6",
311
325
  "pg": ">=8",
312
326
  "react": ">=18",
313
- "jose": ">=6",
314
- "@radixdlt/rola": ">=2",
315
- "sharp": ">=0.33",
316
- "@vercel/blob": ">=2"
327
+ "sharp": ">=0.33"
317
328
  },
318
329
  "peerDependenciesMeta": {
319
330
  "react": {
@@ -0,0 +1,62 @@
1
+ // Shipped by wiki-formant (`npx prisma-models`). Edit it there, not here:
2
+ // `prisma-models --check` fails on a copy that has drifted.
3
+ //
4
+ // A wiki's pages and their revisions, read and written by wiki-formant/store.
5
+ // Its SQL names these tables and columns as written, so they carry no @@map.
6
+ // `siteId` is "" in an app that serves one wiki; a multi-tenant app keys every
7
+ // row to its site, and caper keys a DAO's own layer to the DAO.
8
+ //
9
+ // A page lives at `tagPath` + `slug`. The empty slug is the category's own hub
10
+ // article, and `''/''` the home page. Everything a page says beyond its blocks
11
+ // and title is `metadata`: facet keys, an excerpt, a banner, a subtitle. Which
12
+ // keys a listing may carry is the tree's answer, not the column's.
13
+ //
14
+ // Ids and timestamps default in the database, so the store's raw SQL and a
15
+ // Prisma client write the same rows. `authorId` is the app's own user key, as
16
+ // text; it has no relation because no two apps key their users alike.
17
+
18
+ model WikiPage {
19
+ id String @id @default(dbgenerated("(gen_random_uuid())::text"))
20
+ siteId String @default("")
21
+ tagPath String @default("")
22
+ slug String
23
+ title String
24
+ content Json @default("[]")
25
+ metadata Json @default("{}")
26
+ version String @default("1.0.0")
27
+ authorId String?
28
+ lastVerifiedAt DateTime? @db.Timestamptz(3)
29
+ createdAt DateTime @default(now()) @db.Timestamptz(3)
30
+ updatedAt DateTime @default(now()) @db.Timestamptz(3)
31
+ // Title at weight A, block prose at B, GENERATED ALWAYS ... STORED by
32
+ // `npx fts-ddl '"WikiPage"'`. Declared only so `db push` leaves it alone:
33
+ // push must never create it, since Prisma would write a plain column that
34
+ // stays empty. Read only through raw SQL.
35
+ searchTsv Unsupported("tsvector")? @map("search_tsv")
36
+ revisions WikiRevision[]
37
+
38
+ @@unique([siteId, tagPath, slug])
39
+ @@index([siteId, tagPath, updatedAt])
40
+ @@index([siteId, updatedAt])
41
+ // Declared for the same reason: undeclared, push drops it.
42
+ @@index([searchTsv], type: Gin, map: "WikiPage_search_tsv_idx")
43
+ }
44
+
45
+ // One row per saved version, newest last. A revision holds the whole page as
46
+ // it was saved; what changed is diffed from consecutive rows when it is read,
47
+ // so a script's revision and the editor's carry the same history.
48
+ model WikiRevision {
49
+ id String @id @default(dbgenerated("(gen_random_uuid())::text"))
50
+ pageId String
51
+ title String
52
+ content Json
53
+ version String
54
+ changeType String
55
+ message String?
56
+ authorId String?
57
+ createdAt DateTime @default(now()) @db.Timestamptz(3)
58
+ page WikiPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
59
+
60
+ @@index([pageId, createdAt])
61
+ @@index([authorId, createdAt])
62
+ }
@@ -0,0 +1,68 @@
1
+ -- Shipped by wiki-formant. The tables prisma/wiki.prisma declares, as
2
+ -- `prisma migrate diff` writes them, for a migration that moves an existing
3
+ -- wiki's rows in: create these, INSERT ... SELECT the old rows, retarget any
4
+ -- foreign keys, drop the old tables, then build search_tsv with fts-ddl.
5
+
6
+
7
+
8
+ -- CreateTable
9
+ CREATE TABLE "WikiPage" (
10
+ "id" TEXT NOT NULL DEFAULT (gen_random_uuid())::text,
11
+ "siteId" TEXT NOT NULL DEFAULT '',
12
+ "tagPath" TEXT NOT NULL DEFAULT '',
13
+ "slug" TEXT NOT NULL,
14
+ "title" TEXT NOT NULL,
15
+ "content" JSONB NOT NULL DEFAULT '[]',
16
+ "metadata" JSONB NOT NULL DEFAULT '{}',
17
+ "version" TEXT NOT NULL DEFAULT '1.0.0',
18
+ "authorId" TEXT,
19
+ "lastVerifiedAt" TIMESTAMPTZ(3),
20
+ "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21
+ "updatedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
22
+
23
+ CONSTRAINT "WikiPage_pkey" PRIMARY KEY ("id")
24
+ );
25
+
26
+ -- CreateTable
27
+ CREATE TABLE "WikiRevision" (
28
+ "id" TEXT NOT NULL DEFAULT (gen_random_uuid())::text,
29
+ "pageId" TEXT NOT NULL,
30
+ "title" TEXT NOT NULL,
31
+ "content" JSONB NOT NULL,
32
+ "version" TEXT NOT NULL,
33
+ "changeType" TEXT NOT NULL,
34
+ "message" TEXT,
35
+ "authorId" TEXT,
36
+ "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
37
+
38
+ CONSTRAINT "WikiRevision_pkey" PRIMARY KEY ("id")
39
+ );
40
+
41
+ -- CreateIndex
42
+ CREATE INDEX "WikiPage_siteId_tagPath_updatedAt_idx" ON "WikiPage"("siteId", "tagPath", "updatedAt");
43
+
44
+ -- CreateIndex
45
+ CREATE INDEX "WikiPage_siteId_updatedAt_idx" ON "WikiPage"("siteId", "updatedAt");
46
+
47
+ -- CreateIndex
48
+
49
+ -- CreateIndex
50
+ CREATE UNIQUE INDEX "WikiPage_siteId_tagPath_slug_key" ON "WikiPage"("siteId", "tagPath", "slug");
51
+
52
+ -- CreateIndex
53
+ CREATE INDEX "WikiRevision_pageId_createdAt_idx" ON "WikiRevision"("pageId", "createdAt");
54
+
55
+ -- CreateIndex
56
+ CREATE INDEX "WikiRevision_authorId_createdAt_idx" ON "WikiRevision"("authorId", "createdAt");
57
+
58
+ -- AddForeignKey
59
+ ALTER TABLE "WikiRevision" ADD CONSTRAINT "WikiRevision_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "WikiPage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
60
+
61
+
62
+ -- STRUCTURE S7: the Data API gets nothing. Prisma connects as postgres, which
63
+ -- bypasses RLS; with no policies, anon and authenticated read nothing.
64
+ ALTER TABLE "WikiPage" ENABLE ROW LEVEL SECURITY;
65
+ ALTER TABLE "WikiRevision" ENABLE ROW LEVEL SECURITY;
66
+
67
+ -- The full-text column and its index: `npx fts-ddl '"WikiPage"'` after the
68
+ -- rows are in, never a plain tsvector column (it would stay empty).