wiki-formant 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -225,6 +225,17 @@ export async function GET(request: Request) {
225
225
 
226
226
  `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.
227
227
 
228
+ The rail's breakpoint is necessarily known twice — `matchMedia` here, a media query in your stylesheet — because a media query cannot read a JS constant and a JS constant cannot read a media query. What the two can be stopped from doing is parting in silence, which is the failure that actually happens: all three wikis matched the numbers by hand, all three were right, and nothing said so. Set `--rail-floating: 1` inside the same media query that lays the rail out, and the hook checks the two agree on every breakpoint crossing, in development, naming both sides and the number to change:
229
+
230
+ ```css
231
+ @media (max-width: 767px) { /* the edge of breakpoint={768} */
232
+ :root { --rail-floating: 1; }
233
+ .sidebar { position: fixed; /* … however this wiki floats its rail */ }
234
+ }
235
+ ```
236
+
237
+ Declaring the property is what opts a stylesheet in; without it 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.
238
+
228
239
  `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:
229
240
 
230
241
  ```ts
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // bin/check-pages.mjs — what a crawler reads on a rendered page, checked over a
3
+ // whole site instead of one URL at a time.
4
+ //
5
+ // Four defects were found by hand across the three wikis in one September 2026
6
+ // sweep, and each had been live for months because nothing looked at a rendered
7
+ // page as a document. radix.wiki/contents shipped an h1 and no other heading at
8
+ // all, its category names drawn as bare links. acuiq.com's home page went h1,
9
+ // h2, h4, skipping a level. Twenty-seven radix.wiki titles ran past the point a
10
+ // result truncates them, twenty-six of which no audit had counted because the
11
+ // crawl that found the first one reached a quarter of the site. caper.network
12
+ // served every wiki article with no site name in its title, and one route with
13
+ // the title "Wiki" and nothing else.
14
+ //
15
+ // None of these are visible in source review, and all four are one regex over
16
+ // rendered HTML. The rules themselves live in the package next to the things
17
+ // they are about — `outlineIssues` beside the heading injector, `TITLE_BUDGET`
18
+ // beside `pageMetadata` — so an app can apply them at render time too. This
19
+ // only crawls, and reports.
20
+ //
21
+ // npx check-pages --site https://radix.wiki # exit 1 on any fault
22
+ // npx check-pages --site http://localhost:3000 # against a dev server
23
+ // npx check-pages --site … --limit 200 # default 100
24
+ // npx check-pages --site … --warn # report and exit 0
25
+ // npx check-pages --site … --json # machine-readable
26
+ //
27
+ // URLs come from /sitemap.xml, so a route the sitemap omits is not checked —
28
+ // which is the right default, since an unlisted route is one nobody asked a
29
+ // crawler to read.
30
+ import { outlineIssues } from '../dist/headings.js';
31
+ import { TITLE_BUDGET, TITLE_LIMIT } from '../dist/metadata.js';
32
+
33
+ const arg = name => {
34
+ const i = process.argv.indexOf(name);
35
+ return i >= 0 ? process.argv[i + 1] : undefined;
36
+ };
37
+ const has = name => process.argv.includes(name);
38
+
39
+ const site = (arg('--site') || '').replace(/\/+$/, '');
40
+ const limit = Number(arg('--limit') || 100);
41
+ const WARN_ONLY = has('--warn');
42
+ const JSON_OUT = has('--json');
43
+
44
+ if (!site) {
45
+ console.error('check-pages: --site <origin> is required, e.g. --site https://radix.wiki');
46
+ process.exit(2);
47
+ }
48
+
49
+ const UA = 'wiki-formant/check-pages';
50
+ const get = async url => {
51
+ const res = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml' } });
52
+ return { status: res.status, body: res.ok ? await res.text() : '' };
53
+ };
54
+
55
+ // The sitemap is the URL list, and a sitemap index is one more fetch deep.
56
+ async function sitemapUrls(origin) {
57
+ const seen = [];
58
+ const read = async url => {
59
+ const { status, body } = await get(url);
60
+ if (status !== 200) return;
61
+ const locs = [...body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map(m => m[1]);
62
+ if (/<sitemapindex/i.test(body)) {
63
+ for (const child of locs.slice(0, 20)) await read(child);
64
+ } else {
65
+ seen.push(...locs);
66
+ }
67
+ };
68
+ await read(`${origin}/sitemap.xml`);
69
+ // A sitemap states absolute production URLs by definition, so pointing
70
+ // --site at a dev server would otherwise crawl production and report the
71
+ // build you were trying to replace. The path is what the sitemap contributes;
72
+ // the origin is what you asked for.
73
+ return seen.map(u => {
74
+ try {
75
+ const { pathname, search } = new URL(u);
76
+ return `${origin}${pathname}${search}`;
77
+ } catch {
78
+ return u;
79
+ }
80
+ });
81
+ }
82
+
83
+ const titleOf = html => {
84
+ const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
85
+ return m ? m[1].replace(/\s+/g, ' ').trim() : '';
86
+ };
87
+ const descriptionOf = html => {
88
+ const m = html.match(/<meta[^>]+name=["']description["'][^>]*>/i);
89
+ return m ? (m[0].match(/content=["']([\s\S]*?)["']/i)?.[1] ?? '').trim() : null;
90
+ };
91
+ const decode = s =>
92
+ s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'");
93
+
94
+ const urls = await sitemapUrls(site);
95
+ if (!urls.length) {
96
+ console.log(`check-pages: no URLs in ${site}/sitemap.xml — nothing to check. Skipping.`);
97
+ process.exit(0);
98
+ }
99
+
100
+ const checked = urls.slice(0, limit);
101
+ const faults = [];
102
+ const titles = new Map();
103
+
104
+ for (const url of checked) {
105
+ let page;
106
+ try {
107
+ page = await get(url);
108
+ } catch (e) {
109
+ faults.push({ url, fault: 'unreachable', detail: e.message });
110
+ continue;
111
+ }
112
+ if (page.status !== 200) {
113
+ // A sitemap is a set of promises about what exists; a broken one is the
114
+ // loudest fault here, so it is reported even though it is not about markup.
115
+ faults.push({ url, fault: 'sitemapDead', detail: String(page.status) });
116
+ continue;
117
+ }
118
+
119
+ for (const issue of outlineIssues(page.body)) {
120
+ faults.push({
121
+ url,
122
+ fault: issue.fault,
123
+ detail: issue.fault === 'skippedLevel'
124
+ ? `h${issue.after} → h${issue.level} at "${issue.text?.slice(0, 40)}"`
125
+ : issue.text?.slice(0, 40) ?? '',
126
+ });
127
+ }
128
+
129
+ const title = decode(titleOf(page.body));
130
+ if (!title) faults.push({ url, fault: 'noTitle', detail: '' });
131
+ else {
132
+ if (title.length > TITLE_LIMIT) faults.push({ url, fault: 'titleTooLong', detail: `${title.length} chars: ${title}` });
133
+ else if (title.length > TITLE_BUDGET) faults.push({ url, fault: 'titleTight', detail: `${title.length} chars: ${title}` });
134
+ const sharing = titles.get(title);
135
+ if (sharing) sharing.push(url);
136
+ else titles.set(title, [url]);
137
+ }
138
+
139
+ if (descriptionOf(page.body) === null) faults.push({ url, fault: 'noDescription', detail: '' });
140
+ }
141
+
142
+ // Reported once per title rather than once per page, or a shared title on forty
143
+ // routes drowns everything else.
144
+ for (const [title, sharing] of titles) {
145
+ if (sharing.length > 1) {
146
+ faults.push({ url: sharing[0], fault: 'duplicateTitle', detail: `${sharing.length} pages share "${title}"` });
147
+ }
148
+ }
149
+
150
+ // Two faults are reported and never set the exit code. `noSubheading`: a page
151
+ // that is one h1 and one table — a leaderboard, a token list — has no second
152
+ // section to name, and inventing an h2 to satisfy a checker is the noise this
153
+ // is supposed to remove; it is still reported, because the same shape on a
154
+ // category index meant 190 words of links with no outline at all.
155
+ // `titleTight`: between TITLE_BUDGET and TITLE_LIMIT what a result drops is
156
+ // usually the site name, which is a judgement rather than a defect.
157
+ const ADVISORY = new Set(['noSubheading', 'titleTight']);
158
+ const failing = faults.filter(f => !ADVISORY.has(f.fault));
159
+
160
+ if (JSON_OUT) {
161
+ console.log(JSON.stringify({ site, checked: checked.length, of: urls.length, failing: failing.length, faults }, null, 2));
162
+ } else {
163
+ const byFault = new Map();
164
+ for (const f of faults) byFault.set(f.fault, [...(byFault.get(f.fault) ?? []), f]);
165
+ console.log(`check-pages: ${checked.length} of ${urls.length} sitemap URLs on ${site}\n`);
166
+ if (!faults.length) console.log(' clean — one h1 per page, no skipped levels, every title inside the budget.');
167
+ else if (!failing.length) console.log(' no failures; everything below is advisory.\n');
168
+ for (const [fault, list] of [...byFault].sort((a, b) => b[1].length - a[1].length)) {
169
+ console.log(` ${fault} (${list.length})${ADVISORY.has(fault) ? ' — advisory, does not fail' : ''}`);
170
+ for (const f of list.slice(0, 12)) console.log(` ${f.url.replace(site, '') || '/'}${f.detail ? ` ${f.detail}` : ''}`);
171
+ if (list.length > 12) console.log(` … and ${list.length - 12} more`);
172
+ console.log('');
173
+ }
174
+ }
175
+
176
+ process.exit(failing.length && !WARN_ONLY ? 1 : 0);
@@ -53,3 +53,42 @@ export declare function injectHeadingIds(html: string, options?: HeadingIdOption
53
53
  * consumers use only the injector above.
54
54
  */
55
55
  export declare function headingsFrom(html: string): Heading[];
56
+ /**
57
+ * Every heading in `html`, in document order, whether or not it carries an id —
58
+ * the outline a crawler reads. `headingsFrom` above answers a different
59
+ * question: which headings a rail can link to, which is why it drops the ones
60
+ * with no id. An audit must not, because a chrome heading with no id still
61
+ * takes a level in the outline, and the levels are the thing under test.
62
+ */
63
+ export declare function headingOutline(html: string): Heading[];
64
+ /**
65
+ * What is wrong with a page's heading outline.
66
+ *
67
+ * `noH1` and `multipleH1` are unambiguous. `skippedLevel` means a heading sits
68
+ * more than one level below the one before it — an h2 followed by an h4, which
69
+ * is what acuiq.com's home page shipped. `noSubheading` means the page has an
70
+ * h1 and nothing under it at all, which is what radix.wiki's /contents shipped:
71
+ * a category index whose section names were rendered as bare links, leaving a
72
+ * document with no outline for a reader or a parser to move through.
73
+ *
74
+ * What none of these catch is a heading at the right level under the wrong
75
+ * parent. radix.wiki's /ecosystem listed 140 projects as h3 beneath the single
76
+ * h2 of an unrelated prose section; every level was legal and the nesting was a
77
+ * lie. That one needs a person.
78
+ */
79
+ export type OutlineFault = 'noH1' | 'multipleH1' | 'skippedLevel' | 'noSubheading';
80
+ export interface OutlineIssue {
81
+ fault: OutlineFault;
82
+ /** The offending heading's level, where the fault names one. */
83
+ level?: number;
84
+ /** The level it followed, for `skippedLevel`. */
85
+ after?: number;
86
+ /** Its text, trimmed, for `skippedLevel` and `multipleH1`. */
87
+ text?: string;
88
+ }
89
+ /**
90
+ * `html` is a whole rendered page, not a content fragment: the outline a
91
+ * crawler sees includes the chrome. A page with no headings at all returns a
92
+ * single `noH1` and nothing else, since every later rule would restate it.
93
+ */
94
+ export declare function outlineIssues(html: string): OutlineIssue[];
package/dist/headings.js CHANGED
@@ -86,3 +86,46 @@ export function headingsFrom(html) {
86
86
  }
87
87
  return out;
88
88
  }
89
+ /**
90
+ * Every heading in `html`, in document order, whether or not it carries an id —
91
+ * the outline a crawler reads. `headingsFrom` above answers a different
92
+ * question: which headings a rail can link to, which is why it drops the ones
93
+ * with no id. An audit must not, because a chrome heading with no id still
94
+ * takes a level in the outline, and the levels are the thing under test.
95
+ */
96
+ export function headingOutline(html) {
97
+ const out = [];
98
+ for (const [, tag, attrs, content] of html.matchAll(HEADING)) {
99
+ const text = stripTags(content ?? '');
100
+ if (text)
101
+ out.push({ id: getAttr(attrs ?? '', 'id') ?? '', text, level: Number(tag[1]) });
102
+ }
103
+ return out;
104
+ }
105
+ /**
106
+ * `html` is a whole rendered page, not a content fragment: the outline a
107
+ * crawler sees includes the chrome. A page with no headings at all returns a
108
+ * single `noH1` and nothing else, since every later rule would restate it.
109
+ */
110
+ export function outlineIssues(html) {
111
+ const headings = headingOutline(html);
112
+ const issues = [];
113
+ const h1s = headings.filter(h => h.level === 1);
114
+ if (h1s.length === 0)
115
+ issues.push({ fault: 'noH1' });
116
+ else
117
+ for (const extra of h1s.slice(1))
118
+ issues.push({ fault: 'multipleH1', level: 1, text: extra.text });
119
+ if (!headings.length)
120
+ return issues;
121
+ let previous = 0;
122
+ for (const h of headings) {
123
+ if (previous && h.level > previous + 1) {
124
+ issues.push({ fault: 'skippedLevel', level: h.level, after: previous, text: h.text });
125
+ }
126
+ previous = h.level;
127
+ }
128
+ if (h1s.length === 1 && headings.length === 1)
129
+ issues.push({ fault: 'noSubheading', level: 1 });
130
+ return issues;
131
+ }
package/dist/http.d.ts CHANGED
@@ -99,7 +99,17 @@ export declare function descriptorResponse(request: Request, body: unknown, opts
99
99
  maxAge?: number;
100
100
  extra?: Record<string, string>;
101
101
  }): Response;
102
- /** Strip URLs and collapse whitespace so an excerpt stays one readable line. */
102
+ /**
103
+ * Strip URLs and collapse whitespace so an excerpt stays one readable line.
104
+ *
105
+ * A markdown link whose text is kept and whose target is not leaves the target
106
+ * behind in parentheses, and only the absolute form was being removed: an
107
+ * excerpt opening "[Decentralized science (DeSci)](/wiki/desci/what-is-desci)
108
+ * runs into…" reached a meta description as "Decentralized science (DeSci)
109
+ * (/wiki/desci/what-is-desci) runs into…". Root-relative targets and bare
110
+ * anchors go the same way as absolute ones. The leading character is what
111
+ * separates a target from ordinary parenthetical prose, so "(DeSci)" survives.
112
+ */
103
113
  export declare function cleanSnippet(text: string, max?: number): string;
104
114
  /** One markdown bullet: linked title, excerpt, and the date an agent diffs on. */
105
115
  export declare function pageLine(opts: {
package/dist/http.js CHANGED
@@ -164,10 +164,21 @@ export function descriptorResponse(request, body, opts = {}) {
164
164
  const sent = descriptorHeaders(etag, opts);
165
165
  return notModified(request, etag, null, sent) ?? new Response(text, { headers: sent });
166
166
  }
167
- /** Strip URLs and collapse whitespace so an excerpt stays one readable line. */
167
+ /**
168
+ * Strip URLs and collapse whitespace so an excerpt stays one readable line.
169
+ *
170
+ * A markdown link whose text is kept and whose target is not leaves the target
171
+ * behind in parentheses, and only the absolute form was being removed: an
172
+ * excerpt opening "[Decentralized science (DeSci)](/wiki/desci/what-is-desci)
173
+ * runs into…" reached a meta description as "Decentralized science (DeSci)
174
+ * (/wiki/desci/what-is-desci) runs into…". Root-relative targets and bare
175
+ * anchors go the same way as absolute ones. The leading character is what
176
+ * separates a target from ordinary parenthetical prose, so "(DeSci)" survives.
177
+ */
168
178
  export function cleanSnippet(text, max = 160) {
169
179
  return text
170
- .replace(/\(https?:\/\/[^)]*\)/g, '')
180
+ .replace(/\((?:https?:\/\/|mailto:)[^)]*\)/g, '')
181
+ .replace(/\((?:\/|#)[^)\s]*\)/g, '')
171
182
  .replace(/https?:\/\/\S+/g, '')
172
183
  .replace(/\(\s*\)/g, '')
173
184
  .replace(/\s{2,}/g, ' ')
@@ -121,3 +121,57 @@ export declare function collectionLd(o: CollectionLdOptions): LdNode;
121
121
  * an authored `&amp;` is not what a crawler reads.
122
122
  */
123
123
  export declare function citationsFromReferences(items: readonly ReferenceItem[], max?: number): LdNode[];
124
+ /**
125
+ * Characters a document `<title>` has before a search result truncates it.
126
+ *
127
+ * Google measures pixels, not characters, so this is the round number that
128
+ * approximates ~580px of the default result font and is what every audit in
129
+ * these repos has counted against. The budget covers the WHOLE rendered title,
130
+ * template included: a wiki spending 13 of it on " | RADIX Wiki" has 47 left,
131
+ * which is the number that matters to whoever writes the title.
132
+ */
133
+ export declare const TITLE_BUDGET = 60;
134
+ /**
135
+ * Past this, a title is not merely trimmed but cut into.
136
+ *
137
+ * Between the budget and this limit a title loses its tail, which is often the
138
+ * site name and no loss at all. Past it, the words carrying what the page is
139
+ * about are going too: the radix.wiki ideas board spent its first sixteen
140
+ * characters on a working group and reached 93, and the symptom pages on
141
+ * acuiq.com reached 88 before naming the symptom. That is the line worth
142
+ * failing a build over; the band below it is worth reporting and no more.
143
+ */
144
+ export declare const TITLE_LIMIT = 70;
145
+ /**
146
+ * The document `<title>` for a page: its short form where the page carries one,
147
+ * else its own title.
148
+ *
149
+ * A wiki title is written for the H1 and the listing card, where a prefix that
150
+ * groups the page earns its space — "Governance WG · ", "Radix Week in Review: ".
151
+ * In a search result the same prefix spends the budget before the topic arrives,
152
+ * so a page may store a short form under `key` and the document title takes it.
153
+ * Nothing else does: the H1, the listing card, the markdown twin and the social
154
+ * card all keep the page's own title, and `pageMetadata` above is deliberately
155
+ * not wired to this, because a social card is not length-constrained the same
156
+ * way and the full title reads better on one.
157
+ */
158
+ export declare function documentTitle(title: string, metadata: unknown, key?: string): string;
159
+ /**
160
+ * The longest of `variants` whose rendered length fits, or the shortest one
161
+ * when none of them do.
162
+ *
163
+ * `documentTitle` above is for a page whose short form someone wrote down. A
164
+ * generated page has no one to write it: acuiq.com composes a point's title
165
+ * from its code, its pinyin and its English name, which is 37 characters for
166
+ * LI04 Hegu and 148 for a point carrying three names and three pinyin
167
+ * readings. A single template cannot serve both, and truncating mid-word
168
+ * serves neither. Ordering the forms from fullest to barest and taking the
169
+ * first that fits keeps the English name on the pages where a result would
170
+ * show it and drops it only where it would have been cut off anyway.
171
+ *
172
+ * `suffix` is the template's, counted but not returned - Next applies it.
173
+ */
174
+ export declare function fittingTitle(variants: readonly string[], opts?: {
175
+ suffix?: string;
176
+ limit?: number;
177
+ }): string;
package/dist/metadata.js CHANGED
@@ -107,3 +107,63 @@ export function citationsFromReferences(items, max = 50) {
107
107
  })
108
108
  .slice(0, max);
109
109
  }
110
+ /**
111
+ * Characters a document `<title>` has before a search result truncates it.
112
+ *
113
+ * Google measures pixels, not characters, so this is the round number that
114
+ * approximates ~580px of the default result font and is what every audit in
115
+ * these repos has counted against. The budget covers the WHOLE rendered title,
116
+ * template included: a wiki spending 13 of it on " | RADIX Wiki" has 47 left,
117
+ * which is the number that matters to whoever writes the title.
118
+ */
119
+ export const TITLE_BUDGET = 60;
120
+ /**
121
+ * Past this, a title is not merely trimmed but cut into.
122
+ *
123
+ * Between the budget and this limit a title loses its tail, which is often the
124
+ * site name and no loss at all. Past it, the words carrying what the page is
125
+ * about are going too: the radix.wiki ideas board spent its first sixteen
126
+ * characters on a working group and reached 93, and the symptom pages on
127
+ * acuiq.com reached 88 before naming the symptom. That is the line worth
128
+ * failing a build over; the band below it is worth reporting and no more.
129
+ */
130
+ export const TITLE_LIMIT = 70;
131
+ /**
132
+ * The document `<title>` for a page: its short form where the page carries one,
133
+ * else its own title.
134
+ *
135
+ * A wiki title is written for the H1 and the listing card, where a prefix that
136
+ * groups the page earns its space — "Governance WG · ", "Radix Week in Review: ".
137
+ * In a search result the same prefix spends the budget before the topic arrives,
138
+ * so a page may store a short form under `key` and the document title takes it.
139
+ * Nothing else does: the H1, the listing card, the markdown twin and the social
140
+ * card all keep the page's own title, and `pageMetadata` above is deliberately
141
+ * not wired to this, because a social card is not length-constrained the same
142
+ * way and the full title reads better on one.
143
+ */
144
+ export function documentTitle(title, metadata, key = 'seoTitle') {
145
+ const short = metadata?.[key];
146
+ return typeof short === 'string' && short.trim() ? short : title;
147
+ }
148
+ /**
149
+ * The longest of `variants` whose rendered length fits, or the shortest one
150
+ * when none of them do.
151
+ *
152
+ * `documentTitle` above is for a page whose short form someone wrote down. A
153
+ * generated page has no one to write it: acuiq.com composes a point's title
154
+ * from its code, its pinyin and its English name, which is 37 characters for
155
+ * LI04 Hegu and 148 for a point carrying three names and three pinyin
156
+ * readings. A single template cannot serve both, and truncating mid-word
157
+ * serves neither. Ordering the forms from fullest to barest and taking the
158
+ * first that fits keeps the English name on the pages where a result would
159
+ * show it and drops it only where it would have been cut off anyway.
160
+ *
161
+ * `suffix` is the template's, counted but not returned - Next applies it.
162
+ */
163
+ export function fittingTitle(variants, opts = {}) {
164
+ const { suffix = '', limit = TITLE_LIMIT } = opts;
165
+ const usable = variants.map(v => v.trim()).filter(Boolean);
166
+ if (!usable.length)
167
+ return '';
168
+ return usable.find(v => v.length + suffix.length <= limit) ?? usable[usable.length - 1];
169
+ }
package/dist/react.js CHANGED
@@ -26,7 +26,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
26
26
  // `sidebarBootScript`, a blocking inline script that stamps the remembered
27
27
  // state on <html> before first paint. The hook keeps that attribute in
28
28
  // sync afterwards, so CSS has one source of truth either side of hydration.
29
- import { resolveSidebarOpen, SIDEBAR_ATTRIBUTE } from './sidebar.js';
29
+ import { railBreakpointMismatch, readRailFloating, resolveSidebarOpen, SIDEBAR_ATTRIBUTE, } from './sidebar.js';
30
30
  import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables } from './dom.js';
31
31
  import { comboboxAria } from './combobox.js';
32
32
  import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
@@ -65,6 +65,19 @@ function useCollapsibleSidebar(options = {}) {
65
65
  setIsMobile(matches);
66
66
  // The breakpoint supplies a default, never an override. This is bug 1.
67
67
  setOpenState(current => resolveSidebarOpen({ chosen: chosen.current, current, stored, isMobile: matches }));
68
+ // The same number lives in the stylesheet, and must. Say so out loud the
69
+ // moment the two part, rather than leaving it to whoever next opens the
70
+ // rail on a phone. Development only: this is a wiring mistake, caught
71
+ // once at the desk, not a condition to watch for in production.
72
+ if (process.env.NODE_ENV !== 'production') {
73
+ const complaint = railBreakpointMismatch({
74
+ isMobile: matches,
75
+ floating: readRailFloating(),
76
+ breakpoint,
77
+ });
78
+ if (complaint)
79
+ console.error(complaint);
80
+ }
68
81
  };
69
82
  apply(mql.matches);
70
83
  setReady(true);
package/dist/sidebar.d.ts CHANGED
@@ -20,6 +20,42 @@ export declare function resolveSidebarOpen({ chosen, current, stored, isMobile,
20
20
  }): boolean;
21
21
  /** The attribute the boot script and the hook both write to `<html>`. */
22
22
  export declare const SIDEBAR_ATTRIBUTE = "data-sidebar";
23
+ /**
24
+ * The custom property a consumer's stylesheet sets inside its own rail media
25
+ * query — `1` where the rail stops being a column beside the article, `0`
26
+ * elsewhere.
27
+ *
28
+ * The rail's breakpoint is necessarily known twice: this hook needs it in JS
29
+ * (to close the rail on navigate, and to default a first-ever visit), and the
30
+ * stylesheet needs it in CSS (to lay the rail out, before any script runs and
31
+ * whether or not one ever does). A media query cannot read a JS constant and a
32
+ * JS constant cannot read a media query, so the two numbers cannot be merged —
33
+ * but they can be made unable to disagree QUIETLY, which is the actual hazard.
34
+ *
35
+ * All three wikis matched by hand and all three happened to be right; nothing
36
+ * said so. Declaring this property is what opts a stylesheet into the check.
37
+ */
38
+ export declare const RAIL_FLOATING_PROPERTY = "--rail-floating";
39
+ /**
40
+ * What the stylesheet currently says about the rail: `true` inside the repo's
41
+ * rail media query, `false` outside it, `null` when the property is not
42
+ * declared — a consumer that has not opted in, or a render with no DOM.
43
+ */
44
+ export declare function readRailFloating(): boolean | null;
45
+ /**
46
+ * The complaint to make when CSS and JS disagree about where the rail floats,
47
+ * or `null` when they agree or the stylesheet has not opted in.
48
+ *
49
+ * Pure, and separate from the reading, so the rule is testable without a DOM.
50
+ */
51
+ export declare function railBreakpointMismatch({ isMobile, floating, breakpoint, }: {
52
+ /** What `matchMedia` told the hook. */
53
+ isMobile: boolean;
54
+ /** What the stylesheet says, from `readRailFloating()`. */
55
+ floating: boolean | null;
56
+ /** The breakpoint the hook was given, for naming the number in the message. */
57
+ breakpoint: number;
58
+ }): string | null;
23
59
  /**
24
60
  * A blocking inline script for the document head, so the rail's first paint
25
61
  * already matches what the reader last chose.
package/dist/sidebar.js CHANGED
@@ -21,6 +21,60 @@ export function resolveSidebarOpen({ chosen, current, stored, isMobile, }) {
21
21
  }
22
22
  /** The attribute the boot script and the hook both write to `<html>`. */
23
23
  export const SIDEBAR_ATTRIBUTE = 'data-sidebar';
24
+ /**
25
+ * The custom property a consumer's stylesheet sets inside its own rail media
26
+ * query — `1` where the rail stops being a column beside the article, `0`
27
+ * elsewhere.
28
+ *
29
+ * The rail's breakpoint is necessarily known twice: this hook needs it in JS
30
+ * (to close the rail on navigate, and to default a first-ever visit), and the
31
+ * stylesheet needs it in CSS (to lay the rail out, before any script runs and
32
+ * whether or not one ever does). A media query cannot read a JS constant and a
33
+ * JS constant cannot read a media query, so the two numbers cannot be merged —
34
+ * but they can be made unable to disagree QUIETLY, which is the actual hazard.
35
+ *
36
+ * All three wikis matched by hand and all three happened to be right; nothing
37
+ * said so. Declaring this property is what opts a stylesheet into the check.
38
+ */
39
+ export const RAIL_FLOATING_PROPERTY = '--rail-floating';
40
+ /**
41
+ * What the stylesheet currently says about the rail: `true` inside the repo's
42
+ * rail media query, `false` outside it, `null` when the property is not
43
+ * declared — a consumer that has not opted in, or a render with no DOM.
44
+ */
45
+ export function readRailFloating() {
46
+ if (typeof document === 'undefined')
47
+ return null;
48
+ try {
49
+ const value = getComputedStyle(document.documentElement)
50
+ .getPropertyValue(RAIL_FLOATING_PROPERTY)
51
+ .trim();
52
+ return value === '' ? null : value === '1';
53
+ }
54
+ catch {
55
+ // No CSSOM (jsdom without styles, a blocked stylesheet). Not knowing is not
56
+ // a mismatch, and a diagnostic must never be the thing that breaks a page.
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * The complaint to make when CSS and JS disagree about where the rail floats,
62
+ * or `null` when they agree or the stylesheet has not opted in.
63
+ *
64
+ * Pure, and separate from the reading, so the rule is testable without a DOM.
65
+ */
66
+ export function railBreakpointMismatch({ isMobile, floating, breakpoint, }) {
67
+ if (floating === null || floating === isMobile)
68
+ return null;
69
+ return (`wiki-formant: the rail's breakpoint disagrees between CSS and JS. ` +
70
+ `This hook was given breakpoint=${breakpoint}, so it believes the rail ` +
71
+ `${isMobile ? 'should float' : 'should be a column'} at this width, ` +
72
+ `while ${RAIL_FLOATING_PROPERTY} says it ` +
73
+ `${floating ? 'should float' : 'should be a column'}. ` +
74
+ `Set ${RAIL_FLOATING_PROPERTY}: 1 inside the same media query that lays the ` +
75
+ `rail out, and give that query the edge of breakpoint=${breakpoint} ` +
76
+ `(max-width: ${breakpoint - 1}px).`);
77
+ }
24
78
  /**
25
79
  * A blocking inline script for the document head, so the rail's first paint
26
80
  * already matches what the reader last chose.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.23.1",
3
+ "version": "0.25.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, and conditional-GET plumbing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -177,7 +177,8 @@
177
177
  }
178
178
  },
179
179
  "bin": {
180
- "check-classes": "./bin/check-classes.mjs"
180
+ "check-classes": "./bin/check-classes.mjs",
181
+ "check-pages": "./bin/check-pages.mjs"
181
182
  },
182
183
  "files": [
183
184
  "dist",