wiki-formant 0.20.0 → 0.22.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.
Files changed (50) hide show
  1. package/README.md +84 -7
  2. package/dist/block-views.d.ts +8 -11
  3. package/dist/block-views.js +10 -8
  4. package/dist/blocks.d.ts +31 -1
  5. package/dist/blocks.js +66 -7
  6. package/dist/conformance.d.ts +13 -1
  7. package/dist/conformance.js +23 -1
  8. package/dist/corpus.d.ts +65 -0
  9. package/dist/corpus.js +82 -0
  10. package/dist/crawlers.d.ts +8 -1
  11. package/dist/crawlers.js +14 -1
  12. package/dist/editor.d.ts +5 -0
  13. package/dist/editor.js +24 -0
  14. package/dist/freshness.d.ts +14 -0
  15. package/dist/freshness.js +13 -0
  16. package/dist/headings.d.ts +7 -0
  17. package/dist/headings.js +15 -7
  18. package/dist/http.d.ts +28 -1
  19. package/dist/http.js +29 -4
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/license.d.ts +9 -0
  23. package/dist/license.js +8 -0
  24. package/dist/link-check.d.ts +25 -0
  25. package/dist/link-check.js +56 -1
  26. package/dist/maps.d.ts +25 -2
  27. package/dist/maps.js +91 -5
  28. package/dist/mcp.d.ts +37 -0
  29. package/dist/mcp.js +61 -0
  30. package/dist/metadata.d.ts +69 -0
  31. package/dist/metadata.js +45 -0
  32. package/dist/react-server.d.ts +21 -1
  33. package/dist/react-server.js +43 -13
  34. package/dist/react.d.ts +12 -2
  35. package/dist/react.js +31 -0
  36. package/dist/revisions.d.ts +7 -3
  37. package/dist/revisions.js +5 -3
  38. package/dist/sanitize.d.ts +44 -0
  39. package/dist/sanitize.js +191 -0
  40. package/dist/search.d.ts +20 -0
  41. package/dist/search.js +22 -0
  42. package/dist/text.d.ts +18 -1
  43. package/dist/text.js +30 -0
  44. package/dist/tiptap.d.ts +14 -1
  45. package/dist/tiptap.js +41 -1
  46. package/dist/validation.d.ts +19 -1
  47. package/dist/validation.js +82 -28
  48. package/dist/well-known.d.ts +47 -8
  49. package/dist/well-known.js +58 -2
  50. package/package.json +24 -2
@@ -0,0 +1,191 @@
1
+ // sanitize.ts — the allowlist between an author's saved HTML and every reader's
2
+ // browser.
3
+ //
4
+ // Every wiki here stores raw editor HTML and renders it through
5
+ // `dangerouslySetInnerHTML`, and `wiki-formant/block-views` renders three more
6
+ // such fields itself. Only one of the three had a sanitiser. Another gated
7
+ // writes on an $XRD balance, which is a price rather than a trust boundary, on
8
+ // a site whose readers connect wallets. The renderer that trusts those fields
9
+ // ships from here, so the guard does too.
10
+ //
11
+ // Everything is an allowlist: unknown tags, unknown attributes (every on*
12
+ // handler among them) and non-http(s) URLs are dropped, not escaped. Run it
13
+ // server-side, on the render path, so it covers rows written before it existed
14
+ // and no sanitiser ships to the client. `sanitize-html` is an optional peer.
15
+ import sanitizeHtml from 'sanitize-html';
16
+ // Inline SVG, presentational elements only. The infographics pipeline embeds
17
+ // diagrams as inline SVG in two of the wikis, so a prose-only list would erase
18
+ // them. `foreignObject` (arbitrary HTML), `use`/`image` (external refs),
19
+ // `script` and the animation elements are deliberately absent.
20
+ const SVG_TAGS = [
21
+ 'svg', 'g', 'defs', 'title', 'desc', 'path', 'rect', 'circle', 'ellipse',
22
+ 'line', 'polyline', 'polygon', 'text', 'tspan', 'linearGradient',
23
+ 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern',
24
+ ];
25
+ // Inert geometry and paint values, applied to every SVG tag rather than per
26
+ // element: the tag list above is what bounds the surface.
27
+ const SVG_ATTRS = [
28
+ 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'd', 'points',
29
+ 'width', 'height', 'transform', 'viewBox', 'preserveAspectRatio', 'xmlns',
30
+ 'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity',
31
+ 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'opacity',
32
+ 'font-family', 'font-size', 'font-weight', 'letter-spacing', 'text-anchor',
33
+ 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity', 'gradientUnits',
34
+ 'gradientTransform', 'clip-path', 'mask', 'role', 'aria-label', 'style',
35
+ ];
36
+ /**
37
+ * The embed hosts the editor's iframe, YouTube, tweet and map nodes produce.
38
+ * This is half of a pair: the CSP `frame-src` in each `next.config.ts` must
39
+ * allow the same hosts, or an iframe survives sanitising and is then blocked.
40
+ */
41
+ export const DEFAULT_IFRAME_HOSTS = [
42
+ 'www.youtube.com', 'youtube.com',
43
+ 'www.youtube-nocookie.com', 'youtube-nocookie.com',
44
+ 'platform.twitter.com',
45
+ 'www.google.com', 'maps.google.com',
46
+ 'embed.apple.com', 'maps.apple.com',
47
+ ];
48
+ // Layout and paint only. `position`, `z-index` and friends are left out so a
49
+ // page cannot lay a fake signing prompt over the site's chrome.
50
+ const STYLE_PROPS = [
51
+ 'width', 'height', 'max-width', 'max-height', 'min-width', 'min-height',
52
+ 'margin', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
53
+ 'padding', 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
54
+ 'border', 'border-top', 'border-bottom', 'border-left', 'border-right',
55
+ 'border-radius', 'border-color', 'border-width', 'border-style',
56
+ 'background', 'background-color', 'color', 'opacity',
57
+ 'font-size', 'font-weight', 'font-style', 'line-height',
58
+ 'text-align', 'vertical-align', 'display', 'overflow',
59
+ ];
60
+ // Ordinary CSS tokens plus rgb()/rgba(). Excluding quotes, backslashes, angle
61
+ // brackets and any other `(` keeps `url(...)` and `expression(...)` out of
62
+ // every property without a per-property regex.
63
+ const SAFE_CSS_VALUE = /^(?:[a-z0-9#%.,\-+/ ]|rgba?\([\d\s,.%]+\))*$/i;
64
+ const PROSE_TAGS = [
65
+ 'p', 'br', 'hr', 'div', 'span', 'blockquote', 'pre', 'code',
66
+ 'strong', 'em', 'b', 'i', 's', 'u', 'sub', 'sup', 'mark', 'small',
67
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
68
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
69
+ 'a', 'img', 'figure', 'figcaption',
70
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
71
+ 'iframe',
72
+ ];
73
+ const PROSE_ATTRS = {
74
+ a: ['href', 'target', 'rel', 'id', 'title', 'aria-label', 'tabindex'],
75
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
76
+ // Every data attribute the `wiki-formant/tiptap` nodes store: the embed
77
+ // wrappers, and the tab markup `activateTabGroups` reads back. An allowlist
78
+ // written without the tab pair strips them, and stored tabs never activate.
79
+ div: [
80
+ 'id', 'style',
81
+ 'data-twitter-embed', 'data-tweet-id', 'data-url', 'data-map-embed', 'data-iframe-embed', 'data-youtube-video',
82
+ 'data-tabs', 'data-active-tab', 'data-tab-item', 'data-tab-title',
83
+ ],
84
+ iframe: ['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'scrolling', 'loading', 'referrerpolicy', 'title'],
85
+ figure: ['style', 'data-graphic'],
86
+ figcaption: ['style'],
87
+ span: ['id', 'style', 'title'],
88
+ p: ['id', 'style'],
89
+ // `cite-n` back-link targets ride on the superscript.
90
+ sup: ['id'],
91
+ sub: ['id'],
92
+ th: ['colspan', 'rowspan', 'colwidth', 'scope'],
93
+ td: ['colspan', 'rowspan', 'colwidth'],
94
+ col: ['span', 'width'],
95
+ // Heading ids back the crawlable anchors `injectHeadingIds` writes.
96
+ h1: ['id'], h2: ['id'], h3: ['id'], h4: ['id'], h5: ['id'], h6: ['id'],
97
+ li: ['id'],
98
+ blockquote: ['cite'],
99
+ };
100
+ /**
101
+ * Classes are allowed by NAME, never by attribute. Restricting `style` to
102
+ * layout and paint means nothing while `class` is free: every wiki here ships
103
+ * utility classes like `fixed inset-0 z-50`, which lay a fake prompt over the
104
+ * chrome as well as `position` would. These are the classes the package's own
105
+ * editor nodes write; a wiki adds the ones its stored content uses.
106
+ */
107
+ const EDITOR_CLASSES = {
108
+ a: ['link'],
109
+ code: ['language-*'],
110
+ div: ['iframe-embed', 'map-embed', 'twitter-embed'],
111
+ img: ['rounded-lg', 'max-w-full'],
112
+ table: ['tiptap-table'],
113
+ th: ['p-2', 'font-semibold', 'bg-surface-1'],
114
+ td: ['p-2'],
115
+ };
116
+ // The editor keeps a pasted image inline as base64 (`allowBase64`), so an
117
+ // `<img>` may carry data: — where nothing it holds can run.
118
+ const EDITOR_SCHEMES = { img: ['http', 'https', 'data'] };
119
+ /**
120
+ * A sanitiser bound to one wiki's allowlist.
121
+ *
122
+ * Derive `tags`, `attributes` and `classes` from the HTML actually stored in
123
+ * your pages and revisions, not from guesswork: that is how the SVG set above
124
+ * got here, and a list written from memory erases content on the first render.
125
+ */
126
+ export function createHtmlSanitizer(options = {}) {
127
+ const { iframeHosts = DEFAULT_IFRAME_HOSTS, svg = true, tags = [], attributes = {}, classes = {}, schemesByTag = {} } = options;
128
+ const merge = (base, extra) => {
129
+ const out = { ...base };
130
+ for (const [tag, more] of Object.entries(extra))
131
+ out[tag] = [...new Set([...(out[tag] ?? []), ...more])];
132
+ return out;
133
+ };
134
+ const allowedAttributes = merge(svg ? { ...PROSE_ATTRS, ...Object.fromEntries(SVG_TAGS.map(t => [t, SVG_ATTRS])) } : PROSE_ATTRS, attributes);
135
+ // `class` only ever arrives through `allowedClasses`, which admits the
136
+ // attribute for the tags it names and filters its value by name.
137
+ for (const tag of Object.keys(allowedAttributes)) {
138
+ allowedAttributes[tag] = allowedAttributes[tag].filter(a => a !== 'class');
139
+ }
140
+ const config = {
141
+ allowedTags: [...PROSE_TAGS, ...(svg ? SVG_TAGS : []), ...tags],
142
+ allowedAttributes,
143
+ allowedClasses: merge(EDITOR_CLASSES, classes),
144
+ allowedSchemesByTag: merge(EDITOR_SCHEMES, schemesByTag),
145
+ allowedStyles: { '*': Object.fromEntries(STYLE_PROPS.map(p => [p, [SAFE_CSS_VALUE]])) },
146
+ allowedSchemes: ['http', 'https', 'mailto'],
147
+ allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
148
+ allowProtocolRelative: false,
149
+ allowedIframeHostnames: [...iframeHosts],
150
+ // SVG names are camelCase (clipPath, viewBox). Lower-casing them would still
151
+ // render, since the HTML parser re-adjusts known SVG names, but keeping the
152
+ // case means what is stored is what was allowlisted.
153
+ parser: { lowerCaseTags: false, lowerCaseAttributeNames: false },
154
+ // Drop the contents of a disallowed <script> or <style>, not just the tags.
155
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript'],
156
+ };
157
+ return (html) => (!html || !html.includes('<') ? html : sanitizeHtml(html, config));
158
+ }
159
+ const each = (v, map) => Array.isArray(v) ? v.map(item => (item && typeof item === 'object' ? map(item) : item)) : v;
160
+ const str = (v) => typeof v === 'string';
161
+ /**
162
+ * The core leaf types' HTML fields, cleaned: `content.text`, `codeTabs` code,
163
+ * `linkGrid` group descriptions and `references` item text. Every other field
164
+ * those blocks carry renders as a React text node and is escaped already.
165
+ *
166
+ * Returns any other block untouched, so it drops straight into a
167
+ * `mapBlockTree` pass — a repo whose own types render HTML cleans those itself.
168
+ *
169
+ * `codeTabs` code is treated as stored HTML, which is what the editor writes.
170
+ * A wiki whose highlighter takes the code as SOURCE and escapes it on render
171
+ * must not pass it through here — every `<T>` in a signature would go — and
172
+ * cleans its other three fields with its own switch.
173
+ */
174
+ export function sanitizeCoreLeaf(block, clean) {
175
+ const b = block;
176
+ switch (b.type) {
177
+ case 'content':
178
+ return str(b.text) ? { ...b, text: clean(b.text) } : block;
179
+ case 'codeTabs':
180
+ return { ...b, tabs: each(b.tabs, t => (str(t.code) ? { ...t, code: clean(t.code) } : t)) };
181
+ case 'linkGrid':
182
+ return {
183
+ ...b,
184
+ groups: each(b.groups, g => (str(g.description) ? { ...g, description: clean(g.description) } : g)),
185
+ };
186
+ case 'references':
187
+ return { ...b, items: each(b.items, i => (str(i.text) ? { ...i, text: clean(i.text) } : i)) };
188
+ default:
189
+ return block;
190
+ }
191
+ }
package/dist/search.d.ts CHANGED
@@ -32,6 +32,26 @@ export declare const PROSE_PATTERN = "<[^>]*>|&nbsp;|\\\\[nrt\"\\\\/]|\", \"|\\[
32
32
  * tiers.
33
33
  */
34
34
  export declare function searchTsvSql(content?: string, title?: string): string;
35
+ /**
36
+ * The statements that (re)build a table's `search_tsv` column and its GIN index.
37
+ * Run `column` inside one transaction: DROP then ADD, so no reader ever sees
38
+ * the table without it; then `index`.
39
+ *
40
+ * Rebuilt unconditionally rather than skipped when the column exists. A skip
41
+ * is idempotent about the column and blind to its EXPRESSION: a changed prose
42
+ * expression re-runs clean and changes nothing, and Postgres normalises the
43
+ * stored expression, so comparing it to this string would only produce false
44
+ * rebuilds. The column is derived from `content`, so dropping it loses nothing.
45
+ * Run it BEFORE declaring the column in a Prisma schema: `prisma db push` reads
46
+ * a generated column it does not know about as drift and drops it.
47
+ */
48
+ export declare function searchTsvDdl(table: string, { content, title }?: {
49
+ content?: string;
50
+ title?: string;
51
+ }): {
52
+ column: string[];
53
+ index: string;
54
+ };
35
55
  /**
36
56
  * `ts_headline` options for a search result snippet: one fragment, wide enough
37
57
  * to read as a sentence, with no highlight markers (the caller styles it).
package/dist/search.js CHANGED
@@ -60,6 +60,28 @@ export function searchTsvSql(content = 'content', title = 'title') {
60
60
  return `setweight(to_tsvector('english', coalesce(${title},'')), 'A') || ` +
61
61
  `setweight(to_tsvector('english', coalesce(${proseSql(content)}, '')), 'B')`;
62
62
  }
63
+ /**
64
+ * The statements that (re)build a table's `search_tsv` column and its GIN index.
65
+ * Run `column` inside one transaction: DROP then ADD, so no reader ever sees
66
+ * the table without it; then `index`.
67
+ *
68
+ * Rebuilt unconditionally rather than skipped when the column exists. A skip
69
+ * is idempotent about the column and blind to its EXPRESSION: a changed prose
70
+ * expression re-runs clean and changes nothing, and Postgres normalises the
71
+ * stored expression, so comparing it to this string would only produce false
72
+ * rebuilds. The column is derived from `content`, so dropping it loses nothing.
73
+ * Run it BEFORE declaring the column in a Prisma schema: `prisma db push` reads
74
+ * a generated column it does not know about as drift and drops it.
75
+ */
76
+ export function searchTsvDdl(table, { content = 'content', title = 'title' } = {}) {
77
+ return {
78
+ column: [
79
+ `ALTER TABLE ${table} DROP COLUMN IF EXISTS search_tsv`,
80
+ `ALTER TABLE ${table} ADD COLUMN search_tsv tsvector GENERATED ALWAYS AS (${searchTsvSql(content, title)}) STORED`,
81
+ ],
82
+ index: `CREATE INDEX IF NOT EXISTS ${table}_search_tsv_idx ON ${table} USING GIN (search_tsv)`,
83
+ };
84
+ }
63
85
  /**
64
86
  * `ts_headline` options for a search result snippet: one fragment, wide enough
65
87
  * to read as a sentence, with no highlight markers (the caller styles it).
package/dist/text.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CodeTab, ReferenceItem } from './blocks.js';
1
+ import type { CodeTab, LinkGridGroup, ReferenceItem, StatItem } from './blocks.js';
2
2
  /**
3
3
  * HTML to readable text. Links keep their href in parentheses so a model can
4
4
  * still follow a citation; block-level tags become newlines; list items get a
@@ -27,10 +27,27 @@ export declare const BANNER_VARIANTS: ReadonlyArray<{
27
27
  value: BannerVariant;
28
28
  label: string;
29
29
  }>;
30
+ /** A stored variant this package knows, or `cleanup` for one it does not. */
31
+ export declare function bannerVariant(variant: string): BannerVariant;
30
32
  /** A maintenance notice, inline: `[Notice: Needs citations] …`. */
31
33
  export declare function bannerToText(label: string, text?: string | null): string;
32
34
  /** Each tab under its label, tags stripped — highlighted markup is noise here. */
33
35
  export declare function codeTabsToText(tabs: readonly CodeTab[]): string;
36
+ /** Metric cards, one per line: `99% Uptime`. */
37
+ export declare function statsToText(items: readonly StatItem[]): string;
38
+ /**
39
+ * Each group's heading over its links, hrefs in parentheses the way
40
+ * `stripHtml` keeps them.
41
+ *
42
+ * Two of the three wikis extracted nothing from a link grid, or from stats or
43
+ * page lists, so a hub page read to an agent as far emptier than it is. The
44
+ * third had its own three bodies. These are those bodies.
45
+ */
46
+ export declare function linkGridToText(groups: readonly LinkGridGroup[], intro?: string | null): string;
47
+ /** A resolved page list: one title per line. */
48
+ export declare function pageListToText(pages: readonly {
49
+ title: string;
50
+ }[]): string;
34
51
  /** A numbered reference list, or `''` when there are none. */
35
52
  export declare function referencesToText(items: readonly ReferenceItem[]): string;
36
53
  /**
package/dist/text.js CHANGED
@@ -50,6 +50,10 @@ export const BANNER_VARIANTS = Object.keys(BANNER_LABELS).map(value => ({
50
50
  value,
51
51
  label: BANNER_LABELS[value],
52
52
  }));
53
+ /** A stored variant this package knows, or `cleanup` for one it does not. */
54
+ export function bannerVariant(variant) {
55
+ return Object.hasOwn(BANNER_LABELS, variant) ? variant : 'cleanup';
56
+ }
53
57
  /** A maintenance notice, inline: `[Notice: Needs citations] …`. */
54
58
  export function bannerToText(label, text) {
55
59
  return `[Notice: ${label}]${text ? ' ' + stripHtml(text) : ''}`;
@@ -58,6 +62,32 @@ export function bannerToText(label, text) {
58
62
  export function codeTabsToText(tabs) {
59
63
  return tabs.map(t => `[${t.label}]\n${t.code}`).join('\n');
60
64
  }
65
+ /** Metric cards, one per line: `99% Uptime`. */
66
+ export function statsToText(items) {
67
+ return items.map(s => `${s.value}${s.suffix ?? ''} ${s.label}`).join('\n');
68
+ }
69
+ /**
70
+ * Each group's heading over its links, hrefs in parentheses the way
71
+ * `stripHtml` keeps them.
72
+ *
73
+ * Two of the three wikis extracted nothing from a link grid, or from stats or
74
+ * page lists, so a hub page read to an agent as far emptier than it is. The
75
+ * third had its own three bodies. These are those bodies.
76
+ */
77
+ export function linkGridToText(groups, intro) {
78
+ return [
79
+ ...(intro ? [stripHtml(intro)] : []),
80
+ ...groups.map(g => [
81
+ g.heading,
82
+ ...(g.description ? [stripHtml(g.description)] : []),
83
+ ...g.links.map(l => `- ${l.label} (${l.href})`),
84
+ ].join('\n')),
85
+ ].join('\n\n');
86
+ }
87
+ /** A resolved page list: one title per line. */
88
+ export function pageListToText(pages) {
89
+ return pages.map(p => p.title).join('\n');
90
+ }
61
91
  /** A numbered reference list, or `''` when there are none. */
62
92
  export function referencesToText(items) {
63
93
  if (!items.length)
package/dist/tiptap.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- import { Node as TiptapNode } from '@tiptap/core';
2
+ import { Extension, Node as TiptapNode } from '@tiptap/core';
3
3
  /** Local `cn`. Both wikis import one; the package will not depend on one. */
4
4
  export declare const Iframe: TiptapNode<any, any>;
5
5
  /** The stock extension plus the paste rule it does not ship with. */
@@ -76,3 +76,16 @@ export declare function createTabs({ classNames, icons, tabLabel, }?: TabsOption
76
76
  TabGroup: TiptapNode<any, any>;
77
77
  TabItem: TiptapNode<any, any>;
78
78
  };
79
+ /**
80
+ * Gives each heading in the editor the id its published copy will carry, so an
81
+ * "on this page" rail can list it while the page is being written.
82
+ *
83
+ * A decoration, not an attribute: the id is in the editor's DOM and never in
84
+ * `getHTML()`, so stored HTML stays id-free and `injectHeadingIds` still mints
85
+ * the published ids. Pass the same `slug` the wiki passes there. The dedupe is
86
+ * `uniqueHeadingId`, the one `injectHeadingIds` uses; the copy this replaced
87
+ * restated it, and would have drifted the first time either changed.
88
+ */
89
+ export declare function createHeadingIds({ slug }?: {
90
+ slug?: (text: string) => string;
91
+ }): Extension<any, any>;
package/dist/tiptap.js CHANGED
@@ -12,7 +12,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // and the other `text-accent` without either forking the node. It also keeps
13
13
  // this file from dragging an icon library in behind it.
14
14
  import { useCallback, useEffect, useRef, useState } from 'react';
15
- import { Node as TiptapNode, mergeAttributes } from '@tiptap/core';
15
+ import { Extension, Node as TiptapNode, mergeAttributes } from '@tiptap/core';
16
+ import { Plugin } from '@tiptap/pm/state';
17
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
16
18
  import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
17
19
  import TiptapYoutube from '@tiptap/extension-youtube';
18
20
  import TiptapCodeBlock from '@tiptap/extension-code-block';
@@ -20,6 +22,7 @@ import { onTweetResize, tweetEmbedSrc } from './dom.js';
20
22
  import { toMapEmbedUrl } from './maps.js';
21
23
  import { useClickOutside } from './react.js';
22
24
  import { cx } from './html.js';
25
+ import { slugifyHeading, uniqueHeadingId } from './headings.js';
23
26
  /** Local `cn`. Both wikis import one; the package will not depend on one. */
24
27
  // ---- iframe -----------------------------------------------------------------
25
28
  export const Iframe = TiptapNode.create({
@@ -336,3 +339,40 @@ export function createTabs({ classNames = {}, icons = {}, tabLabel = i => `Tab $
336
339
  });
337
340
  return { TabGroup, TabItem };
338
341
  }
342
+ // ---- heading ids while editing ---------------------------------------------------
343
+ /**
344
+ * Gives each heading in the editor the id its published copy will carry, so an
345
+ * "on this page" rail can list it while the page is being written.
346
+ *
347
+ * A decoration, not an attribute: the id is in the editor's DOM and never in
348
+ * `getHTML()`, so stored HTML stays id-free and `injectHeadingIds` still mints
349
+ * the published ids. Pass the same `slug` the wiki passes there. The dedupe is
350
+ * `uniqueHeadingId`, the one `injectHeadingIds` uses; the copy this replaced
351
+ * restated it, and would have drifted the first time either changed.
352
+ */
353
+ export function createHeadingIds({ slug = slugifyHeading } = {}) {
354
+ return Extension.create({
355
+ name: 'headingIds',
356
+ addProseMirrorPlugins: () => [
357
+ new Plugin({
358
+ props: {
359
+ decorations: ({ doc }) => {
360
+ const used = new Set();
361
+ const decorations = [];
362
+ doc.descendants((node, pos) => {
363
+ if (node.type.name !== 'heading')
364
+ return;
365
+ const id = uniqueHeadingId(slug(node.textContent), used);
366
+ if (!id)
367
+ return false;
368
+ used.add(id);
369
+ decorations.push(Decoration.node(pos, pos + node.nodeSize, { id }));
370
+ return false;
371
+ });
372
+ return DecorationSet.create(doc, decorations);
373
+ },
374
+ },
375
+ }),
376
+ ],
377
+ });
378
+ }
@@ -15,6 +15,10 @@ export declare const okUrl: (u: unknown) => boolean;
15
15
  export declare function validateReferenceItems(items: unknown): boolean;
16
16
  /** `[{ id, heading, links: [{ label, href }] }]` — a link-grid block's groups. */
17
17
  export declare function validateLinkGroups(groups: unknown): boolean;
18
+ /** `[{ label, language?, code }]` — a code-tabs block's tabs. `code` renders as HTML. */
19
+ export declare function validateCodeTabs(tabs: unknown): boolean;
20
+ /** `[{ value, label, suffix? }]` — a stats block's cards. */
21
+ export declare function validateStatItems(items: unknown): boolean;
18
22
  export interface BlockValidatorOptions {
19
23
  /** True for a type this wiki knows at all. */
20
24
  isKnownType: (type: string) => boolean;
@@ -23,10 +27,20 @@ export interface BlockValidatorOptions {
23
27
  /** This wiki's switch over its leaf types. Runs only after id/type pass. */
24
28
  validateAtomic: (block: Record<string, unknown>) => boolean;
25
29
  }
30
+ /** Where a block tree fails, and why: `{ path: '[2].columns[0].blocks[1]', reason }`. */
31
+ export interface BlockIssue {
32
+ path: string;
33
+ reason: string;
34
+ }
26
35
  /**
27
36
  * The block-tree walk, with one wiki's leaf switch plugged into it. The caller
28
37
  * keeps its own type parameter, so this stays free of any repo's types while
29
38
  * the call site still gets a real type guard.
39
+ *
40
+ * `blockIssues` is the same walk reporting WHERE. Every write path here
41
+ * answered a bad tree with "invalid block structure" and nothing else, which
42
+ * leaves an agent writing through MCP to guess, and one repo's seed guard had
43
+ * to walk the tree a second time just to name the offending link.
30
44
  */
31
45
  export declare function createBlockValidator(opts: BlockValidatorOptions): {
32
46
  /** One leaf block; container types are rejected. */
@@ -35,7 +49,11 @@ export declare function createBlockValidator(opts: BlockValidatorOptions): {
35
49
  validateBlock: (block: unknown) => boolean;
36
50
  /** A whole page's content array. */
37
51
  validateBlocks: (content: unknown) => boolean;
52
+ /** Every failure in a page's content array, each with the path to it. */
53
+ blockIssues: (content: unknown) => BlockIssue[];
38
54
  };
55
+ /** One line for an error response: the first few issues, then a count. */
56
+ export declare function describeBlockIssues(issues: readonly BlockIssue[], max?: number): string;
39
57
  /**
40
58
  * A copy of a block with a fresh id at every level, so a duplicated container
41
59
  * does not share child ids with its original.
@@ -43,4 +61,4 @@ export declare function createBlockValidator(opts: BlockValidatorOptions): {
43
61
  export declare function duplicateBlockIds<B extends {
44
62
  type: string;
45
63
  id: string;
46
- }>(block: B, newId: () => string): B;
64
+ }>(block: B, newId?: () => string): B;
@@ -56,55 +56,109 @@ export function validateLinkGroups(groups) {
56
56
  Array.isArray(g.links) &&
57
57
  g.links.every(l => isRecord(l) && typeof l.label === 'string' && okUrl(l.href))));
58
58
  }
59
+ /** `[{ label, language?, code }]` — a code-tabs block's tabs. `code` renders as HTML. */
60
+ export function validateCodeTabs(tabs) {
61
+ return (Array.isArray(tabs) &&
62
+ tabs.every(t => isRecord(t) &&
63
+ typeof t.label === 'string' &&
64
+ typeof t.code === 'string' &&
65
+ (t.language === undefined || typeof t.language === 'string')));
66
+ }
67
+ /** `[{ value, label, suffix? }]` — a stats block's cards. */
68
+ export function validateStatItems(items) {
69
+ return (Array.isArray(items) &&
70
+ items.every(s => isRecord(s) &&
71
+ typeof s.label === 'string' &&
72
+ (typeof s.value === 'string' || typeof s.value === 'number') &&
73
+ (s.suffix === undefined || s.suffix === null || typeof s.suffix === 'string')));
74
+ }
59
75
  /**
60
76
  * The block-tree walk, with one wiki's leaf switch plugged into it. The caller
61
77
  * keeps its own type parameter, so this stays free of any repo's types while
62
78
  * the call site still gets a real type guard.
79
+ *
80
+ * `blockIssues` is the same walk reporting WHERE. Every write path here
81
+ * answered a bad tree with "invalid block structure" and nothing else, which
82
+ * leaves an agent writing through MCP to guess, and one repo's seed guard had
83
+ * to walk the tree a second time just to name the offending link.
63
84
  */
64
85
  export function createBlockValidator(opts) {
65
86
  const { isKnownType, isAtomicType, validateAtomic } = opts;
66
- const atomic = (block) => {
67
- if (!isRecord(block))
68
- return false;
69
- if (typeof block.id !== 'string')
70
- return false;
71
- if (typeof block.type !== 'string' || !isKnownType(block.type) || !isAtomicType(block.type))
72
- return false;
73
- return validateAtomic(block);
74
- };
75
- const one = (block) => {
76
- if (!isRecord(block))
77
- return false;
87
+ const check = (block, path, nested, out) => {
88
+ if (!isRecord(block)) {
89
+ out.push({ path, reason: 'not a block object' });
90
+ return;
91
+ }
78
92
  if (typeof block.id !== 'string')
79
- return false;
80
- if (typeof block.type !== 'string' || !isKnownType(block.type))
81
- return false;
82
- if (block.type === 'columns') {
83
- return (Array.isArray(block.columns) &&
84
- block.columns.every(col => isRecord(col) &&
85
- typeof col.id === 'string' &&
86
- Array.isArray(col.blocks) &&
87
- col.blocks.every(atomic)));
93
+ out.push({ path, reason: '`id` must be a string' });
94
+ if (typeof block.type !== 'string' || !isKnownType(block.type)) {
95
+ out.push({ path, reason: `unknown block type ${JSON.stringify(block.type)}` });
96
+ return;
97
+ }
98
+ if (!nested && block.type === 'columns') {
99
+ if (!Array.isArray(block.columns)) {
100
+ out.push({ path: `${path}.columns`, reason: 'must be an array' });
101
+ return;
102
+ }
103
+ block.columns.forEach((col, i) => {
104
+ const at = `${path}.columns[${i}]`;
105
+ if (!isRecord(col) || typeof col.id !== 'string' || !Array.isArray(col.blocks)) {
106
+ out.push({ path: at, reason: 'a column needs a string `id` and a `blocks` array' });
107
+ return;
108
+ }
109
+ col.blocks.forEach((b, j) => check(b, `${at}.blocks[${j}]`, true, out));
110
+ });
111
+ return;
112
+ }
113
+ if (!nested && block.type === 'infobox') {
114
+ if (!Array.isArray(block.blocks)) {
115
+ out.push({ path: `${path}.blocks`, reason: 'must be an array' });
116
+ return;
117
+ }
118
+ block.blocks.forEach((b, j) => check(b, `${path}.blocks[${j}]`, true, out));
119
+ return;
88
120
  }
89
- if (block.type === 'infobox') {
90
- return Array.isArray(block.blocks) && block.blocks.every(atomic);
121
+ if (!isAtomicType(block.type)) {
122
+ out.push({ path, reason: nested ? `a ${block.type} block cannot sit inside a container` : `${block.type} blocks are not accepted` });
123
+ return;
91
124
  }
92
- return atomic(block);
125
+ if (!validateAtomic(block))
126
+ out.push({ path, reason: `malformed ${block.type} block` });
127
+ };
128
+ const issuesOf = (block, nested) => {
129
+ const out = [];
130
+ check(block, '', nested, out);
131
+ return out;
132
+ };
133
+ const blockIssues = (content) => {
134
+ if (!Array.isArray(content))
135
+ return [{ path: '', reason: 'content must be an array of blocks' }];
136
+ const out = [];
137
+ content.forEach((block, i) => check(block, `[${i}]`, false, out));
138
+ return out;
93
139
  };
94
140
  return {
95
141
  /** One leaf block; container types are rejected. */
96
- validateAtomicBlock: atomic,
142
+ validateAtomicBlock: (block) => issuesOf(block, true).length === 0,
97
143
  /** One block of any kind, containers included. */
98
- validateBlock: one,
144
+ validateBlock: (block) => issuesOf(block, false).length === 0,
99
145
  /** A whole page's content array. */
100
- validateBlocks: (content) => Array.isArray(content) && content.every(one),
146
+ validateBlocks: (content) => blockIssues(content).length === 0,
147
+ /** Every failure in a page's content array, each with the path to it. */
148
+ blockIssues,
101
149
  };
102
150
  }
151
+ /** One line for an error response: the first few issues, then a count. */
152
+ export function describeBlockIssues(issues, max = 3) {
153
+ const shown = issues.slice(0, max).map(i => (i.path ? `${i.path}: ${i.reason}` : i.reason));
154
+ const more = issues.length - shown.length;
155
+ return `${shown.join('; ')}${more > 0 ? ` (+${more} more)` : ''}`;
156
+ }
103
157
  /**
104
158
  * A copy of a block with a fresh id at every level, so a duplicated container
105
159
  * does not share child ids with its original.
106
160
  */
107
- export function duplicateBlockIds(block, newId) {
161
+ export function duplicateBlockIds(block, newId = () => crypto.randomUUID()) {
108
162
  const b = block;
109
163
  if (block.type === 'columns' && Array.isArray(b.columns)) {
110
164
  return {