wiki-formant 0.98.0 → 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 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
@@ -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", children: item.label })] }, item.id ?? i))) }));
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", { children: quote }), _jsxs("figcaption", { children: [src && _jsx("img", { src: src, ...photo, alt: "", loading: "lazy" }), _jsxs("span", { children: [_jsx("strong", { children: author }), role && _jsx("small", { children: role })] })] })] }));
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", { children: 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) }));
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", { children: heading }), _jsx("ul", { className: "places", children: items.map((p, i) => {
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, { children: 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 || p.address.split('\n')[0]}`, loading: "lazy", referrerPolicy: "no-referrer-when-downgrade" }))] }, p.id ?? i));
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().replace(/\n+/g, '\n> ')}\n>\n> — ${[author, role].filter(Boolean).join(', ')}`;
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 && `![](${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/corpus.d.ts CHANGED
@@ -7,9 +7,13 @@ export interface CorpusSection {
7
7
  /** Heading, URL, date and body, exactly as the document will carry them. */
8
8
  section: string;
9
9
  }
10
- /** `maxChars` bounds: the default, the floor and the ceiling. Quote them in the tool's schema. */
10
+ /**
11
+ * `maxChars` bounds: the default, the floor and the ceiling. Quote them in the
12
+ * tool's schema. The default slice, with its JSON around it, stays inside
13
+ * `payloadBudget`'s 120,000, since a call with no arguments is weighed there.
14
+ */
11
15
  export declare const CORPUS_BUDGET: {
12
- readonly default: 200000;
16
+ readonly default: 100000;
13
17
  readonly min: 1000;
14
18
  readonly max: 1000000;
15
19
  };
package/dist/corpus.js CHANGED
@@ -11,8 +11,12 @@
11
11
  //
12
12
  // The corpus itself — which rows, in what order, how a page becomes a section —
13
13
  // stays with each wiki. This is the arithmetic over the sections it hands in.
14
- /** `maxChars` bounds: the default, the floor and the ceiling. Quote them in the tool's schema. */
15
- export const CORPUS_BUDGET = { default: 200_000, min: 1_000, max: 1_000_000 };
14
+ /**
15
+ * `maxChars` bounds: the default, the floor and the ceiling. Quote them in the
16
+ * tool's schema. The default slice, with its JSON around it, stays inside
17
+ * `payloadBudget`'s 120,000, since a call with no arguments is weighed there.
18
+ */
19
+ export const CORPUS_BUDGET = { default: 100_000, min: 1_000, max: 1_000_000 };
16
20
  const CLIP_MARKER = '\n\n[…page clipped at maxChars…]';
17
21
  /**
18
22
  * A preflight (`sizeOnly`) or one page-aligned slice of `sections`.
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
- * four lines every `editor.chain().focus().toggleBold()` in every consumer
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, ' ') },
@@ -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. Every other field those blocks
26
- * carry renders as a React text node and is escaped already.
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(). Excluding quotes, backslashes, angle
50
- // brackets and any other `(` keeps `url(...)` and `expression(...)` out of
51
- // every property without a per-property regex.
52
- const SAFE_CSS_VALUE = /^(?:[a-z0-9#%.,\-+/ ]|rgba?\([\d\s,.%]+\))*$/i;
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. Every other field those blocks
161
- * carry renders as a React text node and is escaped already.
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) ? { ...p, note: clean(p.note) } : p)) };
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.98.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",