wiki-formant 0.50.0 → 0.52.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 +84 -23
- package/bin/_cli.mjs +50 -0
- package/bin/analytics-digest.mjs +4 -23
- package/bin/check-pages.mjs +6 -20
- package/bin/fts-ddl.mjs +4 -19
- package/bin/indexnow.mjs +4 -6
- package/bin/maintenance-log.mjs +6 -31
- package/bin/mark-verified.mjs +5 -30
- package/bin/passkey-invite.mjs +9 -19
- package/bin/prisma-models.mjs +40 -5
- package/dist/base.css +25 -0
- package/dist/block-editor.d.ts +203 -0
- package/dist/block-editor.js +218 -0
- package/dist/block-views.js +2 -3
- package/dist/blocks.d.ts +67 -0
- package/dist/crawlers.d.ts +2 -2
- package/dist/db.d.ts +25 -0
- package/dist/db.js +32 -1
- package/dist/editor.d.ts +61 -0
- package/dist/editor.js +29 -1
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +32 -1
- package/dist/headings.d.ts +10 -0
- package/dist/headings.js +12 -0
- package/dist/history.js +1 -1
- package/dist/http.d.ts +7 -2
- package/dist/http.js +7 -2
- package/dist/indexnow.d.ts +7 -0
- package/dist/indexnow.js +22 -3
- package/dist/maintenance.js +2 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +3 -4
- package/dist/sanitize.js +1 -1
- package/dist/stats.d.ts +28 -0
- package/dist/stats.js +18 -1
- package/dist/tiptap.js +2 -4
- package/package.json +18 -83
package/dist/db.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// db.ts — the pool settings every app here hands to `pg
|
|
1
|
+
// db.ts — the pool settings every app here hands to `pg`, and the config its prisma.config.ts hands the Prisma CLI.
|
|
2
2
|
//
|
|
3
3
|
// Four Prisma client modules carried the same block, comment and all. It is a
|
|
4
4
|
// value, not a client: the app still owns `new pg.Pool(...)`, the adapter and
|
|
@@ -25,3 +25,34 @@ export function pgPoolConfig(url) {
|
|
|
25
25
|
connectionTimeoutMillis: 30_000,
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* What every app's prisma.config.ts hands `defineConfig`, from the folder the
|
|
30
|
+
* file sits in: `defineConfig(prismaConfig(import.meta.dirname))`. Four apps
|
|
31
|
+
* carried the file, three of them byte for byte.
|
|
32
|
+
*
|
|
33
|
+
* Read only by the Prisma CLI, never by the app, so the URL prefers DIRECT_URL:
|
|
34
|
+
* every CLI operation that connects (db push, migrate diff, studio) needs a
|
|
35
|
+
* session connection, and Supabase's transaction pooler on 6543, which
|
|
36
|
+
* DATABASE_URL names, hangs on them rather than failing. A placeholder stands
|
|
37
|
+
* in where neither is set, as on a Vercel build: `prisma generate` reads the
|
|
38
|
+
* schema and never connects, and anything that does connect fails loudly.
|
|
39
|
+
*
|
|
40
|
+
* `schema` is a folder of .prisma files (`prisma-models` writes beside the
|
|
41
|
+
* app's own) or a single file. Paths resolve against `root`, so the CLI works
|
|
42
|
+
* from a subdirectory.
|
|
43
|
+
*/
|
|
44
|
+
export function prismaConfig(root, schema = 'prisma/schema') {
|
|
45
|
+
try {
|
|
46
|
+
process.loadEnvFile(`${root}/.env`);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A Vercel build has no .env and needs none.
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
schema: `${root}/${schema}`,
|
|
53
|
+
migrations: { path: `${root}/prisma/migrations` },
|
|
54
|
+
datasource: {
|
|
55
|
+
url: process.env.DIRECT_URL ?? process.env.DATABASE_URL ?? 'postgresql://placeholder:placeholder@localhost:5432/placeholder',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/editor.d.ts
CHANGED
|
@@ -15,6 +15,9 @@ export type { StarterKitOptions } from '@tiptap/starter-kit';
|
|
|
15
15
|
export type { LinkOptions } from '@tiptap/extension-link';
|
|
16
16
|
export type { ImageOptions } from '@tiptap/extension-image';
|
|
17
17
|
export type { TableOptions } from '@tiptap/extension-table';
|
|
18
|
+
/** The view and the editor's type, so a consumer imports nothing from `@tiptap/*` itself. */
|
|
19
|
+
export { EditorContent } from '@tiptap/react';
|
|
20
|
+
export type { Editor } from '@tiptap/core';
|
|
18
21
|
export interface WikiEditorExtensionOptions {
|
|
19
22
|
/** Empty-document prompt. */
|
|
20
23
|
placeholder?: string;
|
|
@@ -57,6 +60,64 @@ export declare function ToolbarButton({ label, pressed, onPress, className, chil
|
|
|
57
60
|
className?: string;
|
|
58
61
|
children: ReactNode;
|
|
59
62
|
}): import("react").JSX.Element;
|
|
63
|
+
export interface WikiToolbarProps {
|
|
64
|
+
editor: Editor;
|
|
65
|
+
/** `useWikiEditor`'s `isActive`. */
|
|
66
|
+
isActive: (a?: ActiveDescriptor) => boolean;
|
|
67
|
+
/** `useUrlPrompt`'s state: the link button opens it, and shows pressed while it is open. */
|
|
68
|
+
prompt: UrlPromptState;
|
|
69
|
+
/** The formatting buttons, before Remove link, upload and embed. */
|
|
70
|
+
actions: readonly ToolbarAction[];
|
|
71
|
+
/** The insert buttons after them, such as table and tabs. */
|
|
72
|
+
trailing?: readonly ToolbarAction[];
|
|
73
|
+
/** Shows the upload button, which calls this: `useWikiEditor`'s `triggerUpload`. */
|
|
74
|
+
onUpload?: () => void;
|
|
75
|
+
/** Shows the embed button, which opens the prompt on an embed address. */
|
|
76
|
+
embed?: boolean;
|
|
77
|
+
icons: Partial<Record<ToolbarKey, ReactNode>> & {
|
|
78
|
+
unlink: ReactNode;
|
|
79
|
+
upload?: ReactNode;
|
|
80
|
+
embed?: ReactNode;
|
|
81
|
+
};
|
|
82
|
+
classNames?: {
|
|
83
|
+
toolbar?: string;
|
|
84
|
+
button?: string;
|
|
85
|
+
/** Added to a button that is on. */
|
|
86
|
+
pressed?: string;
|
|
87
|
+
divider?: string;
|
|
88
|
+
/** A table control, which is a word rather than an icon. */
|
|
89
|
+
tableButton?: string;
|
|
90
|
+
/** Added to the table controls that delete. */
|
|
91
|
+
danger?: string;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The formatting bar three wikis had each assembled from `toolbarActions`,
|
|
96
|
+
* `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
|
|
97
|
+
* a link button that needs an address and so opens the prompt, a Remove link
|
|
98
|
+
* button while the cursor is in a link, upload and embed between the format
|
|
99
|
+
* and insert groups, and the table controls while the cursor is in a table.
|
|
100
|
+
* Which buttons, which icons and which classes are the caller's.
|
|
101
|
+
*/
|
|
102
|
+
export declare function WikiToolbar({ editor, isActive, prompt, actions, trailing, onUpload, embed, icons, classNames: c }: WikiToolbarProps): import("react").JSX.Element;
|
|
103
|
+
export interface UrlPromptFieldProps {
|
|
104
|
+
prompt: UrlPromptState;
|
|
105
|
+
placeholders?: {
|
|
106
|
+
link?: string;
|
|
107
|
+
embed?: string;
|
|
108
|
+
};
|
|
109
|
+
classNames?: {
|
|
110
|
+
row?: string;
|
|
111
|
+
input?: string;
|
|
112
|
+
apply?: string;
|
|
113
|
+
cancel?: string;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The address field under a toolbar, open while `prompt.kind` is set: Enter
|
|
118
|
+
* applies, Escape closes, and an emptied link address unlinks.
|
|
119
|
+
*/
|
|
120
|
+
export declare function UrlPromptField({ prompt, placeholders, classNames: c }: UrlPromptFieldProps): import("react").JSX.Element | null;
|
|
60
121
|
export type UrlPromptKind = 'link' | 'embed';
|
|
61
122
|
export interface UrlPromptState {
|
|
62
123
|
/** The open field, or null. */
|
package/dist/editor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// editor.tsx — the wiki rich-text editor's engine, minus its chrome.
|
|
4
4
|
//
|
|
5
5
|
// `tiptap.tsx` holds the custom NODES both wikis needed (iframe, tweet, map,
|
|
@@ -32,6 +32,9 @@ import Placeholder from '@tiptap/extension-placeholder';
|
|
|
32
32
|
import { useEditor } from '@tiptap/react';
|
|
33
33
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
34
34
|
import { toMapEmbedUrl } from './maps.js';
|
|
35
|
+
import { cx } from './html.js';
|
|
36
|
+
/** The view and the editor's type, so a consumer imports nothing from `@tiptap/*` itself. */
|
|
37
|
+
export { EditorContent } from '@tiptap/react';
|
|
35
38
|
// ---- paste scrubbing --------------------------------------------------------
|
|
36
39
|
/**
|
|
37
40
|
* Reduce pasted HTML to structure plus the three attributes that carry meaning.
|
|
@@ -219,6 +222,31 @@ export function toolbarActions(keys) {
|
|
|
219
222
|
export function ToolbarButton({ label, pressed, onPress, className, children, }) {
|
|
220
223
|
return (_jsx("button", { type: "button", title: label, "aria-label": label, "aria-pressed": pressed, className: className, onClick: onPress, children: children }));
|
|
221
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* The formatting bar three wikis had each assembled from `toolbarActions`,
|
|
227
|
+
* `ToolbarButton` and `TABLE_ACTIONS`, in the same order with the same rules:
|
|
228
|
+
* a link button that needs an address and so opens the prompt, a Remove link
|
|
229
|
+
* button while the cursor is in a link, upload and embed between the format
|
|
230
|
+
* and insert groups, and the table controls while the cursor is in a table.
|
|
231
|
+
* Which buttons, which icons and which classes are the caller's.
|
|
232
|
+
*/
|
|
233
|
+
export function WikiToolbar({ editor, isActive, prompt, actions, trailing = [], onUpload, embed, icons, classNames: c = {} }) {
|
|
234
|
+
const button = ({ key, label, active, run }) => {
|
|
235
|
+
const pressed = isActive(active) || (key === 'link' && prompt.kind === 'link');
|
|
236
|
+
return (_jsx(ToolbarButton, { label: label, pressed: active ? pressed : undefined, onPress: () => (run ? run(editor) : prompt.toggle('link')), className: cx(c.button, pressed && c.pressed), children: icons[key] }, key));
|
|
237
|
+
};
|
|
238
|
+
return (_jsxs("div", { className: c.toolbar, role: "toolbar", "aria-label": "Text formatting", children: [actions.map(button), editor.isActive('link') && (_jsx(ToolbarButton, { label: "Remove link", onPress: () => editor.chain().focus().extendMarkRange('link').unsetLink().run(), className: c.button, children: icons.unlink })), onUpload && (_jsx(ToolbarButton, { label: "Upload image", onPress: onUpload, className: c.button, children: icons.upload })), embed && (_jsx(ToolbarButton, { label: "Embed", pressed: prompt.kind === 'embed', onPress: () => prompt.toggle('embed'), className: cx(c.button, prompt.kind === 'embed' && c.pressed), children: icons.embed })), trailing.map(button), editor.isActive('table') && (_jsxs(_Fragment, { children: [_jsx("span", { className: c.divider, "aria-hidden": true }), TABLE_ACTIONS.map(([command, label, danger]) => (_jsx("button", { type: "button", className: cx(c.tableButton, danger && c.danger), onClick: () => runTableAction(editor, command), children: label }, command)))] }))] }));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The address field under a toolbar, open while `prompt.kind` is set: Enter
|
|
242
|
+
* applies, Escape closes, and an emptied link address unlinks.
|
|
243
|
+
*/
|
|
244
|
+
export function UrlPromptField({ prompt, placeholders = {}, classNames: c = {} }) {
|
|
245
|
+
if (!prompt.kind)
|
|
246
|
+
return null;
|
|
247
|
+
const link = prompt.kind === 'link';
|
|
248
|
+
return (_jsxs("div", { className: c.row, children: [_jsx("input", { autoFocus: true, type: "text", inputMode: "url", autoCapitalize: "none", className: c.input, "aria-label": link ? 'Link address' : 'Embed address', placeholder: link ? (placeholders.link ?? 'https://… (empty removes the link)') : (placeholders.embed ?? 'YouTube, X, Google or Apple Maps, or any iframe URL'), value: prompt.value, onChange: e => prompt.setValue(e.target.value), onKeyDown: prompt.onKeyDown }), _jsx("button", { type: "button", className: c.apply, onClick: prompt.apply, children: link ? 'Apply' : 'Embed' }), _jsx("button", { type: "button", className: c.cancel, onClick: prompt.close, children: "Cancel" })] }));
|
|
249
|
+
}
|
|
222
250
|
/**
|
|
223
251
|
* What a link or embed field does with its address. A link sets or replaces
|
|
224
252
|
* the link the cursor is in, across the whole link rather than the collapsed
|
package/dist/headers.d.ts
CHANGED
|
@@ -25,3 +25,30 @@ export declare function contentSecurityPolicy(directives?: Partial<Record<CspDir
|
|
|
25
25
|
* sets its policy per request (a nonce) passes none.
|
|
26
26
|
*/
|
|
27
27
|
export declare function securityHeaders(csp?: string | null): HeaderEntry[];
|
|
28
|
+
/** The parts of a Next config `withWikiDefaults` fills in. */
|
|
29
|
+
interface WikiNextConfig {
|
|
30
|
+
distDir?: string;
|
|
31
|
+
experimental?: object;
|
|
32
|
+
images?: object;
|
|
33
|
+
headers?: () => Promise<unknown[]>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An app's Next config with the settings every app here wrote out the same way:
|
|
37
|
+
*
|
|
38
|
+
* - `distDir` from `STUDIO_DIST_DIR`, set only by radix-studio's renders, which
|
|
39
|
+
* boot a second dev server beside the running one, and Next allows one per
|
|
40
|
+
* distDir.
|
|
41
|
+
* - `turbopackFileSystemCacheForBuild: false`. Next 16.3 turns Turbopack's
|
|
42
|
+
* build cache on and Vercel restores it from the last deployment; caper
|
|
43
|
+
* shipped a stylesheet two commits stale that way in October 2026.
|
|
44
|
+
* - AVIF before WebP from the image optimiser.
|
|
45
|
+
* - `securityHeaders` on every route, with `csp` enforced in production only:
|
|
46
|
+
* Next's dev runtime is inline and `eval`. An origin that sets its policy
|
|
47
|
+
* per request (a nonce) passes none.
|
|
48
|
+
*
|
|
49
|
+
* Anything the app sets wins, and its own `headers()` follow these.
|
|
50
|
+
*/
|
|
51
|
+
export declare function withWikiDefaults<T extends WikiNextConfig>(config: T, { csp }?: {
|
|
52
|
+
csp?: string;
|
|
53
|
+
}): T;
|
|
54
|
+
export {};
|
package/dist/headers.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// `youtube-nocookie.com` its sanitiser kept, so those embeds survived cleaning
|
|
12
12
|
// and then rendered blank.
|
|
13
13
|
//
|
|
14
|
-
// Zero imports, so a config pays for nothing it does not use.
|
|
14
|
+
// Zero imports, so a config pays for nothing it does not use. `withWikiDefaults`
|
|
15
|
+
// is here for the same reason: next.config.ts is its only caller.
|
|
15
16
|
/**
|
|
16
17
|
* The embed hosts the editor's iframe, YouTube, tweet and map nodes produce:
|
|
17
18
|
* the sanitiser's default `iframeHosts` and the default `frame-src`, so an
|
|
@@ -78,3 +79,33 @@ export function securityHeaders(csp) {
|
|
|
78
79
|
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
|
|
79
80
|
];
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* An app's Next config with the settings every app here wrote out the same way:
|
|
84
|
+
*
|
|
85
|
+
* - `distDir` from `STUDIO_DIST_DIR`, set only by radix-studio's renders, which
|
|
86
|
+
* boot a second dev server beside the running one, and Next allows one per
|
|
87
|
+
* distDir.
|
|
88
|
+
* - `turbopackFileSystemCacheForBuild: false`. Next 16.3 turns Turbopack's
|
|
89
|
+
* build cache on and Vercel restores it from the last deployment; caper
|
|
90
|
+
* shipped a stylesheet two commits stale that way in October 2026.
|
|
91
|
+
* - AVIF before WebP from the image optimiser.
|
|
92
|
+
* - `securityHeaders` on every route, with `csp` enforced in production only:
|
|
93
|
+
* Next's dev runtime is inline and `eval`. An origin that sets its policy
|
|
94
|
+
* per request (a nonce) passes none.
|
|
95
|
+
*
|
|
96
|
+
* Anything the app sets wins, and its own `headers()` follow these.
|
|
97
|
+
*/
|
|
98
|
+
export function withWikiDefaults(config, { csp } = {}) {
|
|
99
|
+
return {
|
|
100
|
+
...(process.env.STUDIO_DIST_DIR ? { distDir: process.env.STUDIO_DIST_DIR } : {}),
|
|
101
|
+
...config,
|
|
102
|
+
experimental: { turbopackFileSystemCacheForBuild: false, ...config.experimental },
|
|
103
|
+
images: { formats: ['image/avif', 'image/webp'], ...config.images },
|
|
104
|
+
async headers() {
|
|
105
|
+
return [
|
|
106
|
+
{ source: '/:path*', headers: securityHeaders(process.env.NODE_ENV === 'production' ? csp : null) },
|
|
107
|
+
...((await config.headers?.()) ?? []),
|
|
108
|
+
];
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/dist/headings.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type NormaliseLinkOptions } from './links.js';
|
|
1
2
|
/** A heading found in a page's HTML, in document order. */
|
|
2
3
|
export interface Heading {
|
|
3
4
|
id: string;
|
|
@@ -84,3 +85,12 @@ export interface OutlineIssue {
|
|
|
84
85
|
* single `noH1` and nothing else, since every later rule would restate it.
|
|
85
86
|
*/
|
|
86
87
|
export declare function outlineIssues(html: string): OutlineIssue[];
|
|
88
|
+
/**
|
|
89
|
+
* The pass every app runs over a content block's stored HTML before it renders:
|
|
90
|
+
* heading ids, a scroll box round each table, and, given `links`, every anchor
|
|
91
|
+
* tagged internal or external. Four apps wrote the same chain in three orders.
|
|
92
|
+
* Pass one `used` set (and one `links.citedRefs`) across a page's blocks.
|
|
93
|
+
*/
|
|
94
|
+
export declare function articleHtml(html: string, { links, ...headings }?: HeadingIdOptions & {
|
|
95
|
+
links?: NormaliseLinkOptions;
|
|
96
|
+
}): string;
|
package/dist/headings.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// that with a fresh set each time, so a "Notes" in two blocks shipped two
|
|
15
15
|
// `id="notes"`. One `used` set per page is what makes the dedupe hold.
|
|
16
16
|
import { getAttr, stripTags } from './html.js';
|
|
17
|
+
import { scrollTables } from './dom.js';
|
|
18
|
+
import { normaliseLinks } from './links.js';
|
|
17
19
|
/** The default slug rule: lowercase words joined by hyphens. */
|
|
18
20
|
export function slugifyHeading(text) {
|
|
19
21
|
return text
|
|
@@ -131,3 +133,13 @@ export function outlineIssues(html) {
|
|
|
131
133
|
issues.push({ fault: 'noSubheading', level: 1 });
|
|
132
134
|
return issues;
|
|
133
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* The pass every app runs over a content block's stored HTML before it renders:
|
|
138
|
+
* heading ids, a scroll box round each table, and, given `links`, every anchor
|
|
139
|
+
* tagged internal or external. Four apps wrote the same chain in three orders.
|
|
140
|
+
* Pass one `used` set (and one `links.citedRefs`) across a page's blocks.
|
|
141
|
+
*/
|
|
142
|
+
export function articleHtml(html, { links, ...headings } = {}) {
|
|
143
|
+
const out = scrollTables(injectHeadingIds(html, headings));
|
|
144
|
+
return links ? normaliseLinks(out, links) : out;
|
|
145
|
+
}
|
package/dist/history.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// `revisions.ts` decides which bump a save asks for and stays free of
|
|
4
4
|
// dependencies, because every write path imports it. This is the reading half:
|
|
5
5
|
// the same walk, turned into what a reviewer is shown. It needs a text diff, so
|
|
6
|
-
// it needs `fast-diff
|
|
6
|
+
// it needs `fast-diff`; hence its own subpath.
|
|
7
7
|
//
|
|
8
8
|
// radix-wiki had it first and shipped both HTML bodies of every modified block
|
|
9
9
|
// to the browser to diff there: 887 KB of payload for a 13-revision page whose
|
package/dist/http.d.ts
CHANGED
|
@@ -42,8 +42,12 @@ export declare function textHeaders(etag: string, lastModified: string, maxAge?:
|
|
|
42
42
|
*
|
|
43
43
|
* `lastModified` is optional because not every corpus has a row timestamp to
|
|
44
44
|
* offer, and a twin that stamps the epoch is worse than one that stamps
|
|
45
|
-
* nothing. `extra` carries whatever the mount needs on top
|
|
46
|
-
*
|
|
45
|
+
* nothing. `extra` carries whatever the mount needs on top.
|
|
46
|
+
*
|
|
47
|
+
* `noindex` is for a twin of an HTML page: a search engine that reads the twin
|
|
48
|
+
* has the page already, and Google indexed `.md` beside the HTML until four
|
|
49
|
+
* routes each set `X-Robots-Tag` by hand. A document with no HTML page, such
|
|
50
|
+
* as AGENTS.md, leaves it off.
|
|
47
51
|
*
|
|
48
52
|
* Pass `etag`. The twin is the single most recrawled URL a page has, and a
|
|
49
53
|
* response with no validator is a full render on every pass forever — the same
|
|
@@ -55,6 +59,7 @@ export declare function textHeaders(etag: string, lastModified: string, maxAge?:
|
|
|
55
59
|
export declare function markdownHeaders(lastModified?: string | null, opts?: {
|
|
56
60
|
maxAge?: number;
|
|
57
61
|
etag?: string;
|
|
62
|
+
noindex?: boolean;
|
|
58
63
|
extra?: Record<string, string>;
|
|
59
64
|
}): Record<string, string>;
|
|
60
65
|
/**
|
package/dist/http.js
CHANGED
|
@@ -100,8 +100,12 @@ export function textHeaders(etag, lastModified, maxAge = 3600) {
|
|
|
100
100
|
*
|
|
101
101
|
* `lastModified` is optional because not every corpus has a row timestamp to
|
|
102
102
|
* offer, and a twin that stamps the epoch is worse than one that stamps
|
|
103
|
-
* nothing. `extra` carries whatever the mount needs on top
|
|
104
|
-
*
|
|
103
|
+
* nothing. `extra` carries whatever the mount needs on top.
|
|
104
|
+
*
|
|
105
|
+
* `noindex` is for a twin of an HTML page: a search engine that reads the twin
|
|
106
|
+
* has the page already, and Google indexed `.md` beside the HTML until four
|
|
107
|
+
* routes each set `X-Robots-Tag` by hand. A document with no HTML page, such
|
|
108
|
+
* as AGENTS.md, leaves it off.
|
|
105
109
|
*
|
|
106
110
|
* Pass `etag`. The twin is the single most recrawled URL a page has, and a
|
|
107
111
|
* response with no validator is a full render on every pass forever — the same
|
|
@@ -117,6 +121,7 @@ export function markdownHeaders(lastModified, opts = {}) {
|
|
|
117
121
|
'Cache-Control': `public, s-maxage=${maxAge}, stale-while-revalidate=${maxAge * 24}`,
|
|
118
122
|
...(opts.etag ? { ETag: opts.etag } : {}),
|
|
119
123
|
...(lastModified ? { 'Last-Modified': lastModified } : {}),
|
|
124
|
+
...(opts.noindex ? { 'X-Robots-Tag': 'noindex' } : {}),
|
|
120
125
|
...opts.extra,
|
|
121
126
|
};
|
|
122
127
|
}
|
package/dist/indexnow.d.ts
CHANGED
|
@@ -7,6 +7,13 @@ export interface IndexNowEntry {
|
|
|
7
7
|
}
|
|
8
8
|
/** The `<loc>` and `<lastmod>` of every `<url>` in a sitemap. */
|
|
9
9
|
export declare function sitemapEntries(xml: string): IndexNowEntry[];
|
|
10
|
+
/**
|
|
11
|
+
* Every entry of the sitemap at `url`, following a sitemap index one level down
|
|
12
|
+
* to at most 20 children. The `indexnow` and `check-pages` bins each parsed
|
|
13
|
+
* sitemaps, and only one of them read an index. Throws when `url` itself does
|
|
14
|
+
* not answer 200; a child that fails is skipped.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readSitemap(url: string, fetchImpl?: typeof fetch): Promise<IndexNowEntry[]>;
|
|
10
17
|
/**
|
|
11
18
|
* The URLs modified at or after `since`. An entry with no `lastModified` is
|
|
12
19
|
* left out: nothing says it changed, and the protocol asks for changed pages
|
package/dist/indexnow.js
CHANGED
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
// The key is not a secret. An engine verifies a submission by fetching
|
|
13
13
|
// `/<key>.txt` from the origin, so the key is public by design, and the file in
|
|
14
14
|
// each app's `public/` is the only copy.
|
|
15
|
+
import { decodeEntities } from './markdown.js';
|
|
15
16
|
export const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';
|
|
16
17
|
/** The protocol's cap on the URLs in one POST. */
|
|
17
18
|
export const INDEXNOW_BATCH = 10_000;
|
|
18
|
-
const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
19
|
-
const unescapeXml = (s) => s.trim().replace(/&(amp|lt|gt|quot|apos);/g, (_, e) => XML_ENTITIES[e]);
|
|
20
19
|
/** The `<loc>` and `<lastmod>` of every `<url>` in a sitemap. */
|
|
21
20
|
export function sitemapEntries(xml) {
|
|
22
21
|
const entries = [];
|
|
@@ -25,10 +24,30 @@ export function sitemapEntries(xml) {
|
|
|
25
24
|
if (!loc)
|
|
26
25
|
continue;
|
|
27
26
|
const lastmod = /<lastmod>([\s\S]*?)<\/lastmod>/.exec(body)?.[1];
|
|
28
|
-
|
|
27
|
+
const url = decodeEntities(loc.trim());
|
|
28
|
+
entries.push(lastmod ? { url, lastModified: lastmod.trim() } : { url });
|
|
29
29
|
}
|
|
30
30
|
return entries;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Every entry of the sitemap at `url`, following a sitemap index one level down
|
|
34
|
+
* to at most 20 children. The `indexnow` and `check-pages` bins each parsed
|
|
35
|
+
* sitemaps, and only one of them read an index. Throws when `url` itself does
|
|
36
|
+
* not answer 200; a child that fails is skipped.
|
|
37
|
+
*/
|
|
38
|
+
export async function readSitemap(url, fetchImpl = fetch) {
|
|
39
|
+
const res = await fetchImpl(url);
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error(`${url} answered ${res.status}`);
|
|
42
|
+
const xml = await res.text();
|
|
43
|
+
if (!/<sitemapindex/i.test(xml))
|
|
44
|
+
return sitemapEntries(xml);
|
|
45
|
+
const children = [...xml.matchAll(/<loc>([\s\S]*?)<\/loc>/g)].slice(0, 20).map(m => decodeEntities(m[1].trim()));
|
|
46
|
+
const entries = [];
|
|
47
|
+
for (const child of children)
|
|
48
|
+
entries.push(...(await readSitemap(child, fetchImpl).catch(() => [])));
|
|
49
|
+
return entries;
|
|
50
|
+
}
|
|
32
51
|
/**
|
|
33
52
|
* The URLs modified at or after `since`. An entry with no `lastModified` is
|
|
34
53
|
* left out: nothing says it changed, and the protocol asks for changed pages
|
package/dist/maintenance.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// which is what lets an audit spend no probes on its flag list.
|
|
18
18
|
//
|
|
19
19
|
// No subpath exports this: the bins and the tests import it from dist.
|
|
20
|
+
import { escXml } from './feed.js';
|
|
20
21
|
/** Held in state. */
|
|
21
22
|
const MAX = { backlog: 60, runs: 30, feedback: 40, sources: 150 };
|
|
22
23
|
/** Shown on the page. What a reader pays is words, and the entries run long. */
|
|
@@ -63,7 +64,7 @@ export function addBacklog(state, items, today) {
|
|
|
63
64
|
}
|
|
64
65
|
return added;
|
|
65
66
|
}
|
|
66
|
-
const esc = (s) => String(s ?? '')
|
|
67
|
+
const esc = (s) => escXml(String(s ?? ''));
|
|
67
68
|
const more = (shown, total, what) => total > shown ? `<p><em>${total - shown} older ${what} held in state.</em></p>` : '';
|
|
68
69
|
const table = (head, rows) => `<table><tbody><tr>${head.map(h => `<td><strong>${h}</strong></td>`).join('')}</tr>` +
|
|
69
70
|
(rows.join('') || `<tr>${head.map(() => '<td>–</td>').join('')}</tr>`) +
|
package/dist/react.d.ts
CHANGED
|
@@ -171,7 +171,7 @@ export interface TypeaheadState<T> {
|
|
|
171
171
|
* per mount, not module-level, so a stale result cannot outlive the page.
|
|
172
172
|
*
|
|
173
173
|
* The debounce is inlined rather than pulled from a package: this one is nine
|
|
174
|
-
* lines, and
|
|
174
|
+
* lines, and a dependency for nine lines is not worth one.
|
|
175
175
|
*/
|
|
176
176
|
export declare function useTypeahead<T>({ fetch, onPick, onEscape, delay, minLength, cache, }: TypeaheadOptions<T>): TypeaheadState<T>;
|
|
177
177
|
export interface LinkPreviewOptions<T> {
|
package/dist/react.js
CHANGED
|
@@ -3,9 +3,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
// react.tsx — the collapsible wiki sidebar, and the table of contents inside it.
|
|
4
4
|
//
|
|
5
5
|
// This is the one part of the package that needs React, so it is the one part
|
|
6
|
-
// behind its own subpath: `wiki-formant/react`. React is an
|
|
7
|
-
//
|
|
8
|
-
// still installs a package with no runtime dependencies at all.
|
|
6
|
+
// behind its own subpath: `wiki-formant/react`. React is an optional peer, so a
|
|
7
|
+
// consumer that only wants the taxonomy or the MCP transport needs no React.
|
|
9
8
|
//
|
|
10
9
|
// What is shared here is behaviour, not appearance. Every class name is passed
|
|
11
10
|
// in, because the three wikis style their rails differently and always will;
|
|
@@ -292,7 +291,7 @@ export function useClickOutside(onClose) {
|
|
|
292
291
|
* per mount, not module-level, so a stale result cannot outlive the page.
|
|
293
292
|
*
|
|
294
293
|
* The debounce is inlined rather than pulled from a package: this one is nine
|
|
295
|
-
* lines, and
|
|
294
|
+
* lines, and a dependency for nine lines is not worth one.
|
|
296
295
|
*/
|
|
297
296
|
export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength = 1, cache = true, }) {
|
|
298
297
|
const [query, setQuery] = useState('');
|
package/dist/sanitize.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Everything is an allowlist: unknown tags, unknown attributes (every on*
|
|
12
12
|
// handler among them) and non-http(s) URLs are dropped, not escaped. Run it
|
|
13
13
|
// server-side, on the render path, so it covers rows written before it existed
|
|
14
|
-
// and no sanitiser ships to the client.
|
|
14
|
+
// and no sanitiser ships to the client.
|
|
15
15
|
import sanitizeHtml from 'sanitize-html';
|
|
16
16
|
import { DEFAULT_IFRAME_HOSTS } from './headers.js';
|
|
17
17
|
// Inline SVG, presentational elements only. The infographics pipeline embeds
|
package/dist/stats.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
1
2
|
import { type Digest, type StatsFilter } from './analytics.js';
|
|
3
|
+
import type { PasskeyGate } from './passkey.js';
|
|
2
4
|
export interface StatsProps {
|
|
3
5
|
/** One window's figures, from `digest` in `wiki-formant/analytics`. */
|
|
4
6
|
digest: Digest;
|
|
@@ -26,3 +28,29 @@ export declare function statsQuery(params: Record<string, string | string[] | un
|
|
|
26
28
|
* until the importing stylesheet gives them a colour.
|
|
27
29
|
*/
|
|
28
30
|
export declare function Stats({ digest: d, days, filter, ranges }: StatsProps): import("react").JSX.Element;
|
|
31
|
+
export interface StatsGateProps {
|
|
32
|
+
gate: PasskeyGate;
|
|
33
|
+
/** The gate's cookie from the request: `(await cookies()).get(gate.cookie)?.value`. */
|
|
34
|
+
session: string | undefined;
|
|
35
|
+
/** `?invite=` from the link, when there is one. */
|
|
36
|
+
invite?: string | undefined;
|
|
37
|
+
/** The site's host, named in the prompt. */
|
|
38
|
+
host: string;
|
|
39
|
+
/** The route that serves `gate.route`. */
|
|
40
|
+
endpoint?: string;
|
|
41
|
+
/** What a signed-in reader sees, rendered only for one. */
|
|
42
|
+
children: () => ReactNode | Promise<ReactNode>;
|
|
43
|
+
classNames?: {
|
|
44
|
+
text?: string;
|
|
45
|
+
actions?: string;
|
|
46
|
+
button?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A stats page's door: `children` for a signed-in reader, else a line saying
|
|
52
|
+
* what the passkey prompt will do and the button that does it. With an invite
|
|
53
|
+
* it saves a passkey, as it does while the site has none; otherwise it signs
|
|
54
|
+
* in. Three apps wrote this branch and its three sentences.
|
|
55
|
+
*/
|
|
56
|
+
export declare function StatsGate({ gate, session, invite, host, endpoint, children, classNames: c }: StatsGateProps): Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").JSX.Element | null | undefined>;
|
package/dist/stats.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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 } from './passkey-button.js';
|
|
3
4
|
import { StatsFigures } from './stats-figures.js';
|
|
4
5
|
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
5
6
|
const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
|
|
@@ -74,3 +75,19 @@ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
|
|
|
74
75
|
const windows = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: href(n, filter), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }));
|
|
75
76
|
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: href(days, { ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, windows: windows }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => href(days, { ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
76
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* A stats page's door: `children` for a signed-in reader, else a line saying
|
|
80
|
+
* what the passkey prompt will do and the button that does it. With an invite
|
|
81
|
+
* it saves a passkey, as it does while the site has none; otherwise it signs
|
|
82
|
+
* in. Three apps wrote this branch and its three sentences.
|
|
83
|
+
*/
|
|
84
|
+
export async function StatsGate({ gate, session, invite, host, endpoint = '/api/passkey', children, classNames: c = {} }) {
|
|
85
|
+
if (!invite && (await gate.signedIn(session)))
|
|
86
|
+
return children();
|
|
87
|
+
const open = !invite && (await gate.open());
|
|
88
|
+
return (_jsxs(_Fragment, { children: [_jsx("p", { className: c.text, children: invite
|
|
89
|
+
? `Save a passkey on this device to open ${host}'s stats.`
|
|
90
|
+
: open
|
|
91
|
+
? `No passkey is saved for ${host} yet. Save the first one here; any after that needs an invite.`
|
|
92
|
+
: 'Sign in with a passkey saved for this site.' }), _jsx("div", { className: c.actions, children: _jsx(PasskeyButton, { endpoint: endpoint, invite: invite, create: open, className: c.button, errorClassName: c.error }) })] }));
|
|
93
|
+
}
|
package/dist/tiptap.js
CHANGED
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// tiptap.tsx — the custom editor nodes the wikis share.
|
|
4
4
|
//
|
|
5
|
-
// Behind its own subpath
|
|
6
|
-
// `@tiptap/*` packages
|
|
7
|
-
// wants the taxonomy or the MCP transport still installs a package with no
|
|
8
|
-
// runtime dependencies at all.
|
|
5
|
+
// Behind its own subpath so a module that only wants the taxonomy or the MCP
|
|
6
|
+
// transport never loads the `@tiptap/*` packages.
|
|
9
7
|
//
|
|
10
8
|
// What is shared is the node schema and its behaviour, never appearance. Class
|
|
11
9
|
// names and icons are injected, which is what lets one wiki keep `text-jupiter`
|