@raystack/chronicle 0.12.0 → 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 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
- if (optimize && isLocalImage(url) && !isSvg(url))
90
- return buildOptimizedUrl(url, DEFAULT_WIDTH);
91
- return url;
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
- node.url = resolveUrl(node.url, dir);
116
- collect(node.url);
117
- node.url = optimizeUrl(node.url, optimize);
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 resolved = resolveUrl(src, dir);
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
- srcAttr.value = resolveUrl(srcAttr.value, dir);
136
- collect(srcAttr.value);
137
- srcAttr.value = optimizeUrl(srcAttr.value, optimize);
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
- node.properties.src = resolveUrl(src, dir);
146
- collect(node.properties.src);
147
- node.properties.src = optimizeUrl(node.properties.src, optimize);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
- if (optimize && isLocalImage(url) && !isSvg(url)) return buildOptimizedUrl(url, DEFAULT_WIDTH)
27
- return url
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
- node.url = resolveUrl(node.url, dir)
54
- collect(node.url)
55
- node.url = optimizeUrl(node.url, optimize)
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 resolved = resolveUrl(src, dir)
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
- srcAttr.value = resolveUrl(srcAttr.value, dir)
76
- collect(srcAttr.value)
77
- srcAttr.value = optimizeUrl(srcAttr.value, optimize)
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
- node.properties.src = resolveUrl(src, dir)
85
- collect(node.properties.src as string)
86
- node.properties.src = optimizeUrl(node.properties.src as string, optimize)
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$/);
@@ -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 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)))
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 key = cacheKey(url, w, q, format)
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 source = await fs.readFile(filePath)
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
+ }
@@ -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
- margin-bottom: var(--rs-space-7);
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 0 var(--rs-space-2) 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-7);
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: var(--rs-space-3);
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 {
@@ -62,8 +62,6 @@ function renderConfigIcon(
62
62
  return <img src={icon} alt={alt} className={styles.configIcon} />;
63
63
  }
64
64
 
65
- let savedScrollTop = 0;
66
-
67
65
  export function Layout({
68
66
  children,
69
67
  config,
@@ -97,22 +95,20 @@ export function Layout({
97
95
  );
98
96
 
99
97
  useEffect(() => {
100
- const el = scrollRef.current;
101
- if (!el) return;
102
- const onScroll = () => {
103
- savedScrollTop = el.scrollTop;
104
- };
105
- el.addEventListener('scroll', onScroll);
106
- return () => el.removeEventListener('scroll', onScroll);
107
- }, []);
108
-
109
- useEffect(() => {
110
- const el = scrollRef.current;
111
- if (el)
112
- requestAnimationFrame(() => {
113
- el.scrollTop = savedScrollTop;
114
- });
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);
115
110
  setMobileSidebarOpen(false);
111
+ return () => clearTimeout(timer);
116
112
  }, [pathname]);
117
113
 
118
114
  return (
@@ -436,6 +432,7 @@ function ApiSidebarNode({ item, pathname }: { item: Node; pathname: string }) {
436
432
  align='center'
437
433
  gap={3}
438
434
  className={`${styles.apiItem} ${isActive ? styles.apiItemActive : ''}`}
435
+ data-active={isActive}
439
436
  render={<RouterLink to={href} />}
440
437
  >
441
438
  <span className={styles.apiItemName}>{item.name}</span>