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/README.md +42 -3
- package/dist/base.css +100 -0
- package/dist/block-views.d.ts +35 -7
- package/dist/block-views.js +26 -14
- package/dist/blocks.d.ts +26 -3
- package/dist/blocks.js +19 -5
- package/dist/conformance.d.ts +1 -1
- package/dist/conformance.js +18 -12
- package/dist/editor.d.ts +33 -1
- package/dist/editor.js +58 -0
- package/dist/feed.d.ts +10 -0
- package/dist/feed.js +20 -2
- package/dist/freshness.d.ts +33 -0
- package/dist/freshness.js +53 -0
- package/dist/headings.d.ts +7 -0
- package/dist/headings.js +12 -4
- package/dist/metadata.d.ts +54 -0
- package/dist/metadata.js +64 -0
- package/dist/react-server.d.ts +81 -2
- package/dist/react-server.js +71 -2
- package/dist/react.d.ts +83 -1
- package/dist/react.js +128 -0
- package/dist/sanitize.d.ts +5 -7
- package/dist/sanitize.js +5 -9
- package/package.json +4 -3
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
|
|
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
|
+
}
|
package/dist/freshness.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/headings.d.ts
CHANGED
|
@@ -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;
|
package/dist/metadata.d.ts
CHANGED
|
@@ -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 `&` 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 `&` 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
|
+
}
|
package/dist/react-server.d.ts
CHANGED
|
@@ -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;
|
package/dist/react-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|