wiki-formant 0.22.0 → 0.23.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/dist/feed.js CHANGED
@@ -13,6 +13,7 @@
13
13
  //
14
14
  // What stays with the caller is the channel's identity and the block walk that
15
15
  // turns a page into HTML. Every project owns its own type set.
16
+ import { corpusEtag, notModified } from './http.js';
16
17
  // Numeric reference for the apostrophe: `'` is an XML entity that older
17
18
  // readers parsing the feed as HTML do not carry in their entity table.
18
19
  const XML_ESCAPES = {
@@ -70,8 +71,7 @@ export function renderItem(item) {
70
71
  * exist to prevent. An empty feed has no build date rather than a fictional one.
71
72
  */
72
73
  export function renderFeed(channel, items) {
73
- const newest = channel.lastBuild ??
74
- items.reduce((max, item) => (!max || item.date > max ? item.date : max), null);
74
+ const newest = lastBuild(channel, items);
75
75
  return [
76
76
  '<?xml version="1.0" encoding="UTF-8"?>',
77
77
  '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
@@ -88,7 +88,25 @@ export function renderFeed(channel, items) {
88
88
  '</rss>',
89
89
  ].join('\n');
90
90
  }
91
+ const lastBuild = (channel, items) => channel.lastBuild ?? items.reduce((max, item) => (!max || item.date > max ? item.date : max), null);
91
92
  export const FEED_HEADERS = {
92
93
  'Content-Type': 'application/rss+xml; charset=utf-8',
93
94
  'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
94
95
  };
96
+ /**
97
+ * The feed as a response, with the validators a poller revalidates against.
98
+ *
99
+ * All four feeds in the workspace were `new Response(renderFeed(...))` with
100
+ * `Cache-Control: public` and no ETag or Last-Modified, so a reader polling
101
+ * hourly could never be told "unchanged" and took the whole channel every time.
102
+ * The tag comes from the rendered XML, so it moves exactly when the feed does;
103
+ * Last-Modified is the channel's build date.
104
+ */
105
+ export function feedResponse(request, channel, items, headers = FEED_HEADERS) {
106
+ const xml = `${renderFeed(channel, items)}\n`;
107
+ const etag = corpusEtag([xml]);
108
+ const built = lastBuild(channel, items);
109
+ const lastModified = built ? built.toUTCString() : null;
110
+ const sent = { ...headers, ETag: etag, ...(lastModified ? { 'Last-Modified': lastModified } : {}) };
111
+ return notModified(request, etag, lastModified, sent) ?? new Response(xml, { headers: sent });
112
+ }
@@ -1,3 +1,5 @@
1
+ import { isoDate } from './html.js';
2
+ export { isoDate };
1
3
  export interface FreshnessInput {
2
4
  lastVerifiedAt?: Date | string | null;
3
5
  updatedAt?: Date | string | null;
@@ -31,3 +33,34 @@ export declare function freshnessBanner(page: FreshnessInput, nowMs: number, max
31
33
  variant: 'outdated';
32
34
  text: string;
33
35
  } | null;
36
+ type When = Date | string | number;
37
+ /**
38
+ * A calendar day for a reader: `Sep 19, 2026`. Always in UTC. A stored
39
+ * timestamp formatted in the server's zone, or the browser's, lands on the
40
+ * previous day for half the world, and the server and the hydrating client
41
+ * disagree about which day it is.
42
+ */
43
+ export declare function formatDay(date: When, options?: Intl.DateTimeFormatOptions): string;
44
+ /**
45
+ * - `compact`: `now`, `2m`, `3h`, `5d`, `2mo`, `1y` — for dense rows.
46
+ * - `short`: `just now`, `2m ago` … `1y ago`.
47
+ * - `long`: `today`, `yesterday`, `3 days ago`, `1 month ago` — day-grained,
48
+ * because it suits a page cached for hours, where "3 hours ago" would be
49
+ * frozen and wrong by the next reader.
50
+ */
51
+ export type RelativeTimeStyle = 'compact' | 'short' | 'long';
52
+ export interface RelativeTimeOptions {
53
+ style?: RelativeTimeStyle;
54
+ /** From this many whole days on, the date itself (`formatDay`) rather than a distance. */
55
+ absoluteAfterDays?: number;
56
+ }
57
+ /**
58
+ * How long before `now` a moment was. `now` is the render's time, passed in:
59
+ * a server render that read the clock would hydrate against a later one and
60
+ * the text would disagree. A moment after `now` (clock skew) reads as now.
61
+ *
62
+ * Three copies of this had drifted into three bugs between them: `0y` for a
63
+ * moment 360–364 days old, where twelve 30-day months fell through to a floor
64
+ * of zero years, and `1 months ago`.
65
+ */
66
+ export declare function relativeTime(then: When, now: number, options?: RelativeTimeOptions): string;
package/dist/freshness.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // This runs during SSR, and a render that reads the clock is a render that can
10
10
  // disagree with the one the server just sent.
11
11
  import { isoDate } from './html.js';
12
+ export { isoDate };
12
13
  export const DEFAULT_MAX_AGE_DAYS = 180;
13
14
  const DAY_MS = 86_400_000;
14
15
  /** Whole days between `date` and `now`, or null when there is no usable date. */
@@ -53,3 +54,55 @@ export function freshnessBanner(page, nowMs, maxAgeDays = DEFAULT_MAX_AGE_DAYS)
53
54
  return null;
54
55
  return { id: '__freshness__', type: 'banner', variant: 'outdated', text: freshnessNotice(page) };
55
56
  }
57
+ const toMs = (d) => (typeof d === 'number' ? d : d instanceof Date ? d.getTime() : Date.parse(d));
58
+ /**
59
+ * A calendar day for a reader: `Sep 19, 2026`. Always in UTC. A stored
60
+ * timestamp formatted in the server's zone, or the browser's, lands on the
61
+ * previous day for half the world, and the server and the hydrating client
62
+ * disagree about which day it is.
63
+ */
64
+ export function formatDay(date, options) {
65
+ return new Date(toMs(date)).toLocaleDateString('en-US', {
66
+ year: 'numeric',
67
+ month: 'short',
68
+ day: 'numeric',
69
+ timeZone: 'UTC',
70
+ ...options,
71
+ });
72
+ }
73
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`;
74
+ /**
75
+ * How long before `now` a moment was. `now` is the render's time, passed in:
76
+ * a server render that read the clock would hydrate against a later one and
77
+ * the text would disagree. A moment after `now` (clock skew) reads as now.
78
+ *
79
+ * Three copies of this had drifted into three bugs between them: `0y` for a
80
+ * moment 360–364 days old, where twelve 30-day months fell through to a floor
81
+ * of zero years, and `1 months ago`.
82
+ */
83
+ export function relativeTime(then, now, options = {}) {
84
+ const { style = 'compact', absoluteAfterDays } = options;
85
+ const sec = Math.max(0, Math.floor((now - toMs(then)) / 1000));
86
+ const day = Math.floor(sec / 86_400);
87
+ if (absoluteAfterDays !== undefined && day >= absoluteAfterDays)
88
+ return formatDay(then);
89
+ // Nearest, not floor: 60 days is two months to a reader, and flooring by the
90
+ // mean month length made it one.
91
+ const months = Math.max(1, Math.round(day / 30.4375));
92
+ const years = Math.max(1, Math.round(day / 365.25));
93
+ if (style === 'long') {
94
+ if (day === 0)
95
+ return 'today';
96
+ if (day === 1)
97
+ return 'yesterday';
98
+ if (day < 30)
99
+ return plural(day, 'day');
100
+ return day < 365 ? plural(months, 'month') : plural(years, 'year');
101
+ }
102
+ const min = Math.floor(sec / 60);
103
+ const hr = Math.floor(min / 60);
104
+ const span = sec < 60 ? '' : min < 60 ? `${min}m` : hr < 24 ? `${hr}h` : day < 30 ? `${day}d` : day < 365 ? `${months}mo` : `${years}y`;
105
+ if (style === 'compact')
106
+ return span || 'now';
107
+ return span ? `${span} ago` : 'just now';
108
+ }
@@ -28,6 +28,13 @@ export interface HeadingIdOptions {
28
28
  * into a TOC label.
29
29
  */
30
30
  anchor?: (id: string) => string;
31
+ /**
32
+ * Ids already taken on this page, added to as headings are decorated. Pass
33
+ * ONE set across every fragment of a document — each content block of a page,
34
+ * seeded with any id the page template renders itself — or two fragments with
35
+ * the same heading text mint the same id. Omitted, dedupe covers this call only.
36
+ */
37
+ used?: Set<string>;
31
38
  }
32
39
  /**
33
40
  * Give every heading in `html` an id and a permalink anchor. Headings that
package/dist/headings.js CHANGED
@@ -9,7 +9,10 @@
9
9
  //
10
10
  // Deduping, by contrast, is not a choice: two headings with the same text
11
11
  // otherwise mint the same id twice and every link to the second one lands on
12
- // the first, so this always dedupes.
12
+ // the first, so this always dedupes. The unit is the DOCUMENT, not the call: a
13
+ // block wiki calls the injector once per block, and all three consumers did
14
+ // that with a fresh set each time, so a "Notes" in two blocks shipped two
15
+ // `id="notes"`. One `used` set per page is what makes the dedupe hold.
13
16
  import { getAttr, stripTags } from './html.js';
14
17
  /** The default slug rule: lowercase words joined by hyphens. */
15
18
  export function slugifyHeading(text) {
@@ -46,11 +49,16 @@ export function injectHeadingIds(html, options = {}) {
46
49
  return html;
47
50
  const slug = options.slug ?? slugifyHeading;
48
51
  const anchor = options.anchor ?? defaultAnchor;
49
- const used = new Set();
52
+ const used = options.used ?? new Set();
50
53
  return html.replace(HEADING, (match, tag, attrs, content) => {
51
- if (content.includes('heading-anchor'))
52
- return match;
53
54
  const existing = getAttr(attrs, 'id');
55
+ // Already decorated, by an earlier pass or by whoever stored it: left as it
56
+ // is, but its id is still taken, or the next fragment could mint it again.
57
+ if (content.includes('heading-anchor')) {
58
+ if (existing)
59
+ used.add(existing);
60
+ return match;
61
+ }
54
62
  const id = existing || uniqueHeadingId(slug(stripTags(content)), used);
55
63
  if (!id)
56
64
  return match;
@@ -1,3 +1,4 @@
1
+ import type { ReferenceItem } from './blocks.js';
1
2
  export interface ArticleMeta {
2
3
  publishedTime?: string;
3
4
  modifiedTime?: string;
@@ -67,3 +68,56 @@ export declare function pageMetadata(o: PageMetadataOptions): {
67
68
  canonical: string;
68
69
  } | undefined;
69
70
  };
71
+ /** A schema.org node: inline, or an `{ '@id' }` reference into the site's graph. */
72
+ export type LdNode = Record<string, unknown>;
73
+ export interface ArticleLdOptions {
74
+ /** `Article` by default; `BlogPosting`, `TechArticle`, `ScholarlyArticle`… */
75
+ type?: string;
76
+ headline: string;
77
+ /** Absolute canonical URL. */
78
+ url: string;
79
+ description?: string;
80
+ /**
81
+ * Absolute URL, and required: Google wants an image on every article. Pass
82
+ * the card `pageMetadata` was given, so the two cannot disagree.
83
+ */
84
+ image: string;
85
+ published?: Date | string;
86
+ modified?: Date | string;
87
+ publisher: LdNode;
88
+ isPartOf?: LdNode;
89
+ /** Omit rather than invent one: a system-authored row has no person to name. */
90
+ author?: LdNode;
91
+ license?: string;
92
+ /** Defaults to `en`. */
93
+ inLanguage?: string;
94
+ /** See `citationsFromReferences`. Left out when empty. */
95
+ citation?: LdNode[];
96
+ /** What only this wiki states — wordCount, articleSection, about, hasPart. Spread last. */
97
+ extra?: LdNode;
98
+ }
99
+ export declare function articleLd(o: ArticleLdOptions): LdNode;
100
+ export interface CollectionLdOptions {
101
+ name: string;
102
+ /** Absolute. */
103
+ url: string;
104
+ description?: string;
105
+ isPartOf?: LdNode;
106
+ license?: string;
107
+ /** In display order; `url` absolute. */
108
+ items: readonly {
109
+ name: string;
110
+ url: string;
111
+ }[];
112
+ /** How many are listed. `numberOfItems` still counts every one. Defaults to 100. */
113
+ max?: number;
114
+ extra?: LdNode;
115
+ }
116
+ /** An index page and the pages it lists, as a CollectionPage over an ItemList. */
117
+ export declare function collectionLd(o: CollectionLdOptions): LdNode;
118
+ /**
119
+ * A page's `references` items as `Article.citation`, read off the block data
120
+ * rather than re-parsed out of rendered HTML. Tags out and entities decoded, so
121
+ * an authored `&amp;` is not what a crawler reads.
122
+ */
123
+ export declare function citationsFromReferences(items: readonly ReferenceItem[], max?: number): LdNode[];
package/dist/metadata.js CHANGED
@@ -12,6 +12,13 @@
12
12
  // wrote each page's metadata by hand.
13
13
  //
14
14
  // Plain objects in the shape Next's `Metadata` wants, so no `next` import.
15
+ //
16
+ // The schema.org nodes live here too, because they state the same facts to a
17
+ // different reader. Three wikis built their Article and CollectionPage nodes
18
+ // by hand and each dropped something the others kept: two shipped articles
19
+ // with no image while their og:image named a card, one listed citations, one
20
+ // collection page had no ItemList at all.
21
+ import { decodeEntities } from './markdown.js';
15
22
  export function pageMetadata(o) {
16
23
  const { title, description, url, type = 'website', image, imageSize = { width: 1200, height: 630 }, siteName, locale, handle, markdownTwin, article } = o;
17
24
  const images = image ? [{ url: image, ...imageSize, alt: o.imageAlt ?? title }] : undefined;
@@ -43,3 +50,60 @@ export function pageMetadata(o) {
43
50
  },
44
51
  };
45
52
  }
53
+ const iso = (d) => new Date(d).toISOString();
54
+ export function articleLd(o) {
55
+ return {
56
+ '@context': 'https://schema.org',
57
+ '@type': o.type ?? 'Article',
58
+ headline: o.headline,
59
+ url: o.url,
60
+ mainEntityOfPage: { '@type': 'WebPage', '@id': o.url },
61
+ ...(o.description ? { description: o.description } : {}),
62
+ image: o.image,
63
+ ...(o.published ? { datePublished: iso(o.published) } : {}),
64
+ ...(o.modified ? { dateModified: iso(o.modified) } : {}),
65
+ inLanguage: o.inLanguage ?? 'en',
66
+ ...(o.author ? { author: o.author } : {}),
67
+ publisher: o.publisher,
68
+ ...(o.isPartOf ? { isPartOf: o.isPartOf } : {}),
69
+ ...(o.license ? { license: o.license } : {}),
70
+ ...(o.citation?.length ? { citation: o.citation } : {}),
71
+ ...o.extra,
72
+ };
73
+ }
74
+ /** An index page and the pages it lists, as a CollectionPage over an ItemList. */
75
+ export function collectionLd(o) {
76
+ return {
77
+ '@context': 'https://schema.org',
78
+ '@type': 'CollectionPage',
79
+ name: o.name,
80
+ url: o.url,
81
+ ...(o.description ? { description: o.description } : {}),
82
+ ...(o.isPartOf ? { isPartOf: o.isPartOf } : {}),
83
+ ...(o.license ? { license: o.license } : {}),
84
+ mainEntity: {
85
+ '@type': 'ItemList',
86
+ numberOfItems: o.items.length,
87
+ itemListElement: o.items.slice(0, o.max ?? 100).map((item, i) => ({
88
+ '@type': 'ListItem',
89
+ position: i + 1,
90
+ name: item.name,
91
+ url: item.url,
92
+ })),
93
+ },
94
+ ...o.extra,
95
+ };
96
+ }
97
+ /**
98
+ * A page's `references` items as `Article.citation`, read off the block data
99
+ * rather than re-parsed out of rendered HTML. Tags out and entities decoded, so
100
+ * an authored `&amp;` is not what a crawler reads.
101
+ */
102
+ export function citationsFromReferences(items, max = 50) {
103
+ return items
104
+ .flatMap(ref => {
105
+ const name = decodeEntities(ref.text.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
106
+ return name ? [{ '@type': 'CreativeWork', name: name.slice(0, 250), ...(ref.url ? { url: ref.url } : {}) }] : [];
107
+ })
108
+ .slice(0, max);
109
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
- import type { Control, FacetControlGroup } from './taxonomy.js';
2
+ import type { Control, FacetControlGroup, MetadataRow } from './taxonomy.js';
3
3
  /**
4
4
  * The shape a router's link component has to satisfy here.
5
5
  *
@@ -49,6 +49,8 @@ export interface FacetBarProps {
49
49
  /** Lead with the A–Z row: two lines against a facet block's twenty. */
50
50
  alphaFirst?: boolean;
51
51
  classNames?: FacetBarClassNames;
52
+ /** The landmark's accessible name. */
53
+ label?: string;
52
54
  }
53
55
  /**
54
56
  * The section's second axis, as pressable chips.
@@ -60,7 +62,46 @@ export interface FacetBarProps {
60
62
  * `aria-current`, not `aria-pressed`: a link is not a toggle button and does not
61
63
  * take that attribute.
62
64
  */
63
- export declare function FacetBar({ link: Link, facets, letters, alphaLabel, alphaFirst, classNames, }: FacetBarProps): import("react").JSX.Element | null;
65
+ export declare function FacetBar({ link: Link, facets, letters, alphaLabel, alphaFirst, classNames, label: navLabel, }: FacetBarProps): import("react").JSX.Element | null;
66
+ export interface FacetSummaryProps {
67
+ shown: number;
68
+ total: number;
69
+ /** The section, as a reader names it. */
70
+ name: string;
71
+ /** The un-narrowed section. Pass it exactly when a facet or letter is active. */
72
+ clearHref?: string;
73
+ link?: WikiLinkComponent;
74
+ }
75
+ /**
76
+ * How much of the section the reader is looking at, and the way back to all of
77
+ * it. Two wikis printed the same sentence and one printed none; one had the
78
+ * Clear link and another had the only filtered-empty state. This is all three.
79
+ */
80
+ export declare function FacetSummary({ shown, total, name, clearHref, link: Link }: FacetSummaryProps): import("react").JSX.Element;
81
+ export interface RelatedPagesProps {
82
+ /** `rankRelated`'s pages, with the hrefs the wiki builds. */
83
+ pages: readonly {
84
+ href: string;
85
+ title: string;
86
+ detail?: string;
87
+ }[];
88
+ /** The heading's words — the wiki's own, since they name its sections. */
89
+ heading: ReactNode;
90
+ /**
91
+ * The set the heading opens: the section filtered to the facet `rankRelated`
92
+ * found in common. Omit and the heading is plain text.
93
+ */
94
+ href?: string;
95
+ link?: WikiLinkComponent;
96
+ /** Unique on the page; ties the landmark to its heading. */
97
+ id?: string;
98
+ }
99
+ /**
100
+ * The foot-of-article "see also". A labelled complementary landmark over a real
101
+ * list, because the two copies this replaced split those between them: one had
102
+ * the landmark and the list, the other neither.
103
+ */
104
+ export declare function RelatedPages({ pages, heading, href, link: Link, id }: RelatedPagesProps): import("react").JSX.Element | null;
64
105
  /**
65
106
  * A JSON-LD payload, safe to place inside `<script type="application/ld+json">`.
66
107
  *
@@ -174,3 +215,41 @@ export interface PageNavProps {
174
215
  * link stays in the right one.
175
216
  */
176
217
  export declare function PageNav({ prev, next, link: Link, label, prevLabel, nextLabel, prevGlyph, nextGlyph, }: PageNavProps): import("react").JSX.Element | null;
218
+ export interface InfoboxAsideProps {
219
+ /** The landmark's accessible name, e.g. `Key facts about ${title}`. */
220
+ label: string;
221
+ /** Wikipedia's "Part of a series on": the topic's main article. */
222
+ series?: {
223
+ title: string;
224
+ href: string;
225
+ } | null;
226
+ link?: WikiLinkComponent;
227
+ /** Added to `infobox`, for a design system's layout utility. */
228
+ className?: string;
229
+ children?: ReactNode;
230
+ }
231
+ /**
232
+ * The facts panel beside an article. Named, because two of the three asides
233
+ * this replaced had no accessible name, and a complementary landmark without
234
+ * one reads as "complementary" and nothing else.
235
+ */
236
+ export declare function InfoboxAside({ label, series, link: Link, className, children }: InfoboxAsideProps): import("react").JSX.Element;
237
+ /**
238
+ * A metadata value for reading: a date as its day, a URL (or a bare domain) as
239
+ * an external link, and a `<br>`-separated list as lines. Rendered as React
240
+ * nodes, never interpolated into markup: the string builders this replaced had
241
+ * to re-escape a `"` that would otherwise close an href, and one did not.
242
+ */
243
+ export declare function formatFactValue(value: string, type: string): ReactNode;
244
+ export interface InfoboxFactsProps {
245
+ /** `metadataRows`, or any rows of the same shape a wiki derives itself. */
246
+ rows: readonly Pick<MetadataRow, 'label' | 'value' | 'type' | 'href'>[];
247
+ link?: WikiLinkComponent;
248
+ /** Overrides how a row without an `href` is shown. */
249
+ formatValue?: (value: string, type: string) => ReactNode;
250
+ }
251
+ /**
252
+ * The derived facts table. A row with an `href` links into the facet view that
253
+ * shares its value — the way into the set, not dead text.
254
+ */
255
+ export declare function InfoboxFacts({ rows, link: Link, formatValue }: InfoboxFactsProps): import("react").JSX.Element | null;
@@ -19,6 +19,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
19
19
  // back to a full page load on every press. React is an optional peer, as it is
20
20
  // for `wiki-formant/react`.
21
21
  import { Fragment } from 'react';
22
+ import { safeLinkHref } from './validation.js';
23
+ import { isoDate } from './html.js';
22
24
  /**
23
25
  * The default for anything that does not need client-side navigation.
24
26
  *
@@ -36,7 +38,7 @@ export const Anchor = ({ href, className, children }) => _jsx("a", { href: href,
36
38
  * `aria-current`, not `aria-pressed`: a link is not a toggle button and does not
37
39
  * take that attribute.
38
40
  */
39
- export function FacetBar({ link: Link, facets, letters, alphaLabel = 'A–Z', alphaFirst = false, classNames = {}, }) {
41
+ export function FacetBar({ link: Link, facets, letters, alphaLabel = 'A–Z', alphaFirst = false, classNames = {}, label: navLabel = 'Filter pages', }) {
40
42
  if (!facets.length && !letters.length)
41
43
  return null;
42
44
  const { root = 'stack tight', row = 'cluster', label = 'form-label', control = 'tag', controlActive = 'tag tag-removable', count, } = classNames;
@@ -51,7 +53,32 @@ export function FacetBar({ link: Link, facets, letters, alphaLabel = 'A–Z', al
51
53
  n: l.count,
52
54
  ...(l.reset ? { title: `All ${l.count} pages` } : {}),
53
55
  }))] }, "alpha"));
54
- return _jsx("div", { className: root, children: alphaFirst ? [alphaRow, ...facetRows] : [...facetRows, alphaRow] });
56
+ // A landmark: every control in it narrows the list below, and a screen
57
+ // reader user skipping between regions should be able to land on it.
58
+ return (_jsx("nav", { className: root, "aria-label": navLabel, children: alphaFirst ? [alphaRow, ...facetRows] : [...facetRows, alphaRow] }));
59
+ }
60
+ /**
61
+ * How much of the section the reader is looking at, and the way back to all of
62
+ * it. Two wikis printed the same sentence and one printed none; one had the
63
+ * Clear link and another had the only filtered-empty state. This is all three.
64
+ */
65
+ export function FacetSummary({ shown, total, name, clearHref, link: Link = Anchor }) {
66
+ const clear = clearHref ? (_jsxs(_Fragment, { children: [' ', _jsx(Link, { href: clearHref, className: "facet-summary-clear", children: "Clear filters" })] })) : null;
67
+ if (total === 0)
68
+ return _jsx("p", { className: "facet-summary", children: "This section has no pages yet." });
69
+ if (shown === 0)
70
+ return _jsxs("p", { className: "facet-summary", children: ["No pages match these filters.", clear] });
71
+ return (_jsxs("p", { className: "facet-summary", children: [shown === total ? (_jsxs(_Fragment, { children: ["The following ", _jsx("strong", { children: total }), " ", total === 1 ? 'page is' : 'pages are', " in ", name, "."] })) : (_jsxs(_Fragment, { children: ["Showing ", _jsx("strong", { children: shown }), " of ", total, " pages in ", name, "."] })), clear] }));
72
+ }
73
+ /**
74
+ * The foot-of-article "see also". A labelled complementary landmark over a real
75
+ * list, because the two copies this replaced split those between them: one had
76
+ * the landmark and the list, the other neither.
77
+ */
78
+ export function RelatedPages({ pages, heading, href, link: Link = Anchor, id = 'see-also-heading' }) {
79
+ if (!pages.length)
80
+ return null;
81
+ return (_jsxs("aside", { className: "see-also", "aria-labelledby": id, children: [_jsx("h2", { id: id, className: "see-also-heading", children: href ? _jsx(Link, { href: href, children: heading }) : heading }), _jsx("ul", { className: "see-also-list", children: pages.map(p => (_jsx("li", { children: _jsxs(Link, { href: p.href, className: "see-also-item", children: [_jsx("span", { className: "see-also-title", children: p.title }), p.detail && _jsx("span", { className: "see-also-detail", children: p.detail })] }) }, p.href))) })] }));
55
82
  }
56
83
  // ---- structured data --------------------------------------------------------
57
84
  /**
@@ -138,3 +165,45 @@ export function PageNav({ prev, next, link: Link = Anchor, label = 'Article navi
138
165
  return null;
139
166
  return (_jsxs("nav", { className: "page-nav", "aria-label": label, children: [prev ? (_jsxs(Link, { href: prev.href, className: "page-nav-link", rel: "prev", children: [_jsxs("span", { className: "page-nav-label", children: [prevGlyph, prevLabel] }), _jsx("span", { className: "page-nav-title", children: prev.title })] })) : (_jsx("div", {})), next && (_jsxs(Link, { href: next.href, className: "page-nav-link page-nav-link--end", rel: "next", children: [_jsxs("span", { className: "page-nav-label page-nav-label--end", children: [nextLabel, nextGlyph] }), _jsx("span", { className: "page-nav-title", children: next.title })] }))] }));
140
167
  }
168
+ /**
169
+ * The facts panel beside an article. Named, because two of the three asides
170
+ * this replaced had no accessible name, and a complementary landmark without
171
+ * one reads as "complementary" and nothing else.
172
+ */
173
+ export function InfoboxAside({ label, series, link: Link = Anchor, className, children }) {
174
+ return (_jsxs("aside", { className: className ? `infobox ${className}` : 'infobox', "aria-label": label, children: [series && (_jsxs("div", { className: "infobox-series", children: [_jsx("span", { children: "Part of a series on" }), _jsx(Link, { href: series.href, children: series.title })] })), children] }));
175
+ }
176
+ const looksLikeUrl = (v, type) => type === 'url' || /^https?:\/\//i.test(v) || /^[^\s/]+\.[a-z]{2,}(\/\S*)?$/i.test(v);
177
+ function externalLink(v) {
178
+ const href = safeLinkHref(/^[a-z][a-z0-9+.-]*:/i.test(v) ? v : `https://${v}`);
179
+ if (!href)
180
+ return v;
181
+ return (_jsx("a", { href: href, target: "_blank", rel: "noopener", children: v.replace(/^https?:\/\/(www\.)?/i, '').replace(/\/$/, '') }));
182
+ }
183
+ /**
184
+ * A metadata value for reading: a date as its day, a URL (or a bare domain) as
185
+ * an external link, and a `<br>`-separated list as lines. Rendered as React
186
+ * nodes, never interpolated into markup: the string builders this replaced had
187
+ * to re-escape a `"` that would otherwise close an href, and one did not.
188
+ */
189
+ export function formatFactValue(value, type) {
190
+ if (type === 'date') {
191
+ const t = Date.parse(value);
192
+ if (!Number.isNaN(t))
193
+ return isoDate(new Date(t));
194
+ }
195
+ const parts = value.split(/<br\s*\/?>/i).map(s => s.trim()).filter(Boolean);
196
+ if (parts.length > 1) {
197
+ return parts.map((part, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("br", {}), looksLikeUrl(part, type) ? externalLink(part) : part] }, i)));
198
+ }
199
+ return looksLikeUrl(value, type) ? externalLink(value) : value;
200
+ }
201
+ /**
202
+ * The derived facts table. A row with an `href` links into the facet view that
203
+ * shares its value — the way into the set, not dead text.
204
+ */
205
+ export function InfoboxFacts({ rows, link: Link = Anchor, formatValue = formatFactValue }) {
206
+ if (!rows.length)
207
+ return null;
208
+ return (_jsx("table", { className: "infobox-facts", children: _jsx("tbody", { children: rows.map(row => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: row.label }), _jsx("td", { children: row.href ? _jsx(Link, { href: row.href, children: row.value }) : formatValue(row.value, row.type) })] }, row.label))) }) }));
209
+ }
package/dist/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ComboboxAria } from './combobox.js';
2
- import { Component, type CSSProperties, type DependencyList, type RefObject, type KeyboardEvent, type ReactNode } from 'react';
2
+ import { Component, type CSSProperties, type DependencyList, type RefObject, type KeyboardEvent, type ReactNode, type ThHTMLAttributes } from 'react';
3
3
  export interface SidebarOptions {
4
4
  /**
5
5
  * Where the reader's choice is remembered, in `localStorage`. Give each wiki
@@ -253,6 +253,88 @@ export declare function useTableSort<T, K extends string>(rows: readonly T[], co
253
253
  */
254
254
  defaultDirection?: SortDirection | ((key: K) => SortDirection);
255
255
  }): TableSortState<T, K>;
256
+ /**
257
+ * The header cell `useTableSort` drives: spread `headerProps(key)` onto it.
258
+ *
259
+ * The press target is a `<button>` inside the `<th>`, never the cell itself: a
260
+ * cell with an `onClick` cannot be reached or pressed from the keyboard, which
261
+ * is the state one of the two copies this replaced was in. The markup is the
262
+ * one `sortTables` in `wiki-formant/dom` writes for tables stored in articles,
263
+ * so one stylesheet rule keyed on `aria-sort` draws both kinds of arrow.
264
+ */
265
+ export declare function SortHeader<K extends string>({ sortKey, onSort, active: _active, direction: _direction, 'aria-sort': ariaSort, label, icon, children, ...th }: Omit<ThHTMLAttributes<HTMLTableCellElement>, 'aria-sort' | 'children'> & ReturnType<TableSortState<unknown, K>['headerProps']> & {
266
+ /** Names a header whose content is an icon, for the button and its tooltip. */
267
+ label?: string;
268
+ /** Drawn after the content, e.g. a sort glyph; CSS on `aria-sort` is the default. */
269
+ icon?: ReactNode;
270
+ children: ReactNode;
271
+ }): import("react").JSX.Element;
272
+ export interface BlockOperations<T> {
273
+ selectedIndex: number | null;
274
+ setSelectedIndex: (i: number | null) => void;
275
+ update: (i: number, block: T) => void;
276
+ remove: (i: number) => void;
277
+ duplicate: (i: number) => void;
278
+ /** No-op when `to` is off either end. */
279
+ move: (from: number, to: number) => void;
280
+ insert: (block: T, at?: number) => void;
281
+ }
282
+ /**
283
+ * The five edits every block editor makes to its list, with stable callbacks.
284
+ *
285
+ * The list and its setter are read through refs written after render, so a row
286
+ * memoised on these callbacks does not re-render on every keystroke elsewhere
287
+ * — and the setter may be a `useState` setter or a parent's `onChange`, which
288
+ * is what the three copies this replaced were split between. Creating and
289
+ * duplicating a block stay with the caller, whose union it is.
290
+ */
291
+ export declare function useBlockOperations<T>(blocks: readonly T[], setBlocks: (next: T[]) => void, options: {
292
+ duplicate: (block: T) => T;
293
+ }): BlockOperations<T>;
294
+ export interface BlockActionsProps {
295
+ index: number;
296
+ total: number;
297
+ ops: Pick<BlockOperations<unknown>, 'move' | 'duplicate' | 'remove'>;
298
+ icons: {
299
+ up: ReactNode;
300
+ down: ReactNode;
301
+ duplicate?: ReactNode;
302
+ remove: ReactNode;
303
+ };
304
+ className?: string;
305
+ buttonClassName?: string;
306
+ /** Names the block in each button's label, e.g. "Move Table up". */
307
+ blockLabel?: string;
308
+ }
309
+ /**
310
+ * Move up, move down, duplicate, delete. Every button has an accessible name
311
+ * and the moves are disabled at the ends — the three bars this replaced had
312
+ * each of those only sometimes. Clicks do not bubble, so a row that selects on
313
+ * click is not also selected by pressing delete in it. Omit `icons.duplicate`
314
+ * for a bar without it.
315
+ */
316
+ export declare function BlockActions({ index, total, ops, icons, className, buttonClassName, blockLabel }: BlockActionsProps): import("react").JSX.Element;
317
+ /** Resolves to an error message to show, or null on success. */
318
+ export type RestoreRevision<Id> = (id: Id) => Promise<string | null>;
319
+ /**
320
+ * POST `{ revisionId }` to `endpoint`, the transport two of the wikis use; the
321
+ * third passes its server action instead.
322
+ */
323
+ export declare function restoreViaPost<Id>(endpoint: string): RestoreRevision<Id>;
324
+ /**
325
+ * Confirm, restore, report. Of the three copies one asked no confirmation, one
326
+ * dropped a failure silently, and one reported it through `alert`. A restore
327
+ * writes the old state forward as a new revision in all three, which is what
328
+ * the default question says.
329
+ */
330
+ export declare function useRevisionRestore<Id>(restore: RestoreRevision<Id>, options: {
331
+ onRestored: () => void;
332
+ confirm?: (label: string) => string | false;
333
+ }): {
334
+ restore: (id: Id, label: string) => Promise<void>;
335
+ busyId: Id | null;
336
+ error: string | null;
337
+ };
256
338
  /**
257
339
  * "Copied", then not, after a beat. Six call sites across three repos wrote the
258
340
  * same three statements, and four of the six had no rejection handler — so a