@raystack/chronicle 0.10.3 → 0.11.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/dist/cli/index.js +42 -6
- package/package.json +6 -1
- package/src/cli/commands/dev.ts +2 -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/ready.ts +13 -9
- 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 +16 -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");
|
|
@@ -408,7 +434,7 @@ async function createViteConfig(options) {
|
|
|
408
434
|
}],
|
|
409
435
|
remark_unused_directives_default,
|
|
410
436
|
remark_resolve_links_default,
|
|
411
|
-
remark_resolve_images_default,
|
|
437
|
+
[remark_resolve_images_default, { optimize: !isStaticPreset(preset) }],
|
|
412
438
|
remarkMdxMermaid,
|
|
413
439
|
readingTime
|
|
414
440
|
]
|
|
@@ -446,7 +472,8 @@ async function createViteConfig(options) {
|
|
|
446
472
|
}
|
|
447
473
|
},
|
|
448
474
|
ssr: {
|
|
449
|
-
noExternal: ["@raystack/apsara", "dayjs", "fumadocs-core"]
|
|
475
|
+
noExternal: ["@raystack/apsara", "dayjs", "fumadocs-core"],
|
|
476
|
+
external: ["analytics", "use-analytics", "@analytics/google-analytics"]
|
|
450
477
|
},
|
|
451
478
|
environments: {
|
|
452
479
|
client: {
|
|
@@ -464,6 +491,13 @@ async function createViteConfig(options) {
|
|
|
464
491
|
output: {
|
|
465
492
|
dir: resolveOutputDir(projectRoot, preset)
|
|
466
493
|
},
|
|
494
|
+
externals: ["sharp"],
|
|
495
|
+
storage: {
|
|
496
|
+
"image-cache": {
|
|
497
|
+
driver: "fs",
|
|
498
|
+
base: path6.resolve(projectRoot, ".cache/images")
|
|
499
|
+
}
|
|
500
|
+
},
|
|
467
501
|
experimental: {
|
|
468
502
|
database: true
|
|
469
503
|
},
|
|
@@ -473,11 +507,13 @@ async function createViteConfig(options) {
|
|
|
473
507
|
}
|
|
474
508
|
};
|
|
475
509
|
}
|
|
510
|
+
var STATIC_PRESETS;
|
|
476
511
|
var init_vite_config = __esm(() => {
|
|
477
512
|
init_remark_resolve_images();
|
|
478
513
|
init_remark_resolve_links();
|
|
479
514
|
init_remark_reading_time();
|
|
480
515
|
init_remark_unused_directives();
|
|
516
|
+
STATIC_PRESETS = new Set(["static", "vercel-static", "cloudflare-pages", "github-pages"]);
|
|
481
517
|
});
|
|
482
518
|
|
|
483
519
|
// src/cli/index.ts
|
|
@@ -835,14 +871,14 @@ var buildCommand = new Command("build").description("Build for production").opti
|
|
|
835
871
|
// src/cli/commands/dev.ts
|
|
836
872
|
import chalk3 from "chalk";
|
|
837
873
|
import { Command as Command2 } from "commander";
|
|
838
|
-
var devCommand = new Command2("dev").description("Start development server").option("-p, --port <port>", "Port number", "3000").option("--config <path>", "Path to chronicle.yaml").option("--host <host>", "Host address", "localhost").action(async (options) => {
|
|
874
|
+
var devCommand = new Command2("dev").description("Start development server").option("-p, --port <port>", "Port number", "3000").option("--config <path>", "Path to chronicle.yaml").option("--host <host>", "Host address", "localhost").option("--preset <preset>", "Deploy preset (bun, node-server, etc.)").action(async (options) => {
|
|
839
875
|
const { config: config2, projectRoot, configPath } = await loadCLIConfig(options.config);
|
|
840
876
|
const port = parseInt(options.port, 10);
|
|
841
877
|
await linkContent(projectRoot, config2);
|
|
842
878
|
console.log(chalk3.cyan("Starting dev server..."));
|
|
843
879
|
const { createServer } = await import("vite");
|
|
844
880
|
const { createViteConfig: createViteConfig2 } = await Promise.resolve().then(() => (init_vite_config(), exports_vite_config));
|
|
845
|
-
const viteConfig = await createViteConfig2({ packageRoot: PACKAGE_ROOT, projectRoot, configPath });
|
|
881
|
+
const viteConfig = await createViteConfig2({ packageRoot: PACKAGE_ROOT, projectRoot, configPath, preset: options.preset });
|
|
846
882
|
const server = await createServer({
|
|
847
883
|
...viteConfig,
|
|
848
884
|
server: { ...viteConfig.server, port, host: options.host }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raystack/chronicle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
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"
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const devCommand = new Command('dev')
|
|
|
9
9
|
.option('-p, --port <port>', 'Port number', '3000')
|
|
10
10
|
.option('--config <path>', 'Path to chronicle.yaml')
|
|
11
11
|
.option('--host <host>', 'Host address', 'localhost')
|
|
12
|
+
.option('--preset <preset>', 'Deploy preset (bun, node-server, etc.)')
|
|
12
13
|
.action(async options => {
|
|
13
14
|
const { config, projectRoot, configPath } = await loadCLIConfig(options.config);
|
|
14
15
|
const port = parseInt(options.port, 10);
|
|
@@ -20,7 +21,7 @@ export const devCommand = new Command('dev')
|
|
|
20
21
|
const { createServer } = await import('vite');
|
|
21
22
|
const { createViteConfig } = await import('@/server/vite-config');
|
|
22
23
|
|
|
23
|
-
const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath });
|
|
24
|
+
const viteConfig = await createViteConfig({ packageRoot: PACKAGE_ROOT, projectRoot, configPath, preset: options.preset });
|
|
24
25
|
const server = await createServer({
|
|
25
26
|
...viteConfig,
|
|
26
27
|
server: { ...viteConfig.server, port, host: options.host }
|
|
@@ -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/ready.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { defineHandler } from 'nitro';
|
|
2
|
-
import { isSearchReady } from './search';
|
|
2
|
+
import { ensureIndex, isSearchReady } from './search';
|
|
3
|
+
import { LATEST_CONTEXT } from '@/lib/version-source';
|
|
3
4
|
|
|
4
|
-
export default defineHandler(() => {
|
|
5
|
-
|
|
5
|
+
export default defineHandler(async () => {
|
|
6
|
+
ensureIndex(LATEST_CONTEXT).catch(e => console.error('[search:index]', e));
|
|
6
7
|
|
|
7
|
-
if (!
|
|
8
|
-
return Response.
|
|
9
|
-
|
|
10
|
-
{
|
|
11
|
-
);
|
|
8
|
+
if (!isSearchReady()) {
|
|
9
|
+
return new Response(JSON.stringify({ status: 'not_ready', search: false }), {
|
|
10
|
+
status: 503,
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
});
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
return Response.
|
|
15
|
+
return new Response(JSON.stringify({ status: 'ready', search: true }), {
|
|
16
|
+
status: 200,
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
});
|
|
15
19
|
});
|
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');
|
|
@@ -94,7 +100,7 @@ export async function createViteConfig(
|
|
|
94
100
|
}],
|
|
95
101
|
remarkUnusedDirectives,
|
|
96
102
|
remarkResolveLinks,
|
|
97
|
-
remarkResolveImages,
|
|
103
|
+
[remarkResolveImages, { optimize: !isStaticPreset(preset) }],
|
|
98
104
|
remarkMdxMermaid,
|
|
99
105
|
remarkReadingTime,
|
|
100
106
|
],
|
|
@@ -132,7 +138,8 @@ export async function createViteConfig(
|
|
|
132
138
|
}
|
|
133
139
|
},
|
|
134
140
|
ssr: {
|
|
135
|
-
noExternal: ['@raystack/apsara', 'dayjs', 'fumadocs-core']
|
|
141
|
+
noExternal: ['@raystack/apsara', 'dayjs', 'fumadocs-core'],
|
|
142
|
+
external: ['analytics', 'use-analytics', '@analytics/google-analytics'],
|
|
136
143
|
},
|
|
137
144
|
environments: {
|
|
138
145
|
client: {
|
|
@@ -150,6 +157,13 @@ export async function createViteConfig(
|
|
|
150
157
|
output: {
|
|
151
158
|
dir: resolveOutputDir(projectRoot, preset),
|
|
152
159
|
},
|
|
160
|
+
externals: ['sharp'],
|
|
161
|
+
storage: {
|
|
162
|
+
'image-cache': {
|
|
163
|
+
driver: 'fs',
|
|
164
|
+
base: path.resolve(projectRoot, '.cache/images'),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
153
167
|
experimental: {
|
|
154
168
|
database: true,
|
|
155
169
|
},
|
|
@@ -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}>
|