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.
- package/README.md +84 -7
- package/dist/block-views.d.ts +8 -11
- package/dist/block-views.js +10 -8
- package/dist/blocks.d.ts +31 -1
- package/dist/blocks.js +66 -7
- package/dist/conformance.d.ts +13 -1
- package/dist/conformance.js +23 -1
- package/dist/corpus.d.ts +65 -0
- package/dist/corpus.js +82 -0
- package/dist/crawlers.d.ts +8 -1
- package/dist/crawlers.js +14 -1
- package/dist/editor.d.ts +5 -0
- package/dist/editor.js +24 -0
- package/dist/freshness.d.ts +14 -0
- package/dist/freshness.js +13 -0
- package/dist/headings.d.ts +7 -0
- package/dist/headings.js +15 -7
- package/dist/http.d.ts +28 -1
- package/dist/http.js +29 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/license.d.ts +9 -0
- package/dist/license.js +8 -0
- package/dist/link-check.d.ts +25 -0
- package/dist/link-check.js +56 -1
- package/dist/maps.d.ts +25 -2
- package/dist/maps.js +91 -5
- package/dist/mcp.d.ts +37 -0
- package/dist/mcp.js +61 -0
- package/dist/metadata.d.ts +69 -0
- package/dist/metadata.js +45 -0
- package/dist/react-server.d.ts +21 -1
- package/dist/react-server.js +43 -13
- package/dist/react.d.ts +12 -2
- package/dist/react.js +31 -0
- package/dist/revisions.d.ts +7 -3
- package/dist/revisions.js +5 -3
- package/dist/sanitize.d.ts +44 -0
- package/dist/sanitize.js +191 -0
- package/dist/search.d.ts +20 -0
- package/dist/search.js +22 -0
- package/dist/text.d.ts +18 -1
- package/dist/text.js +30 -0
- package/dist/tiptap.d.ts +14 -1
- package/dist/tiptap.js +41 -1
- package/dist/validation.d.ts +19 -1
- package/dist/validation.js +82 -28
- package/dist/well-known.d.ts +47 -8
- package/dist/well-known.js +58 -2
- package/package.json +24 -2
package/dist/sanitize.js
ADDED
|
@@ -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 = "<[^>]*>| |\\\\[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
|
+
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -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
|
|
64
|
+
}>(block: B, newId?: () => string): B;
|
package/dist/validation.js
CHANGED
|
@@ -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
|
|
67
|
-
if (!isRecord(block))
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
80
|
-
if (typeof block.type !== 'string' || !isKnownType(block.type))
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
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:
|
|
142
|
+
validateAtomicBlock: (block) => issuesOf(block, true).length === 0,
|
|
97
143
|
/** One block of any kind, containers included. */
|
|
98
|
-
validateBlock:
|
|
144
|
+
validateBlock: (block) => issuesOf(block, false).length === 0,
|
|
99
145
|
/** A whole page's content array. */
|
|
100
|
-
validateBlocks: (content) =>
|
|
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 {
|