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/dist/store.d.ts
ADDED
|
@@ -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>>;
|
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/taxonomy.d.ts
CHANGED
|
@@ -149,7 +149,11 @@ export interface Taxonomy {
|
|
|
149
149
|
metadataRows: (tagPath: string, page: {
|
|
150
150
|
metadata?: unknown;
|
|
151
151
|
}) => MetadataRow[];
|
|
152
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* Whether a set has outgrown a grid: the one size rule a hub's browsing
|
|
154
|
+
* controls read. `alphaControls` applies it; a sort toggle reads it too, with
|
|
155
|
+
* the same unfiltered count, so the two appear and vanish together.
|
|
156
|
+
*/
|
|
153
157
|
needsAlphaIndex: (pageCount: number) => boolean;
|
|
154
158
|
href: (tagPath: string, state: CategoryState) => string;
|
|
155
159
|
rankRelated: <T extends FacetablePage>(page: T, siblings: T[], tagPath: string, limit?: number) => RelatedRanking<T>;
|
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;
|