seemore 1.0.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/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/cli/index.js +1635 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/package.json +98 -0
- package/src/app/entry.client.tsx +30 -0
- package/src/app/entry.prerender.tsx +67 -0
- package/src/app/features/anchors.ts +23 -0
- package/src/app/features/highlight.ts +77 -0
- package/src/app/features/prefetch.ts +55 -0
- package/src/app/features/preview.tsx +73 -0
- package/src/app/index.html +12 -0
- package/src/app/layout/Breadcrumb.tsx +29 -0
- package/src/app/layout/DocsLayout.tsx +103 -0
- package/src/app/layout/Footer.tsx +72 -0
- package/src/app/layout/Header.tsx +59 -0
- package/src/app/layout/Provider.tsx +60 -0
- package/src/app/layout/Sidebar.tsx +90 -0
- package/src/app/layout/Toc.tsx +59 -0
- package/src/app/lib/features.ts +7 -0
- package/src/app/lib/pages.ts +81 -0
- package/src/app/lib/tree.ts +41 -0
- package/src/app/mdx/Mermaid.tsx +57 -0
- package/src/app/mdx/Pdf.tsx +25 -0
- package/src/app/mdx/components.tsx +57 -0
- package/src/app/router.tsx +43 -0
- package/src/app/search/SearchDialog.tsx +103 -0
- package/src/app/search/client.ts +143 -0
- package/src/app/search/worker.ts +42 -0
- package/src/app/styles/globals.css +224 -0
- package/src/app/virtual.d.ts +17 -0
- package/src/shared/base.ts +67 -0
- package/src/shared/og.ts +12 -0
- package/src/shared/types.ts +77 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { staticClient } from 'fumadocs-core/search/client/orama-static';
|
|
2
|
+
import type { SortedResult } from 'fumadocs-core/search';
|
|
3
|
+
import type { ClientSearchConfig } from '../../shared/types.js';
|
|
4
|
+
|
|
5
|
+
export interface SearchClientLike {
|
|
6
|
+
search: (query: string) => Promise<SortedResult[]>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Build the client the configured provider calls for. */
|
|
10
|
+
export function createSearchClient(config: ClientSearchConfig): SearchClientLike {
|
|
11
|
+
if (config.provider === 'static') return createStaticClient(config.from);
|
|
12
|
+
if (config.provider === 'algolia') return createAlgoliaClient(config);
|
|
13
|
+
return createOramaCloudClient(config);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The static index is parsed and queried off the main thread, falling back to the main
|
|
18
|
+
* thread where workers are unavailable.
|
|
19
|
+
*
|
|
20
|
+
* On any real corpus, parsing the index on the main thread is a visible stall — MkDocs
|
|
21
|
+
* Material moved theirs into a worker for the same reason.
|
|
22
|
+
*/
|
|
23
|
+
function createStaticClient(from: string): SearchClientLike {
|
|
24
|
+
const onMainThread = (): SearchClientLike => {
|
|
25
|
+
const direct = staticClient({ from });
|
|
26
|
+
return { search: async (query) => await direct.search(query) };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (typeof Worker === 'undefined') return onMainThread();
|
|
30
|
+
|
|
31
|
+
let worker: Worker;
|
|
32
|
+
try {
|
|
33
|
+
worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
|
|
34
|
+
} catch {
|
|
35
|
+
return onMainThread();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const pending = new Map<number, { resolve: (r: SortedResult[]) => void; reject: (e: Error) => void }>();
|
|
39
|
+
let nextId = 0;
|
|
40
|
+
let fallback: SearchClientLike | undefined;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A worker that fails to start must not take search down with it. Anything the worker
|
|
44
|
+
* cannot do, the main thread does — more slowly, and still correctly.
|
|
45
|
+
*/
|
|
46
|
+
const degrade = (): SearchClientLike => {
|
|
47
|
+
fallback ??= onMainThread();
|
|
48
|
+
for (const [id, handlers] of pending) {
|
|
49
|
+
pending.delete(id);
|
|
50
|
+
handlers.reject(new Error('seemore: the search worker stopped; retrying on the main thread.'));
|
|
51
|
+
}
|
|
52
|
+
return fallback;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
worker.addEventListener('error', () => {
|
|
56
|
+
degrade();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
worker.addEventListener('message', (event: MessageEvent) => {
|
|
60
|
+
const data = event.data as { type: string; id?: number; results?: SortedResult[]; message?: string };
|
|
61
|
+
if (data.id === undefined) return;
|
|
62
|
+
const handlers = pending.get(data.id);
|
|
63
|
+
if (handlers === undefined) return;
|
|
64
|
+
pending.delete(data.id);
|
|
65
|
+
if (data.type === 'result') handlers.resolve(data.results ?? []);
|
|
66
|
+
else handlers.reject(new Error(data.message ?? 'Search failed.'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
worker.postMessage({ type: 'init', from });
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
async search(query) {
|
|
73
|
+
if (fallback !== undefined) return await fallback.search(query);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
return await new Promise<SortedResult[]>((resolve, reject) => {
|
|
77
|
+
const id = nextId++;
|
|
78
|
+
pending.set(id, { resolve, reject });
|
|
79
|
+
worker.postMessage({ type: 'query', id, query });
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
return await degrade().search(query);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The hosted providers are the escape hatch for a corpus whose static index has grown too
|
|
90
|
+
* large to download. Their SDKs are optional peers of fumadocs, so they are imported only
|
|
91
|
+
* when configured — and a missing one raises rather than returning nothing, because a search
|
|
92
|
+
* box that silently finds no results is the worst of the available failures.
|
|
93
|
+
*/
|
|
94
|
+
function createAlgoliaClient(config: Extract<ClientSearchConfig, { provider: 'algolia' }>): SearchClientLike {
|
|
95
|
+
const ready = (async () => {
|
|
96
|
+
const [lite, { algoliaClient }] = await Promise.all([
|
|
97
|
+
importOrExplain<AlgoliaLite>('algoliasearch/lite', 'algoliasearch'),
|
|
98
|
+
import('fumadocs-core/search/client/algolia'),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
return algoliaClient({
|
|
102
|
+
client: lite.liteClient(config.appId, config.apiKey),
|
|
103
|
+
indexName: config.indexName,
|
|
104
|
+
});
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
return { search: async (query) => await (await ready).search(query) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createOramaCloudClient(config: Extract<ClientSearchConfig, { provider: 'orama-cloud' }>): SearchClientLike {
|
|
111
|
+
const ready = (async () => {
|
|
112
|
+
const [orama, { oramaCloudClient }] = await Promise.all([
|
|
113
|
+
importOrExplain<OramaCore>('@orama/core', '@orama/core'),
|
|
114
|
+
import('fumadocs-core/search/client/orama-cloud'),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
return oramaCloudClient({
|
|
118
|
+
client: new orama.OramaCloud({ projectId: config.endpoint, apiKey: config.apiKey }),
|
|
119
|
+
});
|
|
120
|
+
})();
|
|
121
|
+
|
|
122
|
+
return { search: async (query) => await (await ready).search(query) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Structural shapes for the two optional peers: they are not installed, so their own types
|
|
126
|
+
// are not available to reference.
|
|
127
|
+
interface AlgoliaLite {
|
|
128
|
+
liteClient: (appId: string, apiKey: string) => never;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface OramaCore {
|
|
132
|
+
OramaCloud: new (options: { projectId: string; apiKey: string }) => never;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function importOrExplain<T>(specifier: string, packageName: string): Promise<T> {
|
|
136
|
+
try {
|
|
137
|
+
return (await import(/* @vite-ignore */ specifier)) as T;
|
|
138
|
+
} catch (cause) {
|
|
139
|
+
throw new Error(`Search is configured to use ${packageName}, which is not installed. Run \`npm install ${packageName}\`.`, {
|
|
140
|
+
cause,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
import { staticClient } from 'fumadocs-core/search/client/orama-static';
|
|
3
|
+
import type { SortedResult } from 'fumadocs-core/search';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The static index is parsed and queried off the main thread.
|
|
7
|
+
*
|
|
8
|
+
* On any real corpus, parsing the index on the main thread is a visible stall — MkDocs
|
|
9
|
+
* Material moved theirs into a worker for the same reason.
|
|
10
|
+
*/
|
|
11
|
+
type Incoming = { type: 'init'; from: string } | { type: 'query'; id: number; query: string };
|
|
12
|
+
type Outgoing =
|
|
13
|
+
| { type: 'ready' }
|
|
14
|
+
| { type: 'result'; id: number; results: SortedResult[] }
|
|
15
|
+
| { type: 'error'; id: number; message: string };
|
|
16
|
+
|
|
17
|
+
let client: ReturnType<typeof staticClient> | undefined;
|
|
18
|
+
|
|
19
|
+
const post = (message: Outgoing) => {
|
|
20
|
+
(self as unknown as DedicatedWorkerGlobalScope).postMessage(message);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
self.onmessage = async (event: MessageEvent<Incoming>) => {
|
|
24
|
+
const data = event.data;
|
|
25
|
+
|
|
26
|
+
if (data.type === 'init') {
|
|
27
|
+
client = staticClient({ from: data.from });
|
|
28
|
+
post({ type: 'ready' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (client === undefined) {
|
|
33
|
+
post({ type: 'error', id: data.id, message: 'Search worker received a query before it was initialised.' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
post({ type: 'result', id: data.id, results: await client.search(data.query) });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
post({ type: 'error', id: data.id, message: error instanceof Error ? error.message : String(error) });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
|
|
3
|
+
/* seemore:imports — the configured fumadocs theme preset and the paths Tailwind must scan
|
|
4
|
+
are inserted here by the seemore Vite plugin. They belong above `preset.css`, and above
|
|
5
|
+
every rule, because `@import` is only valid before the first style rule. */
|
|
6
|
+
|
|
7
|
+
@import 'fumadocs-ui/css/preset.css';
|
|
8
|
+
|
|
9
|
+
@layer base {
|
|
10
|
+
body {
|
|
11
|
+
@apply flex min-h-screen flex-col bg-fd-background text-fd-foreground;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@layer components {
|
|
16
|
+
.seemore-shell {
|
|
17
|
+
@apply flex min-h-screen flex-col;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.seemore-header {
|
|
21
|
+
@apply sticky top-0 z-50 flex items-center gap-2 border-b border-fd-border bg-fd-background/80 px-4 py-3 backdrop-blur md:gap-4;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.seemore-brand {
|
|
25
|
+
@apply truncate;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.seemore-sidebar-trigger {
|
|
29
|
+
@apply shrink-0 md:hidden;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.seemore-brand {
|
|
33
|
+
@apply text-base font-semibold;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.seemore-nav {
|
|
37
|
+
@apply flex items-center gap-3 text-sm text-fd-muted-foreground;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.seemore-search-trigger {
|
|
41
|
+
@apply ms-auto inline-flex shrink-0 items-center gap-2 rounded-lg border border-fd-border p-2 text-sm text-fd-muted-foreground sm:px-3 sm:py-1.5;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Icon only where there is no room for the label. */
|
|
45
|
+
.seemore-search-trigger span,
|
|
46
|
+
.seemore-search-trigger kbd {
|
|
47
|
+
@apply hidden sm:inline;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.seemore-search-trigger kbd {
|
|
51
|
+
@apply rounded border border-fd-border px-1 text-xs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.seemore-theme-toggle {
|
|
55
|
+
@apply shrink-0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.seemore-search-error {
|
|
59
|
+
@apply px-4 py-6 text-sm text-fd-muted-foreground;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.seemore-search-suggestion {
|
|
63
|
+
@apply px-4 pb-2 text-sm text-fd-muted-foreground;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.seemore-search-suggestion span {
|
|
67
|
+
@apply opacity-60;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.seemore-theme-toggle {
|
|
71
|
+
@apply rounded-lg border border-fd-border p-2;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.seemore-icon-dark {
|
|
75
|
+
@apply hidden dark:block;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.seemore-icon-light {
|
|
79
|
+
@apply block dark:hidden;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.seemore-body {
|
|
83
|
+
@apply mx-auto flex w-full max-w-7xl flex-1 gap-8 px-4;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.seemore-sidebar {
|
|
87
|
+
@apply h-full;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.seemore-sidebar-link {
|
|
91
|
+
@apply flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-fd-muted-foreground transition-colors;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.seemore-sidebar-link:hover {
|
|
95
|
+
@apply bg-fd-accent text-fd-accent-foreground;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.seemore-sidebar-link[data-active='true'] {
|
|
99
|
+
@apply bg-fd-primary/10 font-medium text-fd-primary;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.seemore-sidebar-folder-label {
|
|
103
|
+
@apply text-fd-foreground;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* The rule the nesting reads from: one guide line per level, indented from its parent. */
|
|
107
|
+
.seemore-sidebar-folder-content {
|
|
108
|
+
@apply ms-4 flex flex-col border-s border-fd-border ps-1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.seemore-sidebar-folder {
|
|
112
|
+
@apply flex flex-col;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.seemore-sidebar-separator {
|
|
116
|
+
@apply mt-4 mb-1 px-3 text-xs font-medium text-fd-muted-foreground uppercase;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* A sticky column on wide screens; a drawer below `md`, driven by the sidebar state. */
|
|
120
|
+
.seemore-sidebar-column {
|
|
121
|
+
/* `pt-20` clears the sticky header, which sits above the drawer. */
|
|
122
|
+
@apply fixed inset-y-0 start-0 z-40 w-72 -translate-x-full overflow-y-auto border-e border-fd-border bg-fd-background px-4 pt-20 pb-4 transition-transform rtl:translate-x-full;
|
|
123
|
+
@apply md:sticky md:top-16 md:z-auto md:h-[calc(100vh-4rem)] md:w-64 md:translate-x-0 md:border-0 md:bg-transparent md:px-0 md:py-6 rtl:md:translate-x-0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.seemore-sidebar-column[data-open='true'] {
|
|
127
|
+
@apply translate-x-0 rtl:translate-x-0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.seemore-sidebar-backdrop {
|
|
131
|
+
@apply fixed inset-0 z-30 bg-black/40 md:hidden;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.seemore-main {
|
|
135
|
+
@apply min-w-0 flex-1 py-8;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.seemore-toc {
|
|
139
|
+
@apply sticky top-16 hidden h-[calc(100vh-4rem)] w-56 shrink-0 py-8 text-sm lg:block;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.seemore-toc-title {
|
|
143
|
+
@apply mb-2 font-medium text-fd-muted-foreground;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.seemore-toc-integrated {
|
|
147
|
+
@apply mt-6 border-t border-fd-border pt-4 text-sm;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.seemore-breadcrumb {
|
|
151
|
+
@apply mb-4 flex flex-wrap items-center gap-2 text-sm text-fd-muted-foreground;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.seemore-page-footer {
|
|
155
|
+
@apply mt-10 grid grid-cols-2 gap-4 border-t border-fd-border pt-6 text-sm;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.seemore-next {
|
|
159
|
+
@apply flex items-center justify-end gap-2 text-end;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.seemore-prev {
|
|
163
|
+
@apply flex items-center gap-2;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.seemore-edit-link {
|
|
167
|
+
@apply mt-8 inline-flex items-center gap-2 text-sm text-fd-muted-foreground;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.seemore-site-footer {
|
|
171
|
+
@apply mt-10 flex flex-wrap gap-4 border-t border-fd-border pt-6 text-sm text-fd-muted-foreground;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.seemore-back-to-top {
|
|
175
|
+
@apply fixed bottom-6 end-6 inline-flex items-center gap-2 rounded-full border border-fd-border bg-fd-background px-4 py-2 text-sm shadow;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.seemore-preview {
|
|
179
|
+
@apply pointer-events-none fixed z-50 max-h-80 w-[400px] overflow-hidden rounded-xl border border-fd-border bg-fd-popover p-4 text-sm shadow-lg;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.seemore-preview-title {
|
|
183
|
+
@apply mb-2 font-medium;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.seemore-preview-body {
|
|
187
|
+
@apply line-clamp-6 opacity-80;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.seemore-loading {
|
|
191
|
+
@apply min-h-[50vh];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.seemore-broken-wikilink {
|
|
195
|
+
@apply cursor-help border-b border-dotted border-fd-muted-foreground text-fd-muted-foreground;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.seemore-mermaid {
|
|
199
|
+
@apply my-6 flex justify-center;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.seemore-mermaid-source {
|
|
203
|
+
@apply w-full overflow-x-auto text-xs opacity-60;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.seemore-mermaid-error {
|
|
207
|
+
@apply my-6 rounded-lg border border-fd-border p-4 text-sm text-fd-muted-foreground;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.seemore-pdf {
|
|
211
|
+
@apply my-6 block;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.seemore-pdf embed {
|
|
215
|
+
@apply block h-[70vh] w-full rounded-lg border border-fd-border;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.seemore-pdf a {
|
|
219
|
+
@apply mt-2 block text-sm text-fd-muted-foreground;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* seemore:user-css — the stylesheet named by `css` in seemore.config.ts is inlined here, at
|
|
224
|
+
the very end, so that it wins against everything above it. */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Ambient declarations for the modules the seemore Vite plugin generates.
|
|
2
|
+
// Types are referenced with `import(...)` because relative imports are not allowed inside an
|
|
3
|
+
// ambient module declaration.
|
|
4
|
+
|
|
5
|
+
declare module 'virtual:seemore/tree' {
|
|
6
|
+
export function getTree(): import('fumadocs-core/source/client').SerializedPageTree;
|
|
7
|
+
export function subscribeTree(listener: () => void): () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare module 'virtual:seemore/routes' {
|
|
11
|
+
export function getRoutes(): import('../shared/types.js').RouteEntry[];
|
|
12
|
+
export function subscribeRoutes(listener: () => void): () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare module 'virtual:seemore/config' {
|
|
16
|
+
export const config: import('../shared/types.js').ClientConfig;
|
|
17
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base-path handling.
|
|
3
|
+
*
|
|
4
|
+
* Internally a base is always normalised to leading + trailing slash (`/sub/`), because a
|
|
5
|
+
* single canonical shape is what makes the "no absolute-root URL leaks" test possible. The
|
|
6
|
+
* trailing slash is stripped again only at the point of output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
10
|
+
|
|
11
|
+
/** `undefined` | `sub` | `/sub` | `/sub/` → `/sub/`. The root base is `/`. */
|
|
12
|
+
export function normaliseBase(base: string | undefined): string {
|
|
13
|
+
if (base === undefined || base === '') return '/';
|
|
14
|
+
if (EXTERNAL.test(base)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Invalid \`base\`: ${JSON.stringify(base)}. \`base\` is a path on the host, not a URL — use "/${base.replace(/^.*?:\/\/[^/]*/, '').replace(/^\/+/, '')}".`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const trimmed = base.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
20
|
+
return trimmed === '' ? '/' : `/${trimmed}/`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True for hrefs that a base must never touch: external, protocol-relative, hash, or relative. */
|
|
24
|
+
export function isExternalHref(href: string): boolean {
|
|
25
|
+
return EXTERNAL.test(href) || href.startsWith('#') || !href.startsWith('/');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Prefix a root-relative path with the base. Idempotent; leaves external hrefs alone. */
|
|
29
|
+
export function withBase(base: string, href: string): string {
|
|
30
|
+
const b = normaliseBase(base);
|
|
31
|
+
if (b === '/' || isExternalHref(href)) return href;
|
|
32
|
+
if (href === '/') return b;
|
|
33
|
+
if (href === b.slice(0, -1) || href.startsWith(b)) return href;
|
|
34
|
+
return b + href.replace(/^\/+/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Inverse of {@link withBase}: turn a browser pathname back into an internal route URL. */
|
|
38
|
+
export function stripBase(base: string, pathname: string): string {
|
|
39
|
+
const b = normaliseBase(base);
|
|
40
|
+
if (b === '/') return pathname;
|
|
41
|
+
if (pathname === b || pathname === b.slice(0, -1)) return '/';
|
|
42
|
+
if (!pathname.startsWith(b)) return pathname;
|
|
43
|
+
return `/${pathname.slice(b.length)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The form React Router wants for `basename`: leading slash, no trailing slash, `/` at root. */
|
|
47
|
+
export function toBasename(base: string): string {
|
|
48
|
+
const b = normaliseBase(base);
|
|
49
|
+
return b === '/' ? '/' : b.slice(0, -1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Browser pathnames are percent-encoded; route URLs are not.
|
|
54
|
+
*
|
|
55
|
+
* `/guía/página-uno` arrives from `location.pathname` as `/gu%C3%ADa/p%C3%A1gina-uno`, and a
|
|
56
|
+
* lookup against the route map misses — so a correctly prerendered page hydrates into "Page
|
|
57
|
+
* not found". `decodeURI`, not `decodeURIComponent`: a literal `%2F` in a filename must stay
|
|
58
|
+
* encoded or it would split into two path segments.
|
|
59
|
+
*/
|
|
60
|
+
export function decodePath(pathname: string): string {
|
|
61
|
+
try {
|
|
62
|
+
return decodeURI(pathname);
|
|
63
|
+
} catch {
|
|
64
|
+
// Malformed escapes are the browser's problem, not ours; match on what we were given.
|
|
65
|
+
return pathname;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/shared/og.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a page's social card lives.
|
|
3
|
+
*
|
|
4
|
+
* Shared, because the build writes the file and the prerendered `<head>` points at it — and
|
|
5
|
+
* a card nothing references is a card nobody sees.
|
|
6
|
+
*/
|
|
7
|
+
export function ogImagePath(url: string): string {
|
|
8
|
+
const clean = url.replace(/^\/+|\/+$/g, '');
|
|
9
|
+
// A path per route, rather than a flattened filename: `/a/b` and `/a-b` are different
|
|
10
|
+
// routes and must not write to the same file.
|
|
11
|
+
return clean === '' ? '/api/og/card.png' : `/api/og/${clean}/card.png`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Types shared by the node pipeline and the browser app. This file ships as source, next to
|
|
5
|
+
* `src/app`, so both halves agree on the shape of the virtual modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const FEATURES = [
|
|
9
|
+
'navigation.instant.prefetch',
|
|
10
|
+
'navigation.instant.preview',
|
|
11
|
+
'navigation.footer',
|
|
12
|
+
'navigation.top',
|
|
13
|
+
'navigation.path',
|
|
14
|
+
'navigation.sections',
|
|
15
|
+
'navigation.prune',
|
|
16
|
+
'toc.follow',
|
|
17
|
+
'toc.integrate',
|
|
18
|
+
'content.code.copy',
|
|
19
|
+
'content.action.edit',
|
|
20
|
+
'search.suggest',
|
|
21
|
+
'search.highlight',
|
|
22
|
+
'social.cards',
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type Feature = (typeof FEATURES)[number];
|
|
26
|
+
/** What a user may write in `features`: a flag, or `!flag` to switch a default-on flag off. */
|
|
27
|
+
export type FeatureFlag = Feature | `!${Feature}`;
|
|
28
|
+
export type ResolvedFeatures = Record<Feature, boolean>;
|
|
29
|
+
|
|
30
|
+
export interface NavItem {
|
|
31
|
+
text: string;
|
|
32
|
+
link?: string;
|
|
33
|
+
items?: NavItem[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ClientSearchConfig =
|
|
37
|
+
| { provider: 'static'; from: string }
|
|
38
|
+
| { provider: 'orama-cloud'; endpoint: string; apiKey: string }
|
|
39
|
+
| { provider: 'algolia'; appId: string; apiKey: string; indexName: string };
|
|
40
|
+
|
|
41
|
+
/** The payload of `virtual:seemore/config`. */
|
|
42
|
+
export interface ClientConfig {
|
|
43
|
+
title: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
base: string;
|
|
46
|
+
theme: string;
|
|
47
|
+
features: ResolvedFeatures;
|
|
48
|
+
nav?: NavItem[];
|
|
49
|
+
footer?: { text?: string; links?: { text: string; link: string }[] };
|
|
50
|
+
editLink?: { base: string; text: string };
|
|
51
|
+
favicon?: string;
|
|
52
|
+
search: ClientSearchConfig;
|
|
53
|
+
contentRoot: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One entry of `virtual:seemore/routes`. */
|
|
57
|
+
export interface RouteEntry {
|
|
58
|
+
url: string;
|
|
59
|
+
/** Virtual path relative to the content root — what an edit link points at. */
|
|
60
|
+
file: string;
|
|
61
|
+
absPath: string;
|
|
62
|
+
title: string;
|
|
63
|
+
description: string | null;
|
|
64
|
+
load: () => Promise<PageModule>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TocEntry {
|
|
68
|
+
title: ReactNode;
|
|
69
|
+
url: string;
|
|
70
|
+
depth: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PageModule {
|
|
74
|
+
default: ComponentType<{ components?: Record<string, unknown> }>;
|
|
75
|
+
/** Exported by fumadocs' `rehype-toc`. */
|
|
76
|
+
toc?: TocEntry[];
|
|
77
|
+
}
|