wiki-formant 0.26.0 → 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 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
+ })[];
@@ -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/markdown.js CHANGED
@@ -81,35 +81,51 @@ export function tableToMarkdown(html) {
81
81
  ...body.map(r => `| ${r.join(' | ')} |`),
82
82
  ].join('\n');
83
83
  }
84
+ // The spaces either side go with it: at a block boundary they are not text.
85
+ const BLOCK_TOKEN = /[ \t]*\u0000(\d+)\u0000[ \t]*/g;
84
86
  /** Block-level HTML → markdown. */
85
87
  export function htmlToMarkdown(html) {
86
88
  let out = html;
89
+ // Each converted block is set aside behind a token and put back at the end.
90
+ // The `<div>` rule reads its body as inline text and collapses whitespace, so
91
+ // a block already converted inside a div lost every newline: a boxed table
92
+ // (`scrollTables`) came out as one line of pipes, and so did a list, a code
93
+ // listing or a callout's paragraphs.
94
+ const blocks = [];
95
+ const block = (md) => `\u0000${blocks.push(`\n\n${md}\n\n`) - 1}\u0000`;
96
+ const restore = (s) => s.replace(BLOCK_TOKEN, (_m, i) => restore(blocks[Number(i)]));
97
+ // A list inside an item is already a block by the time its parent converts;
98
+ // it goes under the item's line, indented, rather than onto it.
99
+ const listItem = (marker, body) => [
100
+ `${marker} ${inlineToMarkdown(body.replace(BLOCK_TOKEN, ''))}`,
101
+ ...[...body.matchAll(BLOCK_TOKEN)].map(m => restore(m[0]).trim().replace(/^/gm, ' ')),
102
+ ].join('\n');
87
103
  // Tables first — their inner markup must not be eaten by the generic rules.
88
- out = out.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, m => `\n\n${tableToMarkdown(m)}\n\n`);
104
+ out = out.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, m => block(tableToMarkdown(m)));
89
105
  // Lists. `<ol>` numbering is computed per-list, which is why this cannot be a
90
106
  // single regex with a `$1` backreference — inside a replace callback `$1` is
91
107
  // a literal, not a substitution. (That exact bug shipped once and rendered
92
108
  // every ordered list as a column of `1. $1`.)
93
109
  out = out.replace(/<ul\b[^>]*>([\s\S]*?)<\/ul>/gi, (_m, body) => {
94
- const items = [...body.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/gi)].map(i => `- ${inlineToMarkdown(i[1])}`);
95
- return `\n\n${items.join('\n')}\n\n`;
110
+ const items = [...body.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/gi)].map(i => listItem('-', i[1]));
111
+ return block(items.join('\n'));
96
112
  });
97
113
  out = out.replace(/<ol\b[^>]*>([\s\S]*?)<\/ol>/gi, (_m, body) => {
98
- const items = [...body.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/gi)].map((i, n) => `${n + 1}. ${inlineToMarkdown(i[1])}`);
99
- return `\n\n${items.join('\n')}\n\n`;
114
+ const items = [...body.matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/gi)].map((i, n) => listItem(`${n + 1}.`, i[1]));
115
+ return block(items.join('\n'));
100
116
  });
101
117
  out = out
102
- .replace(/<pre\b[^>]*>\s*<code\b[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi, (_m, code) => `\n\n\`\`\`\n${decodeEntities(code).trim()}\n\`\`\`\n\n`)
103
- .replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_m, code) => `\n\n\`\`\`\n${decodeEntities(code).trim()}\n\`\`\`\n\n`)
104
- .replace(/<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi, (_m, body) => `\n\n${inlineToMarkdown(body)
118
+ .replace(/<pre\b[^>]*>\s*<code\b[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi, (_m, code) => block(`\`\`\`\n${decodeEntities(code).trim()}\n\`\`\``))
119
+ .replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, (_m, code) => block(`\`\`\`\n${decodeEntities(code).trim()}\n\`\`\``))
120
+ .replace(/<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi, (_m, body) => block(inlineToMarkdown(body)
105
121
  .split('\n')
106
122
  .map(l => `> ${l}`)
107
- .join('\n')}\n\n`)
108
- .replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, level, body) => `\n\n${'#'.repeat(Number(level))} ${inlineToMarkdown(body)}\n\n`)
109
- .replace(/<hr\s*\/?>/gi, '\n\n---\n\n')
110
- .replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_m, body) => `\n\n${inlineToMarkdown(body)}\n\n`)
123
+ .join('\n')))
124
+ .replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, level, body) => block(`${'#'.repeat(Number(level))} ${inlineToMarkdown(body)}`))
125
+ .replace(/<hr\s*\/?>/gi, () => block('---'))
126
+ .replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_m, body) => block(inlineToMarkdown(body)))
111
127
  .replace(/<div\b[^>]*>([\s\S]*?)<\/div>/gi, (_m, body) => `\n\n${inlineToMarkdown(body)}\n\n`);
112
- return decodeEntities(out.replace(/<[^>]+>/g, ''))
128
+ return decodeEntities(restore(out).replace(/<[^>]+>/g, ''))
113
129
  .replace(/[ \t]+$/gm, '')
114
130
  .replace(/\n{3,}/g, '\n\n')
115
131
  .trim();
@@ -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;
@@ -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/react.d.ts CHANGED
@@ -83,7 +83,8 @@ export interface TableOfContentsProps {
83
83
  offsetVar?: string;
84
84
  /** Fallback offset in px when `offsetVar` is unset or unparseable. */
85
85
  offsetFallback?: number;
86
- /** Indent per heading level, in rem. Set 0 for a flat list. */
86
+ /** Indent per heading level, in rem. Set 0 for a flat list, whose items then
87
+ * take their padding from the stylesheet: an inline `padding-left: 0` beat it. */
87
88
  indentRem?: number;
88
89
  /** Rendered instead of nothing when the article has no headings. */
89
90
  empty?: ReactNode;
package/dist/react.js CHANGED
@@ -248,7 +248,7 @@ export function TableOfContents({ containerSelector, headings: providedHeadings,
248
248
  document.getElementById(h.id)?.scrollIntoView({ behavior: 'smooth' });
249
249
  }, className: currentId === h.id
250
250
  ? [classNames.item, classNames.itemActive].filter(Boolean).join(' ')
251
- : classNames.item, "aria-current": currentId === h.id ? 'location' : undefined, style: { paddingLeft: `${(h.level - 1) * indentRem}rem` }, children: h.text }, h.id))) }))] }));
251
+ : classNames.item, "aria-current": currentId === h.id ? 'location' : undefined, style: (h.level - 1) * indentRem ? { paddingLeft: `${(h.level - 1) * indentRem}rem` } : undefined, children: h.text }, h.id))) }))] }));
252
252
  }
253
253
  // ---- outside clicks ---------------------------------------------------------
254
254
  /**
@@ -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.26.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"
@@ -177,8 +181,8 @@
177
181
  }
178
182
  },
179
183
  "bin": {
180
- "check-classes": "./bin/check-classes.mjs",
181
- "check-pages": "./bin/check-pages.mjs"
184
+ "check-classes": "bin/check-classes.mjs",
185
+ "check-pages": "bin/check-pages.mjs"
182
186
  },
183
187
  "files": [
184
188
  "dist",
@@ -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
  }