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/README.md
CHANGED
|
@@ -78,7 +78,12 @@ const toc = headingsFrom(html); // [{ id, text, level }]
|
|
|
78
78
|
Two behaviours worth knowing:
|
|
79
79
|
|
|
80
80
|
- **The slug rule is a parameter.** A heading id is a live URL — readers link to `#the-shape-of-a-code`, and so does the page's own permalink anchor. The two wikis this was lifted from had drifted onto different rules, and unifying them would have silently moved every published anchor on whichever one lost. Pass `slug` to keep the rule you already ship.
|
|
81
|
-
- **Deduping is not a parameter.** Two headings with the same text otherwise mint the same id twice, and every link to the second lands on the first. The
|
|
81
|
+
- **Deduping is not a parameter, but its scope is.** Two headings with the same text otherwise mint the same id twice, and every link to the second lands on the first. The unit that must be unique is the page, so a block wiki that injects block by block passes one `used` set to every call — seeded with any id the template renders itself, such as the title's. All three consumers once passed none, and a heading repeated across two blocks shipped two identical ids.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const used = new Set([slug(page.title)]);
|
|
85
|
+
const blocks = page.blocks.map(b => ({ ...b, text: injectHeadingIds(b.text, { used }) }));
|
|
86
|
+
```
|
|
82
87
|
|
|
83
88
|
`headingsFrom` reads the string, not the rendered DOM — possible only where the body IS a string at render time. A wiki whose content streams in as blocks after mount has to query the DOM, and uses only the injector.
|
|
84
89
|
|
|
@@ -269,6 +274,10 @@ const { inputProps, listProps, optionProps } = combobox();
|
|
|
269
274
|
The arithmetic is `wiki-formant/combobox`, which imports nothing, so the rules
|
|
270
275
|
are unit-tested without a DOM.
|
|
271
276
|
|
|
277
|
+
`SortHeader` is the React half of `sortTables`: spread `useTableSort`'s `headerProps(key)` onto it and it renders a `<th aria-sort>` holding a `<button>`, the markup `sortTables` writes into stored tables, so one stylesheet rule draws both arrows. One of the copies it replaced put `onClick` on the cell, which no keyboard can reach.
|
|
278
|
+
|
|
279
|
+
`useBlockOperations(blocks, setBlocks, { duplicate })` is the five list edits every block editor makes, with ref-stable callbacks, and `BlockActions` is their button bar — every button named, the moves disabled at the ends, clicks kept from bubbling into a row that selects on click. `useRevisionRestore(restore, { onRestored })` confirms, restores and reports an error; `restoreViaPost(endpoint)` is the `restore` for a wiki whose route takes `{ revisionId }`. Of the three restores it replaced, one asked no confirmation and one dropped a failure silently.
|
|
280
|
+
|
|
272
281
|
## Server components
|
|
273
282
|
|
|
274
283
|
`wiki-formant/react` carries `'use client'`, and that is a module-level boundary: anything exported from it hydrates in the consumer's tree whether or not it uses a hook. `wiki-formant/react-server` is the same React, without the directive — for the parts of a wiki that are pure functions of their props and should ship no JavaScript at all.
|
|
@@ -280,6 +289,8 @@ are unit-tested without a DOM.
|
|
|
280
289
|
|
|
281
290
|
`RailShell` stays in `wiki-formant/react`, because it calls `useSidebar` and genuinely is a client component. It renders the rail's landmark, its scroll wrapper and the three collapse states — including the `--instant` class that keeps a remembered-closed rail from animating shut on first paint, and the close-on-tap that a mobile rail needs. Compose your own rail inside it and mark the active link with `isRailLinkActive`; all three wikis do, and the pre-composed component that used to sit here had one consumer and had already lost both of those behaviours.
|
|
282
291
|
|
|
292
|
+
`FacetSummary` is the line under the facet bar — how much of the section is showing, and a Clear link when anything is narrowed — with the filtered-empty and empty-section states. `RelatedPages` is the see-also list: a labelled `<aside>` over a `<ul>`, its heading linked to the set `rankRelated` found in common. `InfoboxAside` is the facts panel's named landmark with its "Part of a series on" line, and `InfoboxFacts` renders `metadataRows` as a row-headed table. That table was an HTML string in two wikis, injected as a fake content block; one re-escaped a `"` before each href by hand and the other did not. As React nodes there is nothing to escape. `formatFactValue` is its default cell: a date as its day, a URL or bare domain as an outbound link, `<br>`-separated values as lines.
|
|
293
|
+
|
|
283
294
|
All three take the router's link component as a prop:
|
|
284
295
|
|
|
285
296
|
```tsx
|
|
@@ -332,6 +343,12 @@ export const generateMetadata = () => ({
|
|
|
332
343
|
});
|
|
333
344
|
```
|
|
334
345
|
|
|
346
|
+
The schema.org nodes sit beside it, because they state the same facts to a different reader. `articleLd` requires `image` — two of three wikis shipped articles without one while their og:image named a card — so pass it the card `pageMetadata` got. `collectionLd` is an index page over an `ItemList`, and `citationsFromReferences` turns a `references` block into `Article.citation`.
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
<JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: { '@id': `${SITE_URL}/#organization` }, citation: citationsFromReferences(refs) })} />
|
|
350
|
+
```
|
|
351
|
+
|
|
335
352
|
## Revisions
|
|
336
353
|
|
|
337
354
|
What changed between two versions of a page, and therefore which semver bump to
|
|
@@ -366,9 +383,11 @@ verbatim in two of them, the comment explaining why the apostrophe escapes
|
|
|
366
383
|
numerically. `renderFeed` is the union.
|
|
367
384
|
|
|
368
385
|
```ts
|
|
369
|
-
|
|
386
|
+
export const GET = async (request: Request) => feedResponse(request, channel, await items());
|
|
370
387
|
```
|
|
371
388
|
|
|
389
|
+
`feedResponse` renders the channel and answers a conditional GET: the ETag is the XML's own, Last-Modified the build date. All four feeds used to return the XML bare, so a poller could never be told the channel was unchanged.
|
|
390
|
+
|
|
372
391
|
**`lastBuildDate` comes from the newest item, not the clock.** One copy stamped
|
|
373
392
|
`new Date()` on every request, telling every poller the feed had changed when it
|
|
374
393
|
had not — the recrawl the conditional-GET helpers exist to prevent. An empty feed
|
|
@@ -437,9 +456,17 @@ if (issues.length) return badRequest(`Invalid content: ${describeBlockIssues(iss
|
|
|
437
456
|
// Invalid content: [2].columns[0].blocks[1]: malformed linkGrid block
|
|
438
457
|
```
|
|
439
458
|
|
|
459
|
+
A `codeTabs` tab's `code` is source text in every wiki here, and every view treats it that way: `CodeTabsView` escapes it into a `<pre><code>`, the sanitiser leaves it alone, the markdown twin fences it verbatim. A wiki that escapes and highlights on the server passes `highlighted` to the view. The package used to assume editors stored highlighted markup; none ever had, and every twin printed `Vec<u8>` as `Vec`.
|
|
460
|
+
|
|
461
|
+
`recentPages` and `pageList` resolve to `ResolvedPageRef`s — build each with `pageRef(page, now)`, which carries the ISO date a `<time>` needs — and `PageRefsView` renders them as a list, with a `renderItem` slot for a wiki that has its own row.
|
|
462
|
+
|
|
463
|
+
## Dates
|
|
464
|
+
|
|
465
|
+
`relativeTime(then, now, { style })` is `compact` (`3h`), `short` (`3h ago`) or `long` (`3 days ago`, day-grained for pages cached for hours), with `absoluteAfterDays` to hand over to the date itself. `now` is always passed in, never read, so a server render and its hydration agree. `formatDay` is always UTC: a stored day formatted in the server's zone, or the browser's, is the previous day for half the world. Both are in `wiki-formant/freshness`, with `isoDate`.
|
|
466
|
+
|
|
440
467
|
## Sanitising stored HTML
|
|
441
468
|
|
|
442
|
-
`wiki-formant/sanitize` is the allowlist between an author's saved HTML and a reader's browser. The block views render
|
|
469
|
+
`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. `sanitize-html` is an optional peer; run it server-side, on the render path, so it covers rows written before it existed and no sanitiser ships to the client.
|
|
443
470
|
|
|
444
471
|
```ts
|
|
445
472
|
import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
|
|
@@ -470,6 +497,8 @@ The ones that take config take it because that is exactly where the two copies d
|
|
|
470
497
|
|
|
471
498
|
`createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
|
|
472
499
|
|
|
500
|
+
`TOOLBAR_ACTIONS` are the formatting commands with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Pick yours in order with `toolbarActions([...])`, render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
|
|
501
|
+
|
|
473
502
|
The redirect resolves through the wiki's own route, because the editor cannot read it cross-origin. `resolveMapUrl` is the client half and `resolveMapHandler` the whole route: exact shortener hosts, one hop, an allowlisted landing host, a timeout, and your sign-in check as `authorize`. One of the two copies it replaced matched `goo.gl` as a substring and followed every redirect for anyone who asked.
|
|
474
503
|
|
|
475
504
|
```ts
|
|
@@ -500,6 +529,16 @@ leaves a wiki able to say what every crawler asked for and nothing about what
|
|
|
500
529
|
its readers asked for. The queries that return zero rows are the valuable ones —
|
|
501
530
|
they name a gap in the corpus in the reader's own words.
|
|
502
531
|
|
|
532
|
+
## Base stylesheet
|
|
533
|
+
|
|
534
|
+
`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, and the `aria-sort` arrow as a mask over `currentColor`. No colour and no scale, so a design system's own rules override it at equal specificity.
|
|
535
|
+
|
|
536
|
+
```css
|
|
537
|
+
@import "wiki-formant/base.css" layer(components);
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
No view emits a Tailwind utility any more — `ColumnsView` states its gap and alignment as data attributes, and an inactive code tab is `hidden` — so a site no longer needs its Tailwind to scan this package for the markup to work.
|
|
541
|
+
|
|
503
542
|
## API
|
|
504
543
|
|
|
505
544
|
Every module has a subpath — `wiki-formant/taxonomy`, `wiki-formant/mcp`, and so on.
|
package/dist/base.css
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/* wiki-formant/base.css — the layout the package's markup does not work without.
|
|
2
|
+
|
|
3
|
+
Structure only: no colour, no type scale, no design token. Every value that
|
|
4
|
+
is a look rather than a mechanism is left to the importing stylesheet, which
|
|
5
|
+
loads this first and overrides anything here with an equally specific rule.
|
|
6
|
+
|
|
7
|
+
@import "wiki-formant/base.css" layer(components);
|
|
8
|
+
|
|
9
|
+
Three wikis had each restated these rules, and each had one missing: one
|
|
10
|
+
emitted the stored-tab and copy-button markup with no CSS for it, so every
|
|
11
|
+
panel showed at once; one never received the utility classes ColumnsView
|
|
12
|
+
used to emit, so two of its four alignments did nothing. */
|
|
13
|
+
|
|
14
|
+
/* ---- ColumnsView ----------------------------------------------------------
|
|
15
|
+
Stacked until there is room for a row. `data-gap` and `data-align` replace
|
|
16
|
+
the Tailwind utilities the view used to emit, which only reached a site whose
|
|
17
|
+
Tailwind happened to scan node_modules. */
|
|
18
|
+
.columns-layout {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
gap: var(--columns-gap, 1rem);
|
|
22
|
+
}
|
|
23
|
+
.columns-layout[data-gap='sm'] { --columns-gap: 0.5rem; }
|
|
24
|
+
.columns-layout[data-gap='lg'] { --columns-gap: 1.5rem; }
|
|
25
|
+
.columns-layout[data-align='start'] { align-items: flex-start; }
|
|
26
|
+
.columns-layout[data-align='center'] { align-items: center; }
|
|
27
|
+
.columns-layout[data-align='end'] { align-items: flex-end; }
|
|
28
|
+
.columns-layout[data-align='stretch'] { align-items: stretch; }
|
|
29
|
+
.column-view {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
min-width: 0;
|
|
33
|
+
}
|
|
34
|
+
@media (min-width: 48rem) {
|
|
35
|
+
.columns-layout { flex-direction: row; }
|
|
36
|
+
.column-view { flex: 1 1 0%; }
|
|
37
|
+
}
|
|
38
|
+
/* A stacked column is as wide as the row, whatever the alignment. */
|
|
39
|
+
@media (max-width: 47.99rem) {
|
|
40
|
+
.columns-layout > .column-view { align-self: stretch; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ---- stored tab groups (activateTabGroups) --------------------------------
|
|
44
|
+
Only the active panel shows. Before the script runs, the first tab's content
|
|
45
|
+
stands in for the group rather than every tab at once. */
|
|
46
|
+
.tab-panel:not(.active) { display: none; }
|
|
47
|
+
[data-tabs]:not([data-tabs-init]) [data-tab-item] ~ [data-tab-item] { display: none; }
|
|
48
|
+
|
|
49
|
+
/* ---- code copy buttons (addCopyButtons) -----------------------------------
|
|
50
|
+
The script positions the <pre>; this pins the button in its corner. Shown
|
|
51
|
+
on hover, on keyboard focus, and always where there is no hover to show it. */
|
|
52
|
+
.code-copy-btn {
|
|
53
|
+
position: absolute;
|
|
54
|
+
top: 0.5rem;
|
|
55
|
+
right: 0.5rem;
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
cursor: pointer;
|
|
58
|
+
opacity: 0;
|
|
59
|
+
transition: opacity 120ms ease;
|
|
60
|
+
}
|
|
61
|
+
pre:hover > .code-copy-btn,
|
|
62
|
+
.code-copy-btn:focus-visible { opacity: 1; }
|
|
63
|
+
@media (hover: none) {
|
|
64
|
+
.code-copy-btn { opacity: 1; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ---- sortable headers (SortHeader, sortTables) ----------------------------
|
|
68
|
+
The arrow is read from `aria-sort` on the cell, so it cannot point one way
|
|
69
|
+
while the rows go the other. It is a mask over currentColor, so it takes the
|
|
70
|
+
header's colour, whatever that is. */
|
|
71
|
+
.sort-header {
|
|
72
|
+
display: inline-flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 0.25em;
|
|
75
|
+
padding: 0;
|
|
76
|
+
border: 0;
|
|
77
|
+
border-radius: 0;
|
|
78
|
+
background: none;
|
|
79
|
+
color: inherit;
|
|
80
|
+
font: inherit;
|
|
81
|
+
text-align: inherit;
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
}
|
|
84
|
+
.sort-header::after {
|
|
85
|
+
content: '';
|
|
86
|
+
flex: none;
|
|
87
|
+
width: 0.75em;
|
|
88
|
+
height: 0.75em;
|
|
89
|
+
background-color: currentColor;
|
|
90
|
+
opacity: 0.4;
|
|
91
|
+
--sort-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E");
|
|
92
|
+
mask: var(--sort-icon) center / contain no-repeat;
|
|
93
|
+
}
|
|
94
|
+
:is([aria-sort='ascending'], [aria-sort='descending']) > .sort-header::after { opacity: 1; }
|
|
95
|
+
[aria-sort='ascending'] > .sort-header::after {
|
|
96
|
+
--sort-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12 7-7 7 7'/%3E%3Cpath d='M12 19V5'/%3E%3C/svg%3E");
|
|
97
|
+
}
|
|
98
|
+
[aria-sort='descending'] > .sort-header::after {
|
|
99
|
+
--sort-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14'/%3E%3Cpath d='m19 12-7 7-7-7'/%3E%3C/svg%3E");
|
|
100
|
+
}
|
package/dist/block-views.d.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import type { CodeTab, LinkGridGroup, ReferenceItem, StatItem } from './blocks.js';
|
|
2
|
+
import type { CodeTab, LinkGridGroup, ReferenceItem, ResolvedPageRef, StatItem } from './blocks.js';
|
|
3
3
|
import type { WikiLinkComponent } from './react-server.js';
|
|
4
|
+
export interface CodeTabsViewProps {
|
|
5
|
+
tabs: readonly CodeTab[];
|
|
6
|
+
/**
|
|
7
|
+
* Every tab's `code` has already been escaped and highlighted into markup on
|
|
8
|
+
* the server, and is written as HTML. Only a wiki whose render path does that
|
|
9
|
+
* for every tree reaching this view may pass it: without it, `code` is source
|
|
10
|
+
* and renders escaped in a `<pre><code>`, which is the safe default.
|
|
11
|
+
*/
|
|
12
|
+
highlighted?: boolean;
|
|
13
|
+
}
|
|
4
14
|
/**
|
|
5
15
|
* Tabbed code samples. Every tab's body stays mounted and the inactive ones are
|
|
6
|
-
* hidden rather than unmounted:
|
|
7
|
-
*
|
|
8
|
-
* it on every tab press.
|
|
16
|
+
* hidden rather than unmounted: remounting a highlighted panel would re-run
|
|
17
|
+
* whatever the consumer's highlighter attached to it on every tab press.
|
|
9
18
|
*/
|
|
10
|
-
export declare function CodeTabsView({ tabs }:
|
|
11
|
-
tabs: readonly CodeTab[];
|
|
12
|
-
}): import("react").JSX.Element | null;
|
|
19
|
+
export declare function CodeTabsView({ tabs, highlighted }: CodeTabsViewProps): import("react").JSX.Element | null;
|
|
13
20
|
export interface ColumnsViewProps<B extends {
|
|
14
21
|
id: string;
|
|
15
22
|
}> {
|
|
@@ -29,6 +36,10 @@ export interface ColumnsViewProps<B extends {
|
|
|
29
36
|
* it was a layout bug rather than a style: `.column-view` is the flow context
|
|
30
37
|
* its stylesheet spaces children in, and an extra element between them means
|
|
31
38
|
* every gap rule inside a column silently matches nothing.
|
|
39
|
+
*
|
|
40
|
+
* Gap and alignment are data attributes that `wiki-formant/base.css` reads.
|
|
41
|
+
* They were Tailwind utilities, which reached a page only if the site's
|
|
42
|
+
* Tailwind scanned this package — and on one site `end` and `stretch` did not.
|
|
32
43
|
*/
|
|
33
44
|
export declare function ColumnsView<B extends {
|
|
34
45
|
id: string;
|
|
@@ -48,6 +59,23 @@ export declare function LinkGridView({ groups, intro, link: Link, }: {
|
|
|
48
59
|
intro?: string;
|
|
49
60
|
link?: WikiLinkComponent;
|
|
50
61
|
}): import("react").JSX.Element;
|
|
62
|
+
/**
|
|
63
|
+
* A list of resolved page references — the body of a `recentPages` or
|
|
64
|
+
* `pageList` block. A real list, with each age in a `<time>` carrying its ISO
|
|
65
|
+
* date: the copies this replaced were bare `<div>` rows, one emitted a
|
|
66
|
+
* `<time>` with no `dateTime`, and their empty states were three different
|
|
67
|
+
* things (a sentence, a different sentence, nothing).
|
|
68
|
+
*
|
|
69
|
+
* `renderItem` replaces the row's inner markup for a wiki with its own row
|
|
70
|
+
* component; the list, its semantics and the empty state stay here.
|
|
71
|
+
*/
|
|
72
|
+
export declare function PageRefsView({ refs, empty, link: Link, renderItem, }: {
|
|
73
|
+
refs: readonly ResolvedPageRef[];
|
|
74
|
+
/** Said when there are none. Omit to render nothing. */
|
|
75
|
+
empty?: string;
|
|
76
|
+
link?: WikiLinkComponent;
|
|
77
|
+
renderItem?: (ref: ResolvedPageRef) => ReactNode;
|
|
78
|
+
}): import("react").JSX.Element | null;
|
|
51
79
|
/**
|
|
52
80
|
* The numbered citation list, with the `#cite-n` back-links the inline markers
|
|
53
81
|
* point at. `processHtml` is a prop because only one wiki runs reference text
|
package/dist/block-views.js
CHANGED
|
@@ -23,18 +23,16 @@ import { Anchor } from './react-server.js';
|
|
|
23
23
|
import { safeLinkHref } from './validation.js';
|
|
24
24
|
import { cx } from './html.js';
|
|
25
25
|
import { BANNER_LABELS, bannerVariant } from './text.js';
|
|
26
|
-
// ---- codeTabs ---------------------------------------------------------------
|
|
27
26
|
/**
|
|
28
27
|
* Tabbed code samples. Every tab's body stays mounted and the inactive ones are
|
|
29
|
-
* hidden rather than unmounted:
|
|
30
|
-
*
|
|
31
|
-
* it on every tab press.
|
|
28
|
+
* hidden rather than unmounted: remounting a highlighted panel would re-run
|
|
29
|
+
* whatever the consumer's highlighter attached to it on every tab press.
|
|
32
30
|
*/
|
|
33
|
-
export function CodeTabsView({ tabs }) {
|
|
31
|
+
export function CodeTabsView({ tabs, highlighted = false }) {
|
|
34
32
|
const [activeTab, setActiveTab] = useState(0);
|
|
35
33
|
if (!tabs.length)
|
|
36
34
|
return null;
|
|
37
|
-
return (_jsxs("div", { className: "code-tabs", children: [_jsx("div", { className: "code-tabs-list", children: tabs.map((tab, i) => (_jsx("button", { type: "button", className: cx('code-tabs-btn', i === activeTab && 'code-tabs-btn-active'), onClick: () => setActiveTab(i), children: tab.label }, i))) }), tabs.map((tab, i) => (_jsx("div", { className: i
|
|
35
|
+
return (_jsxs("div", { className: "code-tabs", children: [_jsx("div", { className: "code-tabs-list", children: tabs.map((tab, i) => (_jsx("button", { type: "button", className: cx('code-tabs-btn', i === activeTab && 'code-tabs-btn-active'), onClick: () => setActiveTab(i), children: tab.label }, i))) }), tabs.map((tab, i) => highlighted ? (_jsx("div", { className: "code-tabs-panel", hidden: i !== activeTab, dangerouslySetInnerHTML: { __html: tab.code } }, i)) : (_jsx("div", { className: "code-tabs-panel", hidden: i !== activeTab, children: _jsx("pre", { children: _jsx("code", { className: tab.language ? `language-${tab.language}` : undefined, children: tab.code }) }) }, i)))] }));
|
|
38
36
|
}
|
|
39
37
|
/**
|
|
40
38
|
* A row of columns, each holding blocks the caller renders.
|
|
@@ -43,16 +41,13 @@ export function CodeTabsView({ tabs }) {
|
|
|
43
41
|
* it was a layout bug rather than a style: `.column-view` is the flow context
|
|
44
42
|
* its stylesheet spaces children in, and an extra element between them means
|
|
45
43
|
* every gap rule inside a column silently matches nothing.
|
|
44
|
+
*
|
|
45
|
+
* Gap and alignment are data attributes that `wiki-formant/base.css` reads.
|
|
46
|
+
* They were Tailwind utilities, which reached a page only if the site's
|
|
47
|
+
* Tailwind scanned this package — and on one site `end` and `stretch` did not.
|
|
46
48
|
*/
|
|
47
49
|
export function ColumnsView({ columns, gap = 'md', align = 'start', render, }) {
|
|
48
|
-
|
|
49
|
-
const alignClass = {
|
|
50
|
-
start: 'items-start',
|
|
51
|
-
center: 'items-center',
|
|
52
|
-
end: 'items-end',
|
|
53
|
-
stretch: 'items-stretch',
|
|
54
|
-
}[align];
|
|
55
|
-
return (_jsx("div", { className: cx('columns-layout', gapClass, alignClass), children: columns.map(col => (_jsx("div", { className: "column-view", children: (col.blocks ?? []).map(bl => (_jsx(Fragment, { children: render(bl) }, bl.id))) }, col.id))) }));
|
|
50
|
+
return (_jsx("div", { className: "columns-layout", "data-gap": gap, "data-align": align, children: columns.map(col => (_jsx("div", { className: "column-view", children: (col.blocks ?? []).map(bl => (_jsx(Fragment, { children: render(bl) }, bl.id))) }, col.id))) }));
|
|
56
51
|
}
|
|
57
52
|
// ---- linkGrid ---------------------------------------------------------------
|
|
58
53
|
/**
|
|
@@ -71,6 +66,23 @@ export function LinkGridView({ groups, intro, link: Link = Anchor, }) {
|
|
|
71
66
|
return /^https?:\/\//.test(href) ? (_jsx("a", { href: href, target: "_blank", rel: "noopener", children: item.label }, i)) : (_jsx(Link, { href: href, children: item.label }, i));
|
|
72
67
|
}) })] }, group.id ?? gi)))] }));
|
|
73
68
|
}
|
|
69
|
+
// ---- recentPages / pageList -------------------------------------------------
|
|
70
|
+
/**
|
|
71
|
+
* A list of resolved page references — the body of a `recentPages` or
|
|
72
|
+
* `pageList` block. A real list, with each age in a `<time>` carrying its ISO
|
|
73
|
+
* date: the copies this replaced were bare `<div>` rows, one emitted a
|
|
74
|
+
* `<time>` with no `dateTime`, and their empty states were three different
|
|
75
|
+
* things (a sentence, a different sentence, nothing).
|
|
76
|
+
*
|
|
77
|
+
* `renderItem` replaces the row's inner markup for a wiki with its own row
|
|
78
|
+
* component; the list, its semantics and the empty state stay here.
|
|
79
|
+
*/
|
|
80
|
+
export function PageRefsView({ refs, empty, link: Link = Anchor, renderItem, }) {
|
|
81
|
+
if (!refs.length)
|
|
82
|
+
return empty ? _jsx("p", { className: "page-refs-empty", children: empty }) : null;
|
|
83
|
+
return (_jsx("ul", { className: "page-refs", children: refs.map(ref => (_jsx("li", { children: renderItem ? (renderItem(ref)) : (_jsxs(Link, { href: ref.href, className: "page-ref", children: [_jsx("span", { className: "page-ref-title", children: ref.title }), ref.timeAgo &&
|
|
84
|
+
(ref.updated ? (_jsx("time", { className: "page-ref-time", dateTime: ref.updated, children: ref.timeAgo })) : (_jsx("span", { className: "page-ref-time", children: ref.timeAgo })))] })) }, ref.href))) }));
|
|
85
|
+
}
|
|
74
86
|
// ---- references -------------------------------------------------------------
|
|
75
87
|
/**
|
|
76
88
|
* The numbered citation list, with the `#cite-n` back-links the inline markers
|
package/dist/blocks.d.ts
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
|
+
import { type RelativeTimeStyle } from './freshness.js';
|
|
1
2
|
import type { BlockGroup } from './revisions.js';
|
|
2
3
|
export interface CodeTab {
|
|
3
4
|
label: string;
|
|
4
5
|
language?: string;
|
|
6
|
+
/** Source text, never markup: every view escapes it unless told otherwise. */
|
|
5
7
|
code: string;
|
|
6
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* A page a `recentPages` or `pageList` block points at, resolved on the server
|
|
11
|
+
* so the list renders with no client fetch and no client clock. Build it with
|
|
12
|
+
* `pageRef`, which is what fills `updated`: a `<time>` with no `dateTime`
|
|
13
|
+
* states an age no machine can read.
|
|
14
|
+
*/
|
|
15
|
+
export interface ResolvedPageRef {
|
|
16
|
+
title: string;
|
|
17
|
+
href: string;
|
|
18
|
+
/** `relativeTime` of `updated`, as of the render. */
|
|
19
|
+
timeAgo?: string;
|
|
20
|
+
/** ISO timestamp. */
|
|
21
|
+
updated?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function pageRef(page: {
|
|
24
|
+
title: string;
|
|
25
|
+
href: string;
|
|
26
|
+
updatedAt: Date | string;
|
|
27
|
+
}, now: number, style?: RelativeTimeStyle): ResolvedPageRef;
|
|
7
28
|
export interface ReferenceItem {
|
|
8
29
|
text: string;
|
|
9
30
|
url?: string | null;
|
|
@@ -109,9 +130,11 @@ export declare function someBlock<B>(blocks: readonly B[], predicate: (block: B)
|
|
|
109
130
|
/**
|
|
110
131
|
* Fenced blocks, one per tab, each under its label.
|
|
111
132
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
133
|
+
* `code` is source, so it goes in verbatim. This used to strip tags on the
|
|
134
|
+
* belief that editors stored highlighted markup; none of the three wikis ever
|
|
135
|
+
* did, and the strip turned `Vec<u8>` into `Vec` in every markdown twin. The
|
|
136
|
+
* fence outgrows any backtick run in the code, so a sample that itself
|
|
137
|
+
* contains a fence cannot close this one early.
|
|
115
138
|
*/
|
|
116
139
|
export declare function codeTabsToMarkdown(tabs: readonly CodeTab[]): string;
|
|
117
140
|
/** A maintenance notice as a blockquote. `label` is the caller's display string. */
|
package/dist/blocks.js
CHANGED
|
@@ -10,8 +10,16 @@
|
|
|
10
10
|
// new block type is a compile error until it is handled) and the bodies come
|
|
11
11
|
// from here. Each renderer takes the block's DATA rather than the block, which
|
|
12
12
|
// keeps this file free of any one repo's type union.
|
|
13
|
-
import { decodeEntities } from './markdown.js';
|
|
14
13
|
import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
|
|
14
|
+
import { relativeTime } from './freshness.js';
|
|
15
|
+
export function pageRef(page, now, style = 'long') {
|
|
16
|
+
return {
|
|
17
|
+
title: page.title,
|
|
18
|
+
href: page.href,
|
|
19
|
+
timeAgo: relativeTime(page.updatedAt, now, { style }),
|
|
20
|
+
updated: new Date(page.updatedAt).toISOString(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
15
23
|
/**
|
|
16
24
|
* The whole tree as markdown. Containers flatten in document order, because a
|
|
17
25
|
* reader following prose does not have columns — and an agent quoting the page
|
|
@@ -139,13 +147,19 @@ export function someBlock(blocks, predicate, containers) {
|
|
|
139
147
|
/**
|
|
140
148
|
* Fenced blocks, one per tab, each under its label.
|
|
141
149
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
150
|
+
* `code` is source, so it goes in verbatim. This used to strip tags on the
|
|
151
|
+
* belief that editors stored highlighted markup; none of the three wikis ever
|
|
152
|
+
* did, and the strip turned `Vec<u8>` into `Vec` in every markdown twin. The
|
|
153
|
+
* fence outgrows any backtick run in the code, so a sample that itself
|
|
154
|
+
* contains a fence cannot close this one early.
|
|
145
155
|
*/
|
|
146
156
|
export function codeTabsToMarkdown(tabs) {
|
|
147
157
|
return tabs
|
|
148
|
-
.map(t =>
|
|
158
|
+
.map(t => {
|
|
159
|
+
const longest = Math.max(0, ...(t.code.match(/`+/g) ?? []).map(run => run.length));
|
|
160
|
+
const fence = '`'.repeat(Math.max(3, longest + 1));
|
|
161
|
+
return `**${t.label}**\n\n${fence}${t.language || ''}\n${t.code.trim()}\n${fence}`;
|
|
162
|
+
})
|
|
149
163
|
.join('\n\n');
|
|
150
164
|
}
|
|
151
165
|
/** A maintenance notice as a blockquote. `label` is the caller's display string. */
|
package/dist/conformance.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface TesterOptions {
|
|
|
66
66
|
export interface Tester {
|
|
67
67
|
base: string;
|
|
68
68
|
endpoint: string;
|
|
69
|
-
/** One JSON-RPC call.
|
|
69
|
+
/** One JSON-RPC call. Waits out a 429 as `Retry-After` asks, up to four tries. */
|
|
70
70
|
rpc(method: string, params?: unknown): Promise<Rpc>;
|
|
71
71
|
/** `tools/call` shorthand. */
|
|
72
72
|
call(name: string, args?: Record<string, unknown>): Promise<Rpc>;
|
package/dist/conformance.js
CHANGED
|
@@ -40,20 +40,26 @@ export function createTester(opts) {
|
|
|
40
40
|
const endpoint = opts.endpoint ?? `${base}/api/mcp`;
|
|
41
41
|
const results = [];
|
|
42
42
|
let calls = 0;
|
|
43
|
+
// The server's own rate limit is part of what is under test elsewhere; here
|
|
44
|
+
// it is noise, so it is waited out — for as long as `Retry-After` says, and a
|
|
45
|
+
// bounded number of times. This used to say "once" and recurse forever at a
|
|
46
|
+
// fixed 5s, so a suite pointed at a budget it could never get under hung.
|
|
47
|
+
const RETRIES = 4;
|
|
43
48
|
const rpc = async (method, params) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
for (let attempt = 1;; attempt++) {
|
|
50
|
+
calls++;
|
|
51
|
+
const res = await fetch(endpoint, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': opts.clientName },
|
|
54
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: calls, method, params }),
|
|
55
|
+
});
|
|
56
|
+
if (res.status !== 429)
|
|
57
|
+
return (await res.json());
|
|
58
|
+
if (attempt === RETRIES)
|
|
59
|
+
throw new Error(`${method}: still rate-limited after ${RETRIES} tries`);
|
|
60
|
+
const wait = Number(res.headers.get('retry-after'));
|
|
61
|
+
await new Promise(r => setTimeout(r, Math.min(60, Number.isFinite(wait) && wait > 0 ? wait : 5) * 1000));
|
|
55
62
|
}
|
|
56
|
-
return (await res.json());
|
|
57
63
|
};
|
|
58
64
|
return {
|
|
59
65
|
base,
|
package/dist/editor.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnyExtension, Editor } from '@tiptap/core';
|
|
2
|
-
import { type ChangeEvent, type RefObject } from 'react';
|
|
2
|
+
import { type ChangeEvent, type ReactNode, type RefObject } from 'react';
|
|
3
3
|
/**
|
|
4
4
|
* Re-exported so the COMMAND AUGMENTATIONS reach consumers.
|
|
5
5
|
*
|
|
@@ -46,6 +46,38 @@ export declare function insertEmbed(editor: Editor, url: string, { resolveMapUrl
|
|
|
46
46
|
export declare const TABLE_ACTIONS: ReadonlyArray<[command: string, label: string, danger?: boolean]>;
|
|
47
47
|
/** `editor.isActive` as a toolbar button's `active` descriptor expresses it. */
|
|
48
48
|
export type ActiveDescriptor = string | [string, Record<string, unknown>];
|
|
49
|
+
/** Run one of `TABLE_ACTIONS` — the one place the command name is looked up untyped. */
|
|
50
|
+
export declare function runTableAction(editor: Editor, command: string): void;
|
|
51
|
+
export type ToolbarKey = 'bold' | 'italic' | 'code' | 'link' | 'h2' | 'h3' | 'h4' | 'bulletList' | 'orderedList' | 'blockquote' | 'codeBlock' | 'divider' | 'table' | 'tabs';
|
|
52
|
+
export interface ToolbarAction {
|
|
53
|
+
key: ToolbarKey;
|
|
54
|
+
/** What a screen reader announces and a tooltip shows. */
|
|
55
|
+
label: string;
|
|
56
|
+
active?: ActiveDescriptor;
|
|
57
|
+
/** Absent on `link`, which needs a URL: call `withUrl` once you have one. */
|
|
58
|
+
run?: (editor: Editor) => void;
|
|
59
|
+
withUrl?: (editor: Editor, url: string) => void;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The formatting commands, labelled. Three toolbars ran these identically and
|
|
63
|
+
* two titled their buttons with the internal key — a screen reader said
|
|
64
|
+
* "codeBlock", and inline code and code block shared one icon and one name.
|
|
65
|
+
* Upload and embed stay with the caller: they are the parts that differ.
|
|
66
|
+
*/
|
|
67
|
+
export declare const TOOLBAR_ACTIONS: readonly ToolbarAction[];
|
|
68
|
+
/** The actions a toolbar shows, in the order it shows them. */
|
|
69
|
+
export declare function toolbarActions(keys: readonly ToolbarKey[]): ToolbarAction[];
|
|
70
|
+
/**
|
|
71
|
+
* A toolbar button that says what it is and whether it is on. `aria-pressed`
|
|
72
|
+
* is right here, unlike on a facet link: this is a toggle.
|
|
73
|
+
*/
|
|
74
|
+
export declare function ToolbarButton({ label, pressed, onPress, className, children, }: {
|
|
75
|
+
label: string;
|
|
76
|
+
pressed?: boolean;
|
|
77
|
+
onPress: () => void;
|
|
78
|
+
className?: string;
|
|
79
|
+
children: ReactNode;
|
|
80
|
+
}): import("react").JSX.Element;
|
|
49
81
|
/**
|
|
50
82
|
* An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
|
|
51
83
|
* back. Two editors carried it character for character, `alert` included.
|
package/dist/editor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
3
|
// editor.tsx — the wiki rich-text editor's engine, minus its chrome.
|
|
3
4
|
//
|
|
4
5
|
// `tiptap.tsx` holds the custom NODES both wikis needed (iframe, tweet, map,
|
|
@@ -136,6 +137,63 @@ export const TABLE_ACTIONS = [
|
|
|
136
137
|
['deleteRow', '-Row', true],
|
|
137
138
|
['deleteTable', '-Tbl', true],
|
|
138
139
|
];
|
|
140
|
+
/** Run one of `TABLE_ACTIONS` — the one place the command name is looked up untyped. */
|
|
141
|
+
export function runTableAction(editor, command) {
|
|
142
|
+
const chain = editor.chain().focus();
|
|
143
|
+
chain[command]?.().run();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The formatting commands, labelled. Three toolbars ran these identically and
|
|
147
|
+
* two titled their buttons with the internal key — a screen reader said
|
|
148
|
+
* "codeBlock", and inline code and code block shared one icon and one name.
|
|
149
|
+
* Upload and embed stay with the caller: they are the parts that differ.
|
|
150
|
+
*/
|
|
151
|
+
export const TOOLBAR_ACTIONS = [
|
|
152
|
+
{ key: 'bold', label: 'Bold', active: 'bold', run: e => e.chain().focus().toggleBold().run() },
|
|
153
|
+
{ key: 'italic', label: 'Italic', active: 'italic', run: e => e.chain().focus().toggleItalic().run() },
|
|
154
|
+
{ key: 'code', label: 'Inline code', active: 'code', run: e => e.chain().focus().toggleCode().run() },
|
|
155
|
+
{ key: 'link', label: 'Link', active: 'link', withUrl: (e, href) => e.chain().focus().setLink({ href }).run() },
|
|
156
|
+
{ key: 'h2', label: 'Heading 2', active: ['heading', { level: 2 }], run: e => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
|
157
|
+
{ key: 'h3', label: 'Heading 3', active: ['heading', { level: 3 }], run: e => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
|
158
|
+
{ key: 'h4', label: 'Heading 4', active: ['heading', { level: 4 }], run: e => e.chain().focus().toggleHeading({ level: 4 }).run() },
|
|
159
|
+
{ key: 'bulletList', label: 'Bulleted list', active: 'bulletList', run: e => e.chain().focus().toggleBulletList().run() },
|
|
160
|
+
{ key: 'orderedList', label: 'Numbered list', active: 'orderedList', run: e => e.chain().focus().toggleOrderedList().run() },
|
|
161
|
+
{ key: 'blockquote', label: 'Quote', active: 'blockquote', run: e => e.chain().focus().toggleBlockquote().run() },
|
|
162
|
+
{ key: 'codeBlock', label: 'Code block', active: 'codeBlock', run: e => e.chain().focus().toggleCodeBlock().run() },
|
|
163
|
+
{ key: 'divider', label: 'Divider', run: e => e.chain().focus().setHorizontalRule().run() },
|
|
164
|
+
{
|
|
165
|
+
key: 'table',
|
|
166
|
+
label: 'Table',
|
|
167
|
+
active: 'table',
|
|
168
|
+
run: e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
key: 'tabs',
|
|
172
|
+
label: 'Tabs',
|
|
173
|
+
active: 'tabGroup',
|
|
174
|
+
// Two, not one: a group of one is a heading with extra steps, and the node
|
|
175
|
+
// refuses to delete its last tab anyway.
|
|
176
|
+
run: e => e
|
|
177
|
+
.chain()
|
|
178
|
+
.focus()
|
|
179
|
+
.insertContent({
|
|
180
|
+
type: 'tabGroup',
|
|
181
|
+
content: [1, 2].map(n => ({ type: 'tabItem', attrs: { title: `Tab ${n}` }, content: [{ type: 'paragraph' }] })),
|
|
182
|
+
})
|
|
183
|
+
.run(),
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
/** The actions a toolbar shows, in the order it shows them. */
|
|
187
|
+
export function toolbarActions(keys) {
|
|
188
|
+
return keys.flatMap(key => TOOLBAR_ACTIONS.filter(a => a.key === key));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A toolbar button that says what it is and whether it is on. `aria-pressed`
|
|
192
|
+
* is right here, unlike on a facet link: this is a toggle.
|
|
193
|
+
*/
|
|
194
|
+
export function ToolbarButton({ label, pressed, onPress, className, children, }) {
|
|
195
|
+
return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
|
|
196
|
+
}
|
|
139
197
|
// ---- the editor -------------------------------------------------------------
|
|
140
198
|
/**
|
|
141
199
|
* An `uploadImage` that POSTs the file as `file` form data and reads `{ url }`
|
package/dist/feed.d.ts
CHANGED
|
@@ -54,3 +54,13 @@ export interface FeedChannel {
|
|
|
54
54
|
*/
|
|
55
55
|
export declare function renderFeed(channel: FeedChannel, items: readonly FeedItem[]): string;
|
|
56
56
|
export declare const FEED_HEADERS: Record<string, string>;
|
|
57
|
+
/**
|
|
58
|
+
* The feed as a response, with the validators a poller revalidates against.
|
|
59
|
+
*
|
|
60
|
+
* All four feeds in the workspace were `new Response(renderFeed(...))` with
|
|
61
|
+
* `Cache-Control: public` and no ETag or Last-Modified, so a reader polling
|
|
62
|
+
* hourly could never be told "unchanged" and took the whole channel every time.
|
|
63
|
+
* The tag comes from the rendered XML, so it moves exactly when the feed does;
|
|
64
|
+
* Last-Modified is the channel's build date.
|
|
65
|
+
*/
|
|
66
|
+
export declare function feedResponse(request: Request, channel: FeedChannel, items: readonly FeedItem[], headers?: Record<string, string>): Response;
|