@raystack/chronicle 0.11.3 → 0.12.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 +44 -14
- package/package.json +1 -1
- package/src/components/api/api-code-snippet.module.css +2 -0
- package/src/components/api/api-overview.module.css +10 -0
- package/src/lib/remark-resolve-images.ts +44 -13
- package/src/server/api/image.test.ts +6 -0
- package/src/server/api/image.ts +63 -20
- package/src/server/api/ready.ts +2 -0
- package/src/themes/default/Layout.module.css +127 -4
- package/src/themes/default/Layout.tsx +104 -18
- package/src/themes/default/Page.module.css +14 -0
package/dist/cli/index.js
CHANGED
|
@@ -71,6 +71,29 @@ var init_image_utils = () => {};
|
|
|
71
71
|
// src/lib/remark-resolve-images.ts
|
|
72
72
|
import path4 from "node:path";
|
|
73
73
|
import { visit } from "unist-util-visit";
|
|
74
|
+
function parseImageParams(src) {
|
|
75
|
+
const qIdx = src.indexOf("?");
|
|
76
|
+
if (qIdx === -1)
|
|
77
|
+
return { base: src, params: {} };
|
|
78
|
+
const base = src.slice(0, qIdx);
|
|
79
|
+
const search = new URLSearchParams(src.slice(qIdx + 1));
|
|
80
|
+
const params = {};
|
|
81
|
+
if (search.has("w"))
|
|
82
|
+
params.w = Number.parseInt(search.get("w"), 10);
|
|
83
|
+
if (search.has("q"))
|
|
84
|
+
params.q = Number.parseInt(search.get("q"), 10);
|
|
85
|
+
return { base, params };
|
|
86
|
+
}
|
|
87
|
+
function appendParams(url, params) {
|
|
88
|
+
if (!params.w && !params.q)
|
|
89
|
+
return url;
|
|
90
|
+
const qs = new URLSearchParams;
|
|
91
|
+
if (params.w)
|
|
92
|
+
qs.set("w", String(params.w));
|
|
93
|
+
if (params.q)
|
|
94
|
+
qs.set("q", String(params.q));
|
|
95
|
+
return `${url}?${qs}`;
|
|
96
|
+
}
|
|
74
97
|
function resolveUrl(src, dir) {
|
|
75
98
|
const normalized = src.replace(/\\/g, "/");
|
|
76
99
|
if (/^[a-z][a-z0-9+\-.]*:/i.test(normalized))
|
|
@@ -86,9 +109,12 @@ function resolveUrl(src, dir) {
|
|
|
86
109
|
return `/_content/${path4.posix.normalize(path4.posix.join(dir, normalized))}`;
|
|
87
110
|
}
|
|
88
111
|
function optimizeUrl(url, optimize) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
const { base, params } = parseImageParams(url);
|
|
113
|
+
const width = params.w || DEFAULT_WIDTH;
|
|
114
|
+
const quality = params.q;
|
|
115
|
+
if (optimize && isLocalImage(base) && !isSvg(base))
|
|
116
|
+
return buildOptimizedUrl(base, width, quality);
|
|
117
|
+
return base;
|
|
92
118
|
}
|
|
93
119
|
var remarkResolveImages = (options) => {
|
|
94
120
|
const optimize = options?.optimize ?? true;
|
|
@@ -112,15 +138,17 @@ var remarkResolveImages = (options) => {
|
|
|
112
138
|
visit(tree, "image", (node) => {
|
|
113
139
|
if (!node.url)
|
|
114
140
|
return;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
const { base, params } = parseImageParams(node.url);
|
|
142
|
+
const resolved = resolveUrl(base, dir);
|
|
143
|
+
collect(resolved);
|
|
144
|
+
node.url = optimizeUrl(appendParams(resolved, params), optimize);
|
|
118
145
|
});
|
|
119
146
|
visit(tree, "html", (node) => {
|
|
120
147
|
node.value = node.value.replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (_, before, src, after) => {
|
|
121
|
-
const
|
|
148
|
+
const { base, params } = parseImageParams(src);
|
|
149
|
+
const resolved = resolveUrl(base, dir);
|
|
122
150
|
collect(resolved);
|
|
123
|
-
return `${before}${optimizeUrl(resolved, optimize)}${after}`;
|
|
151
|
+
return `${before}${optimizeUrl(appendParams(resolved, params), optimize)}${after}`;
|
|
124
152
|
});
|
|
125
153
|
});
|
|
126
154
|
visit(tree, (node) => {
|
|
@@ -132,9 +160,10 @@ var remarkResolveImages = (options) => {
|
|
|
132
160
|
const srcAttr = jsx.attributes.find((a) => a.type === "mdxJsxAttribute" && a.name === "src");
|
|
133
161
|
if (!srcAttr?.value || typeof srcAttr.value !== "string")
|
|
134
162
|
return;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
const { base: jsxBase, params: jsxParams } = parseImageParams(srcAttr.value);
|
|
164
|
+
const jsxResolved = resolveUrl(jsxBase, dir);
|
|
165
|
+
collect(jsxResolved);
|
|
166
|
+
srcAttr.value = optimizeUrl(appendParams(jsxResolved, jsxParams), optimize);
|
|
138
167
|
});
|
|
139
168
|
visit(tree, "element", (node) => {
|
|
140
169
|
if (node.tagName !== "img")
|
|
@@ -142,9 +171,10 @@ var remarkResolveImages = (options) => {
|
|
|
142
171
|
const src = node.properties?.src;
|
|
143
172
|
if (typeof src !== "string")
|
|
144
173
|
return;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
174
|
+
const { base: elBase, params: elParams } = parseImageParams(src);
|
|
175
|
+
const elResolved = resolveUrl(elBase, dir);
|
|
176
|
+
collect(elResolved);
|
|
177
|
+
node.properties.src = optimizeUrl(appendParams(elResolved, elParams), optimize);
|
|
148
178
|
});
|
|
149
179
|
file.data.images = images;
|
|
150
180
|
};
|
package/package.json
CHANGED
|
@@ -60,6 +60,16 @@
|
|
|
60
60
|
|
|
61
61
|
.left,
|
|
62
62
|
.right {
|
|
63
|
+
min-width: 0;
|
|
64
|
+
max-width: 100%;
|
|
63
65
|
width: 100%;
|
|
64
66
|
}
|
|
65
67
|
}
|
|
68
|
+
|
|
69
|
+
@media (max-width: 768px) {
|
|
70
|
+
.layout {
|
|
71
|
+
gap: var(--rs-space-5);
|
|
72
|
+
padding-left: var(--rs-space-5);
|
|
73
|
+
padding-right: var(--rs-space-5);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -7,6 +7,30 @@ import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdas
|
|
|
7
7
|
import { MdxNodeType } from './mdx-utils'
|
|
8
8
|
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from './image-utils'
|
|
9
9
|
|
|
10
|
+
interface ImageParams {
|
|
11
|
+
w?: number
|
|
12
|
+
q?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseImageParams(src: string): { base: string; params: ImageParams } {
|
|
16
|
+
const qIdx = src.indexOf('?')
|
|
17
|
+
if (qIdx === -1) return { base: src, params: {} }
|
|
18
|
+
const base = src.slice(0, qIdx)
|
|
19
|
+
const search = new URLSearchParams(src.slice(qIdx + 1))
|
|
20
|
+
const params: ImageParams = {}
|
|
21
|
+
if (search.has('w')) params.w = Number.parseInt(search.get('w')!, 10)
|
|
22
|
+
if (search.has('q')) params.q = Number.parseInt(search.get('q')!, 10)
|
|
23
|
+
return { base, params }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function appendParams(url: string, params: ImageParams): string {
|
|
27
|
+
if (!params.w && !params.q) return url
|
|
28
|
+
const qs = new URLSearchParams()
|
|
29
|
+
if (params.w) qs.set('w', String(params.w))
|
|
30
|
+
if (params.q) qs.set('q', String(params.q))
|
|
31
|
+
return `${url}?${qs}`
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
function resolveUrl(src: string, dir: string): string {
|
|
11
35
|
const normalized = src.replace(/\\/g, '/')
|
|
12
36
|
if (/^[a-z][a-z0-9+\-.]*:/i.test(normalized)) return normalized
|
|
@@ -23,8 +47,11 @@ interface RemarkResolveImagesOptions {
|
|
|
23
47
|
}
|
|
24
48
|
|
|
25
49
|
function optimizeUrl(url: string, optimize: boolean): string {
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
const { base, params } = parseImageParams(url)
|
|
51
|
+
const width = params.w || DEFAULT_WIDTH
|
|
52
|
+
const quality = params.q
|
|
53
|
+
if (optimize && isLocalImage(base) && !isSvg(base)) return buildOptimizedUrl(base, width, quality)
|
|
54
|
+
return base
|
|
28
55
|
}
|
|
29
56
|
|
|
30
57
|
const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) => {
|
|
@@ -50,18 +77,20 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) =>
|
|
|
50
77
|
|
|
51
78
|
visit(tree, 'image', (node: Image) => {
|
|
52
79
|
if (!node.url) return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
80
|
+
const { base, params } = parseImageParams(node.url)
|
|
81
|
+
const resolved = resolveUrl(base, dir)
|
|
82
|
+
collect(resolved)
|
|
83
|
+
node.url = optimizeUrl(appendParams(resolved, params), optimize)
|
|
56
84
|
})
|
|
57
85
|
|
|
58
86
|
visit(tree, 'html', (node: Html) => {
|
|
59
87
|
node.value = node.value.replace(
|
|
60
88
|
/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi,
|
|
61
89
|
(_, before, src, after) => {
|
|
62
|
-
const
|
|
90
|
+
const { base, params } = parseImageParams(src)
|
|
91
|
+
const resolved = resolveUrl(base, dir)
|
|
63
92
|
collect(resolved)
|
|
64
|
-
return `${before}${optimizeUrl(resolved, optimize)}${after}`
|
|
93
|
+
return `${before}${optimizeUrl(appendParams(resolved, params), optimize)}${after}`
|
|
65
94
|
}
|
|
66
95
|
)
|
|
67
96
|
})
|
|
@@ -72,18 +101,20 @@ const remarkResolveImages: Plugin<[RemarkResolveImagesOptions?]> = (options) =>
|
|
|
72
101
|
if (jsx.name !== 'img') return
|
|
73
102
|
const srcAttr = jsx.attributes.find((a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src')
|
|
74
103
|
if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
104
|
+
const { base: jsxBase, params: jsxParams } = parseImageParams(srcAttr.value)
|
|
105
|
+
const jsxResolved = resolveUrl(jsxBase, dir)
|
|
106
|
+
collect(jsxResolved)
|
|
107
|
+
srcAttr.value = optimizeUrl(appendParams(jsxResolved, jsxParams), optimize)
|
|
78
108
|
})
|
|
79
109
|
|
|
80
110
|
visit(tree, 'element', (node: Element) => {
|
|
81
111
|
if (node.tagName !== 'img') return
|
|
82
112
|
const src = node.properties?.src
|
|
83
113
|
if (typeof src !== 'string') return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
114
|
+
const { base: elBase, params: elParams } = parseImageParams(src)
|
|
115
|
+
const elResolved = resolveUrl(elBase, dir)
|
|
116
|
+
collect(elResolved)
|
|
117
|
+
node.properties.src = optimizeUrl(appendParams(elResolved, elParams), optimize)
|
|
87
118
|
})
|
|
88
119
|
|
|
89
120
|
file.data.images = images
|
|
@@ -48,6 +48,12 @@ describe('cacheKey', () => {
|
|
|
48
48
|
expect(a).not.toBe(b);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
test('returns different keys for different mtime', () => {
|
|
52
|
+
const a = cacheKey('/_content/img.png', 640, 75, 'webp', 1000);
|
|
53
|
+
const b = cacheKey('/_content/img.png', 640, 75, 'webp', 2000);
|
|
54
|
+
expect(a).not.toBe(b);
|
|
55
|
+
});
|
|
56
|
+
|
|
51
57
|
test('key ends with format extension', () => {
|
|
52
58
|
expect(cacheKey('/_content/img.png', 640, 75, 'webp')).toMatch(/\.webp$/);
|
|
53
59
|
expect(cacheKey('/_content/img.png', 640, 75, 'avif')).toMatch(/\.avif$/);
|
package/src/server/api/image.ts
CHANGED
|
@@ -6,10 +6,9 @@ import { useStorage } from 'nitro/storage'
|
|
|
6
6
|
import sharp from 'sharp'
|
|
7
7
|
import { StatusCodes } from 'http-status-codes'
|
|
8
8
|
import { safePath } from '@/server/utils/safe-path'
|
|
9
|
-
import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_QUALITY } from '@/lib/image-utils'
|
|
9
|
+
import { ALLOWED_WIDTHS, ALLOWED_QUALITIES, DEFAULT_WIDTH, DEFAULT_QUALITY, isLocalImage, isSvg } from '@/lib/image-utils'
|
|
10
10
|
|
|
11
|
-
const STORAGE_KEY = 'image-cache'
|
|
12
|
-
const MAX_CACHE_ENTRIES = 500
|
|
11
|
+
export const STORAGE_KEY = 'image-cache'
|
|
13
12
|
|
|
14
13
|
const inflight = new Map<string, Promise<Buffer>>()
|
|
15
14
|
|
|
@@ -29,8 +28,8 @@ export const MIME: Record<string, string> = {
|
|
|
29
28
|
'.webp': 'image/webp',
|
|
30
29
|
}
|
|
31
30
|
|
|
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)
|
|
31
|
+
export function cacheKey(url: string, w: number, q: number, format: OutputFormat, mtime?: number): string {
|
|
32
|
+
const hash = crypto.createHash('sha256').update(`${url}:${w}:${q}:${format}:${mtime ?? 0}`).digest('hex').slice(0, 16)
|
|
34
33
|
return `${hash}.${format}`
|
|
35
34
|
}
|
|
36
35
|
|
|
@@ -42,11 +41,17 @@ function snapQuality(q: number): number {
|
|
|
42
41
|
return closest;
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
async function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
export async function optimizeImage(
|
|
45
|
+
filePath: string,
|
|
46
|
+
w: number,
|
|
47
|
+
q: number,
|
|
48
|
+
format: OutputFormat,
|
|
49
|
+
): Promise<Buffer> {
|
|
50
|
+
const source = await fs.readFile(filePath);
|
|
51
|
+
const pipeline = sharp(source).resize({ width: w, withoutEnlargement: true });
|
|
52
|
+
if (format === 'avif') return pipeline.avif({ quality: q }).toBuffer();
|
|
53
|
+
if (format === 'webp') return pipeline.webp({ quality: q }).toBuffer();
|
|
54
|
+
return pipeline.toBuffer();
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
export default defineHandler(async event => {
|
|
@@ -90,7 +95,11 @@ export default defineHandler(async event => {
|
|
|
90
95
|
const originalMime = MIME[ext] ?? 'application/octet-stream'
|
|
91
96
|
const contentType = format === 'original' ? originalMime : `image/${format}`
|
|
92
97
|
|
|
93
|
-
const
|
|
98
|
+
const stat = await fs.stat(filePath).catch(() => null)
|
|
99
|
+
if (!stat) {
|
|
100
|
+
throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: 'Not Found' })
|
|
101
|
+
}
|
|
102
|
+
const key = cacheKey(url, w, q, format, stat.mtimeMs)
|
|
94
103
|
|
|
95
104
|
const cached = await storage.getItemRaw<Buffer>(key)
|
|
96
105
|
if (cached) {
|
|
@@ -116,16 +125,8 @@ export default defineHandler(async event => {
|
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
const work = (async () => {
|
|
119
|
-
const
|
|
120
|
-
const pipeline = sharp(source).resize({ width: w, withoutEnlargement: true })
|
|
121
|
-
const optimized = format === 'avif'
|
|
122
|
-
? await pipeline.avif({ quality: q }).toBuffer()
|
|
123
|
-
: format === 'webp'
|
|
124
|
-
? await pipeline.webp({ quality: q }).toBuffer()
|
|
125
|
-
: await pipeline.toBuffer()
|
|
126
|
-
|
|
128
|
+
const optimized = await optimizeImage(filePath, w, q, format)
|
|
127
129
|
await storage.setItemRaw(key, optimized)
|
|
128
|
-
await evictIfNeeded(storage)
|
|
129
130
|
return optimized
|
|
130
131
|
})()
|
|
131
132
|
|
|
@@ -154,3 +155,45 @@ export default defineHandler(async event => {
|
|
|
154
155
|
inflight.delete(key)
|
|
155
156
|
}
|
|
156
157
|
})
|
|
158
|
+
|
|
159
|
+
export async function warmupImageCache() {
|
|
160
|
+
const { getPages, getPageImages } = await import('@/lib/source');
|
|
161
|
+
// biome-ignore lint/correctness/useHookAtTopLevel: useStorage is a Nitro DI accessor, not a React hook
|
|
162
|
+
const storage = useStorage(STORAGE_KEY);
|
|
163
|
+
const contentDir = __CHRONICLE_CONTENT_DIR__;
|
|
164
|
+
const format = 'webp' as const;
|
|
165
|
+
const w = DEFAULT_WIDTH;
|
|
166
|
+
const q = DEFAULT_QUALITY;
|
|
167
|
+
|
|
168
|
+
const pages = await getPages();
|
|
169
|
+
const seen = new Set<string>();
|
|
170
|
+
let warmed = 0;
|
|
171
|
+
|
|
172
|
+
for (const page of pages) {
|
|
173
|
+
for (const url of getPageImages(page)) {
|
|
174
|
+
if (!isLocalImage(url) || isSvg(url) || seen.has(url)) continue;
|
|
175
|
+
seen.add(url);
|
|
176
|
+
|
|
177
|
+
const relativePath = url.replace(/^\/_content\//, '');
|
|
178
|
+
const filePath = safePath(contentDir, `/${relativePath}`);
|
|
179
|
+
if (!filePath) continue;
|
|
180
|
+
|
|
181
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
182
|
+
if (!stat) continue;
|
|
183
|
+
|
|
184
|
+
const key = cacheKey(url, w, q, format, stat.mtimeMs);
|
|
185
|
+
const cached = await storage.getItemRaw(key);
|
|
186
|
+
if (cached) continue;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const optimized = await optimizeImage(filePath, w, q, format);
|
|
190
|
+
await storage.setItemRaw(key, optimized);
|
|
191
|
+
warmed++;
|
|
192
|
+
} catch { /* skip unprocessable */ }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (warmed > 0) {
|
|
197
|
+
console.log(`[image-warmup] cached ${warmed} images as webp@${w}w`);
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/server/api/ready.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { defineHandler } from 'nitro';
|
|
2
2
|
import { ensureIndex, isSearchReady } from './search';
|
|
3
|
+
import { warmupImageCache } from './image';
|
|
3
4
|
import { LATEST_CONTEXT } from '@/lib/version-source';
|
|
4
5
|
|
|
5
6
|
export default defineHandler(async () => {
|
|
6
7
|
ensureIndex(LATEST_CONTEXT).catch(e => console.error('[search:index]', e));
|
|
8
|
+
warmupImageCache().catch(e => console.error('[image-warmup]', e));
|
|
7
9
|
|
|
8
10
|
if (!isSearchReady()) {
|
|
9
11
|
return new Response(JSON.stringify({ status: 'not_ready', search: false }), {
|
|
@@ -76,7 +76,9 @@
|
|
|
76
76
|
|
|
77
77
|
.topLinks {
|
|
78
78
|
width: 100%;
|
|
79
|
-
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
gap: 0;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
.topLinkItem {
|
|
@@ -135,7 +137,7 @@
|
|
|
135
137
|
.cardWrapper {
|
|
136
138
|
flex: 1;
|
|
137
139
|
display: flex;
|
|
138
|
-
padding: 0
|
|
140
|
+
padding: 0;
|
|
139
141
|
min-height: 0;
|
|
140
142
|
background: var(--rs-color-background-base-secondary);
|
|
141
143
|
}
|
|
@@ -154,7 +156,7 @@
|
|
|
154
156
|
align-items: center;
|
|
155
157
|
justify-content: space-between;
|
|
156
158
|
height: var(--navbar-height);
|
|
157
|
-
padding: var(--rs-space-4) var(--rs-space-
|
|
159
|
+
padding: var(--rs-space-4) var(--rs-space-8);
|
|
158
160
|
background: var(--rs-color-background-base-primary);
|
|
159
161
|
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
|
|
160
162
|
backdrop-filter: blur(1px);
|
|
@@ -172,7 +174,7 @@
|
|
|
172
174
|
|
|
173
175
|
.groupItems {
|
|
174
176
|
padding-left: 0;
|
|
175
|
-
padding-bottom:
|
|
177
|
+
padding-bottom: 0;
|
|
176
178
|
gap: 0;
|
|
177
179
|
}
|
|
178
180
|
|
|
@@ -190,6 +192,9 @@
|
|
|
190
192
|
|
|
191
193
|
.navGroup .navGroupHeader {
|
|
192
194
|
margin: 0;
|
|
195
|
+
margin-bottom: 0;
|
|
196
|
+
padding-top: 0;
|
|
197
|
+
padding-bottom: 0;
|
|
193
198
|
}
|
|
194
199
|
|
|
195
200
|
.navGroup[data-depth='1'] .navGroupHeader {
|
|
@@ -283,3 +288,121 @@
|
|
|
283
288
|
line-height: var(--rs-line-height-mini);
|
|
284
289
|
flex-shrink: 0;
|
|
285
290
|
}
|
|
291
|
+
|
|
292
|
+
.mobileMenuBtn {
|
|
293
|
+
display: none;
|
|
294
|
+
align-items: center;
|
|
295
|
+
justify-content: center;
|
|
296
|
+
background: none;
|
|
297
|
+
border: none;
|
|
298
|
+
cursor: pointer;
|
|
299
|
+
padding: var(--rs-space-1);
|
|
300
|
+
color: var(--rs-color-foreground-base-primary);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.mobileHeader {
|
|
304
|
+
display: none;
|
|
305
|
+
align-items: center;
|
|
306
|
+
justify-content: space-between;
|
|
307
|
+
height: var(--navbar-height);
|
|
308
|
+
padding: 0 var(--rs-space-5);
|
|
309
|
+
background: var(--rs-color-background-base-primary);
|
|
310
|
+
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
|
|
311
|
+
backdrop-filter: blur(1px);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.mobileMenu {
|
|
315
|
+
display: none;
|
|
316
|
+
position: fixed;
|
|
317
|
+
top: var(--navbar-height);
|
|
318
|
+
left: 0;
|
|
319
|
+
right: 0;
|
|
320
|
+
bottom: 0;
|
|
321
|
+
z-index: 100;
|
|
322
|
+
background: var(--rs-color-background-base-primary);
|
|
323
|
+
overflow-y: auto;
|
|
324
|
+
padding: var(--rs-space-7) var(--rs-space-5);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.mobileMenuFooter {
|
|
328
|
+
margin-top: var(--rs-space-7);
|
|
329
|
+
padding-top: var(--rs-space-5);
|
|
330
|
+
border-top: 0.5px solid var(--rs-color-border-base-primary);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.mobileNav {
|
|
334
|
+
display: none;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
@media (max-width: 768px) {
|
|
338
|
+
.sidebar {
|
|
339
|
+
display: none;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.mobileHeader {
|
|
343
|
+
display: flex;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.mobileMenuBtn {
|
|
347
|
+
display: flex;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.mobileMenu[data-open='true'] {
|
|
351
|
+
display: block;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.subNav {
|
|
355
|
+
display: none;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.content {
|
|
359
|
+
padding: var(--rs-space-10) var(--rs-space-5);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.card {
|
|
363
|
+
width: 100%;
|
|
364
|
+
border-left: none;
|
|
365
|
+
box-shadow: none;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.cardWrapper {
|
|
369
|
+
padding: 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.mobileNav {
|
|
373
|
+
display: flex;
|
|
374
|
+
gap: var(--rs-space-10);
|
|
375
|
+
padding: var(--rs-space-3) var(--rs-space-5);
|
|
376
|
+
background: var(--rs-color-background-base-primary);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.mobileNavLink {
|
|
380
|
+
flex: 1;
|
|
381
|
+
display: flex;
|
|
382
|
+
align-items: center;
|
|
383
|
+
gap: var(--rs-space-3);
|
|
384
|
+
padding: var(--rs-space-4) var(--rs-space-3);
|
|
385
|
+
border: 0.5px solid var(--rs-color-border-base-primary);
|
|
386
|
+
border-radius: var(--rs-radius-4);
|
|
387
|
+
text-decoration: none;
|
|
388
|
+
font-family: var(--rs-font-body);
|
|
389
|
+
font-size: var(--rs-font-size-regular);
|
|
390
|
+
font-weight: var(--rs-font-weight-medium);
|
|
391
|
+
line-height: var(--rs-line-height-regular);
|
|
392
|
+
letter-spacing: var(--rs-letter-spacing-regular);
|
|
393
|
+
color: var(--rs-color-foreground-base-tertiary);
|
|
394
|
+
min-width: 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.mobileNavLink[data-direction='next'] {
|
|
398
|
+
justify-content: flex-end;
|
|
399
|
+
background: var(--rs-color-background-base-secondary);
|
|
400
|
+
color: var(--rs-color-foreground-base-primary);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.mobileNavLabel {
|
|
404
|
+
overflow: hidden;
|
|
405
|
+
text-overflow: ellipsis;
|
|
406
|
+
white-space: nowrap;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -4,7 +4,9 @@ import {
|
|
|
4
4
|
CodeBracketSquareIcon,
|
|
5
5
|
RectangleStackIcon,
|
|
6
6
|
DocumentTextIcon,
|
|
7
|
-
Squares2X2Icon
|
|
7
|
+
Squares2X2Icon,
|
|
8
|
+
Bars3Icon,
|
|
9
|
+
XMarkIcon
|
|
8
10
|
} from '@heroicons/react/24/outline';
|
|
9
11
|
import { Flex, IconButton, Button, Sidebar } from '@raystack/apsara';
|
|
10
12
|
import { PlayIcon } from '@radix-ui/react-icons';
|
|
@@ -60,8 +62,6 @@ function renderConfigIcon(
|
|
|
60
62
|
return <img src={icon} alt={alt} className={styles.configIcon} />;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
let savedScrollTop = 0;
|
|
64
|
-
|
|
65
65
|
export function Layout({
|
|
66
66
|
children,
|
|
67
67
|
config,
|
|
@@ -73,6 +73,7 @@ export function Layout({
|
|
|
73
73
|
const navigate = useNavigate();
|
|
74
74
|
const { page, version } = usePageContext();
|
|
75
75
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
76
|
+
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
76
77
|
const isApiRoute = pathname === '/apis' || pathname.startsWith('/apis/');
|
|
77
78
|
const isApiBase = (basePath: string) =>
|
|
78
79
|
pathname === basePath || pathname.startsWith(`${basePath}/`);
|
|
@@ -94,25 +95,95 @@ export function Layout({
|
|
|
94
95
|
);
|
|
95
96
|
|
|
96
97
|
useEffect(() => {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
});
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
const container = document.querySelector<HTMLElement>(`.${styles.sidebarMain}`);
|
|
100
|
+
if (!container) return;
|
|
101
|
+
const allActive = container.querySelectorAll<HTMLElement>('[data-active="true"]');
|
|
102
|
+
const activeItem = allActive[allActive.length - 1];
|
|
103
|
+
if (!activeItem) return;
|
|
104
|
+
const containerRect = container.getBoundingClientRect();
|
|
105
|
+
const itemRect = activeItem.getBoundingClientRect();
|
|
106
|
+
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
|
|
107
|
+
container.scrollTop += itemRect.top - containerRect.top - containerRect.height / 2 + itemRect.height / 2;
|
|
108
|
+
}
|
|
109
|
+
}, 100);
|
|
110
|
+
setMobileSidebarOpen(false);
|
|
111
|
+
return () => clearTimeout(timer);
|
|
112
112
|
}, [pathname]);
|
|
113
113
|
|
|
114
114
|
return (
|
|
115
115
|
<Flex direction='column' className={cx(styles.layout, classNames?.layout)}>
|
|
116
|
+
<div className={styles.mobileHeader}>
|
|
117
|
+
<SidebarLogo config={config} />
|
|
118
|
+
<Flex align='center' gap={3}>
|
|
119
|
+
{config.search?.enabled && <Search />}
|
|
120
|
+
<ClientThemeSwitcher size={16} />
|
|
121
|
+
{!hideSidebar && (
|
|
122
|
+
<button
|
|
123
|
+
type='button'
|
|
124
|
+
className={styles.mobileMenuBtn}
|
|
125
|
+
onClick={() => setMobileSidebarOpen(o => !o)}
|
|
126
|
+
aria-label={mobileSidebarOpen ? 'Close menu' : 'Open menu'}
|
|
127
|
+
aria-expanded={mobileSidebarOpen}
|
|
128
|
+
aria-controls='mobile-menu'
|
|
129
|
+
>
|
|
130
|
+
{mobileSidebarOpen
|
|
131
|
+
? <XMarkIcon width={16} height={16} />
|
|
132
|
+
: <Bars3Icon width={16} height={16} />}
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
135
|
+
</Flex>
|
|
136
|
+
</div>
|
|
137
|
+
<div id='mobile-menu' className={styles.mobileMenu} data-open={!hideSidebar && mobileSidebarOpen}>
|
|
138
|
+
{showTopLinks ? (
|
|
139
|
+
<div className={styles.topLinks}>
|
|
140
|
+
{contentEntries.map(entry => (
|
|
141
|
+
<Sidebar.Item
|
|
142
|
+
key={entry.href}
|
|
143
|
+
href={entry.href}
|
|
144
|
+
active={activeContentDir === entry.contentDir}
|
|
145
|
+
leadingIcon={renderConfigIcon(entry.icon, entry.label, <DocumentTextIcon width={16} height={16} />)}
|
|
146
|
+
classNames={{ root: styles.topLinkItem, text: styles.topLinkText }}
|
|
147
|
+
render={<RouterLink to={entry.href} />}
|
|
148
|
+
>
|
|
149
|
+
{entry.label}
|
|
150
|
+
</Sidebar.Item>
|
|
151
|
+
))}
|
|
152
|
+
{apiEntries.map(api => (
|
|
153
|
+
<Sidebar.Item
|
|
154
|
+
key={`${api.basePath}-${api.name}`}
|
|
155
|
+
href={api.basePath}
|
|
156
|
+
active={isApiBase(api.basePath)}
|
|
157
|
+
leadingIcon={renderConfigIcon(api.icon, api.name, <CodeBracketSquareIcon width={16} height={16} />)}
|
|
158
|
+
classNames={{ root: styles.topLinkItem, text: styles.topLinkText }}
|
|
159
|
+
render={<RouterLink to={api.basePath} />}
|
|
160
|
+
>
|
|
161
|
+
{api.name} API
|
|
162
|
+
</Sidebar.Item>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
) : null}
|
|
166
|
+
{tree.children.map((item, i) => (
|
|
167
|
+
isApiRoute ? (
|
|
168
|
+
<ApiSidebarNode
|
|
169
|
+
key={item.type === 'page' ? item.url : (item.name?.toString() ?? i)}
|
|
170
|
+
item={item}
|
|
171
|
+
pathname={pathname}
|
|
172
|
+
/>
|
|
173
|
+
) : (
|
|
174
|
+
<SidebarNode
|
|
175
|
+
key={item.type === 'page' ? item.url : (item.name?.toString() ?? i)}
|
|
176
|
+
item={item}
|
|
177
|
+
pathname={pathname}
|
|
178
|
+
/>
|
|
179
|
+
)
|
|
180
|
+
))}
|
|
181
|
+
{config.versions?.length ? (
|
|
182
|
+
<div className={styles.mobileMenuFooter}>
|
|
183
|
+
<VersionSwitcher />
|
|
184
|
+
</div>
|
|
185
|
+
) : null}
|
|
186
|
+
</div>
|
|
116
187
|
<Flex className={cx(styles.body, classNames?.body)}>
|
|
117
188
|
{hideSidebar ? null : (
|
|
118
189
|
<Sidebar
|
|
@@ -221,6 +292,20 @@ export function Layout({
|
|
|
221
292
|
<main className={cx(styles.content, classNames?.content)}>
|
|
222
293
|
{children}
|
|
223
294
|
</main>
|
|
295
|
+
<div className={styles.mobileNav}>
|
|
296
|
+
{prev ? (
|
|
297
|
+
<RouterLink to={prev.url} className={styles.mobileNavLink}>
|
|
298
|
+
<ArrowLeftIcon width={16} height={16} />
|
|
299
|
+
<span className={styles.mobileNavLabel}>{prev.title}</span>
|
|
300
|
+
</RouterLink>
|
|
301
|
+
) : <div />}
|
|
302
|
+
{next ? (
|
|
303
|
+
<RouterLink to={next.url} className={styles.mobileNavLink} data-direction='next'>
|
|
304
|
+
<span className={styles.mobileNavLabel}>{next.title}</span>
|
|
305
|
+
<ArrowRightIcon width={16} height={16} />
|
|
306
|
+
</RouterLink>
|
|
307
|
+
) : <div />}
|
|
308
|
+
</div>
|
|
224
309
|
</div>
|
|
225
310
|
</div>
|
|
226
311
|
</Flex>
|
|
@@ -347,6 +432,7 @@ function ApiSidebarNode({ item, pathname }: { item: Node; pathname: string }) {
|
|
|
347
432
|
align='center'
|
|
348
433
|
gap={3}
|
|
349
434
|
className={`${styles.apiItem} ${isActive ? styles.apiItemActive : ''}`}
|
|
435
|
+
data-active={isActive}
|
|
350
436
|
render={<RouterLink to={href} />}
|
|
351
437
|
>
|
|
352
438
|
<span className={styles.apiItemName}>{item.name}</span>
|
|
@@ -184,3 +184,17 @@
|
|
|
184
184
|
.headerLoader {
|
|
185
185
|
margin-bottom: var(--rs-space-5);
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
@media (max-width: 768px) {
|
|
189
|
+
.page {
|
|
190
|
+
gap: var(--rs-space-5);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.article {
|
|
194
|
+
max-width: 100%;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.title {
|
|
198
|
+
margin-bottom: var(--rs-space-5);
|
|
199
|
+
}
|
|
200
|
+
}
|