@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 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
- var remarkResolveImages = () => {
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.10.4",
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 ImageProps = ComponentProps<'img'>;
4
+ type MDXImageProps = ComponentProps<'img'>;
4
5
 
5
- export function Image({ src, alt, ...props }: ImageProps) {
6
+ export function MDXImage({ src, alt, ...props }: MDXImageProps) {
6
7
  if (!src) return null;
7
8
 
8
- return <img src={src} alt={alt ?? ''} loading='lazy' {...props} />;
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 { Image } from './image'
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: Image,
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 { Image } from './image'
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, useRef, useState } from 'react';
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 [results, setResults] = useState<SearchResult[]>([]);
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 fetchResults = useCallback(async (query: string, signal?: AbortSignal) => {
48
- setIsLoading(true);
49
- try {
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
- setResults([]);
81
- return;
59
+ setDebouncedSearch('');
60
+ updateDebouncedSearch.cancel();
82
61
  }
83
- if (!search) {
84
- fetchResults('');
85
- return;
86
- }
87
- debouncedSearch(search);
88
- return () => debouncedSearch.cancel();
89
- }, [open, search, fetchResults, debouncedSearch]);
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(search ? results : suggestions);
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={(e) => setSearch(e.target.value)}
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 };
@@ -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);
@@ -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
- const remarkResolveImages: Plugin = () => {
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
@@ -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
- <RootHead config={config} />
41
- <Suspense fallback={<PageFallback />}>
42
- {isApi ? (
43
- <ApiLayout>
44
- <ApiPage slug={apiSlug} />
45
- </ApiLayout>
46
- ) : (
47
- <DocsLayout hideSidebar={isLanding}>
48
- {isLanding ? <LandingPage /> : <DocsPage slug={docsSlug} />}
49
- </DocsLayout>
50
- )}
51
- </Suspense>
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
+ })
@@ -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: r.id,
220
- url: r.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
- <link key={src} rel="preload" as="image" href={src} />
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
- line-height: 1.4;
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: block;
85
- max-width: 100%;
86
- overflow-x: auto;
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);
@@ -77,9 +77,14 @@
77
77
 
78
78
  .content {
79
79
  flex: 1;
80
+ margin-left: calc(-1 * var(--paper-sidebar-width));
80
81
  background: var(--rs-color-background-neutral-primary);
81
82
  }
82
83
 
84
+ .contentFull {
85
+ margin-left: 0;
86
+ }
87
+
83
88
  .hiddenTrigger {
84
89
  display: none;
85
90
  }
@@ -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: calc(1024px + var(--rs-space-17));
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
- line-height: 1.4;
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: block;
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={`${styles.main} ${readerMode ? styles.readerMode : ''}`}>
44
+ <main className={styles.main}>
45
45
  <div className={styles.navbar}>
46
46
  <div className={styles.navLeft}>
47
47
  <div className={styles.arrows}>