wiki-formant 0.86.3 → 0.87.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
@@ -728,7 +728,7 @@ leafBlocks(blocks, BLOCK_SHAPE.containers);
728
728
 
729
729
  The dispatch over a repo's own block union stays in that repo; the bodies come from here. `coreAtomicText` has a prose body for every core leaf — the stats, link-grid and page-list bodies joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
730
730
 
731
- The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `testimonial`, `recentPages`, `pageList`, and `list`, which does what the two page lists do between them — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
731
+ The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `testimonial`, `events`, `recentPages`, `pageList`, and `list`, which does what the two page lists do between them — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
732
732
 
733
733
  ```ts
734
734
  import { coreAtomicText, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
@@ -782,7 +782,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
782
782
 
783
783
  ## Sanitising stored HTML
784
784
 
785
- `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render two HTML fields themselves — `linkGrid` descriptions and `references` text — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. Run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
785
+ `wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render three HTML fields themselves — `linkGrid` descriptions, and `references` and `events` text — and a repo renders `content.text` beside them, so the guard ships with the renderer. `codeTabs` is not one of them: its code is source. Run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
786
786
 
787
787
  ```ts
788
788
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
@@ -883,15 +883,17 @@ const SCHEMA: BlockSchema<Block> = {
883
883
  <BlockEditor blocks={blocks} onChange={setBlocks} schema={SCHEMA} kit={KIT} />
884
884
  ```
885
885
 
886
- The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
886
+ The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `EventsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
887
887
 
888
888
  An editor that draws each block in place, with a list of its own (miow's), hosts the same fields in `<BlockFieldsKit kit={KIT}>`, without `<BlockEditor>`. Only the two container fields need the full editor, since they hold a block list.
889
889
 
890
890
  A `testimonial` is `{ quote, author, role?, avatarUrl? }`, drawn by `TestimonialView` as `figure.testimonial`: the words in a `blockquote`, then a `figcaption` holding the photo and a `span` of `strong` (who) and `small` (their role). radix-wiki and miow each had one under its own field names; a site with sized images passes `photo`, the `<img>` attributes (`src`, `srcSet`, `sizes`) it made from `avatarUrl`. They are data, because a server component renders the view.
891
891
 
892
+ An `events` block is `{ upcoming?, past?, items: [{ id, date, end?, text }] }`: exhibitions, shows, talks, each dated as precisely as it is known (`EVENT_DATE`: `2026`, `2026-11` or `2026-11-12`), its end no earlier than its start, and its `text` inline HTML saying what and where. The items are stored in any order. `EventsView` sets those to come or on now soonest first under `upcoming` ("Upcoming"), with "Now on" beside one that has begun, and those that have ended latest first under `past` ("Past"), as `section.events` holding an `h2` and an `ol.event-list` for each; `base.css` gives every row's dates one column, shared by both lists. A date compares at its own precision, so `2026-08` is on until August ends. The page is split twice: by `day`, the day it was rendered (`today()` on the server), and again in the browser by the visitor's own day, because a cached page can be a day old and a show that closed since then is past. A wiki whose renderer is a client component fills `day` on the server when the page is read (it is never stored), so hydration matches what the server drew. `splitEvents`, `eventWhen` ("12 – 18 Nov 2026"), `eventBegun`, `eventEnded` and `eventEndsBefore` are the same rules for a twin, a feed or an editor; `coreAtomicText` and `coreAtomicToMarkdown` split by `day` too. `EventsFields` edits the rows in the order they were written, each with how its dates read and where the page sets it today, and `sanitizeCoreLeaf` cleans each event's text. miow had it first.
893
+
892
894
  A banner with no text of its own shows `BANNER_MESSAGES` from `wiki-formant/text`, a sentence per variant that names no wiki; `BannerView` takes it unless `message` overrides it.
893
895
 
894
- The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `TestimonialBlock`, `ListBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
896
+ The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `TestimonialBlock`, `EventsBlock`, `ListBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
895
897
 
896
898
  `ListBlock` is one list of the wiki's pages where those two were two: the pages in a section (`source: 'under'`), the newest, in a section or across the wiki (`'newest'`, `count`), or pages picked by hand (`'chosen'`, `pages`, keyed as the wiki keys a page, an id or a path), shown as links with their age or as cards (`show`). A wiki resolves it to `items` as it does a page list. `ListFields` edits it, with `pages` to pick from and `sections` to name; a wiki with a source of its own, a shop's products say, passes it in `sources` and draws its fields as `children`.
897
899
 
@@ -1101,7 +1103,7 @@ the site's stylesheet gives them and the box a look.
1101
1103
 
1102
1104
  ## Base stylesheet
1103
1105
 
1104
- `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the stats block's grid, the code-tab strip, the banner's row and the infobox's wrapping, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity. The one set of tokens it declares is the page frame every site lays out by: `--content-width` (72rem, gutters inside), `--sidebar-width` (18rem) and `--page-pad` (16 / 24 / 32px at base / 640 / 1024). They sit in the components layer, so a site never restates them in `@theme`, which would lose; an element that must differ sets its own.
1106
+ `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the stats block's grid, the events block's shared date column, the code-tab strip, the banner's row and the infobox's wrapping, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity. The one set of tokens it declares is the page frame every site lays out by: `--content-width` (72rem, gutters inside), `--sidebar-width` (18rem) and `--page-pad` (16 / 24 / 32px at base / 640 / 1024). They sit in the components layer, so a site never restates them in `@theme`, which would lose; an element that must differ sets its own.
1105
1107
 
1106
1108
  ```css
1107
1109
  @import "wiki-formant/base.css" layer(components);
package/dist/base.css CHANGED
@@ -155,7 +155,10 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
155
155
  The structure three or four sites had each restated around this markup;
156
156
  padding, gaps, radii and colour stay theirs. A stats block is two columns,
157
157
  three or four from 40rem; a code-tab strip is a row of buttons; a banner sets
158
- its icon beside its text; a long address in an infobox wraps. */
158
+ its icon beside its text; a long address in an infobox wraps; an events
159
+ block sets every row's dates in one column, shared by Upcoming and Past
160
+ through a subgrid, and stacks them over the words wherever it is narrower
161
+ than 30rem: a phone, or a narrow column. */
159
162
  .stat-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
160
163
  @media (min-width: 40rem) {
161
164
  .stat-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@@ -171,6 +174,14 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
171
174
  sits outside the article's prose box, and on radix.wiki ran 709px. */
172
175
  .link-grid-group-description { max-width: var(--measure); }
173
176
  .infobox-series { display: flex; flex-direction: column; }
177
+ .events { display: grid; grid-template-columns: auto 1fr; container-type: inline-size; }
178
+ .events > *, .event-list li { grid-column: 1 / -1; }
179
+ .event-list, .event-list li { display: grid; grid-template-columns: subgrid; }
180
+ .event-list { margin: 0; padding: 0; list-style: none; }
181
+ .event-list time { white-space: nowrap; }
182
+ @container (max-width: 30rem) {
183
+ :is(.event-list, .event-list li, .event-list time) { display: block; }
184
+ }
174
185
  .heading-anchor,
175
186
  .ref-backlink,
176
187
  .reference-link { text-decoration: none; }
@@ -297,6 +308,16 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
297
308
 
298
309
  .facet-summary { margin: 0; font-size: var(--ui-small); color: var(--ui-muted); }
299
310
 
311
+ .events { column-gap: 1rem; margin-block: 1rem; }
312
+ .events h2 { margin: 0 0 1rem; }
313
+ .events ol ~ h2 { margin-top: 2.5rem; }
314
+ .event-list { row-gap: 0.75rem; }
315
+ .event-list time { color: var(--ui-muted); font-variant-numeric: tabular-nums; }
316
+ @container (max-width: 30rem) {
317
+ .event-list li + li { margin-top: 0.75rem; }
318
+ }
319
+ .event-now { margin-left: 0.5em; padding: 0.1em 0.55em; border-radius: 999px; background: var(--ui-accent); color: var(--ui-on-accent); font-size: 0.8em; white-space: nowrap; }
320
+
300
321
  .link-grid { display: flex; flex-direction: column; gap: 1rem; margin-block: 1rem; }
301
322
  .link-grid-group-description { margin: 0 0 0.75rem; font-size: var(--ui-small); color: var(--ui-muted); }
302
323
  .link-grid-group-description p { margin: 0; }
@@ -1,5 +1,5 @@
1
1
  import { type ComponentType, type ReactElement, type ReactNode } from 'react';
2
- import { type BannerBlock, type CodeTabsBlock, type ColumnsBlock, type InfoboxBlock, type LinkGridBlock, type ReferencesBlock, type StatsBlock } from './blocks.js';
2
+ import { type BannerBlock, type CodeTabsBlock, type ColumnsBlock, type EventsBlock, type InfoboxBlock, type LinkGridBlock, type ReferencesBlock, type StatsBlock } from './blocks.js';
3
3
  export interface BlockEditorClassNames {
4
4
  /** A page's block list. */
5
5
  list?: string;
@@ -167,6 +167,16 @@ export declare function StatsFields({ block, onUpdate, hint }: {
167
167
  onUpdate: (block: StatsBlock) => void;
168
168
  hint?: string;
169
169
  }): import("react").JSX.Element;
170
+ /**
171
+ * Events in the order they were written, each with how its dates read and
172
+ * where the page sets it today: the page sorts them itself, and moves each on
173
+ * once it ends. `text` is inline HTML, as a reference's is.
174
+ */
175
+ export declare function EventsFields({ block, onUpdate, hint }: {
176
+ block: EventsBlock;
177
+ onUpdate: (block: EventsBlock) => void;
178
+ hint?: string;
179
+ }): import("react").JSX.Element;
170
180
  /** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
171
181
  export declare function ReferencesFields({ block, onUpdate, title, noun, hint, }: {
172
182
  block: ReferencesBlock;
@@ -23,7 +23,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
23
23
  // card re-renders on every keystroke.
24
24
  import { createContext, memo, useCallback, useContext, useMemo, useState, } from 'react';
25
25
  import { BlockActions, useBlockOperations } from './react.js';
26
- import { CODE_LANGS, DEFAULT_LANG, LIST_SOURCES, } from './blocks.js';
26
+ import { CODE_LANGS, DEFAULT_LANG, EVENT_DATE, eventBegun, eventEnded, eventWhen, LIST_SOURCES, today, } from './blocks.js';
27
27
  import { BANNER_VARIANTS } from './text.js';
28
28
  import { MAX_BLOCK_ROWS } from './validation.js';
29
29
  import { cx } from './html.js';
@@ -163,6 +163,21 @@ export function StatsFields({ block, onUpdate, hint }) {
163
163
  const set = (next) => onUpdate({ ...block, items: next });
164
164
  return (_jsxs("div", { className: c.stack, children: [_jsx(Choice, { label: "Per row", value: String(block.columns), options: ['2', '3', '4'].map(n => ({ value: n, label: n })), onChange: n => onUpdate({ ...block, columns: Number(n) }) }), items.map((item, i) => (_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Figure", className: c.figure, value: item.value, onChange: value => set(replaceAt(items, i, { ...item, value: value ?? '' })) }), _jsx(TextInput, { label: "What it counts", value: item.label, onChange: label => set(replaceAt(items, i, { ...item, label: label ?? '' })) }), _jsx(Remove, { label: `Remove figure ${i + 1}`, onPress: () => set(removeAt(items, i)) })] }, item.id))), _jsx(Add, { onPress: () => set([...items, { id: newId(), value: '', label: '' }]), children: "Add figure" }), hint && _jsx("small", { className: c.muted, children: hint })] }));
165
165
  }
166
+ /**
167
+ * Events in the order they were written, each with how its dates read and
168
+ * where the page sets it today: the page sorts them itself, and moves each on
169
+ * once it ends. `text` is inline HTML, as a reference's is.
170
+ */
171
+ export function EventsFields({ block, onUpdate, hint }) {
172
+ const c = useEditorContext().kit.classNames;
173
+ const items = block.items;
174
+ const set = (next) => onUpdate({ ...block, items: next });
175
+ const day = today();
176
+ return (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Heading over those to come", placeholder: "Upcoming", optional: true, value: block.upcoming, onChange: upcoming => onUpdate({ ...block, upcoming }) }), _jsx(TextInput, { label: "Heading over those that have ended", placeholder: "Past", optional: true, value: block.past, onChange: past => onUpdate({ ...block, past }) })] }), items.map((e, i) => {
177
+ const dated = EVENT_DATE.test(e.date) && (!e.end || EVENT_DATE.test(e.end));
178
+ return (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(TextInput, { label: "Starts", placeholder: "Starts, e.g. 2026-11", value: e.date, onChange: date => set(replaceAt(items, i, { ...e, date: date ?? '' })) }), _jsx(TextInput, { label: "Ends (optional)", optional: true, value: e.end, onChange: end => set(replaceAt(items, i, { ...e, end })) }), _jsx(Remove, { label: `Remove event ${i + 1}`, onPress: () => set(removeAt(items, i)) })] }), _jsx(TextInput, { label: "What it is and where", placeholder: "What it is and where, e.g. Group show, Park Royal Gallery, London", value: e.text, onChange: text => set(replaceAt(items, i, { ...e, text: text ?? '' })) }), _jsx("small", { className: c.muted, children: dated ? `${eventWhen(e)} · ${eventEnded(e, day) ? 'past' : eventBegun(e, day) ? 'now on' : 'upcoming'}` : 'A year, month or day: 2026, 2026-11 or 2026-11-12' })] }, e.id));
179
+ }), _jsx(Add, { onPress: () => set([...items, { id: newId(), date: day.slice(0, 7), text: '' }]), children: "Add event" }), _jsx("small", { className: c.muted, children: hint ?? 'Readers see those to come soonest first and those that have ended latest first, each moving on its own once it ends.' })] }));
180
+ }
166
181
  /** `title` is the heading a references block shows when it stores none, and `noun` what one row is called. */
167
182
  export function ReferencesFields({ block, onUpdate, title = 'References', noun = 'reference', hint, }) {
168
183
  const c = useEditorContext().kit.classNames;
@@ -1,5 +1,5 @@
1
1
  import { type ImgHTMLAttributes, type ReactNode } from 'react';
2
- import type { CodeTab, LinkGridGroup, ReferenceItem, ResolvedPageRef, StatItem } from './blocks.js';
2
+ import type { CodeTab, EventItem, LinkGridGroup, ReferenceItem, ResolvedPageRef, StatItem } from './blocks.js';
3
3
  import type { WikiLinkComponent } from './react-server.js';
4
4
  export interface CodeTabsViewProps {
5
5
  tabs: readonly CodeTab[];
@@ -123,6 +123,31 @@ export interface TestimonialViewProps {
123
123
  }
124
124
  /** Someone's words over who said them, with their photo when there is one. */
125
125
  export declare function TestimonialView({ quote, author, role, avatarUrl, photo }: TestimonialViewProps): import("react").JSX.Element | null;
126
+ export interface EventsViewProps {
127
+ items: readonly (EventItem & {
128
+ id?: string;
129
+ })[];
130
+ /** The heading over those to come or on now. Default `Upcoming`. */
131
+ upcoming?: string | null;
132
+ /** The heading over those that have ended. Default `Past`. */
133
+ past?: string | null;
134
+ /**
135
+ * The day the server split them by, `today()` where it rendered. A page is
136
+ * drawn by that day, so hydration matches it, then by the visitor's own: a
137
+ * cached page can be a day old, and a show that closed since then is past.
138
+ * Omitted, the server and the browser each use their own clock.
139
+ */
140
+ day?: string | null;
141
+ }
142
+ /**
143
+ * Events under two `h2`s, those to come or on now soonest first and those that
144
+ * have ended latest first, as `section.events` holding an `ol.event-list` under
145
+ * each heading. A row is the `<time>` and a `span` of the event's inline HTML,
146
+ * with `small.event-now` on one that has begun. `base.css` sets every row's
147
+ * dates in one column shared by both lists. The text is written as HTML, so it
148
+ * reaches here through `sanitizeCoreLeaf`, as a reference's does.
149
+ */
150
+ export declare function EventsView({ items, upcoming, past, day }: EventsViewProps): import("react").JSX.Element;
126
151
  /**
127
152
  * A maintenance notice.
128
153
  *
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  // Behind its own subpath for the reason `react.tsx` is: React is an optional
6
6
  // peer, so a consumer that only wants the taxonomy or the MCP transport needs no React.
7
7
  //
8
- // WHY THESE FIVE AND NOT THE SWITCH. The block union is per-repo and staying
8
+ // WHY THE LEAVES AND NOT THE SWITCH. The block union is per-repo and staying
9
9
  // that way (see the Block Model note in the workspace CLAUDE.md) — a closed
10
10
  // union is what makes `switch (block.type)` exhaustive, so a new block type is
11
11
  // a compile error rather than a silent blank. What is NOT per-repo is what a
@@ -17,7 +17,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
17
17
  // fork. Everything that
18
18
  // genuinely differs — the prose of a banner, whether references run through an
19
19
  // HTML processor, the router's link — arrives as a prop.
20
- import { Fragment, useState } from 'react';
20
+ import { Fragment, useState, useSyncExternalStore } from 'react';
21
+ import { eventBegun, eventWhen, splitEvents, today } from './blocks.js';
21
22
  import { Anchor } from './react-server.js';
22
23
  import { safeLinkHref } from './validation.js';
23
24
  import { cx } from './html.js';
@@ -122,6 +123,24 @@ export function TestimonialView({ quote, author, role, avatarUrl, photo }) {
122
123
  const src = avatarUrl && safeLinkHref(avatarUrl);
123
124
  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 })] })] })] }));
124
125
  }
126
+ // ---- events -----------------------------------------------------------------
127
+ const still = () => () => { };
128
+ /**
129
+ * Events under two `h2`s, those to come or on now soonest first and those that
130
+ * have ended latest first, as `section.events` holding an `ol.event-list` under
131
+ * each heading. A row is the `<time>` and a `span` of the event's inline HTML,
132
+ * with `small.event-now` on one that has begun. `base.css` sets every row's
133
+ * dates in one column shared by both lists. The text is written as HTML, so it
134
+ * reaches here through `sanitizeCoreLeaf`, as a reference's does.
135
+ */
136
+ export function EventsView({ items, upcoming, past, day }) {
137
+ const now = useSyncExternalStore(still, today, () => day || today());
138
+ const split = splitEvents(items, now);
139
+ return (_jsx("section", { className: "events", children: [
140
+ [upcoming || 'Upcoming', split.upcoming],
141
+ [past || 'Past', split.past],
142
+ ].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) }));
143
+ }
125
144
  // ---- banner -----------------------------------------------------------------
126
145
  /**
127
146
  * A maintenance notice.
package/dist/blocks.d.ts CHANGED
@@ -42,6 +42,16 @@ export interface StatItem {
42
42
  label: string;
43
43
  suffix?: string | null;
44
44
  }
45
+ /**
46
+ * An exhibition, a show, a talk: when it starts and, if later, ends, each as
47
+ * precisely as it is known (`EVENT_DATE`), and a line of inline HTML saying
48
+ * what it is and where.
49
+ */
50
+ export interface EventItem {
51
+ date: string;
52
+ end?: string | null;
53
+ text: string;
54
+ }
45
55
  export interface LinkGridLink {
46
56
  label: string;
47
57
  href: string;
@@ -66,6 +76,10 @@ export type EditableLinkGridGroup = LinkGridGroup & {
66
76
  id: string;
67
77
  links: LinkGridLink[];
68
78
  };
79
+ export type EditableEventItem = EventItem & {
80
+ id: string;
81
+ end?: string;
82
+ };
69
83
  export interface ContentBlock {
70
84
  id: string;
71
85
  type: 'content';
@@ -103,6 +117,21 @@ export interface TestimonialBlock {
103
117
  role?: string;
104
118
  avatarUrl?: string;
105
119
  }
120
+ /**
121
+ * Events in any order, which the page sets under two headings by the day it is
122
+ * read: `upcoming` (unset, "Upcoming") over those to come or on now, `past`
123
+ * (unset, "Past") over those that have ended. `day` is never stored: a wiki
124
+ * whose renderer is a client component fills it when the page is read, with
125
+ * `today()` on the server, so its hydration splits the block as the server did.
126
+ */
127
+ export interface EventsBlock {
128
+ id: string;
129
+ type: 'events';
130
+ upcoming?: string;
131
+ past?: string;
132
+ items: EditableEventItem[];
133
+ day?: string;
134
+ }
106
135
  /** A Wikipedia-style notice. `text` replaces the variant's standard message. */
107
136
  export interface BannerBlock {
108
137
  id: string;
@@ -244,6 +273,36 @@ export declare function someBlock<B>(blocks: readonly B[], predicate: (block: B)
244
273
  export declare function codeTabsToMarkdown(tabs: readonly CodeTab[]): string;
245
274
  /** Metric cards as a bullet list, or `''` when empty. */
246
275
  export declare function statsToMarkdown(items: readonly StatItem[]): string;
276
+ /** A date as precisely as it is known: a year, a month or a day (`2026`, `2026-11`, `2026-11-12`). */
277
+ export declare const EVENT_DATE: RegExp;
278
+ type Dated = {
279
+ date: string;
280
+ end?: string | null;
281
+ };
282
+ /** A day as an event's date is written, by the clock of whoever reads it: the server's, or the visitor's. */
283
+ export declare const today: (d?: Date) => string;
284
+ export declare const eventEnded: (e: Dated, day: string) => boolean;
285
+ export declare const eventBegun: (e: Dated, day: string) => boolean;
286
+ /** Whether `end` falls before `date`, at the coarser of the two. */
287
+ export declare const eventEndsBefore: ({ date, end }: Dated) => boolean;
288
+ /** Those still to come or on, soonest first, and those that have ended, latest first. */
289
+ export declare function splitEvents<E extends Dated>(items: readonly E[], day: string): {
290
+ upcoming: E[];
291
+ past: E[];
292
+ };
293
+ /** When it is, as it reads: "Nov 2026", "12 Nov 2026", "May – Aug 2026", "12 – 18 Nov 2026". */
294
+ export declare function eventWhen({ date, end }: Dated): string;
295
+ /** The fields an events block's text and twin read. */
296
+ export interface EventsData {
297
+ upcoming?: string | null;
298
+ past?: string | null;
299
+ items: readonly EventItem[];
300
+ day?: string | null;
301
+ }
302
+ /** Events under their headings, split by the day the twin is read, as the page is. */
303
+ export declare function eventsToMarkdown(block: EventsData): string;
304
+ /** Events as prose, `12 Nov 2026: Group show, London` a line, under their headings. */
305
+ export declare function eventsToText(block: EventsData): string;
247
306
  /** A flat bullet list of links — resolved page lists, feed items, link rails. */
248
307
  export declare function linkList(items: readonly LinkGridLink[]): string;
249
308
  /**
@@ -283,7 +342,9 @@ export type CoreAtomicBlock<P = ResolvedPageRef> = {
283
342
  quote: string;
284
343
  author: string;
285
344
  role?: string | null;
286
- } | {
345
+ } | ({
346
+ type: 'events';
347
+ } & EventsData) | {
287
348
  type: 'recentPages' | 'pageList';
288
349
  resolvedPages?: readonly P[] | null;
289
350
  } | {
@@ -396,6 +457,14 @@ export interface CoreBlockDefaults {
396
457
  quote: string;
397
458
  author: string;
398
459
  };
460
+ events: () => {
461
+ type: 'events';
462
+ items: {
463
+ id: string;
464
+ date: string;
465
+ text: string;
466
+ }[];
467
+ };
399
468
  }
400
469
  /**
401
470
  * The new-block factories for the core types, which all three wikis had typed
@@ -409,3 +478,4 @@ export type BlockTree<A extends {
409
478
  id: string;
410
479
  type: string;
411
480
  }> = A | InfoboxBlock<A> | ColumnsBlock<A>;
481
+ export {};
package/dist/blocks.js CHANGED
@@ -203,6 +203,69 @@ export function linkGridToMarkdown(groups, intro) {
203
203
  ].join('\n'));
204
204
  return `${lead}${rendered.join('\n\n')}`;
205
205
  }
206
+ // ---- events -----------------------------------------------------------------
207
+ //
208
+ // Pure, so a server render, a page's twin and the visitor's browser all split a
209
+ // block the same way. miow had them first; every wiki here has the block now.
210
+ /** A date as precisely as it is known: a year, a month or a day (`2026`, `2026-11`, `2026-11-12`). */
211
+ export const EVENT_DATE = /^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$/;
212
+ /** A day as an event's date is written, by the clock of whoever reads it: the server's, or the visitor's. */
213
+ export const today = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
214
+ // A date compares with a day at its own precision, so "2026-08" lasts all August.
215
+ const at = (day, when) => day.slice(0, when.length);
216
+ const lastDay = (e) => e.end || e.date;
217
+ export const eventEnded = (e, day) => at(day, lastDay(e)) > lastDay(e);
218
+ export const eventBegun = (e, day) => at(day, e.date) >= e.date;
219
+ /** Whether `end` falls before `date`, at the coarser of the two. */
220
+ export const eventEndsBefore = ({ date, end }) => !!end && end.slice(0, date.length) < date.slice(0, end.length);
221
+ /** Those still to come or on, soonest first, and those that have ended, latest first. */
222
+ export function splitEvents(items, day) {
223
+ return {
224
+ upcoming: items.filter(e => !eventEnded(e, day)).sort((a, b) => a.date.localeCompare(b.date)),
225
+ past: items.filter(e => eventEnded(e, day)).sort((a, b) => lastDay(b).localeCompare(lastDay(a)) || b.date.localeCompare(a.date)),
226
+ };
227
+ }
228
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
229
+ /** When it is, as it reads: "Nov 2026", "12 Nov 2026", "May – Aug 2026", "12 – 18 Nov 2026". */
230
+ export function eventWhen({ date, end }) {
231
+ const [y, m, d] = date.split('-');
232
+ const words = (s, year = true) => {
233
+ const [yy, mm, dd] = s.split('-');
234
+ return [dd && Number(dd), mm && MONTHS[Number(mm) - 1], year && yy].filter(Boolean).join(' ');
235
+ };
236
+ if (!end || end === date)
237
+ return words(date);
238
+ const [ey, em, ed] = end.split('-');
239
+ // The year said once where both share it, and the month too where both days fall in it.
240
+ if (y !== ey || !m || !em)
241
+ return `${words(date)} – ${words(end)}`;
242
+ if (m === em && d && ed)
243
+ return `${Number(d)} – ${words(end)}`;
244
+ return `${words(date, false)} – ${words(end)}`;
245
+ }
246
+ /** Each heading with its events, in the order the page shows them, and the day they were split by. */
247
+ function eventSections({ upcoming, past, items, day }) {
248
+ const on = day || today();
249
+ const split = splitEvents(items, on);
250
+ return [
251
+ { heading: upcoming || 'Upcoming', items: split.upcoming, ended: false },
252
+ { heading: past || 'Past', items: split.past, ended: true },
253
+ ]
254
+ .filter(s => s.items.length)
255
+ .map(s => ({ ...s, now: (e) => !s.ended && eventBegun(e, on) }));
256
+ }
257
+ /** Events under their headings, split by the day the twin is read, as the page is. */
258
+ export function eventsToMarkdown(block) {
259
+ return eventSections(block)
260
+ .map(s => `## ${s.heading}\n\n${s.items.map(e => `- ${eventWhen(e)}: ${inlineToMarkdown(e.text)}${s.now(e) ? ' (now on)' : ''}`).join('\n')}`)
261
+ .join('\n\n');
262
+ }
263
+ /** Events as prose, `12 Nov 2026: Group show, London` a line, under their headings. */
264
+ export function eventsToText(block) {
265
+ return eventSections(block)
266
+ .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
+ .join('\n\n');
268
+ }
206
269
  /** A flat bullet list of links — resolved page lists, feed items, link rails. */
207
270
  export function linkList(items) {
208
271
  return items.map(l => `- [${l.label}](${l.href})`).join('\n');
@@ -221,6 +284,7 @@ export function coreAtomicText(block) {
221
284
  case 'stats': return statsToText(block.items);
222
285
  case 'linkGrid': return linkGridToText(block.groups, block.intro);
223
286
  case 'testimonial': return testimonialToText(block.quote, block.author, block.role);
287
+ case 'events': return eventsToText(block);
224
288
  case 'recentPages':
225
289
  case 'pageList': return pageListToText(block.resolvedPages ?? []);
226
290
  case 'list': return pageListToText(block.items ?? []);
@@ -236,6 +300,7 @@ export function coreAtomicToMarkdown(block, opts) {
236
300
  case 'stats': return statsToMarkdown(block.items);
237
301
  case 'linkGrid': return linkGridToMarkdown(block.groups, block.intro);
238
302
  case 'testimonial': return testimonialToMarkdown(block.quote, block.author, block.role);
303
+ case 'events': return eventsToMarkdown(block);
239
304
  case 'recentPages':
240
305
  case 'pageList':
241
306
  case 'list':
@@ -278,5 +343,7 @@ export function coreBlockDefaults(opts = {}) {
278
343
  references: () => ({ type: 'references', title: referencesTitle, items: [] }),
279
344
  banner: () => ({ type: 'banner', variant: 'stub' }),
280
345
  testimonial: () => ({ type: 'testimonial', quote: '', author: '' }),
346
+ // Dated this month, so the new row is valid as it stands and reads as coming up.
347
+ events: () => ({ type: 'events', items: [{ id: newId(), date: today().slice(0, 7), text: '' }] }),
281
348
  };
282
349
  }
@@ -22,7 +22,7 @@ export interface HtmlSanitizerOptions {
22
22
  export declare function createHtmlSanitizer(options?: HtmlSanitizerOptions): (html: string) => string;
23
23
  /**
24
24
  * The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
25
- * descriptions and `references` item text. Every other field those blocks
25
+ * descriptions, and `references` and `events` item text. Every other field those blocks
26
26
  * carry renders as a React text node and is escaped already.
27
27
  *
28
28
  * Returns any other block untouched, so it drops straight into a
package/dist/sanitize.js CHANGED
@@ -157,7 +157,7 @@ const each = (v, map) => Array.isArray(v) ? v.map(item => (item && typeof item =
157
157
  const str = (v) => typeof v === 'string';
158
158
  /**
159
159
  * The core leaf types' HTML fields, cleaned: `content.text`, `linkGrid` group
160
- * descriptions and `references` item text. Every other field those blocks
160
+ * descriptions, and `references` and `events` item text. Every other field those blocks
161
161
  * carry renders as a React text node and is escaped already.
162
162
  *
163
163
  * Returns any other block untouched, so it drops straight into a
@@ -177,6 +177,7 @@ export function sanitizeCoreLeaf(block, clean) {
177
177
  groups: each(b.groups, g => (str(g.description) ? { ...g, description: clean(g.description) } : g)),
178
178
  };
179
179
  case 'references':
180
+ case 'events':
180
181
  return { ...b, items: each(b.items, i => (str(i.text) ? { ...i, text: clean(i.text) } : i)) };
181
182
  default:
182
183
  return block;
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // As elsewhere in this package, the dispatch stays with the caller so a new
10
10
  // block type is a compile error there until it is handled.
11
- import { coreBlockShape, LIST_SOURCES } from './blocks.js';
11
+ import { coreBlockShape, EVENT_DATE, eventEndsBefore, LIST_SOURCES } from './blocks.js';
12
12
  /**
13
13
  * An href acceptable to persist: http, https, mailto, tel, or a relative path or
14
14
  * fragment. Everything else — `javascript:`, `data:`, `vbscript:`, a
@@ -77,6 +77,17 @@ export function validateStatItems(items) {
77
77
  (typeof s.value === 'string' || typeof s.value === 'number') &&
78
78
  (s.suffix === undefined || s.suffix === null || typeof s.suffix === 'string')));
79
79
  }
80
+ /** `[{ id, date, end?, text }]` — an events block's events, each dated by `EVENT_DATE` and ending no earlier than it starts. @internal */
81
+ export function validateEventItems(items) {
82
+ return (Array.isArray(items) &&
83
+ items.every(e => isRecord(e) &&
84
+ typeof e.id === 'string' &&
85
+ typeof e.text === 'string' &&
86
+ typeof e.date === 'string' &&
87
+ EVENT_DATE.test(e.date) &&
88
+ (e.end === undefined || (typeof e.end === 'string' && EVENT_DATE.test(e.end))) &&
89
+ !eventEndsBefore(e)));
90
+ }
80
91
  // ---- the core leaf set ------------------------------------------------------
81
92
  /**
82
93
  * The most rows an author-supplied count may ask for. Every `limit` and every
@@ -133,6 +144,8 @@ export function coreAtomicValidator(opts = {}) {
133
144
  return validateLinkGroups(b.groups);
134
145
  case 'testimonial':
135
146
  return typeof b.quote === 'string' && typeof b.author === 'string' && optionalString(b.role) && optionalString(b.avatarUrl);
147
+ case 'events':
148
+ return optionalString(b.upcoming) && optionalString(b.past) && validateEventItems(b.items);
136
149
  default:
137
150
  return false;
138
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.86.3",
3
+ "version": "0.87.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",