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, ],