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/dist/store.js ADDED
@@ -0,0 +1,237 @@
1
+ // store.ts – a wiki's pages and revisions, over the app's own connection.
2
+ //
3
+ // Three wikis each kept their own copy of this layer, on three schemas that had
4
+ // drifted apart: `pages(tag_path, slug)` with cuid ids, `wiki_pages(caper_id,
5
+ // tag_path, slug)` with int ids and no version, `WikiPage(section, slug)` with
6
+ // no author. Each copy wrote its page and its revision its own way, and two of
7
+ // them read the page, diffed it, and wrote it back with nothing to stop a
8
+ // second editor's save landing in between and being overwritten.
9
+ //
10
+ // The tables are now the package's (`prisma/wiki.prisma`, copied by the
11
+ // `prisma-models` bin), so the SQL is too. The app supplies one `Sql` function
12
+ // and, in a multi-tenant app, the site the store reads and writes. Caching
13
+ // stays with the caller, because each framework spells it differently.
14
+ //
15
+ // A write is one statement: the page update and its revision row are a
16
+ // data-modifying CTE guarded on the version the save read, so a save that lost
17
+ // a race writes nothing and says so (409) instead of erasing the other edit.
18
+ import { computeRevisionDiff } from './revisions.js';
19
+ import { bump } from './versioning.js';
20
+ import { withChanges } from './history.js';
21
+ import { proseSql, escapeLikeTerm, HEADLINE_OPTIONS, FTS_RANK_NORMALIZATION } from './search.js';
22
+ import { openingText, matchSnippet } from './text.js';
23
+ import { describe } from './metadata.js';
24
+ import { decodeEntities } from './markdown.js';
25
+ import { HttpError } from './http.js';
26
+ const PAGE = '"WikiPage"';
27
+ const REVISION = '"WikiRevision"';
28
+ const COLUMNS = `id, "siteId", "tagPath", slug, title, content, metadata, version, "authorId", "lastVerifiedAt", "createdAt", "updatedAt"`;
29
+ const REVISION_COLUMNS = `id, "pageId", title, content, version, "changeType", message, "authorId", "createdAt"`;
30
+ const REVISION_COUNT = `(SELECT count(*)::int FROM ${REVISION} r WHERE r."pageId" = p.id) AS "revisionCount"`;
31
+ const INITIAL_VERSION = '1.0.0';
32
+ /** A row as a listing carries it, for an app that runs a listing query of its own. */
33
+ export function toListing(row) {
34
+ const { content, ...rest } = row;
35
+ return { ...rest, snippet: describe(openingText(content)) };
36
+ }
37
+ const conflict = (message) => new HttpError(409, message);
38
+ /** Re-order rows to match an id list, dropping ids that no longer resolve. */
39
+ export function orderByIds(rows, ids) {
40
+ const byId = new Map(rows.map(row => [row.id, row]));
41
+ return ids.map(id => byId.get(id)).filter((row) => row !== undefined);
42
+ }
43
+ export function wikiStore(o) {
44
+ const site = o.siteId ?? '';
45
+ const opening = o.snippet ?? ((page) => describe(openingText(page.content)));
46
+ const listing = (row) => {
47
+ const { content: _, ...rest } = row;
48
+ return { ...rest, snippet: opening(row) };
49
+ };
50
+ const hidden = [...(o.hidden ?? [])];
51
+ const rows = async (query, ...values) => (await o.sql(query, ...values));
52
+ const one = async (query, ...values) => (await rows(query, ...values))[0] ?? null;
53
+ const isHidden = (tagPath) => hidden.some(h => tagPath === h || tagPath.startsWith(`${h}/`));
54
+ /** A page with how many revisions it has, which every article's foot states. */
55
+ const get = (tagPath, slug) => one(`SELECT ${COLUMNS}, ${REVISION_COUNT} FROM ${PAGE} p WHERE "siteId" = $1 AND "tagPath" = $2 AND slug = $3`, site, tagPath, slug);
56
+ const getById = (id) => one(`SELECT ${COLUMNS}, ${REVISION_COUNT} FROM ${PAGE} p WHERE "siteId" = $1 AND id = $2`, site, id);
57
+ const ORDER = {
58
+ title: 'title ASC',
59
+ recent: '"updatedAt" DESC',
60
+ // A post or an event is as recent as the day it is about.
61
+ date: `NULLIF(metadata->>'date', '') DESC NULLS LAST, "updatedAt" DESC`,
62
+ };
63
+ /** A category's pages, its hub article (the empty slug) excepted: the hub heads the page, it is not a card in it. */
64
+ const list = async (tagPath, { sort = 'title', limit = 200, offset = 0 } = {}) => (await rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND "tagPath" = $2 AND slug <> '' ORDER BY ${ORDER[sort]} LIMIT $3::int OFFSET $4::int`, site, tagPath, limit, offset)).map(listing);
65
+ /** A category's pages with their content, for a listing that reads the bodies (a reading time, a word count). */
66
+ const articles = (tagPath, { sort = 'title', limit = 200, offset = 0 } = {}) => rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND "tagPath" = $2 AND slug <> '' ORDER BY ${ORDER[sort]} LIMIT $3::int OFFSET $4::int`, site, tagPath, limit, offset);
67
+ /** Every page under a category, by title: the card that lists a subcategory's articles. */
68
+ const subtree = (tagPath) => rows(`SELECT "tagPath", slug, title FROM ${PAGE}
69
+ WHERE "siteId" = $1 AND "tagPath" LIKE $2 AND slug <> '' AND NOT ("tagPath" = ANY($3::text[]))
70
+ ORDER BY title ASC`, site, `${escapeLikeTerm(tagPath)}/%`, hidden);
71
+ /**
72
+ * The newest pages. One that names a path gets that path, hidden or not; one
73
+ * that names none is asking what changed on the wiki, and the answer is
74
+ * article space.
75
+ */
76
+ const recent = async ({ tagPath, limit = 10 } = {}) => (await (tagPath !== undefined
77
+ ? rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND "tagPath" = $2 ORDER BY "updatedAt" DESC LIMIT $3::int`, site, tagPath, limit)
78
+ : rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND NOT ("tagPath" = ANY($2::text[])) ORDER BY "updatedAt" DESC LIMIT $3::int`, site, hidden, limit))).map(listing);
79
+ const byIds = async (ids) => ids.length
80
+ ? orderByIds((await rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND id = ANY($2::text[])`, site, [...ids])).map(listing), ids)
81
+ : [];
82
+ /** Every page without its content: the sitemap, llms.txt and a site's page list. */
83
+ const refs = () => rows(`SELECT id, "siteId", "tagPath", slug, title, metadata, version, "authorId", "lastVerifiedAt", "createdAt", "updatedAt" FROM ${PAGE} WHERE "siteId" = $1 ORDER BY "tagPath", slug`, site);
84
+ /** Every page with its content, for a corpus export. Hidden paths are left out. */
85
+ const corpus = () => rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND NOT ("tagPath" = ANY($2::text[])) ORDER BY "tagPath", slug`, site, hidden);
86
+ const stats = async () => {
87
+ const row = await one(`SELECT count(*)::int AS pages, max("updatedAt") AS updated FROM ${PAGE} WHERE "siteId" = $1`, site);
88
+ return { pages: row?.pages ?? 0, updated: row?.updated ?? null };
89
+ };
90
+ /**
91
+ * Ranked search over titles and prose, in four tiers:
92
+ *
93
+ * 0 title starts with the term
94
+ * 1 title contains it
95
+ * 2 prose contains it, literally
96
+ * 3 full-text match on `search_tsv`: stems, stopwords dropped, ranked
97
+ *
98
+ * The literal tiers keep identifiers, tickers and version strings working,
99
+ * which the english dictionary folds or splits; tier 3 is reached only by a
100
+ * query they could not answer, so it adds recall without displacing anything.
101
+ * Hidden paths are left out unless `tagPath` names one: a maintenance log
102
+ * quotes every edit and would head every body match. The home
103
+ * page is never a result. A tier-3 row's snippet is Postgres' own headline,
104
+ * because a stem match has no literal substring to window on.
105
+ */
106
+ const search = async (query, { tagPath = null, skip = 0, take = 25 } = {}) => {
107
+ const term = escapeLikeTerm(query);
108
+ if (!term)
109
+ return { items: [], total: 0 };
110
+ const prose = proseSql('p.content');
111
+ const found = await rows(`WITH q AS (SELECT websearch_to_tsquery('english', $1) AS tsq),
112
+ matched AS (
113
+ SELECT p.*, q.tsq,
114
+ CASE WHEN p.title ILIKE $2 THEN 0
115
+ WHEN p.title ILIKE $3 THEN 1
116
+ WHEN NOT (p."tagPath" = ANY($4::text[])) AND ${prose} ILIKE $3 THEN 2
117
+ ELSE 3 END AS tier,
118
+ ts_rank_cd(p.search_tsv, q.tsq, ${FTS_RANK_NORMALIZATION}) AS fts
119
+ FROM ${PAGE} p CROSS JOIN q
120
+ WHERE p."siteId" = $5 AND NOT (p."tagPath" = '' AND p.slug = '')
121
+ AND ($6::text IS NOT NULL OR NOT (p."tagPath" = ANY($4::text[])))
122
+ AND ($6::text IS NULL OR p."tagPath" = $6)
123
+ AND (p.title ILIKE $3
124
+ OR (NOT (p."tagPath" = ANY($4::text[]))
125
+ AND (${prose} ILIKE $3 OR (q.tsq IS NOT NULL AND p.search_tsv @@ q.tsq))))
126
+ ),
127
+ paged AS (
128
+ SELECT *, count(*) OVER ()::int AS total FROM matched
129
+ ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC
130
+ LIMIT $7::int OFFSET $8::int
131
+ )
132
+ SELECT ${COLUMNS}, tier, total,
133
+ CASE WHEN tier = 3 THEN ts_headline('english', ${proseSql()}, tsq, '${HEADLINE_OPTIONS}') END AS headline
134
+ FROM paged
135
+ ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC`, query.trim(), `${term}%`, `%${term}%`, hidden, site, tagPath, take, skip);
136
+ const items = found.map(({ tier, total: _, headline, content, ...row }) => ({
137
+ ...row,
138
+ score: tier,
139
+ snippet: (headline ? decodeEntities(headline).replace(/\s+/g, ' ').trim() : '')
140
+ || matchSnippet(content, query, () => opening({ content: content, metadata: row.metadata })),
141
+ }));
142
+ return { items, total: found[0]?.total ?? 0 };
143
+ };
144
+ /**
145
+ * A page's revisions, newest first, each with what changed against the one
146
+ * before it. `limit` counts what is returned; one more is read so the oldest
147
+ * shown is diffed against its real predecessor, not against nothing.
148
+ */
149
+ const history = async (pageId, { limit = 50, offset = 0 } = {}) => {
150
+ const revisions = await rows(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 ORDER BY "createdAt" DESC, id DESC LIMIT $2::int OFFSET $3::int`, pageId, limit + 1, offset);
151
+ const entries = withChanges(revisions, { containers: o.containers });
152
+ return revisions.length > limit ? entries.slice(0, limit) : entries;
153
+ };
154
+ const revision = (pageId, revisionId) => one(`SELECT ${REVISION_COLUMNS} FROM ${REVISION} WHERE "pageId" = $1 AND id = $2`, pageId, revisionId);
155
+ const create = async (input) => {
156
+ if (input.title === undefined || input.content === undefined)
157
+ throw new HttpError(400, 'A new page needs a title and content');
158
+ const page = await one(`WITH up AS (
159
+ INSERT INTO ${PAGE} ("siteId", "tagPath", slug, title, content, metadata, version, "authorId", "lastVerifiedAt")
160
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, CASE WHEN $9::boolean THEN now() END)
161
+ ON CONFLICT ("siteId", "tagPath", slug) DO NOTHING
162
+ RETURNING ${COLUMNS}
163
+ ), rev AS (
164
+ INSERT INTO ${REVISION} ("pageId", title, content, version, "changeType", message, "authorId")
165
+ SELECT id, title, content, version, 'major', $10::text, "authorId" FROM up
166
+ )
167
+ SELECT * FROM up`, site, input.tagPath, input.slug, input.title, JSON.stringify(input.content), JSON.stringify(input.metadata ?? {}), INITIAL_VERSION, input.authorId ?? null, input.verified === true, input.message ?? 'Initial version');
168
+ if (!page)
169
+ throw conflict('A page at that path was created while this one was being written');
170
+ return { page, created: true, changeType: 'major', summary: 'page created' };
171
+ };
172
+ /**
173
+ * Create or update a page, writing a revision whenever its title or blocks
174
+ * changed. The bump is the diff's (`major` for structure, `minor` for prose)
175
+ * unless `change` overrides it; a metadata-only save with no `change` moves no
176
+ * version and writes no revision, the way an infobox fix has always behaved.
177
+ */
178
+ const save = async (input) => {
179
+ const existing = await get(input.tagPath, input.slug);
180
+ if (!existing)
181
+ return create(input);
182
+ if (input.baseVersion !== undefined && input.baseVersion !== existing.version) {
183
+ throw conflict(`This page changed since it was opened: it is at v${existing.version} now, not v${input.baseVersion}. Reload it and apply the edit again.`);
184
+ }
185
+ const slug = input.newSlug && input.newSlug !== existing.slug ? input.newSlug : existing.slug;
186
+ if (slug !== existing.slug && (await get(existing.tagPath, slug)))
187
+ throw conflict('A page with that slug already exists in this category');
188
+ const title = input.title ?? existing.title;
189
+ const content = input.content ?? existing.content;
190
+ const diff = computeRevisionDiff({
191
+ currentVersion: existing.version,
192
+ oldContent: existing.content,
193
+ newContent: content,
194
+ oldTitle: existing.title,
195
+ newTitle: title,
196
+ containers: o.containers,
197
+ });
198
+ const changeType = input.change ?? diff.changeType;
199
+ const version = changeType === 'none' ? existing.version : bump(existing.version, changeType);
200
+ const page = await one(`WITH up AS (
201
+ UPDATE ${PAGE} SET title = $3, slug = $4, content = $5::jsonb, metadata = COALESCE($6::jsonb, metadata),
202
+ version = $7, "updatedAt" = now(),
203
+ "lastVerifiedAt" = CASE WHEN $8::boolean THEN now() ELSE "lastVerifiedAt" END
204
+ WHERE id = $1 AND version = $2
205
+ RETURNING ${COLUMNS}
206
+ ), rev AS (
207
+ INSERT INTO ${REVISION} ("pageId", title, content, version, "changeType", message, "authorId")
208
+ SELECT id, title, content, version, $9::text, $10::text, $11::text FROM up WHERE $9::text <> 'none'
209
+ )
210
+ SELECT * FROM up`, existing.id, existing.version, title, slug, JSON.stringify(content), input.metadata === undefined ? null : JSON.stringify(input.metadata), version, input.verified === true, changeType, input.message ?? diff.summary, input.authorId ?? null);
211
+ if (!page)
212
+ throw conflict('This page was saved by someone else while this edit was being written. Reload it and apply the edit again.');
213
+ return { page, created: false, changeType, summary: diff.summary };
214
+ };
215
+ /** Put a page back as a revision had it, as a major change of its own. */
216
+ const restore = async (tagPath, slug, revisionId, by = {}) => {
217
+ const page = await get(tagPath, slug);
218
+ if (!page)
219
+ throw new HttpError(404, 'Page not found');
220
+ const rev = await revision(page.id, revisionId);
221
+ if (!rev)
222
+ throw new HttpError(404, 'Revision not found');
223
+ return save({
224
+ tagPath, slug, title: rev.title, content: rev.content, authorId: by.authorId,
225
+ change: 'major', message: `Restored to v${rev.version}`, baseVersion: page.version,
226
+ });
227
+ };
228
+ /** Deletes a page and its history. True when there was one to delete. */
229
+ const remove = async (tagPath, slug) => (await rows(`DELETE FROM ${PAGE} WHERE "siteId" = $1 AND "tagPath" = $2 AND slug = $3 RETURNING id`, site, tagPath, slug)).length > 0;
230
+ /** Stamps a page's facts as checked without editing it. */
231
+ const verify = async (tagPath, slug) => (await rows(`UPDATE ${PAGE} SET "lastVerifiedAt" = now() WHERE "siteId" = $1 AND "tagPath" = $2 AND slug = $3 RETURNING id`, site, tagPath, slug)).length > 0;
232
+ return {
233
+ siteId: site, hidden, isHidden,
234
+ get, getById, list, articles, subtree, recent, byIds, refs, corpus, stats, search,
235
+ history, revision, save, restore, remove, verify,
236
+ };
237
+ }
package/dist/text.d.ts CHANGED
@@ -28,6 +28,12 @@ export type BannerVariant = 'stub' | 'unsourced' | 'outdated' | 'promotional' |
28
28
  * the markdown twin and the MDX export all render the same six strings.
29
29
  */
30
30
  export declare const BANNER_LABELS: Record<BannerVariant, string>;
31
+ /**
32
+ * The sentence under each banner when the block carries none of its own,
33
+ * naming no wiki, so any wiki may show it. A wiki that words a notice for its
34
+ * own subject (radix-wiki's names the Radix ecosystem) passes its own.
35
+ */
36
+ export declare const BANNER_MESSAGES: Record<BannerVariant, string>;
31
37
  /**
32
38
  * The banner variants as an editor's option list, in the order a writer meets
33
39
  * them: the two that describe a gap first, then the three that describe a flaw.
package/dist/text.js CHANGED
@@ -68,6 +68,19 @@ export const BANNER_LABELS = {
68
68
  cleanup: 'Needs cleanup',
69
69
  coi: 'Conflict of interest',
70
70
  };
71
+ /**
72
+ * The sentence under each banner when the block carries none of its own,
73
+ * naming no wiki, so any wiki may show it. A wiki that words a notice for its
74
+ * own subject (radix-wiki's names the Radix ecosystem) passes its own.
75
+ */
76
+ export const BANNER_MESSAGES = {
77
+ stub: 'This article is a stub. You can help by expanding it.',
78
+ unsourced: 'This article needs additional citations for verification. Please add references to reliable sources.',
79
+ outdated: 'Some information here may be out of date. Please help bring it up to date.',
80
+ promotional: 'This article may read like an advertisement. Please help rewrite it from a neutral point of view.',
81
+ cleanup: "This article may require cleanup to meet the wiki's quality standards.",
82
+ coi: 'This page may have been written by the subject it describes, and may need review for a neutral point of view.',
83
+ };
71
84
  /**
72
85
  * The banner variants as an editor's option list, in the order a writer meets
73
86
  * them: the two that describe a gap first, then the three that describe a flaw.
package/dist/tree.d.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { MetadataKeyDefinition } from './taxonomy.js';
2
+ import type { ListSort } from './store.js';
3
+ export interface WikiNode {
4
+ /** The path segment. A root node with the empty slug stands for the home page. */
5
+ slug: string;
6
+ name: string;
7
+ description?: string;
8
+ /** A glyph or an icon name; the app draws it. */
9
+ icon?: string;
10
+ /**
11
+ * The article that *is* this category's topic, as a full `tagPath/slug`. It
12
+ * need not live in the category. Inherited: the nearest declaration down the
13
+ * trail is the series a page under it belongs to.
14
+ */
15
+ mainArticle?: string;
16
+ /** Inherited: a node beneath a hidden one is back office too. */
17
+ hidden?: boolean;
18
+ /** A listing's default order. Inherited. */
19
+ sort?: ListSort;
20
+ /** Accumulated down the trail, in declaration order. */
21
+ metadataKeys?: readonly MetadataKeyDefinition[];
22
+ /** Slugs under this node in the order a listing should lead with; the rest follow. */
23
+ pages?: readonly string[];
24
+ children?: readonly WikiNode[];
25
+ }
26
+ export interface Resolved<N extends WikiNode> {
27
+ node: N | null;
28
+ /** Every node from the root to this one. Empty when the path is not in the tree. */
29
+ trail: N[];
30
+ valid: boolean;
31
+ hidden: boolean;
32
+ sort: ListSort;
33
+ mainArticle?: string;
34
+ metadataKeys: MetadataKeyDefinition[];
35
+ }
36
+ export interface TreePath<N extends WikiNode> {
37
+ path: string;
38
+ node: N;
39
+ hidden: boolean;
40
+ depth: number;
41
+ }
42
+ export interface TreeOptions {
43
+ /** Keys every page carries whatever its category, such as a quality grade. Listed after the trail's. */
44
+ globalKeys?: readonly MetadataKeyDefinition[];
45
+ }
46
+ export declare function createTree<N extends WikiNode>(nodes: readonly N[], opts?: TreeOptions): {
47
+ nodes: readonly N[];
48
+ resolve: (tagPath: string | readonly string[]) => Resolved<N>;
49
+ paths: () => TreePath<N>[];
50
+ /** Back-office paths, which a listing leaves out unless it names one. Pass to `wikiStore({ hidden })`. */
51
+ hiddenPaths: string[];
52
+ isValid: (tagPath: string | readonly string[]) => boolean;
53
+ node: (tagPath: string | readonly string[]) => N | null;
54
+ metadataKeys: (tagPath: string | readonly string[]) => MetadataKeyDefinition[];
55
+ /** A category's name, or its last segment unslugged when the tree does not know it. */
56
+ label: (tagPath: string | readonly string[]) => string;
57
+ /**
58
+ * One crumb per segment, so the trail can never be shorter than the URL. A
59
+ * segment the tree knows is a link to its category; one it does not is text.
60
+ */
61
+ crumbs: (tagPath: string | readonly string[]) => {
62
+ path: string;
63
+ label: string;
64
+ linked: boolean;
65
+ }[];
66
+ };
67
+ export type WikiTree<N extends WikiNode = WikiNode> = ReturnType<typeof createTree<N>>;
68
+ /** The views a page or a category has beside itself, each a final path segment. */
69
+ export declare const WIKI_MODES: readonly ["edit", "history"];
70
+ export type WikiMode = (typeof WIKI_MODES)[number];
71
+ export interface ParsedWikiPath<M extends string = WikiMode> {
72
+ type: 'home' | 'category' | 'page' | 'invalid';
73
+ tagPath: string;
74
+ slug: string;
75
+ mode: M | null;
76
+ }
77
+ /**
78
+ * What a path means: the home page, a category (its hub article is the empty
79
+ * slug), a page, or nothing, each optionally in one of `modes`. A path that is
80
+ * itself a category wins over a mode suffix, so a category named `history` is
81
+ * still reachable; `/<category>/edit` edits the category's hub.
82
+ *
83
+ * Static routes are the app's: check them before calling this.
84
+ */
85
+ export declare function parseWikiPath<M extends string = WikiMode>(tree: Pick<WikiTree, 'isValid'>, segments?: readonly string[], { modes }?: {
86
+ modes?: readonly M[];
87
+ }): ParsedWikiPath<M>;
88
+ /**
89
+ * A slug no page in `tagPath` can hold, because `parseWikiPath` reads that URL
90
+ * as something else: a mode, or a child category. Creating a page treats it as
91
+ * taken.
92
+ */
93
+ export declare function isReservedSlug(tree: Pick<WikiTree, 'isValid'>, tagPath: string, slug: string, modes?: readonly string[]): boolean;
94
+ /** A page's path: the category for its hub, `/` for the home page. */
95
+ export declare function wikiPath(tagPath: string, slug: string): string;
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.60.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
+ }