@raystack/chronicle 0.10.4 → 0.11.1
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/dist/cli/index.js +42 -5
- package/package.json +6 -1
- package/src/components/analytics/AnalyticsProvider.tsx +64 -0
- package/src/components/mdx/image.tsx +7 -3
- package/src/components/mdx/index.tsx +3 -3
- package/src/components/ui/search.tsx +28 -44
- package/src/lib/image-utils.test.ts +71 -0
- package/src/lib/image-utils.ts +18 -0
- package/src/lib/page-context.tsx +2 -1
- package/src/lib/preload.ts +11 -0
- package/src/lib/remark-resolve-images.ts +16 -2
- package/src/server/App.tsx +15 -12
- package/src/server/api/image.test.ts +70 -0
- package/src/server/api/image.ts +154 -0
- package/src/server/api/search.ts +10 -5
- package/src/server/entry-client.tsx +2 -1
- package/src/server/entry-server.tsx +5 -3
- package/src/server/vite-config.ts +17 -2
- package/src/themes/default/Page.module.css +48 -16
- package/src/themes/paper/Layout.module.css +5 -0
- package/src/themes/paper/Layout.tsx +1 -1
- package/src/themes/paper/Page.module.css +31 -16
- package/src/themes/paper/Page.tsx +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -55,6 +55,19 @@ var init_mdx_utils = __esm(() => {
|
|
|
55
55
|
};
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
// src/lib/image-utils.ts
|
|
59
|
+
function isLocalImage(url) {
|
|
60
|
+
return url.startsWith("/_content/");
|
|
61
|
+
}
|
|
62
|
+
function isSvg(url) {
|
|
63
|
+
return url.split("?")[0].endsWith(".svg");
|
|
64
|
+
}
|
|
65
|
+
function buildOptimizedUrl(url, width, quality = DEFAULT_QUALITY) {
|
|
66
|
+
return `/api/image?url=${encodeURIComponent(url)}&w=${width}&q=${quality}`;
|
|
67
|
+
}
|
|
68
|
+
var DEFAULT_WIDTH = 1024, DEFAULT_QUALITY = 75;
|
|
69
|
+
var init_image_utils = () => {};
|
|
70
|
+
|
|
58
71
|
// src/lib/remark-resolve-images.ts
|
|
59
72
|
import path4 from "node:path";
|
|
60
73
|
import { visit } from "unist-util-visit";
|
|
@@ -71,7 +84,13 @@ function resolveUrl(src, dir) {
|
|
|
71
84
|
return `/_content${src}`;
|
|
72
85
|
return `/_content/${path4.posix.normalize(path4.posix.join(dir, src))}`;
|
|
73
86
|
}
|
|
74
|
-
|
|
87
|
+
function optimizeUrl(url, optimize) {
|
|
88
|
+
if (optimize && isLocalImage(url) && !isSvg(url))
|
|
89
|
+
return buildOptimizedUrl(url, DEFAULT_WIDTH);
|
|
90
|
+
return url;
|
|
91
|
+
}
|
|
92
|
+
var remarkResolveImages = (options) => {
|
|
93
|
+
const optimize = options?.optimize ?? true;
|
|
75
94
|
return (tree, file) => {
|
|
76
95
|
const filePath = file.path;
|
|
77
96
|
if (!filePath)
|
|
@@ -94,12 +113,13 @@ var remarkResolveImages = () => {
|
|
|
94
113
|
return;
|
|
95
114
|
node.url = resolveUrl(node.url, dir);
|
|
96
115
|
collect(node.url);
|
|
116
|
+
node.url = optimizeUrl(node.url, optimize);
|
|
97
117
|
});
|
|
98
118
|
visit(tree, "html", (node) => {
|
|
99
119
|
node.value = node.value.replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (_, before, src, after) => {
|
|
100
120
|
const resolved = resolveUrl(src, dir);
|
|
101
121
|
collect(resolved);
|
|
102
|
-
return `${before}${resolved}${after}`;
|
|
122
|
+
return `${before}${optimizeUrl(resolved, optimize)}${after}`;
|
|
103
123
|
});
|
|
104
124
|
});
|
|
105
125
|
visit(tree, (node) => {
|
|
@@ -113,6 +133,7 @@ var remarkResolveImages = () => {
|
|
|
113
133
|
return;
|
|
114
134
|
srcAttr.value = resolveUrl(srcAttr.value, dir);
|
|
115
135
|
collect(srcAttr.value);
|
|
136
|
+
srcAttr.value = optimizeUrl(srcAttr.value, optimize);
|
|
116
137
|
});
|
|
117
138
|
visit(tree, "element", (node) => {
|
|
118
139
|
if (node.tagName !== "img")
|
|
@@ -122,12 +143,14 @@ var remarkResolveImages = () => {
|
|
|
122
143
|
return;
|
|
123
144
|
node.properties.src = resolveUrl(src, dir);
|
|
124
145
|
collect(node.properties.src);
|
|
146
|
+
node.properties.src = optimizeUrl(node.properties.src, optimize);
|
|
125
147
|
});
|
|
126
148
|
file.data.images = images;
|
|
127
149
|
};
|
|
128
150
|
}, remark_resolve_images_default;
|
|
129
151
|
var init_remark_resolve_images = __esm(() => {
|
|
130
152
|
init_mdx_utils();
|
|
153
|
+
init_image_utils();
|
|
131
154
|
remark_resolve_images_default = remarkResolveImages;
|
|
132
155
|
});
|
|
133
156
|
|
|
@@ -350,6 +373,9 @@ function getDatabaseConnector(preset) {
|
|
|
350
373
|
return { connector: "sqlite", options: { name: "chronicle-search" } };
|
|
351
374
|
}
|
|
352
375
|
}
|
|
376
|
+
function isStaticPreset(preset) {
|
|
377
|
+
return !!preset && STATIC_PRESETS.has(preset);
|
|
378
|
+
}
|
|
353
379
|
function resolveOutputDir(projectRoot, preset) {
|
|
354
380
|
if (preset === "vercel" || preset === "vercel-static")
|
|
355
381
|
return path6.resolve(projectRoot, ".vercel/output");
|
|
@@ -380,7 +406,8 @@ async function createViteConfig(options) {
|
|
|
380
406
|
plugins: [
|
|
381
407
|
nitro({
|
|
382
408
|
serverDir: path6.resolve(packageRoot, "src/server"),
|
|
383
|
-
...preset && { preset }
|
|
409
|
+
...preset && { preset },
|
|
410
|
+
ignore: ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"]
|
|
384
411
|
}),
|
|
385
412
|
mdx({
|
|
386
413
|
default: defineFumadocsConfig({
|
|
@@ -408,7 +435,7 @@ async function createViteConfig(options) {
|
|
|
408
435
|
}],
|
|
409
436
|
remark_unused_directives_default,
|
|
410
437
|
remark_resolve_links_default,
|
|
411
|
-
remark_resolve_images_default,
|
|
438
|
+
[remark_resolve_images_default, { optimize: !isStaticPreset(preset) }],
|
|
412
439
|
remarkMdxMermaid,
|
|
413
440
|
readingTime
|
|
414
441
|
]
|
|
@@ -446,7 +473,8 @@ async function createViteConfig(options) {
|
|
|
446
473
|
}
|
|
447
474
|
},
|
|
448
475
|
ssr: {
|
|
449
|
-
noExternal: ["@raystack/apsara", "dayjs", "fumadocs-core"]
|
|
476
|
+
noExternal: ["@raystack/apsara", "dayjs", "fumadocs-core"],
|
|
477
|
+
external: ["analytics", "use-analytics", "@analytics/google-analytics"]
|
|
450
478
|
},
|
|
451
479
|
environments: {
|
|
452
480
|
client: {
|
|
@@ -464,6 +492,13 @@ async function createViteConfig(options) {
|
|
|
464
492
|
output: {
|
|
465
493
|
dir: resolveOutputDir(projectRoot, preset)
|
|
466
494
|
},
|
|
495
|
+
externals: ["sharp"],
|
|
496
|
+
storage: {
|
|
497
|
+
"image-cache": {
|
|
498
|
+
driver: "fs",
|
|
499
|
+
base: path6.resolve(projectRoot, ".cache/images")
|
|
500
|
+
}
|
|
501
|
+
},
|
|
467
502
|
experimental: {
|
|
468
503
|
database: true
|
|
469
504
|
},
|
|
@@ -473,11 +508,13 @@ async function createViteConfig(options) {
|
|
|
473
508
|
}
|
|
474
509
|
};
|
|
475
510
|
}
|
|
511
|
+
var STATIC_PRESETS;
|
|
476
512
|
var init_vite_config = __esm(() => {
|
|
477
513
|
init_remark_resolve_images();
|
|
478
514
|
init_remark_resolve_links();
|
|
479
515
|
init_remark_reading_time();
|
|
480
516
|
init_remark_unused_directives();
|
|
517
|
+
STATIC_PRESETS = new Set(["static", "vercel-static", "cloudflare-pages", "github-pages"]);
|
|
481
518
|
});
|
|
482
519
|
|
|
483
520
|
// src/cli/index.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raystack/chronicle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Config-driven documentation framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"typescript": "5.9.3"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@analytics/google-analytics": "^1.1.0",
|
|
39
40
|
"@codemirror/lang-json": "^6.0.2",
|
|
40
41
|
"@codemirror/state": "^6.5.4",
|
|
41
42
|
"@codemirror/theme-one-dark": "^6.1.3",
|
|
@@ -51,12 +52,14 @@
|
|
|
51
52
|
"@shikijs/rehype": "^4.0.2",
|
|
52
53
|
"@tanstack/react-query": "5.100.10",
|
|
53
54
|
"@vitejs/plugin-react": "^6.0.1",
|
|
55
|
+
"analytics": "^0.8.19",
|
|
54
56
|
"chalk": "^5.6.2",
|
|
55
57
|
"class-variance-authority": "^0.7.1",
|
|
56
58
|
"codemirror": "^6.0.2",
|
|
57
59
|
"commander": "^14.0.2",
|
|
58
60
|
"fumadocs-core": "16.8.1",
|
|
59
61
|
"fumadocs-mdx": "14.3.1",
|
|
62
|
+
"github-slugger": "^2.0.0",
|
|
60
63
|
"glob": "^11.0.0",
|
|
61
64
|
"gray-matter": "^4.0.3",
|
|
62
65
|
"h3": "^2.0.1-rc.16",
|
|
@@ -74,10 +77,12 @@
|
|
|
74
77
|
"remark-mdx-frontmatter": "^5.2.0",
|
|
75
78
|
"remark-parse": "^11.0.0",
|
|
76
79
|
"satori": "^0.25.0",
|
|
80
|
+
"sharp": "^0.34.5",
|
|
77
81
|
"slugify": "^1.6.6",
|
|
78
82
|
"std-env": "^4.1.0",
|
|
79
83
|
"unified": "^11.0.5",
|
|
80
84
|
"unist-util-visit": "^5.1.0",
|
|
85
|
+
"use-analytics": "^1.1.0",
|
|
81
86
|
"vite": "8.0.3",
|
|
82
87
|
"yaml": "^2.8.2",
|
|
83
88
|
"zod": "^4.3.6"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useLocation } from 'react-router'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import type { AnalyticsConfig } from '@/types'
|
|
5
|
+
import type { AnalyticsInstance } from 'analytics'
|
|
6
|
+
|
|
7
|
+
function PageViewTracker({ analytics }: { analytics: AnalyticsInstance }) {
|
|
8
|
+
const { pathname } = useLocation()
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
try { analytics.page() } catch { /* noop */ }
|
|
12
|
+
}, [pathname, analytics])
|
|
13
|
+
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function AnalyticsProvider({
|
|
18
|
+
config,
|
|
19
|
+
appName,
|
|
20
|
+
children,
|
|
21
|
+
}: {
|
|
22
|
+
config: AnalyticsConfig
|
|
23
|
+
appName: string
|
|
24
|
+
children: ReactNode
|
|
25
|
+
}) {
|
|
26
|
+
const [analytics, setAnalytics] = useState<AnalyticsInstance | null>(null)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!config.enabled) {
|
|
30
|
+
setAnalytics(null)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let cancelled = false
|
|
35
|
+
|
|
36
|
+
const init = async () => {
|
|
37
|
+
try {
|
|
38
|
+
const plugins: unknown[] = []
|
|
39
|
+
if (config.googleAnalytics?.measurementId) {
|
|
40
|
+
const { default: googleAnalytics } = await import('@analytics/google-analytics')
|
|
41
|
+
plugins.push(
|
|
42
|
+
googleAnalytics({
|
|
43
|
+
measurementIds: [config.googleAnalytics.measurementId],
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
const { default: Analytics } = await import('analytics')
|
|
48
|
+
if (!cancelled) setAnalytics(Analytics({ app: appName, plugins }))
|
|
49
|
+
} catch {
|
|
50
|
+
if (!cancelled) setAnalytics(null)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
void init()
|
|
55
|
+
return () => { cancelled = true }
|
|
56
|
+
}, [config.enabled, config.googleAnalytics?.measurementId, appName])
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
{analytics && <PageViewTracker analytics={analytics} />}
|
|
61
|
+
{children}
|
|
62
|
+
</>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { ComponentProps } from 'react';
|
|
2
|
+
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
|
|
2
3
|
|
|
3
|
-
type
|
|
4
|
+
type MDXImageProps = ComponentProps<'img'>;
|
|
4
5
|
|
|
5
|
-
export function
|
|
6
|
+
export function MDXImage({ src, alt, ...props }: MDXImageProps) {
|
|
6
7
|
if (!src) return null;
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
const optimize = isLocalImage(src) && !isSvg(src);
|
|
10
|
+
const imgSrc = optimize ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
|
|
11
|
+
|
|
12
|
+
return <img src={imgSrc} alt={alt ?? ''} loading='lazy' decoding='async' {...props} />;
|
|
9
13
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MDXComponents } from 'mdx/types'
|
|
2
|
-
import {
|
|
2
|
+
import { MDXImage } from './image'
|
|
3
3
|
import { Link } from './link'
|
|
4
4
|
import { MdxTable, MdxThead, MdxTbody, MdxTr, MdxTh, MdxTd } from './table'
|
|
5
5
|
import { MdxPre, MdxCode } from './code'
|
|
@@ -25,7 +25,7 @@ MdxTabs.Content = Tabs.Content
|
|
|
25
25
|
|
|
26
26
|
export const mdxComponents: MDXComponents = {
|
|
27
27
|
p: MdxParagraph,
|
|
28
|
-
img:
|
|
28
|
+
img: MDXImage,
|
|
29
29
|
a: Link,
|
|
30
30
|
table: MdxTable,
|
|
31
31
|
thead: MdxThead,
|
|
@@ -45,5 +45,5 @@ export const mdxComponents: MDXComponents = {
|
|
|
45
45
|
Mermaid,
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
export {
|
|
48
|
+
export { MDXImage } from './image'
|
|
49
49
|
export { Link } from './link'
|
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
MagnifyingGlassIcon
|
|
5
5
|
} from '@heroicons/react/24/outline';
|
|
6
6
|
import { Badge, Command, IconButton, Text } from '@raystack/apsara';
|
|
7
|
+
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
7
8
|
import { debounce } from 'lodash-es';
|
|
8
|
-
import { useCallback, useEffect, useMemo,
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
|
|
9
10
|
import { useNavigate } from 'react-router';
|
|
10
11
|
import { MethodBadge } from '@/components/api/method-badge';
|
|
11
12
|
import { usePageContext } from '@/lib/page-context';
|
|
@@ -36,57 +37,40 @@ function buildSearchUrl(query: string, tag?: string): string {
|
|
|
36
37
|
export function Search({ classNames }: SearchProps) {
|
|
37
38
|
const [open, setOpen] = useState(false);
|
|
38
39
|
const [search, setSearch] = useState('');
|
|
39
|
-
const [
|
|
40
|
-
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
|
41
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
40
|
+
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
42
41
|
const navigate = useNavigate();
|
|
43
42
|
const { version } = usePageContext();
|
|
44
43
|
const tag = version.dir ?? undefined;
|
|
45
|
-
const abortRef = useRef<AbortController | null>(null);
|
|
46
44
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const res = await fetch(buildSearchUrl(query, tag), { signal });
|
|
51
|
-
if (!res.ok || signal?.aborted) return;
|
|
52
|
-
const data: SearchResult[] = await res.json();
|
|
53
|
-
if (signal?.aborted) return;
|
|
54
|
-
if (query) {
|
|
55
|
-
setResults(data);
|
|
56
|
-
} else {
|
|
57
|
-
setSuggestions(data);
|
|
58
|
-
}
|
|
59
|
-
} catch (err) {
|
|
60
|
-
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
61
|
-
console.error('Search fetch failed:', err);
|
|
62
|
-
} finally {
|
|
63
|
-
setIsLoading(false);
|
|
64
|
-
}
|
|
65
|
-
}, [tag]);
|
|
66
|
-
|
|
67
|
-
const debouncedSearch = useMemo(
|
|
68
|
-
() => debounce((query: string) => {
|
|
69
|
-
abortRef.current?.abort();
|
|
70
|
-
const controller = new AbortController();
|
|
71
|
-
abortRef.current = controller;
|
|
72
|
-
fetchResults(query, controller.signal);
|
|
73
|
-
}, 150),
|
|
74
|
-
[fetchResults]
|
|
45
|
+
const updateDebouncedSearch = useMemo(
|
|
46
|
+
() => debounce((value: string) => setDebouncedSearch(value), 150),
|
|
47
|
+
[]
|
|
75
48
|
);
|
|
76
49
|
|
|
50
|
+
const onSearchChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
51
|
+
const value = e.target.value;
|
|
52
|
+
setSearch(value);
|
|
53
|
+
updateDebouncedSearch(value);
|
|
54
|
+
}, [updateDebouncedSearch]);
|
|
55
|
+
|
|
77
56
|
useEffect(() => {
|
|
78
57
|
if (!open) {
|
|
79
58
|
setSearch('');
|
|
80
|
-
|
|
81
|
-
|
|
59
|
+
setDebouncedSearch('');
|
|
60
|
+
updateDebouncedSearch.cancel();
|
|
82
61
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
62
|
+
}, [open, updateDebouncedSearch]);
|
|
63
|
+
|
|
64
|
+
const { data = [], isLoading } = useQuery<SearchResult[]>({
|
|
65
|
+
queryKey: ['search', debouncedSearch, tag],
|
|
66
|
+
queryFn: async ({ signal }) => {
|
|
67
|
+
const res = await fetch(buildSearchUrl(debouncedSearch, tag), { signal });
|
|
68
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
69
|
+
return res.json();
|
|
70
|
+
},
|
|
71
|
+
enabled: open,
|
|
72
|
+
placeholderData: keepPreviousData,
|
|
73
|
+
});
|
|
90
74
|
|
|
91
75
|
const onSelect = useCallback(
|
|
92
76
|
(url: string) => {
|
|
@@ -108,7 +92,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
108
92
|
return () => document.removeEventListener('keydown', down);
|
|
109
93
|
}, []);
|
|
110
94
|
|
|
111
|
-
const displayResults = deduplicateByUrl(
|
|
95
|
+
const displayResults = deduplicateByUrl(data);
|
|
112
96
|
|
|
113
97
|
return (
|
|
114
98
|
<>
|
|
@@ -129,7 +113,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
129
113
|
placeholder='Search'
|
|
130
114
|
leadingIcon={<MagnifyingGlassIcon width={16} height={16} />}
|
|
131
115
|
value={search}
|
|
132
|
-
onChange={
|
|
116
|
+
onChange={onSearchChange}
|
|
133
117
|
className={styles.input}
|
|
134
118
|
/>
|
|
135
119
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
isLocalImage,
|
|
4
|
+
isSvg,
|
|
5
|
+
buildOptimizedUrl,
|
|
6
|
+
ALLOWED_WIDTHS,
|
|
7
|
+
DEFAULT_WIDTH,
|
|
8
|
+
DEFAULT_QUALITY,
|
|
9
|
+
} from './image-utils';
|
|
10
|
+
|
|
11
|
+
describe('isLocalImage', () => {
|
|
12
|
+
test('returns true for /_content/ URLs', () => {
|
|
13
|
+
expect(isLocalImage('/_content/docs/photo.png')).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('returns false for external URLs', () => {
|
|
17
|
+
expect(isLocalImage('https://example.com/img.png')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('returns false for relative URLs', () => {
|
|
21
|
+
expect(isLocalImage('/images/logo.png')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('isSvg', () => {
|
|
26
|
+
test('returns true for .svg files', () => {
|
|
27
|
+
expect(isSvg('/_content/logo.svg')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('returns true for .svg with query string', () => {
|
|
31
|
+
expect(isSvg('/_content/logo.svg?v=1')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('returns false for .png files', () => {
|
|
35
|
+
expect(isSvg('/_content/photo.png')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('buildOptimizedUrl', () => {
|
|
40
|
+
test('builds URL with width and default quality', () => {
|
|
41
|
+
const url = buildOptimizedUrl('/_content/img.png', 640);
|
|
42
|
+
expect(url).toBe(`/api/image?url=%2F_content%2Fimg.png&w=640&q=${DEFAULT_QUALITY}`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('builds URL with custom quality', () => {
|
|
46
|
+
const url = buildOptimizedUrl('/_content/img.png', 320, 50);
|
|
47
|
+
expect(url).toBe('/api/image?url=%2F_content%2Fimg.png&w=320&q=50');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('encodes special characters in URL', () => {
|
|
51
|
+
const url = buildOptimizedUrl('/_content/my image (1).png', 640);
|
|
52
|
+
expect(url).toContain('my%20image%20(1).png');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('constants', () => {
|
|
57
|
+
test('ALLOWED_WIDTHS is sorted ascending', () => {
|
|
58
|
+
for (let i = 1; i < ALLOWED_WIDTHS.length; i++) {
|
|
59
|
+
expect(ALLOWED_WIDTHS[i]).toBeGreaterThan(ALLOWED_WIDTHS[i - 1]);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('DEFAULT_WIDTH is in ALLOWED_WIDTHS', () => {
|
|
64
|
+
expect(ALLOWED_WIDTHS).toContain(DEFAULT_WIDTH);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('DEFAULT_QUALITY is between 1 and 100', () => {
|
|
68
|
+
expect(DEFAULT_QUALITY).toBeGreaterThanOrEqual(1);
|
|
69
|
+
expect(DEFAULT_QUALITY).toBeLessThanOrEqual(100);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const ALLOWED_WIDTHS = [320, 640, 768, 1024, 1280, 1536, 1920];
|
|
2
|
+
const ALLOWED_QUALITIES = [60, 75, 90, 100];
|
|
3
|
+
const DEFAULT_WIDTH = 1024;
|
|
4
|
+
const DEFAULT_QUALITY = 75;
|
|
5
|
+
|
|
6
|
+
export function isLocalImage(url: string): boolean {
|
|
7
|
+
return url.startsWith('/_content/');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isSvg(url: string): boolean {
|
|
11
|
+
return url.split('?')[0].endsWith('.svg');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildOptimizedUrl(url: string, width: number, quality = DEFAULT_QUALITY): string {
|
|
15
|
+
return `/api/image?url=${encodeURIComponent(url)}&w=${width}&q=${quality}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_WIDTH, DEFAULT_QUALITY };
|
package/src/lib/page-context.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import type { VersionContext } from '@/lib/version-source';
|
|
|
14
14
|
import { LATEST_CONTEXT } from '@/lib/version-source';
|
|
15
15
|
import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types';
|
|
16
16
|
import { queryClient } from '@/lib/preload';
|
|
17
|
+
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
|
|
17
18
|
|
|
18
19
|
export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>;
|
|
19
20
|
|
|
@@ -136,7 +137,7 @@ export function PageProvider({
|
|
|
136
137
|
if (data.images?.length) {
|
|
137
138
|
for (const src of data.images) {
|
|
138
139
|
const img = new Image();
|
|
139
|
-
img.src = src;
|
|
140
|
+
img.src = isLocalImage(src) && !isSvg(src) ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
const { content, toc } = await loadMdx(data.originalPath || data.relativePath);
|
package/src/lib/preload.ts
CHANGED
|
@@ -40,3 +40,14 @@ export function prefetchPageData(pathname: string) {
|
|
|
40
40
|
queryFn: () => fetchPageDataByPathname(pathname),
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
export function prefetchSearchSuggestions() {
|
|
45
|
+
queryClient.prefetchQuery({
|
|
46
|
+
queryKey: ['search', '', undefined],
|
|
47
|
+
queryFn: async () => {
|
|
48
|
+
const res = await fetch('/api/search');
|
|
49
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
50
|
+
return res.json();
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { Image, Html } from 'mdast'
|
|
|
5
5
|
import type { Element } from 'hast'
|
|
6
6
|
import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx'
|
|
7
7
|
import { MdxNodeType } from './mdx-utils'
|
|
8
|
+
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from './image-utils'
|
|
8
9
|
|
|
9
10
|
function resolveUrl(src: string, dir: string): string {
|
|
10
11
|
if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src
|
|
@@ -16,7 +17,17 @@ function resolveUrl(src: string, dir: string): string {
|
|
|
16
17
|
return `/_content/${path.posix.normalize(path.posix.join(dir, src))}`
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
interface RemarkResolveImagesOptions {
|
|
21
|
+
optimize?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function optimizeUrl(url: string, optimize: boolean): string {
|
|
25
|
+
if (optimize && isLocalImage(url) && !isSvg(url)) return buildOptimizedUrl(url, DEFAULT_WIDTH)
|
|
26
|
+
return url
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => {
|
|
30
|
+
const optimize = options?.optimize ?? true
|
|
20
31
|
return (tree, file) => {
|
|
21
32
|
const filePath = file.path
|
|
22
33
|
if (!filePath) return
|
|
@@ -40,6 +51,7 @@ const remarkResolveImages: Plugin = () => {
|
|
|
40
51
|
if (!node.url) return
|
|
41
52
|
node.url = resolveUrl(node.url, dir)
|
|
42
53
|
collect(node.url)
|
|
54
|
+
node.url = optimizeUrl(node.url, optimize)
|
|
43
55
|
})
|
|
44
56
|
|
|
45
57
|
visit(tree, 'html', (node: Html) => {
|
|
@@ -48,7 +60,7 @@ const remarkResolveImages: Plugin = () => {
|
|
|
48
60
|
(_, before, src, after) => {
|
|
49
61
|
const resolved = resolveUrl(src, dir)
|
|
50
62
|
collect(resolved)
|
|
51
|
-
return `${before}${resolved}${after}`
|
|
63
|
+
return `${before}${optimizeUrl(resolved, optimize)}${after}`
|
|
52
64
|
}
|
|
53
65
|
)
|
|
54
66
|
})
|
|
@@ -61,6 +73,7 @@ const remarkResolveImages: Plugin = () => {
|
|
|
61
73
|
if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
|
|
62
74
|
srcAttr.value = resolveUrl(srcAttr.value, dir)
|
|
63
75
|
collect(srcAttr.value)
|
|
76
|
+
srcAttr.value = optimizeUrl(srcAttr.value, optimize)
|
|
64
77
|
})
|
|
65
78
|
|
|
66
79
|
visit(tree, 'element', (node: Element) => {
|
|
@@ -69,6 +82,7 @@ const remarkResolveImages: Plugin = () => {
|
|
|
69
82
|
if (typeof src !== 'string') return
|
|
70
83
|
node.properties.src = resolveUrl(src, dir)
|
|
71
84
|
collect(node.properties.src as string)
|
|
85
|
+
node.properties.src = optimizeUrl(node.properties.src as string, optimize)
|
|
72
86
|
})
|
|
73
87
|
|
|
74
88
|
file.data.images = images
|
package/src/server/App.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import '@raystack/apsara/style.css';
|
|
|
3
3
|
import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
|
|
4
4
|
import { lazy, Suspense } from 'react';
|
|
5
5
|
import { Navigate, useLocation } from 'react-router';
|
|
6
|
+
import { AnalyticsProvider } from '@/components/analytics/AnalyticsProvider';
|
|
6
7
|
import { Head } from '@/lib/head';
|
|
7
8
|
import { usePageContext } from '@/lib/page-context';
|
|
8
9
|
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
@@ -37,18 +38,20 @@ export function App() {
|
|
|
37
38
|
enableSystem={themeConfig.enableSystem}
|
|
38
39
|
forcedTheme={themeConfig.forcedTheme}
|
|
39
40
|
>
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
<AnalyticsProvider config={config.analytics ?? { enabled: false }} appName={config.site.title}>
|
|
42
|
+
<RootHead config={config} />
|
|
43
|
+
<Suspense fallback={<PageFallback />}>
|
|
44
|
+
{isApi ? (
|
|
45
|
+
<ApiLayout>
|
|
46
|
+
<ApiPage slug={apiSlug} />
|
|
47
|
+
</ApiLayout>
|
|
48
|
+
) : (
|
|
49
|
+
<DocsLayout hideSidebar={isLanding}>
|
|
50
|
+
{isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
|
|
51
|
+
</DocsLayout>
|
|
52
|
+
)}
|
|
53
|
+
</Suspense>
|
|
54
|
+
</AnalyticsProvider>
|
|
52
55
|
</ThemeProvider>
|
|
53
56
|
);
|
|
54
57
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { negotiateFormat, cacheKey, MIME } from './image';
|
|
3
|
+
|
|
4
|
+
describe('negotiateFormat', () => {
|
|
5
|
+
test('returns avif when Accept includes image/avif', () => {
|
|
6
|
+
expect(negotiateFormat('image/avif,image/webp,*/*')).toBe('avif');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('returns webp when Accept includes image/webp but not avif', () => {
|
|
10
|
+
expect(negotiateFormat('image/webp,image/png,*/*')).toBe('webp');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('returns original when Accept has neither avif nor webp', () => {
|
|
14
|
+
expect(negotiateFormat('image/png,*/*')).toBe('original');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('returns original for null Accept header', () => {
|
|
18
|
+
expect(negotiateFormat(null)).toBe('original');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('prefers avif over webp when both present', () => {
|
|
22
|
+
expect(negotiateFormat('image/webp,image/avif')).toBe('avif');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('cacheKey', () => {
|
|
27
|
+
test('returns deterministic key for same inputs', () => {
|
|
28
|
+
const a = cacheKey('/_content/img.png', 640, 75, 'webp');
|
|
29
|
+
const b = cacheKey('/_content/img.png', 640, 75, 'webp');
|
|
30
|
+
expect(a).toBe(b);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('returns different keys for different widths', () => {
|
|
34
|
+
const a = cacheKey('/_content/img.png', 640, 75, 'webp');
|
|
35
|
+
const b = cacheKey('/_content/img.png', 1024, 75, 'webp');
|
|
36
|
+
expect(a).not.toBe(b);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('returns different keys for different formats', () => {
|
|
40
|
+
const a = cacheKey('/_content/img.png', 640, 75, 'webp');
|
|
41
|
+
const b = cacheKey('/_content/img.png', 640, 75, 'avif');
|
|
42
|
+
expect(a).not.toBe(b);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('returns different keys for different quality', () => {
|
|
46
|
+
const a = cacheKey('/_content/img.png', 640, 75, 'webp');
|
|
47
|
+
const b = cacheKey('/_content/img.png', 640, 50, 'webp');
|
|
48
|
+
expect(a).not.toBe(b);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('key ends with format extension', () => {
|
|
52
|
+
expect(cacheKey('/_content/img.png', 640, 75, 'webp')).toMatch(/\.webp$/);
|
|
53
|
+
expect(cacheKey('/_content/img.png', 640, 75, 'avif')).toMatch(/\.avif$/);
|
|
54
|
+
expect(cacheKey('/_content/img.png', 640, 75, 'original')).toMatch(/\.original$/);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('MIME', () => {
|
|
59
|
+
test('maps common image extensions', () => {
|
|
60
|
+
expect(MIME['.png']).toBe('image/png');
|
|
61
|
+
expect(MIME['.jpg']).toBe('image/jpeg');
|
|
62
|
+
expect(MIME['.jpeg']).toBe('image/jpeg');
|
|
63
|
+
expect(MIME['.gif']).toBe('image/gif');
|
|
64
|
+
expect(MIME['.webp']).toBe('image/webp');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('does not include svg (handled separately)', () => {
|
|
68
|
+
expect(MIME['.svg']).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { defineHandler, HTTPError } from 'nitro'
|
|
5
|
+
import { useStorage } from 'nitro/storage'
|
|
6
|
+
import sharp from 'sharp'
|
|
7
|
+
import { StatusCodes } from 'http-status-codes'
|
|
8
|
+
import { safePath } from '@/server/utils/safe-path'
|
|
9
|
+
import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_QUALITY } from '@/lib/image-utils'
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = 'image-cache'
|
|
12
|
+
const MAX_CACHE_ENTRIES = 500
|
|
13
|
+
|
|
14
|
+
const inflight = new Map<string, Promise<Buffer>>()
|
|
15
|
+
|
|
16
|
+
export type OutputFormat = 'avif' | 'webp' | 'original'
|
|
17
|
+
|
|
18
|
+
export function negotiateFormat(accept: string | null): OutputFormat {
|
|
19
|
+
if (accept?.includes('image/avif')) return 'avif'
|
|
20
|
+
if (accept?.includes('image/webp')) return 'webp'
|
|
21
|
+
return 'original'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const MIME: Record<string, string> = {
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.jpg': 'image/jpeg',
|
|
27
|
+
'.jpeg': 'image/jpeg',
|
|
28
|
+
'.gif': 'image/gif',
|
|
29
|
+
'.webp': 'image/webp',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cacheKey(url: string, w: number, q: number, format: OutputFormat): string {
|
|
33
|
+
const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}`).digest('hex').slice(0, 16)
|
|
34
|
+
return `${hash}.${format}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function snapQuality(q: number): number {
|
|
38
|
+
let closest = ALLOWED_QUALITIES[0];
|
|
39
|
+
for (const aq of ALLOWED_QUALITIES) {
|
|
40
|
+
if (Math.abs(aq - q) < Math.abs(closest - q)) closest = aq;
|
|
41
|
+
}
|
|
42
|
+
return closest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function evictIfNeeded(storage: ReturnType<typeof useStorage>) {
|
|
46
|
+
const keys = await storage.getKeys()
|
|
47
|
+
if (keys.length <= MAX_CACHE_ENTRIES) return
|
|
48
|
+
const toRemove = keys.slice(0, keys.length - MAX_CACHE_ENTRIES)
|
|
49
|
+
await Promise.all(toRemove.map(k => storage.removeItem(k)))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default defineHandler(async event => {
|
|
53
|
+
const storage = useStorage(STORAGE_KEY)
|
|
54
|
+
|
|
55
|
+
const url = event.url.searchParams.get('url')
|
|
56
|
+
const wParam = event.url.searchParams.get('w')
|
|
57
|
+
const qParam = event.url.searchParams.get('q')
|
|
58
|
+
|
|
59
|
+
if (!url || !wParam) {
|
|
60
|
+
throw new HTTPError({ status: StatusCodes.BAD_REQUEST, message: 'Missing url or w parameter' })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!url.startsWith('/_content/')) {
|
|
64
|
+
throw new HTTPError({ status: StatusCodes.BAD_REQUEST, message: 'Only local content images allowed' })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const w = Number.parseInt(wParam, 10)
|
|
68
|
+
if (!ALLOWED_WIDTHS.includes(w)) {
|
|
69
|
+
throw new HTTPError({ status: StatusCodes.BAD_REQUEST, message: `Width must be one of: ${ALLOWED_WIDTHS.join(', ')}` })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const q = snapQuality(qParam ? Number.parseInt(qParam, 10) : DEFAULT_QUALITY)
|
|
73
|
+
|
|
74
|
+
if (url.split('?')[0].endsWith('.svg')) {
|
|
75
|
+
return Response.redirect(url, StatusCodes.TEMPORARY_REDIRECT)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const contentDir = __CHRONICLE_CONTENT_DIR__
|
|
79
|
+
const relativePath = url.replace(/^\/_content\//, '')
|
|
80
|
+
const filePath = safePath(contentDir, `/${relativePath}`)
|
|
81
|
+
if (!filePath) {
|
|
82
|
+
throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const accept = event.headers.get('accept')
|
|
86
|
+
const format = negotiateFormat(accept)
|
|
87
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
88
|
+
const originalMime = MIME[ext] ?? 'application/octet-stream'
|
|
89
|
+
const contentType = format === 'original' ? originalMime : `image/${format}`
|
|
90
|
+
|
|
91
|
+
const key = cacheKey(url, w, q, format)
|
|
92
|
+
|
|
93
|
+
const cached = await storage.getItemRaw<Buffer>(key)
|
|
94
|
+
if (cached) {
|
|
95
|
+
return new Response(cached, {
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': contentType,
|
|
98
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
99
|
+
'Vary': 'Accept',
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const existing = inflight.get(key)
|
|
105
|
+
if (existing) {
|
|
106
|
+
const optimized = await existing
|
|
107
|
+
return new Response(optimized, {
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': contentType,
|
|
110
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
111
|
+
'Vary': 'Accept',
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const work = (async () => {
|
|
117
|
+
const source = await fs.readFile(filePath)
|
|
118
|
+
const pipeline = sharp(source).resize({ width: w, withoutEnlargement: true })
|
|
119
|
+
const optimized = format === 'avif'
|
|
120
|
+
? await pipeline.avif({ quality: q }).toBuffer()
|
|
121
|
+
: format === 'webp'
|
|
122
|
+
? await pipeline.webp({ quality: q }).toBuffer()
|
|
123
|
+
: await pipeline.toBuffer()
|
|
124
|
+
|
|
125
|
+
await storage.setItemRaw(key, optimized)
|
|
126
|
+
await evictIfNeeded(storage)
|
|
127
|
+
return optimized
|
|
128
|
+
})()
|
|
129
|
+
|
|
130
|
+
inflight.set(key, work)
|
|
131
|
+
try {
|
|
132
|
+
const optimized = await work
|
|
133
|
+
return new Response(optimized, {
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': contentType,
|
|
136
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
137
|
+
'Vary': 'Accept',
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
} catch {
|
|
141
|
+
const source = await fs.readFile(filePath).catch(() => null)
|
|
142
|
+
if (!source) {
|
|
143
|
+
throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' })
|
|
144
|
+
}
|
|
145
|
+
return new Response(source, {
|
|
146
|
+
headers: {
|
|
147
|
+
'Content-Type': 'application/octet-stream',
|
|
148
|
+
'Cache-Control': 'public, max-age=86400',
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
} finally {
|
|
152
|
+
inflight.delete(key)
|
|
153
|
+
}
|
|
154
|
+
})
|
package/src/server/api/search.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import GithubSlugger from 'github-slugger';
|
|
1
2
|
import { defineHandler, HTTPError } from 'nitro';
|
|
2
3
|
import { useDatabase } from 'nitro/database';
|
|
3
4
|
import type { OpenAPIV3 } from 'openapi-types';
|
|
@@ -143,15 +144,17 @@ function findMatch(
|
|
|
143
144
|
title: string,
|
|
144
145
|
headings: string,
|
|
145
146
|
body: string,
|
|
146
|
-
): { match: 'title' | 'heading' | 'body'; snippet: string } {
|
|
147
|
+
): { match: 'title' | 'heading' | 'body'; snippet: string; slug?: string } {
|
|
147
148
|
if (title.toLowerCase().includes(query)) {
|
|
148
149
|
return { match: 'title', snippet: title };
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
const slugger = new GithubSlugger();
|
|
151
153
|
const headingList = headings.split('\n').filter(Boolean);
|
|
152
154
|
for (const h of headingList) {
|
|
155
|
+
const slug = slugger.slug(h);
|
|
153
156
|
if (h.toLowerCase().includes(query)) {
|
|
154
|
-
return { match: 'heading', snippet: h };
|
|
157
|
+
return { match: 'heading', snippet: h, slug };
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -214,10 +217,12 @@ export default defineHandler(async event => {
|
|
|
214
217
|
|
|
215
218
|
const queryLower = query.toLowerCase();
|
|
216
219
|
return Response.json((result.rows ?? []).map(r => {
|
|
217
|
-
const { match, snippet } = findMatch(queryLower, r.title as string, r.headings as string, r.body as string);
|
|
220
|
+
const { match, snippet, slug } = findMatch(queryLower, r.title as string, r.headings as string, r.body as string);
|
|
221
|
+
const id = match === 'heading' && slug ? `${r.id}#${slug}` : r.id as string;
|
|
222
|
+
const url = match === 'heading' && slug ? `${r.url}#${slug}` : r.url as string;
|
|
218
223
|
return {
|
|
219
|
-
id
|
|
220
|
-
url
|
|
224
|
+
id,
|
|
225
|
+
url,
|
|
221
226
|
type: r.type,
|
|
222
227
|
content: r.title,
|
|
223
228
|
match,
|
|
@@ -7,7 +7,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|
|
7
7
|
import { mdxComponents } from '@/components/mdx';
|
|
8
8
|
import { getApiConfigsForVersion } from '@/lib/config';
|
|
9
9
|
import { PageProvider } from '@/lib/page-context';
|
|
10
|
-
import { queryClient } from '@/lib/preload';
|
|
10
|
+
import { prefetchSearchSuggestions, queryClient } from '@/lib/preload';
|
|
11
11
|
import { resolveRoute, RouteType } from '@/lib/route-resolver';
|
|
12
12
|
import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source';
|
|
13
13
|
import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types';
|
|
@@ -56,6 +56,7 @@ async function hydrate() {
|
|
|
56
56
|
window as unknown as { __PAGE_DATA__?: EmbeddedData }
|
|
57
57
|
).__PAGE_DATA__;
|
|
58
58
|
|
|
59
|
+
prefetchSearchSuggestions();
|
|
59
60
|
const config: ChronicleConfig = embedded?.config ?? defaultConfig;
|
|
60
61
|
const tree: Root = embedded?.tree ?? { name: 'root', children: [] };
|
|
61
62
|
|
|
@@ -13,6 +13,7 @@ import { getPageTree, getPage, getPageNav, loadPageModule, extractFrontmatter, g
|
|
|
13
13
|
import { getFirstApiUrl } from '@/lib/api-routes';
|
|
14
14
|
import { StatusCodes } from 'http-status-codes';
|
|
15
15
|
import { resolveDocsRedirect } from '@/lib/tree-utils';
|
|
16
|
+
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
|
|
16
17
|
import { useNitroApp } from 'nitro/app';
|
|
17
18
|
import { App } from './App';
|
|
18
19
|
|
|
@@ -126,9 +127,10 @@ export default {
|
|
|
126
127
|
{assets.js.map((attr: { href: string }) => (
|
|
127
128
|
<link key={attr.href} rel="modulepreload" {...attr} />
|
|
128
129
|
))}
|
|
129
|
-
{pageImages.map((src: string) =>
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
{[...new Set(pageImages)].map((src: string) => {
|
|
131
|
+
const href = isLocalImage(src) && !isSvg(src) ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
|
|
132
|
+
return <link key={src} rel="preload" as="image" href={href} />;
|
|
133
|
+
})}
|
|
132
134
|
<script type="module" src={assets.entry} />
|
|
133
135
|
<script dangerouslySetInnerHTML={{ __html: `window.__PAGE_DATA__ = ${safeJson}` }} />
|
|
134
136
|
</head>
|
|
@@ -25,6 +25,12 @@ function getDatabaseConnector(preset?: string): { connector: string; options?: R
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const STATIC_PRESETS = new Set(['static', 'vercel-static', 'cloudflare-pages', 'github-pages']);
|
|
29
|
+
|
|
30
|
+
function isStaticPreset(preset?: string): boolean {
|
|
31
|
+
return !!preset && STATIC_PRESETS.has(preset);
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
function resolveOutputDir(projectRoot: string, preset?: string): string {
|
|
29
35
|
if (preset === 'vercel' || preset === 'vercel-static') return path.resolve(projectRoot, '.vercel/output');
|
|
30
36
|
return path.resolve(projectRoot, '.output');
|
|
@@ -67,6 +73,7 @@ export async function createViteConfig(
|
|
|
67
73
|
nitro({
|
|
68
74
|
serverDir: path.resolve(packageRoot, 'src/server'),
|
|
69
75
|
...(preset && { preset }),
|
|
76
|
+
ignore: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'],
|
|
70
77
|
}),
|
|
71
78
|
mdx({
|
|
72
79
|
default: defineFumadocsConfig({
|
|
@@ -94,7 +101,7 @@ export async function createViteConfig(
|
|
|
94
101
|
}],
|
|
95
102
|
remarkUnusedDirectives,
|
|
96
103
|
remarkResolveLinks,
|
|
97
|
-
remarkResolveImages,
|
|
104
|
+
[remarkResolveImages, { optimize: !isStaticPreset(preset) }],
|
|
98
105
|
remarkMdxMermaid,
|
|
99
106
|
remarkReadingTime,
|
|
100
107
|
],
|
|
@@ -132,7 +139,8 @@ export async function createViteConfig(
|
|
|
132
139
|
}
|
|
133
140
|
},
|
|
134
141
|
ssr: {
|
|
135
|
-
noExternal: ['@raystack/apsara', 'dayjs', 'fumadocs-core']
|
|
142
|
+
noExternal: ['@raystack/apsara', 'dayjs', 'fumadocs-core'],
|
|
143
|
+
external: ['analytics', 'use-analytics', '@analytics/google-analytics'],
|
|
136
144
|
},
|
|
137
145
|
environments: {
|
|
138
146
|
client: {
|
|
@@ -150,6 +158,13 @@ export async function createViteConfig(
|
|
|
150
158
|
output: {
|
|
151
159
|
dir: resolveOutputDir(projectRoot, preset),
|
|
152
160
|
},
|
|
161
|
+
externals: ['sharp'],
|
|
162
|
+
storage: {
|
|
163
|
+
'image-cache': {
|
|
164
|
+
driver: 'fs',
|
|
165
|
+
base: path.resolve(projectRoot, '.cache/images'),
|
|
166
|
+
},
|
|
167
|
+
},
|
|
153
168
|
experimental: {
|
|
154
169
|
database: true,
|
|
155
170
|
},
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
.content h6 {
|
|
28
28
|
margin-top: var(--rs-space-8);
|
|
29
29
|
margin-bottom: var(--rs-space-5);
|
|
30
|
-
|
|
30
|
+
font-family: var(--rs-font-title);
|
|
31
|
+
font-weight: var(--rs-font-weight-medium);
|
|
32
|
+
color: var(--rs-color-foreground-base-primary);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
.content > :is(h1, h2, h3, h4, h5, h6):first-child {
|
|
@@ -35,10 +37,39 @@
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
.content h1 {
|
|
40
|
+
font-size: var(--rs-font-size-t4);
|
|
41
|
+
line-height: var(--rs-line-height-t4);
|
|
38
42
|
margin-top: 0;
|
|
39
43
|
margin-bottom: var(--rs-space-10);
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
.content h2 {
|
|
47
|
+
font-size: var(--rs-font-size-t3);
|
|
48
|
+
line-height: var(--rs-line-height-t3);
|
|
49
|
+
margin-top: var(--rs-space-8);
|
|
50
|
+
margin-bottom: var(--rs-space-8);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.content h3 {
|
|
54
|
+
font-size: var(--rs-font-size-t2);
|
|
55
|
+
line-height: var(--rs-line-height-t2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.content h4 {
|
|
59
|
+
font-size: var(--rs-font-size-t1);
|
|
60
|
+
line-height: var(--rs-line-height-t1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.content h5 {
|
|
64
|
+
font-size: var(--rs-font-size-large);
|
|
65
|
+
line-height: var(--rs-line-height-large);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.content h6 {
|
|
69
|
+
font-size: var(--rs-font-size-regular);
|
|
70
|
+
line-height: var(--rs-line-height-regular);
|
|
71
|
+
}
|
|
72
|
+
|
|
42
73
|
.content p {
|
|
43
74
|
color: var(--rs-color-foreground-base-primary);
|
|
44
75
|
font-family: var(--rs-font-body);
|
|
@@ -48,18 +79,6 @@
|
|
|
48
79
|
line-height: 171.429%;
|
|
49
80
|
}
|
|
50
81
|
|
|
51
|
-
.content h2 {
|
|
52
|
-
margin-top: var(--rs-space-8);
|
|
53
|
-
margin-bottom: var(--rs-space-8);
|
|
54
|
-
color: var(--rs-color-foreground-base-primary);
|
|
55
|
-
font-family: var(--rs-font-title);
|
|
56
|
-
font-size: var(--rs-font-size-t3);
|
|
57
|
-
font-style: normal;
|
|
58
|
-
font-weight: var(--rs-font-weight-medium);
|
|
59
|
-
line-height: var(--rs-line-height-t3);
|
|
60
|
-
letter-spacing: var(--rs-letter-spacing-t3);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
82
|
.content ul,
|
|
64
83
|
.content ol {
|
|
65
84
|
padding-left: var(--rs-space-5);
|
|
@@ -71,6 +90,10 @@
|
|
|
71
90
|
margin: var(--rs-space-2) 0;
|
|
72
91
|
}
|
|
73
92
|
|
|
93
|
+
.content a {
|
|
94
|
+
font-size: inherit;
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
.content [role="tablist"] {
|
|
75
98
|
margin-bottom: var(--rs-space-3);
|
|
76
99
|
}
|
|
@@ -81,12 +104,21 @@
|
|
|
81
104
|
}
|
|
82
105
|
|
|
83
106
|
.content table {
|
|
84
|
-
display:
|
|
85
|
-
|
|
86
|
-
|
|
107
|
+
display: table;
|
|
108
|
+
table-layout: fixed;
|
|
109
|
+
width: 100%;
|
|
87
110
|
margin-bottom: var(--rs-space-5);
|
|
88
111
|
}
|
|
89
112
|
|
|
113
|
+
.content table td,
|
|
114
|
+
.content table th {
|
|
115
|
+
overflow: visible;
|
|
116
|
+
white-space: normal;
|
|
117
|
+
text-overflow: unset;
|
|
118
|
+
word-wrap: break-word;
|
|
119
|
+
vertical-align: top;
|
|
120
|
+
}
|
|
121
|
+
|
|
90
122
|
.content details {
|
|
91
123
|
border: 1px solid var(--rs-color-border-base-primary);
|
|
92
124
|
border-radius: var(--rs-radius-2);
|
|
@@ -82,7 +82,7 @@ function LayoutInner({
|
|
|
82
82
|
) : null}
|
|
83
83
|
</aside>
|
|
84
84
|
) : null}
|
|
85
|
-
<div className={cx(styles.content, classNames?.content)}>
|
|
85
|
+
<div className={cx(styles.content, classNames?.content, { [styles.contentFull]: !showSidebar })}>
|
|
86
86
|
{config.search?.enabled && <Search classNames={{ trigger: styles.hiddenTrigger }} />}
|
|
87
87
|
{children}
|
|
88
88
|
</div>
|
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
.main {
|
|
2
2
|
flex: 1;
|
|
3
3
|
width: 100%;
|
|
4
|
-
max-width:
|
|
4
|
+
max-width: 1024px;
|
|
5
5
|
margin: 0 auto;
|
|
6
6
|
padding-top: var(--rs-space-12);
|
|
7
|
-
padding-right: var(--rs-space-17);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
.readerMode {
|
|
11
|
-
padding-right: 0;
|
|
12
|
-
margin: 0 auto;
|
|
13
7
|
}
|
|
14
8
|
|
|
15
9
|
.navbar {
|
|
@@ -147,37 +141,45 @@
|
|
|
147
141
|
.content h4,
|
|
148
142
|
.content h5,
|
|
149
143
|
.content h6 {
|
|
150
|
-
|
|
144
|
+
font-family: var(--rs-font-title);
|
|
145
|
+
font-weight: var(--rs-font-weight-medium);
|
|
146
|
+
color: var(--rs-color-foreground-base-primary);
|
|
151
147
|
}
|
|
152
148
|
|
|
153
149
|
.content h1 {
|
|
150
|
+
font-size: var(--rs-font-size-t4);
|
|
151
|
+
line-height: var(--rs-line-height-t4);
|
|
154
152
|
margin: 2rem 0 1rem;
|
|
155
|
-
font-size: 2rem;
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
.content h2 {
|
|
156
|
+
font-size: var(--rs-font-size-t3);
|
|
157
|
+
line-height: var(--rs-line-height-t3);
|
|
159
158
|
margin: 1.75rem 0 0.75rem;
|
|
160
|
-
font-size: 1.5rem;
|
|
161
159
|
}
|
|
162
160
|
|
|
163
161
|
.content h3 {
|
|
162
|
+
font-size: var(--rs-font-size-t2);
|
|
163
|
+
line-height: var(--rs-line-height-t2);
|
|
164
164
|
margin: 1.5rem 0 0.5rem;
|
|
165
|
-
font-size: 1.25rem;
|
|
166
165
|
}
|
|
167
166
|
|
|
168
167
|
.content h4 {
|
|
168
|
+
font-size: var(--rs-font-size-t1);
|
|
169
|
+
line-height: var(--rs-line-height-t1);
|
|
169
170
|
margin: 1.25rem 0 0.5rem;
|
|
170
|
-
font-size: 1.1rem;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
.content h5 {
|
|
174
|
+
font-size: var(--rs-font-size-large);
|
|
175
|
+
line-height: var(--rs-line-height-large);
|
|
174
176
|
margin: 1rem 0 0.5rem;
|
|
175
|
-
font-size: 1rem;
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
.content h6 {
|
|
180
|
+
font-size: var(--rs-font-size-regular);
|
|
181
|
+
line-height: var(--rs-line-height-regular);
|
|
179
182
|
margin: 1rem 0 0.5rem;
|
|
180
|
-
font-size: 0.875rem;
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
.content p {
|
|
@@ -198,12 +200,25 @@
|
|
|
198
200
|
}
|
|
199
201
|
|
|
200
202
|
.content table {
|
|
201
|
-
display:
|
|
203
|
+
display: table;
|
|
204
|
+
table-layout: fixed;
|
|
202
205
|
width: 100%;
|
|
203
|
-
overflow-x: auto;
|
|
204
206
|
margin-bottom: var(--rs-space-5);
|
|
205
207
|
}
|
|
206
208
|
|
|
209
|
+
.content table td,
|
|
210
|
+
.content table th {
|
|
211
|
+
overflow: visible;
|
|
212
|
+
white-space: normal;
|
|
213
|
+
text-overflow: unset;
|
|
214
|
+
word-wrap: break-word;
|
|
215
|
+
vertical-align: top;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.content a {
|
|
219
|
+
font-size: inherit;
|
|
220
|
+
}
|
|
221
|
+
|
|
207
222
|
.content [role="tablist"] {
|
|
208
223
|
margin-bottom: var(--rs-space-3);
|
|
209
224
|
}
|
|
@@ -41,7 +41,7 @@ export function Page({ page, tree }: ThemePageProps) {
|
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
43
|
<>
|
|
44
|
-
<main className={
|
|
44
|
+
<main className={styles.main}>
|
|
45
45
|
<div className={styles.navbar}>
|
|
46
46
|
<div className={styles.navLeft}>
|
|
47
47
|
<div className={styles.arrows}>
|