wiki-formant 0.98.1 → 0.99.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 +2 -1
- package/dist/block-views.js +8 -6
- package/dist/blocks.js +9 -9
- package/dist/editor.d.ts +20 -4
- package/dist/editor.js +24 -1
- package/dist/sanitize.d.ts +14 -2
- package/dist/sanitize.js +43 -9
- package/dist/text.js +2 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -601,6 +601,7 @@ const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
|
|
|
601
601
|
- The default list is what `wiki-formant/tiptap`'s nodes store plus a presentational SVG subset. Extend `tags`, `attributes`, `classes` and `schemesByTag` from your stored HTML, never from memory.
|
|
602
602
|
- A YouTube iframe comes out as the `YouTube` node's markup, whoever wrote it, and `base.css` draws it as a 16:9 player.
|
|
603
603
|
- Classes pass by name only, since a site's own utilities can draw over the chrome.
|
|
604
|
+
- `sanitizeLine(html, { links })` cleans what a `WikiLine` stores: its marks and `wikiTextStyle`'s spans. `sanitizeCoreLeaf` runs it over the core leaves' one-line fields (a figure, a quote and who said it, the events' and places' headings, a place's name), which `block-views` draws as HTML.
|
|
604
605
|
|
|
605
606
|
## Image uploads
|
|
606
607
|
|
|
@@ -665,7 +666,7 @@ const url = useUrlPrompt(editor, { resolveMapUrl });
|
|
|
665
666
|
|
|
666
667
|
- `WikiToolbar` shows each command the editor's extensions support (`supports`) and nothing else, in fixed groups, with the table controls while the cursor is in a table.
|
|
667
668
|
- `useUrlPrompt` is the link and embed field: it opens on the current link, unlinks on an emptied one, and closes when its editor changes.
|
|
668
|
-
- `WikiLine` is a one-line field with its own `marks`, undo and `onFocus`, so one toolbar can drive the field last touched.
|
|
669
|
+
- `WikiLine` is a one-line field with its own `marks`, undo and `onFocus`, so one toolbar can drive the field last touched. Its `style` mark is `wikiTextStyle`: a font, colour and size on a run of words, which an article editor takes through `nodes` and an app's own controls set with `setTextStyle`.
|
|
669
670
|
- A link is stored as its `href` and the `link` class only; whether it is external is decided at render by `articleHtml`'s `links` pass. `uploadImageTo('/api/upload')` is the `uploadImage` an editor passes.
|
|
670
671
|
|
|
671
672
|
## Block editor
|
package/dist/block-views.js
CHANGED
|
@@ -23,7 +23,9 @@ import { clock, DEFAULT_ZONE, hoursRows, mapEmbed, mapHref, openState } from './
|
|
|
23
23
|
import { Anchor } from './react-server.js';
|
|
24
24
|
import { safeLinkHref } from './validation.js';
|
|
25
25
|
import { cx } from './html.js';
|
|
26
|
-
import { BANNER_LABELS, BANNER_MESSAGES, bannerVariant } from './text.js';
|
|
26
|
+
import { BANNER_LABELS, BANNER_MESSAGES, bannerVariant, plainText } from './text.js';
|
|
27
|
+
/** A one-line field as the HTML it holds, cleaned by `sanitizeCoreLeaf` (`sanitizeLine`) on its way here. */
|
|
28
|
+
const line = (html) => ({ dangerouslySetInnerHTML: { __html: html } });
|
|
27
29
|
/**
|
|
28
30
|
* Tabbed code samples. Every tab's body stays mounted and the inactive ones are
|
|
29
31
|
* hidden rather than unmounted: remounting a highlighted panel would re-run
|
|
@@ -115,14 +117,14 @@ export function StatsView({ items, columns = 4, }) {
|
|
|
115
117
|
const rows = items ?? [];
|
|
116
118
|
if (!rows.length)
|
|
117
119
|
return null;
|
|
118
|
-
return (_jsx("div", { className: cx('stat-grid', `stat-grid-${columns}`), children: rows.map((item, i) => (_jsxs("div", { className: "stat-card", children: [_jsxs("span", { className: "stat-value", children: [item.value, item.suffix && _jsx("span", { className: "stat-suffix", children: item.suffix })] }), _jsx("span", { className: "stat-label",
|
|
120
|
+
return (_jsx("div", { className: cx('stat-grid', `stat-grid-${columns}`), children: rows.map((item, i) => (_jsxs("div", { className: "stat-card", children: [_jsxs("span", { className: "stat-value", children: [_jsx("span", { ...line(String(item.value)) }), item.suffix && _jsx("span", { className: "stat-suffix", children: item.suffix })] }), _jsx("span", { className: "stat-label", ...line(item.label) })] }, item.id ?? i))) }));
|
|
119
121
|
}
|
|
120
122
|
/** Someone's words over who said them, with their photo when there is one. */
|
|
121
123
|
export function TestimonialView({ quote, author, role, avatarUrl, photo }) {
|
|
122
124
|
if (!quote.trim())
|
|
123
125
|
return null;
|
|
124
126
|
const src = avatarUrl && safeLinkHref(avatarUrl);
|
|
125
|
-
return (_jsxs("figure", { className: "testimonial", children: [_jsx("blockquote", {
|
|
127
|
+
return (_jsxs("figure", { className: "testimonial", children: [_jsx("blockquote", { ...line(quote) }), _jsxs("figcaption", { children: [src && _jsx("img", { src: src, ...photo, alt: "", loading: "lazy" }), _jsxs("span", { children: [_jsx("strong", { ...line(author) }), role && _jsx("small", { ...line(role) })] })] })] }));
|
|
126
128
|
}
|
|
127
129
|
// ---- events -----------------------------------------------------------------
|
|
128
130
|
const still = () => () => { };
|
|
@@ -140,7 +142,7 @@ export function EventsView({ items, upcoming, past, day }) {
|
|
|
140
142
|
return (_jsx("section", { className: "events", children: [
|
|
141
143
|
[upcoming || 'Upcoming', split.upcoming],
|
|
142
144
|
[past || 'Past', split.past],
|
|
143
|
-
].map(([heading, rows], ended) => rows.length ? (_jsxs(Fragment, { children: [_jsx("h2", {
|
|
145
|
+
].map(([heading, rows], ended) => rows.length ? (_jsxs(Fragment, { children: [_jsx("h2", { ...line(heading) }), _jsx("ol", { className: "event-list", children: rows.map((e, i) => (_jsxs("li", { children: [_jsx("time", { dateTime: e.date, children: eventWhen(e) }), _jsxs("span", { children: [_jsx("span", { dangerouslySetInnerHTML: { __html: e.text } }), !ended && eventBegun(e, now) && _jsx("small", { className: "event-now", children: "Now on" })] })] }, e.id ?? i))) })] }, ended)) : null) }));
|
|
144
146
|
}
|
|
145
147
|
// ---- locations --------------------------------------------------------------
|
|
146
148
|
const everyMinute = (tick) => {
|
|
@@ -169,10 +171,10 @@ export function PlaceHours({ hours, timeZone = DEFAULT_ZONE }) {
|
|
|
169
171
|
*/
|
|
170
172
|
export function LocationsView({ heading, map, items }) {
|
|
171
173
|
const Name = heading ? 'h3' : 'h2';
|
|
172
|
-
return (_jsxs("section", { className: "locations", children: [heading && _jsx("h2", {
|
|
174
|
+
return (_jsxs("section", { className: "locations", children: [heading && _jsx("h2", { ...line(heading) }), _jsx("ul", { className: "places", children: items.map((p, i) => {
|
|
173
175
|
const link = safeLinkHref(mapHref(p));
|
|
174
176
|
const image = p.image && safeLinkHref(p.image);
|
|
175
|
-
return (_jsxs("li", { className: "place", children: [image && _jsx("img", { src: image, ...p.photo, alt: "", className: "place-picture", loading: "lazy" }), p.name && _jsx(Name, {
|
|
177
|
+
return (_jsxs("li", { className: "place", children: [image && _jsx("img", { src: image, ...p.photo, alt: "", className: "place-picture", loading: "lazy" }), p.name && _jsx(Name, { ...line(p.name) }), p.address && _jsx("address", { children: link ? _jsx("a", { href: link, children: p.address.trim() }) : p.address.trim() }), !!p.hours?.length && _jsx(PlaceHours, { hours: p.hours, timeZone: p.timeZone }), p.note && _jsx("p", { dangerouslySetInnerHTML: { __html: p.note } }), (p.phone || p.email) && (_jsxs("p", { className: "place-contact", children: [p.phone && _jsx("a", { href: `tel:${p.phone.replace(/[^\d+]/g, '')}`, children: p.phone }), p.email && _jsx("a", { href: `mailto:${p.email}`, children: p.email })] })), map && p.address && (_jsx("iframe", { className: "place-map", src: mapEmbed({ address: p.address, map: p.map }), title: `Map of ${(p.name && plainText(p.name)) || p.address.split('\n')[0]}`, loading: "lazy", referrerPolicy: "no-referrer-when-downgrade" }))] }, p.id ?? i));
|
|
176
178
|
}) })] }));
|
|
177
179
|
}
|
|
178
180
|
// ---- banner -----------------------------------------------------------------
|
package/dist/blocks.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
|
|
14
14
|
import { relativeTime } from './freshness.js';
|
|
15
15
|
import { hoursRows, mapHref } from './hours.js';
|
|
16
|
-
import { BANNER_LABELS, bannerToText, codeTabsToText, linkGridToText, pageListToText, referencesToText, statsToText, stripHtml, testimonialToText, } from './text.js';
|
|
16
|
+
import { BANNER_LABELS, bannerToText, codeTabsToText, linkGridToText, pageListToText, plainText, referencesToText, statsToText, stripHtml, testimonialToText, } from './text.js';
|
|
17
17
|
// ---- the shapes the standard block types carry ------------------------------
|
|
18
18
|
/**
|
|
19
19
|
* The languages a code block or code tab offers, and the one a new one opens
|
|
@@ -187,11 +187,11 @@ export function referencesToMarkdown(items, title = 'References') {
|
|
|
187
187
|
export function statsToMarkdown(items) {
|
|
188
188
|
if (!items.length)
|
|
189
189
|
return '';
|
|
190
|
-
return items.map(s => `- **${s.value}${s.suffix ?? ''}** — ${s.label}`).join('\n');
|
|
190
|
+
return items.map(s => `- **${plainText(String(s.value))}${s.suffix ?? ''}** — ${inlineToMarkdown(s.label)}`).join('\n');
|
|
191
191
|
}
|
|
192
192
|
/** A quotation over its attribution. @internal */
|
|
193
193
|
export function testimonialToMarkdown(quote, author, role) {
|
|
194
|
-
return `> ${quote.trim().
|
|
194
|
+
return `> ${quote.trim().split(/\n+/).map(inlineToMarkdown).join('\n> ')}\n>\n> — ${[author, role].filter(Boolean).map(w => inlineToMarkdown(w)).join(', ')}`;
|
|
195
195
|
}
|
|
196
196
|
/** Grouped links under their headings, with an optional lead paragraph. @internal */
|
|
197
197
|
export function linkGridToMarkdown(groups, intro) {
|
|
@@ -258,13 +258,13 @@ function eventSections({ upcoming, past, items, day }) {
|
|
|
258
258
|
/** Events under their headings, split by the day the twin is read, as the page is. */
|
|
259
259
|
export function eventsToMarkdown(block) {
|
|
260
260
|
return eventSections(block)
|
|
261
|
-
.map(s => `## ${s.heading}\n\n${s.items.map(e => `- ${eventWhen(e)}: ${inlineToMarkdown(e.text)}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
|
|
261
|
+
.map(s => `## ${inlineToMarkdown(s.heading)}\n\n${s.items.map(e => `- ${eventWhen(e)}: ${inlineToMarkdown(e.text)}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
|
|
262
262
|
.join('\n\n');
|
|
263
263
|
}
|
|
264
264
|
/** Events as prose, `12 Nov 2026: Group show, London` a line, under their headings. */
|
|
265
265
|
export function eventsToText(block) {
|
|
266
266
|
return eventSections(block)
|
|
267
|
-
.map(s => `${s.heading}\n${s.items.map(e => `${eventWhen(e)}: ${stripHtml(e.text).replace(/\s+/g, ' ')}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
|
|
267
|
+
.map(s => `${plainText(s.heading)}\n${s.items.map(e => `${eventWhen(e)}: ${stripHtml(e.text).replace(/\s+/g, ' ')}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
|
|
268
268
|
.join('\n\n');
|
|
269
269
|
}
|
|
270
270
|
const tel = (phone) => `tel:${phone.replace(/[^\d+]/g, '')}`;
|
|
@@ -276,7 +276,7 @@ export function locationsToMarkdown({ heading, items }) {
|
|
|
276
276
|
const map = mapHref(p);
|
|
277
277
|
const address = addressLine(p.address);
|
|
278
278
|
return [
|
|
279
|
-
p.name && `${level} ${p.name}`,
|
|
279
|
+
p.name && `${level} ${inlineToMarkdown(p.name)}`,
|
|
280
280
|
p.image && ``,
|
|
281
281
|
address && (map ? `[${address}](${map})` : address),
|
|
282
282
|
p.hours?.length && hoursRows(p.hours).map(r => `- ${r.label}: ${r.times}`).join('\n'),
|
|
@@ -286,12 +286,12 @@ export function locationsToMarkdown({ heading, items }) {
|
|
|
286
286
|
.filter(Boolean)
|
|
287
287
|
.join('\n\n');
|
|
288
288
|
};
|
|
289
|
-
return [heading && `## ${heading}`, ...items.map(place)].filter(Boolean).join('\n\n');
|
|
289
|
+
return [heading && `## ${inlineToMarkdown(heading)}`, ...items.map(place)].filter(Boolean).join('\n\n');
|
|
290
290
|
}
|
|
291
291
|
/** Each place as prose: its name, address, hours a row to a line, note, phone and email. */
|
|
292
292
|
export function locationsToText({ heading, items }) {
|
|
293
293
|
const place = (p) => [
|
|
294
|
-
p.name,
|
|
294
|
+
p.name && plainText(p.name),
|
|
295
295
|
addressLine(p.address),
|
|
296
296
|
...(p.hours?.length ? hoursRows(p.hours).map(r => `${r.label} ${r.times}`) : []),
|
|
297
297
|
p.note && stripHtml(p.note).replace(/\s+/g, ' '),
|
|
@@ -300,7 +300,7 @@ export function locationsToText({ heading, items }) {
|
|
|
300
300
|
]
|
|
301
301
|
.filter(Boolean)
|
|
302
302
|
.join('\n');
|
|
303
|
-
return [heading, ...items.map(place)].filter(Boolean).join('\n\n');
|
|
303
|
+
return [heading && plainText(heading), ...items.map(place)].filter(Boolean).join('\n\n');
|
|
304
304
|
}
|
|
305
305
|
/** A flat bullet list of links — resolved page lists, feed items, link rails. */
|
|
306
306
|
export function linkList(items) {
|
package/dist/editor.d.ts
CHANGED
|
@@ -5,19 +5,35 @@ import { type ChangeEvent, type KeyboardEvent, type ReactNode, type RefObject }
|
|
|
5
5
|
*
|
|
6
6
|
* Each of these packages ships a `declare module '@tiptap/core'` block that adds
|
|
7
7
|
* its commands to `ChainedCommands` — `toggleBold`, `insertTable`, `setLink`,
|
|
8
|
-
* `setImage`. Because this module owns the extensions, it has to carry
|
|
8
|
+
* `setImage`, `removeEmptyTextStyle`. Because this module owns the extensions, it has to carry
|
|
9
9
|
* the augmentation across the package boundary, and a re-exported type is what
|
|
10
10
|
* makes the emitted `.d.ts` load the module that declares it. Without these
|
|
11
|
-
*
|
|
11
|
+
* lines every `editor.chain().focus().toggleBold()` in every consumer
|
|
12
12
|
* stops type-checking.
|
|
13
13
|
*/
|
|
14
14
|
export type { StarterKitOptions } from '@tiptap/starter-kit';
|
|
15
15
|
export type { LinkOptions } from '@tiptap/extension-link';
|
|
16
16
|
export type { ImageOptions } from '@tiptap/extension-image';
|
|
17
17
|
export type { TableOptions } from '@tiptap/extension-table';
|
|
18
|
+
export type { TextStyleOptions } from '@tiptap/extension-text-style';
|
|
18
19
|
/** The view and the editor's type, so a consumer imports nothing from `@tiptap/*` itself. */
|
|
19
20
|
export { EditorContent } from '@tiptap/react';
|
|
20
21
|
export type { Editor } from '@tiptap/core';
|
|
22
|
+
/** What `wikiTextStyle` may set on a run of words, each one property of its span's `style`. */
|
|
23
|
+
export interface TextStyleAttrs {
|
|
24
|
+
fontFamily?: string | null;
|
|
25
|
+
color?: string | null;
|
|
26
|
+
fontSize?: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Tiptap's TextStyle, carrying a font, a colour and a size: a run of words
|
|
30
|
+
* styled apart from the block around it, the same in a line as in an article.
|
|
31
|
+
* Each is read back from stored HTML, so a span the sanitiser kept is edited
|
|
32
|
+
* as it was written. Set with `setTextStyle`, which drops an empty span.
|
|
33
|
+
*/
|
|
34
|
+
export declare const wikiTextStyle: import("@tiptap/core").Mark<import("@tiptap/extension-text-style").TextStyleOptions, any>;
|
|
35
|
+
/** Style the selected words, or with null take a property off them; a span left with none goes. */
|
|
36
|
+
export declare const setTextStyle: (editor: Editor, attrs: TextStyleAttrs) => boolean;
|
|
21
37
|
export interface WikiEditorExtensionOptions {
|
|
22
38
|
/** Empty-document prompt. */
|
|
23
39
|
placeholder?: string;
|
|
@@ -169,8 +185,8 @@ export interface WikiEditorState {
|
|
|
169
185
|
* the buttons its extensions allow; the icons and classes are the app's.
|
|
170
186
|
*/
|
|
171
187
|
export declare function useWikiEditor({ value, onChange, placeholder, nodes, proseClass, uploadImage, debounceMs, }: WikiEditorOptions): WikiEditorState;
|
|
172
|
-
/** What a one-line field may keep. With none, its value is plain text. */
|
|
173
|
-
export type LineMark = 'bold' | 'italic' | 'link';
|
|
188
|
+
/** What a one-line field may keep: an article's inline marks, and `wikiTextStyle`'s font, colour and size. With none, its value is plain text. */
|
|
189
|
+
export type LineMark = 'bold' | 'italic' | 'code' | 'link' | 'style';
|
|
174
190
|
export interface WikiLineProps {
|
|
175
191
|
as: 'h1' | 'h2' | 'h3' | 'p' | 'span' | 'div' | 'small' | 'figcaption';
|
|
176
192
|
value: string;
|
package/dist/editor.js
CHANGED
|
@@ -23,6 +23,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
23
23
|
// the taxonomy or the MCP transport installs none of them.
|
|
24
24
|
import StarterKit from '@tiptap/starter-kit';
|
|
25
25
|
import TiptapLink from '@tiptap/extension-link';
|
|
26
|
+
import TextStyle from '@tiptap/extension-text-style';
|
|
26
27
|
import TiptapImage from '@tiptap/extension-image';
|
|
27
28
|
import TiptapTable from '@tiptap/extension-table';
|
|
28
29
|
import TiptapTableRow from '@tiptap/extension-table-row';
|
|
@@ -83,6 +84,27 @@ export const wikiLink = TiptapLink.extend({
|
|
|
83
84
|
return Object.fromEntries(Object.entries(this.parent?.() ?? {}).filter(([name]) => name !== 'target' && name !== 'rel'));
|
|
84
85
|
},
|
|
85
86
|
}).configure({ openOnClick: false, HTMLAttributes: { class: 'link', target: null, rel: null } });
|
|
87
|
+
const STYLE_CSS = { fontFamily: 'font-family', color: 'color', fontSize: 'font-size' };
|
|
88
|
+
/**
|
|
89
|
+
* Tiptap's TextStyle, carrying a font, a colour and a size: a run of words
|
|
90
|
+
* styled apart from the block around it, the same in a line as in an article.
|
|
91
|
+
* Each is read back from stored HTML, so a span the sanitiser kept is edited
|
|
92
|
+
* as it was written. Set with `setTextStyle`, which drops an empty span.
|
|
93
|
+
*/
|
|
94
|
+
export const wikiTextStyle = TextStyle.extend({
|
|
95
|
+
addAttributes() {
|
|
96
|
+
return Object.fromEntries(Object.entries(STYLE_CSS).map(([name, css]) => [
|
|
97
|
+
name,
|
|
98
|
+
{
|
|
99
|
+
default: null,
|
|
100
|
+
parseHTML: (el) => el.style.getPropertyValue(css) || null,
|
|
101
|
+
renderHTML: (attrs) => (attrs[name] ? { style: `${css}: ${attrs[name]}` } : {}),
|
|
102
|
+
},
|
|
103
|
+
]));
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
/** Style the selected words, or with null take a property off them; a span left with none goes. */
|
|
107
|
+
export const setTextStyle = (editor, attrs) => editor.chain().focus().setMark('textStyle', attrs).removeEmptyTextStyle().run();
|
|
86
108
|
/**
|
|
87
109
|
* The editor's extension set.
|
|
88
110
|
*
|
|
@@ -514,7 +536,6 @@ export function WikiLine({ as: Tag, value, onChange, placeholder, marks = [], cl
|
|
|
514
536
|
orderedList: false,
|
|
515
537
|
listItem: false,
|
|
516
538
|
codeBlock: false,
|
|
517
|
-
code: false,
|
|
518
539
|
horizontalRule: false,
|
|
519
540
|
hardBreak: false,
|
|
520
541
|
strike: false,
|
|
@@ -522,8 +543,10 @@ export function WikiLine({ as: Tag, value, onChange, placeholder, marks = [], cl
|
|
|
522
543
|
gapcursor: false,
|
|
523
544
|
...(has('bold') ? {} : { bold: false }),
|
|
524
545
|
...(has('italic') ? {} : { italic: false }),
|
|
546
|
+
...(has('code') ? {} : { code: false }),
|
|
525
547
|
}),
|
|
526
548
|
...(has('link') ? [wikiLink] : []),
|
|
549
|
+
...(has('style') ? [wikiTextStyle] : []),
|
|
527
550
|
],
|
|
528
551
|
content: content(value),
|
|
529
552
|
editorProps: { transformPastedHTML: pastedLine, transformPastedText: t => t.replace(/\s+/g, ' ') },
|
package/dist/sanitize.d.ts
CHANGED
|
@@ -20,10 +20,22 @@ export interface HtmlSanitizerOptions {
|
|
|
20
20
|
* got here, and a list written from memory erases content on the first render.
|
|
21
21
|
*/
|
|
22
22
|
export declare function createHtmlSanitizer(options?: HtmlSanitizerOptions): (html: string) => string;
|
|
23
|
+
/**
|
|
24
|
+
* A one-line field's HTML, cleaned: bold, italic, code, a link and a styled
|
|
25
|
+
* span, and nothing else, so a heading or a caption drawn from it as HTML holds
|
|
26
|
+
* no block, picture or script. `links: false` drops a link's tag and keeps its
|
|
27
|
+
* words, for a field drawn inside a link already, a button's label.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sanitizeLine(html: string, { links }?: {
|
|
30
|
+
links?: boolean;
|
|
31
|
+
}): string;
|
|
23
32
|
/**
|
|
24
33
|
* The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
|
|
25
|
-
* descriptions, `references` and `events` item text, and a place's note
|
|
26
|
-
*
|
|
34
|
+
* descriptions, `references` and `events` item text, and a place's note, by
|
|
35
|
+
* `clean`; and their one-line fields, a figure's value and label, a quote and
|
|
36
|
+
* who said it, the events' headings and a place's heading and name, by
|
|
37
|
+
* `sanitizeLine`. Every other field those blocks carry renders as a React text
|
|
38
|
+
* node and is escaped already.
|
|
27
39
|
*
|
|
28
40
|
* Returns any other block untouched, so it drops straight into a
|
|
29
41
|
* `mapBlockTree` pass — a repo whose own types render HTML cleans those itself.
|
package/dist/sanitize.js
CHANGED
|
@@ -43,13 +43,14 @@ const STYLE_PROPS = [
|
|
|
43
43
|
'border', 'border-top', 'border-bottom', 'border-left', 'border-right',
|
|
44
44
|
'border-radius', 'border-color', 'border-width', 'border-style',
|
|
45
45
|
'background', 'background-color', 'color', 'opacity',
|
|
46
|
-
'font-size', 'font-weight', 'font-style', 'line-height',
|
|
46
|
+
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
|
|
47
47
|
'text-align', 'vertical-align', 'display', 'overflow',
|
|
48
48
|
];
|
|
49
|
-
// Ordinary CSS tokens plus rgb()/rgba()
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
49
|
+
// Ordinary CSS tokens plus rgb()/rgba(), and var() naming a custom property, so
|
|
50
|
+
// a run of words can take the site's own colour or web font. Excluding quotes,
|
|
51
|
+
// backslashes, angle brackets and any other `(` keeps `url(...)` and
|
|
52
|
+
// `expression(...)` out of every property without a per-property regex.
|
|
53
|
+
const SAFE_CSS_VALUE = /^(?:[a-z0-9#%.,\-+/ ]|rgba?\([\d\s,.%]+\)|var\(--[a-z0-9-]+\))*$/i;
|
|
53
54
|
const PROSE_TAGS = [
|
|
54
55
|
'p', 'br', 'hr', 'div', 'span', 'blockquote', 'pre', 'code',
|
|
55
56
|
'strong', 'em', 'b', 'i', 's', 'u', 'sub', 'sup', 'mark', 'small',
|
|
@@ -145,6 +146,29 @@ export function createHtmlSanitizer(options = {}) {
|
|
|
145
146
|
};
|
|
146
147
|
return (html) => (!html || !html.includes('<') ? html : asYouTubeNodes(sanitizeHtml(html, config)));
|
|
147
148
|
}
|
|
149
|
+
// What a `WikiLine` keeps: its marks, the link's class, and `wikiTextStyle`'s
|
|
150
|
+
// three properties, each held to the same safe value as an article's.
|
|
151
|
+
const LINE_TAGS = ['strong', 'em', 'b', 'i', 'code', 'span', 'a'];
|
|
152
|
+
const LINE_STYLES = Object.fromEntries(['font-family', 'color', 'font-size'].map(p => [p, [SAFE_CSS_VALUE]]));
|
|
153
|
+
/**
|
|
154
|
+
* A one-line field's HTML, cleaned: bold, italic, code, a link and a styled
|
|
155
|
+
* span, and nothing else, so a heading or a caption drawn from it as HTML holds
|
|
156
|
+
* no block, picture or script. `links: false` drops a link's tag and keeps its
|
|
157
|
+
* words, for a field drawn inside a link already, a button's label.
|
|
158
|
+
*/
|
|
159
|
+
export function sanitizeLine(html, { links = true } = {}) {
|
|
160
|
+
if (!html || !html.includes('<'))
|
|
161
|
+
return html;
|
|
162
|
+
return sanitizeHtml(html, {
|
|
163
|
+
allowedTags: links ? LINE_TAGS : LINE_TAGS.filter(t => t !== 'a'),
|
|
164
|
+
allowedAttributes: { a: ['href'], span: ['style'] },
|
|
165
|
+
allowedClasses: { a: ['link'] },
|
|
166
|
+
allowedStyles: { span: LINE_STYLES },
|
|
167
|
+
allowedSchemesByTag: { a: ['http', 'https', 'mailto', 'tel'] },
|
|
168
|
+
allowProtocolRelative: false,
|
|
169
|
+
nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript'],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
148
172
|
// A YouTube player the editor did not write: an agent's, an import's, a pasted
|
|
149
173
|
// embed code. Run over the sanitiser's own output, whose markup is regular.
|
|
150
174
|
const BARE_YOUTUBE = /(<div data-youtube-video>)?(<iframe\b[^>]*\bsrc="https:\/\/www\.youtube(?:-nocookie)?\.com\/embed\/[^"]*"[^>]*><\/iframe>)/g;
|
|
@@ -155,10 +179,15 @@ const BARE_YOUTUBE = /(<div data-youtube-video>)?(<iframe\b[^>]*\bsrc="https:\/\
|
|
|
155
179
|
const asYouTubeNodes = (html) => html.includes('youtube') ? html.replace(BARE_YOUTUBE, (all, wrapped, frame) => (wrapped ? all : `<div data-youtube-video>${frame}</div>`)) : html;
|
|
156
180
|
const each = (v, map) => Array.isArray(v) ? v.map(item => (item && typeof item === 'object' ? map(item) : item)) : v;
|
|
157
181
|
const str = (v) => typeof v === 'string';
|
|
182
|
+
/** The named one-line fields of `o` that hold words, each through `sanitizeLine`. */
|
|
183
|
+
const lines = (o, ...keys) => Object.fromEntries(keys.filter(k => str(o[k])).map(k => [k, sanitizeLine(o[k])]));
|
|
158
184
|
/**
|
|
159
185
|
* The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
|
|
160
|
-
* descriptions, `references` and `events` item text, and a place's note
|
|
161
|
-
*
|
|
186
|
+
* descriptions, `references` and `events` item text, and a place's note, by
|
|
187
|
+
* `clean`; and their one-line fields, a figure's value and label, a quote and
|
|
188
|
+
* who said it, the events' headings and a place's heading and name, by
|
|
189
|
+
* `sanitizeLine`. Every other field those blocks carry renders as a React text
|
|
190
|
+
* node and is escaped already.
|
|
162
191
|
*
|
|
163
192
|
* Returns any other block untouched, so it drops straight into a
|
|
164
193
|
* `mapBlockTree` pass — a repo whose own types render HTML cleans those itself.
|
|
@@ -177,10 +206,15 @@ export function sanitizeCoreLeaf(block, clean) {
|
|
|
177
206
|
groups: each(b.groups, g => (str(g.description) ? { ...g, description: clean(g.description) } : g)),
|
|
178
207
|
};
|
|
179
208
|
case 'references':
|
|
180
|
-
case 'events':
|
|
181
209
|
return { ...b, items: each(b.items, i => (str(i.text) ? { ...i, text: clean(i.text) } : i)) };
|
|
210
|
+
case 'events':
|
|
211
|
+
return { ...b, ...lines(b, 'upcoming', 'past'), items: each(b.items, i => (str(i.text) ? { ...i, text: clean(i.text) } : i)) };
|
|
182
212
|
case 'locations':
|
|
183
|
-
return { ...b, items: each(b.items, p => (str(p.note)
|
|
213
|
+
return { ...b, ...lines(b, 'heading'), items: each(b.items, p => ({ ...p, ...lines(p, 'name'), ...(str(p.note) && { note: clean(p.note) }) })) };
|
|
214
|
+
case 'stats':
|
|
215
|
+
return { ...b, items: each(b.items, s => ({ ...s, ...lines(s, 'value', 'label') })) };
|
|
216
|
+
case 'testimonial':
|
|
217
|
+
return { ...b, ...lines(b, 'quote', 'author', 'role') };
|
|
184
218
|
default:
|
|
185
219
|
return block;
|
|
186
220
|
}
|
package/dist/text.js
CHANGED
|
@@ -109,11 +109,11 @@ export function codeTabsToText(tabs) {
|
|
|
109
109
|
}
|
|
110
110
|
/** `“Their words” — Name, role`. @internal */
|
|
111
111
|
export function testimonialToText(quote, author, role) {
|
|
112
|
-
return `“${quote.trim()}” — ${[author, role].filter(Boolean).join(', ')}`;
|
|
112
|
+
return `“${stripHtml(quote).trim()}” — ${[author, role].filter(Boolean).map(w => plainText(w)).join(', ')}`;
|
|
113
113
|
}
|
|
114
114
|
/** Metric cards, one per line: `99% Uptime`. @internal */
|
|
115
115
|
export function statsToText(items) {
|
|
116
|
-
return items.map(s => `${s.value}${s.suffix ?? ''} ${s.label}`).join('\n');
|
|
116
|
+
return items.map(s => `${plainText(String(s.value))}${s.suffix ?? ''} ${plainText(s.label)}`).join('\n');
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
119
|
* Each group's heading over its links, hrefs in parentheses the way
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.99.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, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -327,6 +327,7 @@
|
|
|
327
327
|
"@tiptap/extension-table-cell": "^2.27.3",
|
|
328
328
|
"@tiptap/extension-table-header": "^2.27.3",
|
|
329
329
|
"@tiptap/extension-table-row": "^2.27.3",
|
|
330
|
+
"@tiptap/extension-text-style": "^2.27.3",
|
|
330
331
|
"@tiptap/extension-youtube": "^2.27.3",
|
|
331
332
|
"@tiptap/pm": "^2.27.3",
|
|
332
333
|
"@tiptap/react": "^2.27.3",
|