wiki-formant 0.57.1 → 0.59.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 +15 -12
- package/dist/base.css +67 -0
- package/dist/chart.d.ts +4 -2
- package/dist/chart.js +10 -4
- package/dist/react.d.ts +12 -9
- package/dist/react.js +11 -6
- package/dist/sidebar.js +5 -5
- package/dist/stats-figures.d.ts +14 -0
- package/dist/stats-figures.js +43 -5
- package/dist/stats.js +1 -2
- package/package.json +1 -6
- package/dist/passkey-button.d.ts +0 -14
- package/dist/passkey-button.js +0 -45
package/README.md
CHANGED
|
@@ -287,16 +287,18 @@ The default `frame-src` is `DEFAULT_IFRAME_HOSTS`, the same list the sanitiser's
|
|
|
287
287
|
|
|
288
288
|
`SidebarProvider` / `useSidebar` hold the rail's collapse state — remembered across loads, defaulted from the viewport only when the reader has never chosen. Pair it with `sidebarBootScript` from `wiki-formant/sidebar` (framework-free, so a server component can stamp it into `<head>`) or a remembered-closed rail paints open and animates shut on every load. `TableOfContents` is the "on this page" list, with scroll-spy, reading either headings you already know or the rendered article.
|
|
289
289
|
|
|
290
|
-
The rail's breakpoint is necessarily known twice — `matchMedia` here, a media query in
|
|
290
|
+
The rail's breakpoint is necessarily known twice — `matchMedia` here, a media query in a stylesheet — because a media query cannot read a JS constant and a JS constant cannot read a media query. `base.css` holds the CSS half: with `RailShell prefix="wiki-sidebar"` inside a `.wiki-layout` beside a `.wiki-layout__main`, the rail is a sticky column from 1024px and, below it, a drawer stacked above the article that the same open/closed choice shows or removes, table of contents included. 1024 is the default `breakpoint` of `SidebarProvider` and `sidebarBootScript`, so a wiki on `base.css`'s rail passes none. It is measured: an 18rem rail, a full `--measure` of prose (63ch of Inter is 636px) and the page gutters need 972px, and 1024 is the gutter tier above that. The site supplies `--rail-top` (what the sticky rail clears), `--rail-border` (the rule beside the column, which becomes the drawer's foot) and the surface, padding and gap.
|
|
291
|
+
|
|
292
|
+
What the two halves can be stopped from doing is parting in silence. `base.css` sets `--rail-floating: 1` inside its media query, and the hook checks the two agree on every breakpoint crossing, in development, naming both sides and the number to change. A wiki that lays its rail out itself opts in the same way:
|
|
291
293
|
|
|
292
294
|
```css
|
|
293
295
|
@media (max-width: 767px) { /* the edge of breakpoint={768} */
|
|
294
296
|
:root { --rail-floating: 1; }
|
|
295
|
-
.sidebar {
|
|
297
|
+
.sidebar { /* … however this wiki lays its rail out */ }
|
|
296
298
|
}
|
|
297
299
|
```
|
|
298
300
|
|
|
299
|
-
|
|
301
|
+
Without the property the check stays quiet, since a package cannot require CSS it does not ship. The mechanism is `railBreakpointMismatch` and `readRailFloating` from `wiki-formant/sidebar`, both exported, the first pure.
|
|
300
302
|
|
|
301
303
|
`useTypeahead` is the search field's state machine. Five surfaces across the three wikis had three implementations and no two agreed on what a search field does; this is their union, because each had a piece the others lacked:
|
|
302
304
|
|
|
@@ -369,7 +371,7 @@ are unit-tested without a DOM.
|
|
|
369
371
|
|
|
370
372
|
`PageNav` is the previous/next pair at the foot of an article — the sequential read the infobox rail's lateral links do not cover. Ordering is the caller's, because it is the one part that is never portable: a wiki's sequence is its section's configured sort, a knowledge base's is a taxonomy walk. Pair it with `adjacentPages` from `wiki-formant/pagination` over a list you already hold — neither wiki needs a query for it, and the two indexed lookups the neighbours used to cost were the reason one of them dropped the control.
|
|
371
373
|
|
|
372
|
-
`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-
|
|
374
|
+
`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-navigate that a mobile drawer needs. Only a followed link closes it: a search field or a table-of-contents entry inside the drawer leaves it open. 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.
|
|
373
375
|
|
|
374
376
|
`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.
|
|
375
377
|
|
|
@@ -840,14 +842,13 @@ return (
|
|
|
840
842
|
);
|
|
841
843
|
```
|
|
842
844
|
|
|
843
|
-
`StatsGate` (`wiki-formant/stats`) renders `children` and a sign-out button
|
|
845
|
+
`StatsGate` (`wiki-formant/stats`) renders `children` and a sign-out button for a signed-in reader and, for anyone else, the line saying what the prompt will do and the passkey button that does it; `classNames` places the text, the button's box, the button and its error. Those buttons are the browser half of `wiki-formant/passkey`, and they have no subpath of their own.
|
|
844
846
|
|
|
845
|
-
`PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
|
|
846
|
-
own subpath, because it imports `@simplewebauthn/browser`.
|
|
847
847
|
`Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
|
|
848
848
|
headline figures, each with its change from the window before
|
|
849
849
|
(`.stats-delta[data-better]`, a share or for the bounce rate points, for the
|
|
850
|
-
site's stylesheet to colour), one of them
|
|
850
|
+
site's stylesheet to colour), one of them by day, week or month (a count's
|
|
851
|
+
total, a rate's mean), every non-empty
|
|
851
852
|
ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
852
853
|
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
853
854
|
figure is a button that charts it: visitors, agents, page views, bounce rate,
|
|
@@ -897,7 +898,7 @@ that makes 200 or fewer, so a year opens weekly and five years monthly.
|
|
|
897
898
|
|
|
898
899
|
```tsx
|
|
899
900
|
// Daily points: the chart slices and steps them itself; 30D up.
|
|
900
|
-
<TimeChart series={days} label="Transactions" aggregate="
|
|
901
|
+
<TimeChart series={days} label="Transactions" aggregate="sum" fromZero />
|
|
901
902
|
|
|
902
903
|
// A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
|
|
903
904
|
// range), once each.
|
|
@@ -915,8 +916,10 @@ the slot empty, except the last, which is the price now. A price's line is
|
|
|
915
916
|
straight between closes and a count's is curved.
|
|
916
917
|
|
|
917
918
|
`aggregate` is how a week or month is made from its days: `last` for a price,
|
|
918
|
-
`
|
|
919
|
-
|
|
919
|
+
`sum` for a count, so a week reads as the week's total, and `mean` for a rate,
|
|
920
|
+
which no total makes sense of. A stepped range starts on a whole week or month,
|
|
921
|
+
so its first point is not a few days of one; the week in progress is its total
|
|
922
|
+
so far. `fromZero` starts a count's axis at zero and stops it there, with
|
|
920
923
|
no negative ticks under it. A single range draws no range buttons and shows every point.
|
|
921
924
|
`chartSteps` is exported for a chart drawn elsewhere.
|
|
922
925
|
|
|
@@ -932,7 +935,7 @@ range can make it, and `onView` hears the range and step on mount and on every
|
|
|
932
935
|
change, for a page that keeps them in its URL:
|
|
933
936
|
|
|
934
937
|
```tsx
|
|
935
|
-
<TimeChart series={days} label="Transactions" aggregate="
|
|
938
|
+
<TimeChart series={days} label="Transactions" aggregate="sum" fromZero
|
|
936
939
|
markers={[{ time: haltAt, label: 'Halt' }]} step="month" onView={v => keep(v)} />
|
|
937
940
|
```
|
|
938
941
|
`base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
|
package/dist/base.css
CHANGED
|
@@ -65,6 +65,73 @@
|
|
|
65
65
|
.page-with-infobox > .page-main-content { grid-area: content; min-width: 0; }
|
|
66
66
|
.page-with-infobox > .infobox { grid-area: infobox; min-width: 0; }
|
|
67
67
|
|
|
68
|
+
/* ---- the wiki rail (RailShell prefix="wiki-sidebar") ------------------------
|
|
69
|
+
A column beside the article from 1024px; below that, a drawer stacked above
|
|
70
|
+
it, which the same open/closed choice shows or removes. Three wikis had three
|
|
71
|
+
answers here (an overlay with no scrim at 768px, this drawer at 900px, the
|
|
72
|
+
rail moved under the article at 1024px), and an 18rem overlay on a phone hid
|
|
73
|
+
the page it was the rail of.
|
|
74
|
+
|
|
75
|
+
1024 is where the rail stops fitting, rounded up to the gutter tier: 18rem of
|
|
76
|
+
rail plus a full `--measure` of prose plus the page gutters needs 972px on
|
|
77
|
+
the widest of the three (63ch of Inter is 636px), and 1024 is the next edge
|
|
78
|
+
the gutters already step at. It is also the default `breakpoint` of
|
|
79
|
+
`SidebarProvider` and `sidebarBootScript`; pass none. `--rail-floating` below
|
|
80
|
+
is how the hook checks the two still agree.
|
|
81
|
+
|
|
82
|
+
.wiki-layout > aside.wiki-sidebar > .wiki-sidebar__scroll
|
|
83
|
+
> .wiki-layout__main
|
|
84
|
+
|
|
85
|
+
The site sets `--rail-top` (what the sticky rail clears, a header's height),
|
|
86
|
+
`--rail-border` (the rule between rail and article, which becomes the
|
|
87
|
+
drawer's foot) and the rail's surface, padding and gap. Closed is the class
|
|
88
|
+
or `html[data-sidebar='closed']`, whichever says so first: the boot script
|
|
89
|
+
writes the attribute before React can, and React writes the class. */
|
|
90
|
+
.wiki-layout { display: flex; align-items: flex-start; }
|
|
91
|
+
.wiki-layout__main { flex: 1 1 0%; min-width: 0; }
|
|
92
|
+
.wiki-sidebar {
|
|
93
|
+
position: sticky;
|
|
94
|
+
top: var(--rail-top, 0px);
|
|
95
|
+
flex-shrink: 0;
|
|
96
|
+
width: var(--sidebar-width, 18rem);
|
|
97
|
+
height: calc(100dvh - var(--rail-top, 0px));
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
border-inline-end: var(--rail-border, none);
|
|
100
|
+
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
101
|
+
}
|
|
102
|
+
/* Closed gives the column back; the scroll box keeps its width, so nothing
|
|
103
|
+
reflows on the way out. */
|
|
104
|
+
.wiki-sidebar--closed,
|
|
105
|
+
html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
|
|
106
|
+
/* Until the hook has read storage: a rail left closed is closed on first paint
|
|
107
|
+
rather than sliding shut on every load. */
|
|
108
|
+
.wiki-sidebar--instant { transition: none; }
|
|
109
|
+
.wiki-sidebar__scroll {
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
width: var(--sidebar-width, 18rem);
|
|
113
|
+
height: 100%;
|
|
114
|
+
overflow-y: auto;
|
|
115
|
+
}
|
|
116
|
+
@media (max-width: 1023px) {
|
|
117
|
+
:root { --rail-floating: 1; }
|
|
118
|
+
.wiki-layout { flex-direction: column; align-items: stretch; }
|
|
119
|
+
.wiki-sidebar {
|
|
120
|
+
position: static;
|
|
121
|
+
width: auto;
|
|
122
|
+
height: auto;
|
|
123
|
+
border-inline-end: none;
|
|
124
|
+
border-block-end: var(--rail-border, none);
|
|
125
|
+
transition: none;
|
|
126
|
+
}
|
|
127
|
+
.wiki-sidebar--closed,
|
|
128
|
+
html[data-sidebar='closed'] .wiki-sidebar { display: none; }
|
|
129
|
+
/* The cap is on the scroll box, not the rail: a percentage height inside an
|
|
130
|
+
auto-height parent is auto, so a capped rail clipped a long table of
|
|
131
|
+
contents instead of scrolling it. */
|
|
132
|
+
.wiki-sidebar__scroll { width: auto; height: auto; max-height: 70dvh; }
|
|
133
|
+
}
|
|
134
|
+
|
|
68
135
|
/* ---- the block views and the infobox ---------------------------------------
|
|
69
136
|
The structure three or four sites had each restated around this markup;
|
|
70
137
|
padding, gaps, radii and colour stay theirs. A stats block is two columns,
|
package/dist/chart.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export type ChartView = {
|
|
|
28
28
|
range: ChartRange;
|
|
29
29
|
step: ChartStep | null;
|
|
30
30
|
};
|
|
31
|
+
/** How a week or month is made from its days: a price's last, a count's total, a rate's mean. */
|
|
32
|
+
export type ChartAggregate = 'last' | 'sum' | 'mean';
|
|
31
33
|
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
32
34
|
export declare const CHART_RANGES: readonly ChartRange[];
|
|
33
35
|
/** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
|
|
@@ -51,8 +53,8 @@ export interface TimeChartProps {
|
|
|
51
53
|
ranges?: readonly ChartRange[];
|
|
52
54
|
/** The range shown first. */
|
|
53
55
|
range?: ChartRange;
|
|
54
|
-
/** A week or month of prices keeps its last; of counts, the
|
|
55
|
-
aggregate?:
|
|
56
|
+
/** A week or month of prices keeps its last; of counts, the total of its days; of rates, their mean. */
|
|
57
|
+
aggregate?: ChartAggregate;
|
|
56
58
|
/** The value axis starts at zero, so a quiet week does not read as a collapse. */
|
|
57
59
|
fromZero?: boolean;
|
|
58
60
|
/**
|
package/dist/chart.js
CHANGED
|
@@ -48,8 +48,8 @@ function bucketStart(time, step) {
|
|
|
48
48
|
/**
|
|
49
49
|
* Points, oldest first, one per step. A price keeps the last value in each
|
|
50
50
|
* step, as a candle does its close: the first open, the highest high, the
|
|
51
|
-
* lowest low. A count
|
|
52
|
-
*
|
|
51
|
+
* lowest low. A count is the total of its days; a rate, their mean. Volume is
|
|
52
|
+
* summed.
|
|
53
53
|
*
|
|
54
54
|
* @internal
|
|
55
55
|
*/
|
|
@@ -66,6 +66,8 @@ export function bucketChart(points, step, aggregate) {
|
|
|
66
66
|
}
|
|
67
67
|
if (aggregate === 'last')
|
|
68
68
|
last.value = p.value;
|
|
69
|
+
else if (aggregate === 'sum')
|
|
70
|
+
last.value += p.value;
|
|
69
71
|
else
|
|
70
72
|
last.value += (p.value - last.value) / ++n;
|
|
71
73
|
if (last.high !== undefined && p.high !== undefined)
|
|
@@ -256,7 +258,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
256
258
|
if (!ready || !c)
|
|
257
259
|
return;
|
|
258
260
|
c.api.priceScale('right').applyOptions({ scaleMargins: { top: MARGIN_TOP, bottom: fromZero ? 0 : 0.1 } });
|
|
259
|
-
c.area.applyOptions({ lineType: aggregate === '
|
|
261
|
+
c.area.applyOptions({ lineType: aggregate === 'last' ? c.straight : c.curved, autoscaleInfoProvider: fromZero ? fromZeroScale : ownScale });
|
|
260
262
|
}, [ready, fromZero, aggregate]);
|
|
261
263
|
// One request per resolution for the life of the loader: every range from 30D up reads the same days.
|
|
262
264
|
const cache = useRef(new Map());
|
|
@@ -298,7 +300,11 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
298
300
|
return drawn(windowed, [], null);
|
|
299
301
|
const { steps, fallback } = chartSteps(end - windowed[0].time);
|
|
300
302
|
const chosen = step && steps.includes(step) ? step : fallback;
|
|
301
|
-
|
|
303
|
+
if (chosen === 'day')
|
|
304
|
+
return drawn(windowed, steps, chosen);
|
|
305
|
+
// A stepped range starts on a whole week or month, so its first total is not a few days of one.
|
|
306
|
+
const from = bucketStart(windowed[0].time, chosen);
|
|
307
|
+
return drawn(bucketChart(points.filter(p => p.time >= from), chosen, aggregate), steps, chosen);
|
|
302
308
|
}, [points, range, ranges.length, resolution, step, aggregate]);
|
|
303
309
|
// While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
|
|
304
310
|
useEffect(() => {
|
package/dist/react.d.ts
CHANGED
|
@@ -423,11 +423,9 @@ export declare class ErrorBoundary extends Component<{
|
|
|
423
423
|
export interface RailShellProps {
|
|
424
424
|
children: ReactNode;
|
|
425
425
|
/**
|
|
426
|
-
* Class prefix the stylesheet implements
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
* exist, what each means, and that the attribute outranks the class — is a
|
|
430
|
-
* contract both stylesheets already spell out in near-identical comments.
|
|
426
|
+
* Class prefix the stylesheet implements. `"wiki-sidebar"` is the one
|
|
427
|
+
* `wiki-formant/base.css` lays out, column and drawer both; another prefix
|
|
428
|
+
* leaves the layout to the site's own stylesheet.
|
|
431
429
|
*/
|
|
432
430
|
prefix: string;
|
|
433
431
|
/** Accessible name. A rail with none is an unlabelled landmark. */
|
|
@@ -436,7 +434,7 @@ export interface RailShellProps {
|
|
|
436
434
|
}
|
|
437
435
|
/**
|
|
438
436
|
* The collapsible rail's outer shell: the landmark, its three states, and the
|
|
439
|
-
* scroll container, with a
|
|
437
|
+
* scroll container, with a followed link closing the rail on mobile.
|
|
440
438
|
*
|
|
441
439
|
* THE THREE STATES ARE NOT TWO. `--open` / `--closed` is the reader's choice;
|
|
442
440
|
* `--instant` suppresses the transition until that choice has been read out of
|
|
@@ -451,9 +449,14 @@ export interface RailShellProps {
|
|
|
451
449
|
* paint and the stylesheet lets that attribute win. The class is what keeps
|
|
452
450
|
* React's view in sync afterwards.
|
|
453
451
|
*
|
|
454
|
-
* Closing on navigate is scoped to mobile because that is where the rail
|
|
455
|
-
*
|
|
456
|
-
* it on every click would be a rail that folds itself away as you
|
|
452
|
+
* Closing on navigate is scoped to mobile because that is where the rail is a
|
|
453
|
+
* drawer above the article — on a desktop it is a column beside it, and
|
|
454
|
+
* collapsing it on every click would be a rail that folds itself away as you
|
|
455
|
+
* use it. Only a link closes it. Any tap used to, so a search field inside the
|
|
456
|
+
* rail shut it on focus, and a table-of-contents entry removed the drawer from
|
|
457
|
+
* above the heading it had just started scrolling to.
|
|
458
|
+
*
|
|
459
|
+
* With `prefix="wiki-sidebar"`, `wiki-formant/base.css` lays the rail out.
|
|
457
460
|
*/
|
|
458
461
|
export declare function RailShell({ children, prefix, label, className }: RailShellProps): import("react").JSX.Element;
|
|
459
462
|
/**
|
package/dist/react.js
CHANGED
|
@@ -809,7 +809,7 @@ export class ErrorBoundary extends Component {
|
|
|
809
809
|
}
|
|
810
810
|
/**
|
|
811
811
|
* The collapsible rail's outer shell: the landmark, its three states, and the
|
|
812
|
-
* scroll container, with a
|
|
812
|
+
* scroll container, with a followed link closing the rail on mobile.
|
|
813
813
|
*
|
|
814
814
|
* THE THREE STATES ARE NOT TWO. `--open` / `--closed` is the reader's choice;
|
|
815
815
|
* `--instant` suppresses the transition until that choice has been read out of
|
|
@@ -824,14 +824,19 @@ export class ErrorBoundary extends Component {
|
|
|
824
824
|
* paint and the stylesheet lets that attribute win. The class is what keeps
|
|
825
825
|
* React's view in sync afterwards.
|
|
826
826
|
*
|
|
827
|
-
* Closing on navigate is scoped to mobile because that is where the rail
|
|
828
|
-
*
|
|
829
|
-
* it on every click would be a rail that folds itself away as you
|
|
827
|
+
* Closing on navigate is scoped to mobile because that is where the rail is a
|
|
828
|
+
* drawer above the article — on a desktop it is a column beside it, and
|
|
829
|
+
* collapsing it on every click would be a rail that folds itself away as you
|
|
830
|
+
* use it. Only a link closes it. Any tap used to, so a search field inside the
|
|
831
|
+
* rail shut it on focus, and a table-of-contents entry removed the drawer from
|
|
832
|
+
* above the heading it had just started scrolling to.
|
|
833
|
+
*
|
|
834
|
+
* With `prefix="wiki-sidebar"`, `wiki-formant/base.css` lays the rail out.
|
|
830
835
|
*/
|
|
831
836
|
export function RailShell({ children, prefix, label, className }) {
|
|
832
837
|
const { open, setOpen, isMobile, ready } = useSidebar();
|
|
833
|
-
const close = useCallback(() => {
|
|
834
|
-
if (isMobile)
|
|
838
|
+
const close = useCallback((e) => {
|
|
839
|
+
if (isMobile && e.target.closest('a[href]'))
|
|
835
840
|
setOpen(false);
|
|
836
841
|
}, [isMobile, setOpen]);
|
|
837
842
|
const classes = [
|
package/dist/sidebar.js
CHANGED
|
@@ -22,9 +22,10 @@ export function resolveSidebarOpen({ chosen, current, stored, isMobile, }) {
|
|
|
22
22
|
/** The attribute the boot script and the hook both write to `<html>`. */
|
|
23
23
|
export const SIDEBAR_ATTRIBUTE = 'data-sidebar';
|
|
24
24
|
/**
|
|
25
|
-
* The custom property
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* The custom property set inside the rail's media query — `1` where the rail
|
|
26
|
+
* stops being a column beside the article, unset elsewhere. `base.css` sets it
|
|
27
|
+
* at the default breakpoint; a stylesheet that lays its own rail out sets it in
|
|
28
|
+
* its own query.
|
|
28
29
|
*
|
|
29
30
|
* The rail's breakpoint is necessarily known twice: this hook needs it in JS
|
|
30
31
|
* (to close the rail on navigate, and to default a first-ever visit), and the
|
|
@@ -33,8 +34,7 @@ export const SIDEBAR_ATTRIBUTE = 'data-sidebar';
|
|
|
33
34
|
* JS constant cannot read a media query, so the two numbers cannot be merged —
|
|
34
35
|
* but they can be made unable to disagree QUIETLY, which is the actual hazard.
|
|
35
36
|
*
|
|
36
|
-
*
|
|
37
|
-
* said so. Declaring this property is what opts a stylesheet into the check.
|
|
37
|
+
* Declaring this property is what opts a stylesheet into the check.
|
|
38
38
|
*
|
|
39
39
|
* @internal
|
|
40
40
|
*/
|
package/dist/stats-figures.d.ts
CHANGED
|
@@ -11,3 +11,17 @@ export declare function StatsFigures({ digest, first, windows }: {
|
|
|
11
11
|
first: number;
|
|
12
12
|
windows: ReactNode;
|
|
13
13
|
}): import("react").JSX.Element;
|
|
14
|
+
export interface PasskeyButtonProps {
|
|
15
|
+
/** The route that serves `gate.route`. */
|
|
16
|
+
endpoint: string;
|
|
17
|
+
/** The invite token from the link, when there is one. */
|
|
18
|
+
invite?: string | undefined;
|
|
19
|
+
className?: string;
|
|
20
|
+
errorClassName?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function PasskeyButton({ endpoint, invite, className, errorClassName }: PasskeyButtonProps): import("react").JSX.Element;
|
|
23
|
+
/** Ends the session at `endpoint` (`gate.signOut` as DELETE) and reloads the page. */
|
|
24
|
+
export declare function PasskeySignOut({ endpoint, className }: {
|
|
25
|
+
endpoint: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}): import("react").JSX.Element;
|
package/dist/stats-figures.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
// stats-figures.tsx — the stats page's headline figures and
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// stats-figures.tsx — the stats page's client half: the headline figures and
|
|
4
|
+
// the chart under them, and `StatsGate`'s passkey buttons. Each figure is a
|
|
5
|
+
// button that charts that figure by day, week or month, so the chart is not only
|
|
6
|
+
// visitors. Its own module because it holds state; `Stats` stays a server
|
|
7
|
+
// component and hands it the digest, which is plain JSON.
|
|
8
|
+
import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
|
|
7
9
|
import { useMemo, useState } from 'react';
|
|
8
10
|
import { TimeChart, stepDecimals } from './chart.js';
|
|
9
11
|
const count = (n) => n.toLocaleString('en');
|
|
@@ -34,6 +36,7 @@ const figuresOf = ({ previous: p, ...d }) => [
|
|
|
34
36
|
delta: change(d.bounce_rate, p.bounce_rate, { points: true, lowerIsBetter: true }),
|
|
35
37
|
day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
|
|
36
38
|
format: percent,
|
|
39
|
+
aggregate: 'mean',
|
|
37
40
|
},
|
|
38
41
|
{
|
|
39
42
|
label: 'Visit length',
|
|
@@ -41,6 +44,7 @@ const figuresOf = ({ previous: p, ...d }) => [
|
|
|
41
44
|
delta: change(d.visit_duration, p.visit_duration),
|
|
42
45
|
day: r => (r.visits ? r.seconds / r.visits : null),
|
|
43
46
|
format: duration,
|
|
47
|
+
aggregate: 'mean',
|
|
44
48
|
},
|
|
45
49
|
...(d.follow_clicks
|
|
46
50
|
? [
|
|
@@ -74,5 +78,39 @@ export function StatsFigures({ digest, first, windows }) {
|
|
|
74
78
|
return value == null ? null : { time, value };
|
|
75
79
|
}).filter(p => p !== null);
|
|
76
80
|
}, [byDay, first, figure]);
|
|
77
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label:
|
|
81
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: figure.label, ranges: ['all'], aggregate: figure.aggregate ?? 'sum', fromZero: true, format: figure.format, controls: windows, className: "stats-chart" })] }));
|
|
82
|
+
}
|
|
83
|
+
async function post(endpoint, body) {
|
|
84
|
+
const res = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
throw new Error((await res.json().catch(() => null))?.error ?? 'Sign-in failed.');
|
|
87
|
+
return res.status === 204 ? null : res.json();
|
|
88
|
+
}
|
|
89
|
+
export function PasskeyButton({ endpoint, invite, className, errorClassName }) {
|
|
90
|
+
const saving = !!invite;
|
|
91
|
+
const [busy, setBusy] = useState(false);
|
|
92
|
+
const [error, setError] = useState(null);
|
|
93
|
+
async function go() {
|
|
94
|
+
setBusy(true);
|
|
95
|
+
setError(null);
|
|
96
|
+
try {
|
|
97
|
+
const optionsJSON = await post(endpoint, { invite });
|
|
98
|
+
const response = saving ? await startRegistration({ optionsJSON }) : await startAuthentication({ optionsJSON });
|
|
99
|
+
await post(endpoint, { invite, response });
|
|
100
|
+
location.replace(location.pathname + location.search.replace(/([?&])invite=[^&]*&?/, '$1').replace(/[?&]$/, ''));
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
// A prompt the person closed throws NotAllowedError, which needs no message.
|
|
104
|
+
setError(e instanceof Error && e.name !== 'NotAllowedError' ? e.message : null);
|
|
105
|
+
setBusy(false);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: className, onClick: go, disabled: busy, children: saving ? 'Save a passkey' : 'Sign in with a passkey' }), error && (_jsx("p", { role: "alert", className: errorClassName, children: error }))] }));
|
|
109
|
+
}
|
|
110
|
+
/** Ends the session at `endpoint` (`gate.signOut` as DELETE) and reloads the page. */
|
|
111
|
+
export function PasskeySignOut({ endpoint, className }) {
|
|
112
|
+
return (_jsx("button", { type: "button", className: className, onClick: async () => {
|
|
113
|
+
await fetch(endpoint, { method: 'DELETE' }).catch(() => null);
|
|
114
|
+
location.reload();
|
|
115
|
+
}, children: "Sign out" }));
|
|
78
116
|
}
|
package/dist/stats.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { STATS_FILTERS } from './analytics.js';
|
|
3
|
-
import { PasskeyButton, PasskeySignOut } from './
|
|
4
|
-
import { StatsFigures } from './stats-figures.js';
|
|
3
|
+
import { PasskeyButton, PasskeySignOut, StatsFigures } from './stats-figures.js';
|
|
5
4
|
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
6
5
|
const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
|
|
7
6
|
/** A window as its `?days=` value. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -82,11 +82,6 @@
|
|
|
82
82
|
"import": "./dist/passkey.js",
|
|
83
83
|
"default": "./dist/passkey.js"
|
|
84
84
|
},
|
|
85
|
-
"./passkey-button": {
|
|
86
|
-
"types": "./dist/passkey-button.d.ts",
|
|
87
|
-
"import": "./dist/passkey-button.js",
|
|
88
|
-
"default": "./dist/passkey-button.js"
|
|
89
|
-
},
|
|
90
85
|
"./rola": {
|
|
91
86
|
"types": "./dist/rola.d.ts",
|
|
92
87
|
"import": "./dist/rola.js",
|
package/dist/passkey-button.d.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export interface PasskeyButtonProps {
|
|
2
|
-
/** The route that serves `gate.route`. */
|
|
3
|
-
endpoint: string;
|
|
4
|
-
/** The invite token from the link, when there is one. */
|
|
5
|
-
invite?: string | undefined;
|
|
6
|
-
className?: string;
|
|
7
|
-
errorClassName?: string;
|
|
8
|
-
}
|
|
9
|
-
export declare function PasskeyButton({ endpoint, invite, className, errorClassName }: PasskeyButtonProps): import("react").JSX.Element;
|
|
10
|
-
/** Ends the session at `endpoint` (`gate.signOut` as DELETE) and reloads the page. */
|
|
11
|
-
export declare function PasskeySignOut({ endpoint, className }: {
|
|
12
|
-
endpoint: string;
|
|
13
|
-
className?: string;
|
|
14
|
-
}): import("react").JSX.Element;
|
package/dist/passkey-button.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// passkey-button.tsx – the browser half of `wiki-formant/passkey`.
|
|
4
|
-
//
|
|
5
|
-
// Its own subpath because it imports `@simplewebauthn/browser`, an optional
|
|
6
|
-
// peer that only an app with a passkey door installs. With an invite it saves a
|
|
7
|
-
// new passkey; otherwise it signs in with a saved one. Either way, success
|
|
8
|
-
// reloads the page without the invite, and the server sees the new cookie.
|
|
9
|
-
// `PasskeySignOut` calls the route's DELETE (`gate.signOut`) and reloads.
|
|
10
|
-
import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
|
|
11
|
-
import { useState } from 'react';
|
|
12
|
-
async function post(endpoint, body) {
|
|
13
|
-
const res = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
14
|
-
if (!res.ok)
|
|
15
|
-
throw new Error((await res.json().catch(() => null))?.error ?? 'Sign-in failed.');
|
|
16
|
-
return res.status === 204 ? null : res.json();
|
|
17
|
-
}
|
|
18
|
-
export function PasskeyButton({ endpoint, invite, className, errorClassName }) {
|
|
19
|
-
const saving = !!invite;
|
|
20
|
-
const [busy, setBusy] = useState(false);
|
|
21
|
-
const [error, setError] = useState(null);
|
|
22
|
-
async function go() {
|
|
23
|
-
setBusy(true);
|
|
24
|
-
setError(null);
|
|
25
|
-
try {
|
|
26
|
-
const optionsJSON = await post(endpoint, { invite });
|
|
27
|
-
const response = saving ? await startRegistration({ optionsJSON }) : await startAuthentication({ optionsJSON });
|
|
28
|
-
await post(endpoint, { invite, response });
|
|
29
|
-
location.replace(location.pathname + location.search.replace(/([?&])invite=[^&]*&?/, '$1').replace(/[?&]$/, ''));
|
|
30
|
-
}
|
|
31
|
-
catch (e) {
|
|
32
|
-
// A prompt the person closed throws NotAllowedError, which needs no message.
|
|
33
|
-
setError(e instanceof Error && e.name !== 'NotAllowedError' ? e.message : null);
|
|
34
|
-
setBusy(false);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: className, onClick: go, disabled: busy, children: saving ? 'Save a passkey' : 'Sign in with a passkey' }), error && (_jsx("p", { role: "alert", className: errorClassName, children: error }))] }));
|
|
38
|
-
}
|
|
39
|
-
/** Ends the session at `endpoint` (`gate.signOut` as DELETE) and reloads the page. */
|
|
40
|
-
export function PasskeySignOut({ endpoint, className }) {
|
|
41
|
-
return (_jsx("button", { type: "button", className: className, onClick: async () => {
|
|
42
|
-
await fetch(endpoint, { method: 'DELETE' }).catch(() => null);
|
|
43
|
-
location.reload();
|
|
44
|
-
}, children: "Sign out" }));
|
|
45
|
-
}
|