wiki-formant 0.23.0 → 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 +11 -0
- package/bin/check-pages.mjs +176 -0
- package/dist/headings.d.ts +39 -0
- package/dist/headings.js +43 -0
- package/dist/http.d.ts +11 -1
- package/dist/http.js +13 -2
- package/dist/metadata.d.ts +54 -0
- package/dist/metadata.js +60 -0
- package/dist/react.d.ts +7 -0
- package/dist/react.js +42 -3
- package/dist/sidebar.d.ts +36 -0
- package/dist/sidebar.js +54 -0
- package/package.json +6 -3
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/�?39;|'/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);
|
package/dist/headings.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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, ' ')
|
package/dist/metadata.d.ts
CHANGED
|
@@ -121,3 +121,57 @@ export declare function collectionLd(o: CollectionLdOptions): LdNode;
|
|
|
121
121
|
* an authored `&` 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.d.ts
CHANGED
|
@@ -423,6 +423,13 @@ export declare function RailShell({ children, prefix, label, className }: RailSh
|
|
|
423
423
|
* matched on the PATH ONLY, deliberately: `?edit=` and `?history=` are modes a
|
|
424
424
|
* page enters, not destinations a rail selects, and reading them would drag
|
|
425
425
|
* `useSearchParams` into a statically-rendered wiki.
|
|
426
|
+
*
|
|
427
|
+
* The path is decoded first. Next serves a catch-all route's prerender with the
|
|
428
|
+
* joined segments percent-encoded — `/wiki/tech%2Fcore-concepts` for
|
|
429
|
+
* `/wiki/tech/core-concepts` — so an encoded path matches neither its own href
|
|
430
|
+
* nor its section's prefix, and React does not patch a className on hydration.
|
|
431
|
+
* Both wikis were shipping every page below the top level with nothing in the
|
|
432
|
+
* rail marked, and it only came right if you arrived by client navigation.
|
|
426
433
|
*/
|
|
427
434
|
export declare function isRailLinkActive(activePath: string, href: string, tree?: boolean): boolean;
|
|
428
435
|
export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
|
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);
|
|
@@ -137,8 +150,15 @@ export function TableOfContents({ containerSelector, headings: providedHeadings,
|
|
|
137
150
|
// article per mutation is what makes a rail janky.
|
|
138
151
|
timer = setTimeout(() => {
|
|
139
152
|
const root = document.querySelector(containerSelector);
|
|
140
|
-
|
|
153
|
+
// No container is an answer, not a reason to skip: a rail that bailed
|
|
154
|
+
// here kept the LAST page's headings, so a section index reached from
|
|
155
|
+
// one of its own articles listed that article's sections under "on this
|
|
156
|
+
// page". It only looked right when a loading skeleton happened to render
|
|
157
|
+
// the container and scan to nothing.
|
|
158
|
+
if (!root) {
|
|
159
|
+
setScanned(prev => (prev.length ? [] : prev));
|
|
141
160
|
return;
|
|
161
|
+
}
|
|
142
162
|
const used = new Set();
|
|
143
163
|
const next = [];
|
|
144
164
|
for (const el of Array.from(root.querySelectorAll('h1, h2, h3'))) {
|
|
@@ -747,10 +767,29 @@ export function RailShell({ children, prefix, label, className }) {
|
|
|
747
767
|
* matched on the PATH ONLY, deliberately: `?edit=` and `?history=` are modes a
|
|
748
768
|
* page enters, not destinations a rail selects, and reading them would drag
|
|
749
769
|
* `useSearchParams` into a statically-rendered wiki.
|
|
770
|
+
*
|
|
771
|
+
* The path is decoded first. Next serves a catch-all route's prerender with the
|
|
772
|
+
* joined segments percent-encoded — `/wiki/tech%2Fcore-concepts` for
|
|
773
|
+
* `/wiki/tech/core-concepts` — so an encoded path matches neither its own href
|
|
774
|
+
* nor its section's prefix, and React does not patch a className on hydration.
|
|
775
|
+
* Both wikis were shipping every page below the top level with nothing in the
|
|
776
|
+
* rail marked, and it only came right if you arrived by client navigation.
|
|
750
777
|
*/
|
|
751
778
|
export function isRailLinkActive(activePath, href, tree = false) {
|
|
752
|
-
|
|
779
|
+
const path = decodePath(activePath);
|
|
780
|
+
return tree ? path === href || path.startsWith(`${href}/`) : path === href;
|
|
753
781
|
}
|
|
782
|
+
/** `decodeURIComponent` throws on a stray `%`; a path it cannot read is its own answer. */
|
|
783
|
+
const decodePath = (path) => {
|
|
784
|
+
if (!path.includes('%'))
|
|
785
|
+
return path;
|
|
786
|
+
try {
|
|
787
|
+
return decodeURIComponent(path);
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return path;
|
|
791
|
+
}
|
|
792
|
+
};
|
|
754
793
|
// ---- rendered-article passes --------------------------------------------------
|
|
755
794
|
/**
|
|
756
795
|
* The `wiki-formant/dom` passes over a rendered article: tab groups, copy
|
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.
|
|
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",
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
"markdown",
|
|
19
19
|
"agent"
|
|
20
20
|
],
|
|
21
|
-
"sideEffects": [
|
|
21
|
+
"sideEffects": [
|
|
22
|
+
"*.css"
|
|
23
|
+
],
|
|
22
24
|
"exports": {
|
|
23
25
|
".": {
|
|
24
26
|
"types": "./dist/index.d.ts",
|
|
@@ -175,7 +177,8 @@
|
|
|
175
177
|
}
|
|
176
178
|
},
|
|
177
179
|
"bin": {
|
|
178
|
-
"check-classes": "./bin/check-classes.mjs"
|
|
180
|
+
"check-classes": "./bin/check-classes.mjs",
|
|
181
|
+
"check-pages": "./bin/check-pages.mjs"
|
|
179
182
|
},
|
|
180
183
|
"files": [
|
|
181
184
|
"dist",
|