diff --git a/bun.lock b/bun.lock
index b5cde6f..731b6be 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,7 +8,7 @@
"std-env": "^4.0.0",
},
"devDependencies": {
- "@raystack/chronicle": "^0.6.1",
+ "@raystack/chronicle": "workspace:*",
"remark-reading-time": "^2.1.0",
},
},
@@ -66,12 +66,16 @@
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@raystack/tools-config": "0.56.0",
+ "@types/hast": "^3.0.4",
"@types/lodash": "^4.17.23",
+ "@types/mdast": "^4.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
+ "@types/unist": "^3.0.3",
+ "mdast-util-mdx-jsx": "^3.2.0",
"semver": "^7.7.4",
"typescript": "5.9.3",
},
diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json
index e9dffd5..e881beb 100644
--- a/packages/chronicle/package.json
+++ b/packages/chronicle/package.json
@@ -22,12 +22,16 @@
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@raystack/tools-config": "0.56.0",
+ "@types/hast": "^3.0.4",
"@types/lodash": "^4.17.23",
+ "@types/mdast": "^4.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
+ "@types/unist": "^3.0.3",
+ "mdast-util-mdx-jsx": "^3.2.0",
"semver": "^7.7.4",
"typescript": "5.9.3"
},
diff --git a/packages/chronicle/src/lib/remark-resolve-images.ts b/packages/chronicle/src/lib/remark-resolve-images.ts
new file mode 100644
index 0000000..eceb4fe
--- /dev/null
+++ b/packages/chronicle/src/lib/remark-resolve-images.ts
@@ -0,0 +1,59 @@
+import path from 'node:path'
+import { visit } from 'unist-util-visit'
+import type { Plugin } from 'unified'
+import type { Image, Html } from 'mdast'
+import type { Element } from 'hast'
+import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx'
+
+function resolveUrl(src: string, dir: string): string {
+ if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src
+ if (src.startsWith('//')) return src
+ if (src.startsWith('#')) return src
+ if (src.startsWith('/_content/')) return src
+
+ if (src.startsWith('/')) return `/_content${src}`
+ return `/_content/${path.posix.normalize(path.posix.join(dir, src))}`
+}
+
+const remarkResolveImages: Plugin = () => {
+ return (tree, file) => {
+ const filePath = file.path
+ if (!filePath) return
+
+ const contentIdx = filePath.lastIndexOf('/content/')
+ if (contentIdx === -1) return
+
+ const relative = filePath.slice(contentIdx + '/content/'.length)
+ const dir = path.posix.dirname(relative)
+
+ visit(tree, 'image', (node: Image) => {
+ if (!node.url) return
+ node.url = resolveUrl(node.url, dir)
+ })
+
+ visit(tree, 'html', (node: Html) => {
+ node.value = node.value.replace(
+ /(
]*\bsrc=["'])([^"']+)(["'])/gi,
+ (_, before, src, after) => `${before}${resolveUrl(src, dir)}${after}`
+ )
+ })
+
+ visit(tree, (node) => {
+ if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return
+ const jsx = node as MdxJsxFlowElement | MdxJsxTextElement
+ if (jsx.name !== 'img') return
+ const srcAttr = jsx.attributes.find((a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src')
+ if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
+ srcAttr.value = resolveUrl(srcAttr.value, dir)
+ })
+
+ visit(tree, 'element', (node: Element) => {
+ if (node.tagName !== 'img') return
+ const src = node.properties?.src
+ if (typeof src !== 'string') return
+ node.properties.src = resolveUrl(src, dir)
+ })
+ }
+}
+
+export default remarkResolveImages
diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts
index 0119f9f..ee1556e 100644
--- a/packages/chronicle/src/lib/source.ts
+++ b/packages/chronicle/src/lib/source.ts
@@ -23,6 +23,11 @@ const frontmatterGlob: Record> = import.meta.glo
{ eager: true, import: 'frontmatter' }
);
+const readingTimeGlob: Record = import.meta.glob(
+ '../../.content/**/*.{mdx,md}',
+ { eager: true, import: 'readingTime' }
+);
+
const metaGlob: Record> = import.meta.glob(
'../../.content/**/meta.json',
{ eager: true }
@@ -38,10 +43,12 @@ function buildFiles() {
for (const [key, data] of Object.entries(frontmatterGlob)) {
const originalPath = key.slice(CONTENT_PREFIX.length);
const relativePath = originalPath.replace(/readme\.(mdx?)$/i, 'index.$1');
+ const rt = readingTimeGlob[key];
+ const _readingTime = rt?.minutes != null ? Math.max(1, Math.round(rt.minutes)) : undefined;
files.push({
type: 'page',
path: relativePath,
- data: { ...data, _relativePath: relativePath, _originalPath: originalPath }
+ data: { ...data, _readingTime, _relativePath: relativePath, _originalPath: originalPath }
});
}
diff --git a/packages/chronicle/src/server/api/apis-proxy.ts b/packages/chronicle/src/server/api/apis-proxy.ts
index 83e8642..3089aa6 100644
--- a/packages/chronicle/src/server/api/apis-proxy.ts
+++ b/packages/chronicle/src/server/api/apis-proxy.ts
@@ -51,11 +51,11 @@ export default defineHandler(async event => {
? await response.json()
: await response.text();
- return {
+ return Response.json({
status: response.status,
statusText: response.statusText,
body: responseBody
- };
+ });
} catch (error) {
const message =
error instanceof Error
diff --git a/packages/chronicle/src/server/api/health.ts b/packages/chronicle/src/server/api/health.ts
index 6691999..986274d 100644
--- a/packages/chronicle/src/server/api/health.ts
+++ b/packages/chronicle/src/server/api/health.ts
@@ -1,5 +1,5 @@
import { defineHandler } from 'nitro';
export default defineHandler(() => {
- return { status: 'ok' };
+ return Response.json({ status: 'ok' });
});
diff --git a/packages/chronicle/src/server/api/page.ts b/packages/chronicle/src/server/api/page.ts
index 6d4ac03..32f792e 100644
--- a/packages/chronicle/src/server/api/page.ts
+++ b/packages/chronicle/src/server/api/page.ts
@@ -1,5 +1,5 @@
import { defineHandler, HTTPError } from 'nitro';
-import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, loadPageModule } from '@/lib/source';
+import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
export default defineHandler(async event => {
const slugParam = event.url.searchParams.get('slug') ?? '';
@@ -11,18 +11,12 @@ export default defineHandler(async event => {
}
const nav = await getPageNav(slug);
- const originalPath = getOriginalPath(page);
- const relativePath = getRelativePath(page);
- const mdxModule = (originalPath || relativePath) ? await loadPageModule(originalPath || relativePath) : null;
- return {
- frontmatter: {
- ...extractFrontmatter(page, slug[slug.length - 1]),
- _readingTime: mdxModule?._readingTime,
- },
- relativePath,
- originalPath,
+ return Response.json({
+ frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
+ relativePath: getRelativePath(page),
+ originalPath: getOriginalPath(page),
prev: nav.prev,
next: nav.next,
- };
+ });
});
diff --git a/packages/chronicle/src/server/api/search.ts b/packages/chronicle/src/server/api/search.ts
index 5120a51..ac12532 100644
--- a/packages/chronicle/src/server/api/search.ts
+++ b/packages/chronicle/src/server/api/search.ts
@@ -125,7 +125,7 @@ export default defineHandler(async event => {
if (!query) {
const docs = await getDocs(ctx);
- return docs
+ return Response.json(docs
.filter(d => d.type === 'page')
.slice(0, 8)
.map(d => ({
@@ -133,13 +133,13 @@ export default defineHandler(async event => {
url: d.url,
type: d.type,
content: d.title
- }));
+ })));
}
- return index.search(query).map(r => ({
+ return Response.json(index.search(query).map(r => ({
id: r.id,
url: r.url,
type: r.type,
content: r.title
- }));
+ })));
});
diff --git a/packages/chronicle/src/server/api/specs.ts b/packages/chronicle/src/server/api/specs.ts
index 00f6903..dc06763 100644
--- a/packages/chronicle/src/server/api/specs.ts
+++ b/packages/chronicle/src/server/api/specs.ts
@@ -15,7 +15,7 @@ export default defineHandler(async event => {
}
const apiConfigs = getApiConfigsForVersion(config, versionDir);
- if (!apiConfigs.length) return [];
+ if (!apiConfigs.length) return Response.json([]);
- return loadApiSpecs(apiConfigs);
+ return Response.json(await loadApiSpecs(apiConfigs));
});
diff --git a/packages/chronicle/src/server/routes/[...slug].md.ts b/packages/chronicle/src/server/routes/[...slug].md.ts
index ee1c518..9fefb4e 100644
--- a/packages/chronicle/src/server/routes/[...slug].md.ts
+++ b/packages/chronicle/src/server/routes/[...slug].md.ts
@@ -34,6 +34,5 @@ export default defineHandler(async event => {
throw new HTTPError({ status: 404, message: 'Not Found' });
}
- event.res.headers.set('Content-Type', 'text/markdown; charset=utf-8');
- return matter(raw).content;
+ return new Response(matter(raw).content, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } });
});
diff --git a/packages/chronicle/src/server/routes/[version]/llms.txt.ts b/packages/chronicle/src/server/routes/[version]/llms.txt.ts
index 80997a1..b54f439 100644
--- a/packages/chronicle/src/server/routes/[version]/llms.txt.ts
+++ b/packages/chronicle/src/server/routes/[version]/llms.txt.ts
@@ -21,6 +21,5 @@ export default defineHandler(async event => {
ctx,
);
- event.res.headers.set('Content-Type', 'text/plain');
- return body;
+ return new Response(body, { headers: { 'Content-Type': 'text/plain' } });
});
diff --git a/packages/chronicle/src/server/routes/_content/[...path].ts b/packages/chronicle/src/server/routes/_content/[...path].ts
new file mode 100644
index 0000000..9846f79
--- /dev/null
+++ b/packages/chronicle/src/server/routes/_content/[...path].ts
@@ -0,0 +1,40 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { defineHandler, HTTPError } from 'nitro';
+import { safePath } from '@/server/utils/safe-path';
+
+const MIME: Record = {
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+ '.webp': 'image/webp',
+ '.ico': 'image/x-icon',
+ '.pdf': 'application/pdf',
+};
+
+export default defineHandler(async event => {
+ const pathname = event.path?.replace(/^\/_content/, '') || '';
+ if (!pathname || pathname.endsWith('.md') || pathname.endsWith('.mdx')) {
+ throw new HTTPError({ status: 404, message: 'Not Found' });
+ }
+
+ const contentDir = __CHRONICLE_CONTENT_DIR__;
+ let filePath: string | null = null;
+ try { filePath = safePath(contentDir, pathname); } catch { /* malformed URL encoding */ }
+ if (!filePath) throw new HTTPError({ status: 404, message: 'Not Found' });
+
+ const data = await fs.readFile(filePath).catch(() => null);
+ if (!data) throw new HTTPError({ status: 404, message: 'Not Found' });
+
+ const ext = path.extname(filePath).toLowerCase();
+ const contentType = MIME[ext] ?? 'application/octet-stream';
+
+ return new Response(data, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=86400',
+ },
+ });
+});
diff --git a/packages/chronicle/src/server/routes/llms.txt.ts b/packages/chronicle/src/server/routes/llms.txt.ts
index 2d150cf..0beed37 100644
--- a/packages/chronicle/src/server/routes/llms.txt.ts
+++ b/packages/chronicle/src/server/routes/llms.txt.ts
@@ -4,7 +4,7 @@ import { buildLlmsTxt } from '@/lib/llms';
import { extractFrontmatter, getPagesForVersion } from '@/lib/source';
import { LATEST_CONTEXT } from '@/lib/version-source';
-export default defineHandler(async event => {
+export default defineHandler(async () => {
const config = loadConfig();
const pages = await getPagesForVersion(LATEST_CONTEXT);
@@ -14,6 +14,5 @@ export default defineHandler(async event => {
LATEST_CONTEXT,
);
- event.res.headers.set('Content-Type', 'text/plain');
- return body;
+ return new Response(body, { headers: { 'Content-Type': 'text/plain' } });
});
diff --git a/packages/chronicle/src/server/routes/og.tsx b/packages/chronicle/src/server/routes/og.tsx
index 30d647e..3e1d8b5 100644
--- a/packages/chronicle/src/server/routes/og.tsx
+++ b/packages/chronicle/src/server/routes/og.tsx
@@ -69,7 +69,5 @@ export default defineHandler(async event => {
},
);
- event.res.headers.set('Content-Type', 'image/svg+xml');
- event.res.headers.set('Cache-Control', 'public, max-age=86400');
- return svg;
+ return new Response(svg, { headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' } });
});
diff --git a/packages/chronicle/src/server/routes/robots.txt.ts b/packages/chronicle/src/server/routes/robots.txt.ts
index 31c06d0..f2bc6ef 100644
--- a/packages/chronicle/src/server/routes/robots.txt.ts
+++ b/packages/chronicle/src/server/routes/robots.txt.ts
@@ -1,11 +1,10 @@
import { defineHandler } from 'nitro';
import { loadConfig } from '@/lib/config';
-export default defineHandler(event => {
+export default defineHandler(() => {
const config = loadConfig();
const sitemap = config.url ? `\nSitemap: ${config.url}/sitemap.xml` : '';
const body = `User-agent: *\nAllow: /${sitemap}`;
- event.res.headers.set('Content-Type', 'text/plain');
- return body;
+ return new Response(body, { headers: { 'Content-Type': 'text/plain' } });
});
diff --git a/packages/chronicle/src/server/routes/sitemap.xml.ts b/packages/chronicle/src/server/routes/sitemap.xml.ts
index 41ecf65..eeedfa7 100644
--- a/packages/chronicle/src/server/routes/sitemap.xml.ts
+++ b/packages/chronicle/src/server/routes/sitemap.xml.ts
@@ -4,12 +4,11 @@ import { getAllVersions, getApiConfigsForVersion, loadConfig } from '@/lib/confi
import { loadApiSpecs } from '@/lib/openapi';
import { getPages } from '@/lib/source';
-export default defineHandler(async event => {
+export default defineHandler(async () => {
const config = loadConfig();
if (!config.url) {
- event.res.headers.set('Content-Type', 'application/xml');
- return '';
+ return new Response('', { headers: { 'Content-Type': 'application/xml' } });
}
const baseUrl = config.url.replace(/\/$/, '');
@@ -43,6 +42,5 @@ export default defineHandler(async event => {
${[...docPages, ...apiPages].join('\n')}
`;
- event.res.headers.set('Content-Type', 'application/xml');
- return xml;
+ return new Response(xml, { headers: { 'Content-Type': 'application/xml' } });
});
diff --git a/packages/chronicle/src/server/vite-config.ts b/packages/chronicle/src/server/vite-config.ts
index b285fdf..6262b1d 100644
--- a/packages/chronicle/src/server/vite-config.ts
+++ b/packages/chronicle/src/server/vite-config.ts
@@ -7,6 +7,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import remarkDirective from 'remark-directive';
import { type InlineConfig } from 'vite';
+import remarkResolveImages from '../lib/remark-resolve-images';
import remarkResolveLinks from '../lib/remark-resolve-links';
import remarkReadingTime from 'remark-reading-time';
import remarkUnusedDirectives from '../lib/remark-unused-directives';
@@ -56,6 +57,7 @@ export async function createViteConfig(
mdx({
default: defineFumadocsConfig({
mdxOptions: {
+ remarkImageOptions: false,
valueToExport: ['readingTime'],
remarkPlugins: [
remarkDirective,
@@ -78,6 +80,7 @@ export async function createViteConfig(
}],
remarkUnusedDirectives,
remarkResolveLinks,
+ remarkResolveImages,
remarkMdxMermaid,
remarkReadingTime,
],