wiki-formant 0.26.1 → 0.27.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 +29 -0
- package/dist/base.css +13 -0
- package/dist/history.d.ts +37 -0
- package/dist/history.js +67 -0
- package/dist/react-server.d.ts +14 -0
- package/dist/react-server.js +18 -0
- package/dist/revisions.d.ts +6 -0
- package/dist/revisions.js +16 -0
- package/package.json +12 -3
package/README.md
CHANGED
|
@@ -387,6 +387,35 @@ One copy also built two Maps keyed by a recursive `JSON.stringify` of every
|
|
|
387
387
|
block, on every save, and never read either one. Matching is by id and always
|
|
388
388
|
was. That half is not here.
|
|
389
389
|
|
|
390
|
+
## History
|
|
391
|
+
|
|
392
|
+
What each revision changed, as a history view shows it. `revisions` stays free
|
|
393
|
+
of dependencies because every save imports it; this is the reading half, and it
|
|
394
|
+
needs `fast-diff`, an optional peer.
|
|
395
|
+
|
|
396
|
+
```tsx
|
|
397
|
+
import { withChanges } from 'wiki-formant/history';
|
|
398
|
+
import { RevisionChanges } from 'wiki-formant/react-server';
|
|
399
|
+
|
|
400
|
+
const rows = withChanges(revisionsNewestFirst); // each row needs its `content`
|
|
401
|
+
<RevisionChanges changes={rows[0].changes} label={type => BLOCK_LABELS[type]} />
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
- **Diffed on the server, from stored content.** radix-wiki used to send both
|
|
405
|
+
HTML bodies of every modified block to the browser and diff them there: 887 KB
|
|
406
|
+
of payload for a 13-revision page whose drawn diffs came to 25 KB. A row
|
|
407
|
+
leaves `withChanges` without its content.
|
|
408
|
+
- **Words, not letters.** Semantic cleanup is on, so a rewritten sentence reads
|
|
409
|
+
as the old words struck and the new ones added.
|
|
410
|
+
- **Leaves only.** A container's own entry restates its children. A leaf whose
|
|
411
|
+
type changed is a replacement and gets no text diff. `leafText` names the prose
|
|
412
|
+
field of a block beyond the core `content` one.
|
|
413
|
+
- **A window diffs against what precedes it.** The oldest row is diffed against
|
|
414
|
+
an empty page, so fetch one revision more than you show and drop the last row.
|
|
415
|
+
|
|
416
|
+
`RevisionChanges` marks struck and added text as `<del>` and `<ins>` and keys
|
|
417
|
+
each change on `data-action`; `base.css` only resets the list.
|
|
418
|
+
|
|
390
419
|
## Feeds
|
|
391
420
|
|
|
392
421
|
Three repos, four feeds, 47 of 64 significant lines identical — including,
|
package/dist/base.css
CHANGED
|
@@ -103,3 +103,16 @@ pre:hover > .code-copy-btn,
|
|
|
103
103
|
[aria-sort='descending'] > .sort-header::after {
|
|
104
104
|
--sort-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14'/%3E%3Cpath d='m19 12-7 7-7-7'/%3E%3C/svg%3E");
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
/* ---- RevisionChanges ------------------------------------------------------
|
|
108
|
+
A list of changes rather than a bulleted one, each diff directly under its
|
|
109
|
+
heading line. `del` and `ins` keep their browser defaults, a strike and an
|
|
110
|
+
underline, so a diff still reads before a stylesheet colours it. */
|
|
111
|
+
.revision-changes {
|
|
112
|
+
display: grid;
|
|
113
|
+
gap: 0.5em;
|
|
114
|
+
margin: 0;
|
|
115
|
+
padding: 0;
|
|
116
|
+
list-style: none;
|
|
117
|
+
}
|
|
118
|
+
.revision-diff { margin: 0.25em 0 0; }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type BlockChange, type BlockGroup, type DiffBlock } from './revisions.js';
|
|
2
|
+
/** A stretch of text: -1 struck, 0 kept, 1 added. */
|
|
3
|
+
export type DiffPart = [-1 | 0 | 1, string];
|
|
4
|
+
/** A change as a history view lists it: the diff it draws, not the two bodies behind it. */
|
|
5
|
+
export type HistoryChange = BlockChange<DiffPart[]>;
|
|
6
|
+
/**
|
|
7
|
+
* Two HTML bodies as the words struck and added between them, or `undefined`
|
|
8
|
+
* when their text is the same. Semantic cleanup is on: without it a rewritten
|
|
9
|
+
* sentence comes out as fragments of the letters the two versions share.
|
|
10
|
+
*/
|
|
11
|
+
export declare function textDiff(from: string, to: string): DiffPart[] | undefined;
|
|
12
|
+
export interface HistoryOptions<B> {
|
|
13
|
+
/** Defaults to `coreBlockGroups`, as in `diffBlocks`. */
|
|
14
|
+
containers?: (block: B) => BlockGroup<B>[] | null;
|
|
15
|
+
/**
|
|
16
|
+
* The HTML a leaf's diff is drawn from, or `undefined` for a block with no
|
|
17
|
+
* prose. Defaults to a `content` block's `text`, the core model's one prose leaf.
|
|
18
|
+
*/
|
|
19
|
+
leafText?: (block: B) => string | undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* What changed between two versions of a page, leaves only: a container's own
|
|
23
|
+
* entry restates its children. A leaf whose type changed is a replacement, and
|
|
24
|
+
* one side's text against the other's would read as an edit, so it gets none.
|
|
25
|
+
*/
|
|
26
|
+
export declare function historyChanges<B extends DiffBlock>(oldContent: readonly B[], newContent: readonly B[], opts?: HistoryOptions<B>): HistoryChange[];
|
|
27
|
+
/**
|
|
28
|
+
* A page's revisions, newest first, each carrying what changed against the one
|
|
29
|
+
* after it in the list and without the content it was diffed from. The last is
|
|
30
|
+
* diffed against an empty page, so a caller showing a window of the history
|
|
31
|
+
* passes one revision more than it shows and drops the last result.
|
|
32
|
+
*/
|
|
33
|
+
export declare function withChanges<R extends {
|
|
34
|
+
content: unknown;
|
|
35
|
+
}, B extends DiffBlock = DiffBlock>(revisions: readonly R[], opts?: HistoryOptions<B>): (Omit<R, 'content'> & {
|
|
36
|
+
changes: HistoryChange[];
|
|
37
|
+
})[];
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// history.ts – what changed between revisions, as a history view draws it.
|
|
2
|
+
//
|
|
3
|
+
// `revisions.ts` decides which bump a save asks for and stays free of
|
|
4
|
+
// dependencies, because every write path imports it. This is the reading half:
|
|
5
|
+
// the same walk, turned into what a reviewer is shown. It needs a text diff, so
|
|
6
|
+
// it needs `fast-diff`, an optional peer; hence its own subpath.
|
|
7
|
+
//
|
|
8
|
+
// radix-wiki had it first and shipped both HTML bodies of every modified block
|
|
9
|
+
// to the browser to diff there: 887 KB of payload for a 13-revision page whose
|
|
10
|
+
// drawn diffs came to 25 KB. The other two wikis showed a revision's message
|
|
11
|
+
// and nothing of what it changed.
|
|
12
|
+
import fastDiff from 'fast-diff';
|
|
13
|
+
import { diffBlocks, extractBlocks } from './revisions.js';
|
|
14
|
+
import { coreBlockGroups } from './blocks.js';
|
|
15
|
+
import { stripHtml } from './text.js';
|
|
16
|
+
/** Kept text is context, so a run longer than this keeps only its ends. */
|
|
17
|
+
const KEPT_MAX = 60;
|
|
18
|
+
const clip = (t) => (t.length > KEPT_MAX ? `${t.slice(0, 30)}…${t.slice(-30)}` : t);
|
|
19
|
+
/**
|
|
20
|
+
* Two HTML bodies as the words struck and added between them, or `undefined`
|
|
21
|
+
* when their text is the same. Semantic cleanup is on: without it a rewritten
|
|
22
|
+
* sentence comes out as fragments of the letters the two versions share.
|
|
23
|
+
*/
|
|
24
|
+
export function textDiff(from, to) {
|
|
25
|
+
const a = stripHtml(from);
|
|
26
|
+
const b = stripHtml(to);
|
|
27
|
+
if (a === b)
|
|
28
|
+
return undefined;
|
|
29
|
+
const parts = fastDiff(a, b, undefined, true)
|
|
30
|
+
.filter(([, t]) => t.trim())
|
|
31
|
+
.map(([op, t]) => [op, op === 0 ? clip(t) : t]);
|
|
32
|
+
return parts.length ? parts : undefined;
|
|
33
|
+
}
|
|
34
|
+
const contentText = (block) => block.type === 'content' ? block.text : undefined;
|
|
35
|
+
/**
|
|
36
|
+
* What changed between two versions of a page, leaves only: a container's own
|
|
37
|
+
* entry restates its children. A leaf whose type changed is a replacement, and
|
|
38
|
+
* one side's text against the other's would read as an edit, so it gets none.
|
|
39
|
+
*/
|
|
40
|
+
export function historyChanges(oldContent, newContent, opts = {}) {
|
|
41
|
+
const containers = opts.containers ?? (coreBlockGroups);
|
|
42
|
+
const leafText = opts.leafText ?? contentText;
|
|
43
|
+
const containerIds = new Set([...extractBlocks(oldContent, containers), ...extractBlocks(newContent, containers)]
|
|
44
|
+
.filter(({ block }) => containers(block))
|
|
45
|
+
.map(({ block }) => block.id));
|
|
46
|
+
return diffBlocks(oldContent, newContent, {
|
|
47
|
+
containers,
|
|
48
|
+
leafDiff: (from, to) => {
|
|
49
|
+
const a = from ? leafText(from) : '';
|
|
50
|
+
const b = to ? leafText(to) : '';
|
|
51
|
+
return a === undefined || b === undefined ? undefined : textDiff(a, b);
|
|
52
|
+
},
|
|
53
|
+
}).filter(c => !containerIds.has(c.id));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A page's revisions, newest first, each carrying what changed against the one
|
|
57
|
+
* after it in the list and without the content it was diffed from. The last is
|
|
58
|
+
* diffed against an empty page, so a caller showing a window of the history
|
|
59
|
+
* passes one revision more than it shows and drops the last result.
|
|
60
|
+
*/
|
|
61
|
+
export function withChanges(revisions, opts = {}) {
|
|
62
|
+
const blocks = (r) => (r?.content ?? []);
|
|
63
|
+
return revisions.map((revision, i) => {
|
|
64
|
+
const { content: _, ...rest } = revision;
|
|
65
|
+
return { ...rest, changes: historyChanges(blocks(revisions[i + 1]), blocks(revision), opts) };
|
|
66
|
+
});
|
|
67
|
+
}
|
package/dist/react-server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from 'react';
|
|
2
2
|
import type { Control, FacetControlGroup, MetadataRow } from './taxonomy.js';
|
|
3
|
+
import type { HistoryChange } from './history.js';
|
|
3
4
|
/**
|
|
4
5
|
* The shape a router's link component has to satisfy here.
|
|
5
6
|
*
|
|
@@ -253,3 +254,16 @@ export interface InfoboxFactsProps {
|
|
|
253
254
|
* shares its value — the way into the set, not dead text.
|
|
254
255
|
*/
|
|
255
256
|
export declare function InfoboxFacts({ rows, link: Link, formatValue }: InfoboxFactsProps): import("react").JSX.Element | null;
|
|
257
|
+
export interface RevisionChangesProps {
|
|
258
|
+
/** One revision's changes, from `historyChanges` or `withChanges` in `wiki-formant/history`. */
|
|
259
|
+
changes: readonly HistoryChange[];
|
|
260
|
+
/** What a block type is called where a reader sees it, such as the editor's label. Defaults to the type. */
|
|
261
|
+
label?: (type: string) => string;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* What one revision changed, block by block, each prose leaf with its text
|
|
265
|
+
* diff. Struck and added text are `<del>` and `<ins>`, which a screen reader
|
|
266
|
+
* announces as such; their colours, and the action's, keyed on `data-action`,
|
|
267
|
+
* are the importing stylesheet's.
|
|
268
|
+
*/
|
|
269
|
+
export declare function RevisionChanges({ changes, label }: RevisionChangesProps): import("react").JSX.Element | null;
|
package/dist/react-server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
21
21
|
import { Fragment } from 'react';
|
|
22
22
|
import { safeLinkHref } from './validation.js';
|
|
23
23
|
import { isoDate } from './html.js';
|
|
24
|
+
import { blockLocation } from './revisions.js';
|
|
24
25
|
/**
|
|
25
26
|
* The default for anything that does not need client-side navigation.
|
|
26
27
|
*
|
|
@@ -207,3 +208,20 @@ export function InfoboxFacts({ rows, link: Link = Anchor, formatValue = formatFa
|
|
|
207
208
|
return null;
|
|
208
209
|
return (_jsx("table", { className: "infobox-facts", children: _jsx("tbody", { children: rows.map(row => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: row.label }), _jsx("td", { children: row.href ? _jsx(Link, { href: row.href, children: row.value }) : formatValue(row.value, row.type) })] }, row.label))) }) }));
|
|
209
210
|
}
|
|
211
|
+
const ACTION_LABEL = {
|
|
212
|
+
added: 'Added',
|
|
213
|
+
removed: 'Removed',
|
|
214
|
+
modified: 'Modified',
|
|
215
|
+
moved: 'Moved',
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* What one revision changed, block by block, each prose leaf with its text
|
|
219
|
+
* diff. Struck and added text are `<del>` and `<ins>`, which a screen reader
|
|
220
|
+
* announces as such; their colours, and the action's, keyed on `data-action`,
|
|
221
|
+
* are the importing stylesheet's.
|
|
222
|
+
*/
|
|
223
|
+
export function RevisionChanges({ changes, label = type => type }) {
|
|
224
|
+
if (!changes.length)
|
|
225
|
+
return null;
|
|
226
|
+
return (_jsx("ul", { className: "revision-changes", children: changes.map((c, i) => (_jsxs("li", { className: "revision-change", "data-action": c.action, children: [_jsx("span", { className: "revision-change-action", children: ACTION_LABEL[c.action] }), ` – ${label(c.type)} at ${blockLocation(c.path)}`, c.leafDiff && (_jsx("p", { className: "revision-diff", children: c.leafDiff.map(([op, text], j) => op === -1 ? _jsx("del", { children: text }, j) : op === 1 ? _jsx("ins", { children: text }, j) : _jsx("span", { children: text }, j)) }))] }, i))) }));
|
|
227
|
+
}
|
package/dist/revisions.d.ts
CHANGED
|
@@ -56,6 +56,12 @@ interface Located<B> {
|
|
|
56
56
|
block: B;
|
|
57
57
|
path: string;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* A path as a reader says it: `root.1.columns.0.blocks.2` is
|
|
61
|
+
* "Block 2 → Column 1 → Block 3". Positions count from one; a segment that
|
|
62
|
+
* names a group (`blocks`, `columns`) only says what the next number counts.
|
|
63
|
+
*/
|
|
64
|
+
export declare function blockLocation(path: string): string;
|
|
59
65
|
/** Every block in the tree, flattened, each with the path that addresses it. */
|
|
60
66
|
export declare function extractBlocks<B>(blocks: readonly B[], containers: (block: B) => BlockGroup<B>[] | null, basePath?: string): Located<B>[];
|
|
61
67
|
/**
|
package/dist/revisions.js
CHANGED
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
// Matching is by id.
|
|
7
7
|
import { incrementVersion, parseVersion } from './versioning.js';
|
|
8
8
|
import { coreBlockGroups } from './blocks.js';
|
|
9
|
+
/**
|
|
10
|
+
* A path as a reader says it: `root.1.columns.0.blocks.2` is
|
|
11
|
+
* "Block 2 → Column 1 → Block 3". Positions count from one; a segment that
|
|
12
|
+
* names a group (`blocks`, `columns`) only says what the next number counts.
|
|
13
|
+
*/
|
|
14
|
+
export function blockLocation(path) {
|
|
15
|
+
let group = '';
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const seg of path.split('.').slice(1)) {
|
|
18
|
+
if (/^\d+$/.test(seg))
|
|
19
|
+
out.push(`${group === 'columns' ? 'Column' : 'Block'} ${Number(seg) + 1}`);
|
|
20
|
+
else
|
|
21
|
+
group = seg;
|
|
22
|
+
}
|
|
23
|
+
return out.join(' → ');
|
|
24
|
+
}
|
|
9
25
|
/** Every block in the tree, flattened, each with the path that addresses it. */
|
|
10
26
|
export function extractBlocks(blocks, containers, basePath = 'root') {
|
|
11
27
|
const out = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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, and conditional-GET plumbing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -78,6 +78,10 @@
|
|
|
78
78
|
"types": "./dist/revisions.d.ts",
|
|
79
79
|
"import": "./dist/revisions.js"
|
|
80
80
|
},
|
|
81
|
+
"./history": {
|
|
82
|
+
"types": "./dist/history.d.ts",
|
|
83
|
+
"import": "./dist/history.js"
|
|
84
|
+
},
|
|
81
85
|
"./search": {
|
|
82
86
|
"types": "./dist/search.d.ts",
|
|
83
87
|
"import": "./dist/search.js"
|
|
@@ -209,6 +213,7 @@
|
|
|
209
213
|
"@types/node": "^22.0.0",
|
|
210
214
|
"@types/react": "^19.0.0",
|
|
211
215
|
"@types/sanitize-html": "^2.16.1",
|
|
216
|
+
"fast-diff": "^1.3.0",
|
|
212
217
|
"jose": "^6.2.10",
|
|
213
218
|
"jsdom": "^30.1.0",
|
|
214
219
|
"react": "^19.2.4",
|
|
@@ -227,12 +232,13 @@
|
|
|
227
232
|
"@tiptap/extension-table-header": ">=2.10",
|
|
228
233
|
"@tiptap/extension-table-row": ">=2.10",
|
|
229
234
|
"@tiptap/extension-youtube": ">=2.10",
|
|
235
|
+
"@tiptap/pm": ">=2.10",
|
|
230
236
|
"@tiptap/react": ">=2.10",
|
|
231
237
|
"@tiptap/starter-kit": ">=2.10",
|
|
238
|
+
"fast-diff": ">=1.3",
|
|
232
239
|
"jose": ">=5",
|
|
233
240
|
"react": ">=18",
|
|
234
|
-
"sanitize-html": ">=2"
|
|
235
|
-
"@tiptap/pm": ">=2.10"
|
|
241
|
+
"sanitize-html": ">=2"
|
|
236
242
|
},
|
|
237
243
|
"peerDependenciesMeta": {
|
|
238
244
|
"react": {
|
|
@@ -285,6 +291,9 @@
|
|
|
285
291
|
},
|
|
286
292
|
"@tiptap/pm": {
|
|
287
293
|
"optional": true
|
|
294
|
+
},
|
|
295
|
+
"fast-diff": {
|
|
296
|
+
"optional": true
|
|
288
297
|
}
|
|
289
298
|
}
|
|
290
299
|
}
|