Skip to content

Commit a8048f4

Browse files
rsbhclaude
andauthored
fix: serve static content files and resolve image paths (#46)
* fix: return web Response from all server handlers Nitro wraps plain return values in NodeResponse, which Bun rejects in dev mode (nitrojs/nitro#4228). Return standard Response/Response.json directly to bypass this. Also read readingTime from eager frontmatter glob instead of loading full MDX module in API route. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: serve static content files via /_content route Catch-all route serves non-md/mdx files (images, PDFs, etc.) from the .content directory. Blocks .md/.mdx and path traversal. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: resolve image paths in MDX relative to source file Remark plugin rewrites image src (both markdown ![](…) and JSX <img>) to absolute /_content/ URLs based on the MD file's location within the content directory. External URLs left untouched. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add @types/mdast, @types/hast, mdast-util-mdx-jsx deps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve image paths in MDX and disable fumadocs remarkImage Remark plugin resolves image src (markdown, HTML, JSX, HAST) relative to the MD file's location. Disable fumadocs remarkImage to prevent it from converting src to JS imports before our plugin runs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: type-safe remark-resolve-images, add @types/unist Replace string array visit with type-checked node.type guard to fix TS overload error. Add @types/unist to chronicle devDeps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle protocol-relative URLs and malformed path encoding Skip protocol-relative URLs (//cdn.example.com) in image resolver. Catch malformed percent-encoding in _content route safePath call. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5da4bf3 commit a8048f4

17 files changed

Lines changed: 144 additions & 41 deletions

File tree

bun.lock

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/chronicle/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@
2222
"devDependencies": {
2323
"@biomejs/biome": "^2.3.13",
2424
"@raystack/tools-config": "0.56.0",
25+
"@types/hast": "^3.0.4",
2526
"@types/lodash": "^4.17.23",
27+
"@types/mdast": "^4.0.4",
2628
"@types/mdx": "^2.0.13",
2729
"@types/node": "^25.1.0",
2830
"@types/react": "^19.2.10",
2931
"@types/react-dom": "^19.2.3",
3032
"@types/semver": "^7.7.1",
33+
"@types/unist": "^3.0.3",
34+
"mdast-util-mdx-jsx": "^3.2.0",
3135
"semver": "^7.7.4",
3236
"typescript": "5.9.3"
3337
},
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import path from 'node:path'
2+
import { visit } from 'unist-util-visit'
3+
import type { Plugin } from 'unified'
4+
import type { Image, Html } from 'mdast'
5+
import type { Element } from 'hast'
6+
import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx'
7+
8+
function resolveUrl(src: string, dir: string): string {
9+
if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src
10+
if (src.startsWith('//')) return src
11+
if (src.startsWith('#')) return src
12+
if (src.startsWith('/_content/')) return src
13+
14+
if (src.startsWith('/')) return `/_content${src}`
15+
return `/_content/${path.posix.normalize(path.posix.join(dir, src))}`
16+
}
17+
18+
const remarkResolveImages: Plugin = () => {
19+
return (tree, file) => {
20+
const filePath = file.path
21+
if (!filePath) return
22+
23+
const contentIdx = filePath.lastIndexOf('/content/')
24+
if (contentIdx === -1) return
25+
26+
const relative = filePath.slice(contentIdx + '/content/'.length)
27+
const dir = path.posix.dirname(relative)
28+
29+
visit(tree, 'image', (node: Image) => {
30+
if (!node.url) return
31+
node.url = resolveUrl(node.url, dir)
32+
})
33+
34+
visit(tree, 'html', (node: Html) => {
35+
node.value = node.value.replace(
36+
/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi,
37+
(_, before, src, after) => `${before}${resolveUrl(src, dir)}${after}`
38+
)
39+
})
40+
41+
visit(tree, (node) => {
42+
if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return
43+
const jsx = node as MdxJsxFlowElement | MdxJsxTextElement
44+
if (jsx.name !== 'img') return
45+
const srcAttr = jsx.attributes.find((a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src')
46+
if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
47+
srcAttr.value = resolveUrl(srcAttr.value, dir)
48+
})
49+
50+
visit(tree, 'element', (node: Element) => {
51+
if (node.tagName !== 'img') return
52+
const src = node.properties?.src
53+
if (typeof src !== 'string') return
54+
node.properties.src = resolveUrl(src, dir)
55+
})
56+
}
57+
}
58+
59+
export default remarkResolveImages

packages/chronicle/src/lib/source.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ const frontmatterGlob: Record<string, Record<string, unknown>> = import.meta.glo
2323
{ eager: true, import: 'frontmatter' }
2424
);
2525

26+
const readingTimeGlob: Record<string, { text: string; minutes: number; words: number; time: number } | undefined> = import.meta.glob(
27+
'../../.content/**/*.{mdx,md}',
28+
{ eager: true, import: 'readingTime' }
29+
);
30+
2631
const metaGlob: Record<string, Record<string, unknown>> = import.meta.glob(
2732
'../../.content/**/meta.json',
2833
{ eager: true }
@@ -38,10 +43,12 @@ function buildFiles() {
3843
for (const [key, data] of Object.entries(frontmatterGlob)) {
3944
const originalPath = key.slice(CONTENT_PREFIX.length);
4045
const relativePath = originalPath.replace(/readme\.(mdx?)$/i, 'index.$1');
46+
const rt = readingTimeGlob[key];
47+
const _readingTime = rt?.minutes != null ? Math.max(1, Math.round(rt.minutes)) : undefined;
4148
files.push({
4249
type: 'page',
4350
path: relativePath,
44-
data: { ...data, _relativePath: relativePath, _originalPath: originalPath }
51+
data: { ...data, _readingTime, _relativePath: relativePath, _originalPath: originalPath }
4552
});
4653
}
4754

packages/chronicle/src/server/api/apis-proxy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ export default defineHandler(async event => {
5151
? await response.json()
5252
: await response.text();
5353

54-
return {
54+
return Response.json({
5555
status: response.status,
5656
statusText: response.statusText,
5757
body: responseBody
58-
};
58+
});
5959
} catch (error) {
6060
const message =
6161
error instanceof Error
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineHandler } from 'nitro';
22

33
export default defineHandler(() => {
4-
return { status: 'ok' };
4+
return Response.json({ status: 'ok' });
55
});
Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineHandler, HTTPError } from 'nitro';
2-
import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath, loadPageModule } from '@/lib/source';
2+
import { getPage, getPageNav, extractFrontmatter, getRelativePath, getOriginalPath } from '@/lib/source';
33

44
export default defineHandler(async event => {
55
const slugParam = event.url.searchParams.get('slug') ?? '';
@@ -11,18 +11,12 @@ export default defineHandler(async event => {
1111
}
1212

1313
const nav = await getPageNav(slug);
14-
const originalPath = getOriginalPath(page);
15-
const relativePath = getRelativePath(page);
16-
const mdxModule = (originalPath || relativePath) ? await loadPageModule(originalPath || relativePath) : null;
1714

18-
return {
19-
frontmatter: {
20-
...extractFrontmatter(page, slug[slug.length - 1]),
21-
_readingTime: mdxModule?._readingTime,
22-
},
23-
relativePath,
24-
originalPath,
15+
return Response.json({
16+
frontmatter: extractFrontmatter(page, slug[slug.length - 1]),
17+
relativePath: getRelativePath(page),
18+
originalPath: getOriginalPath(page),
2519
prev: nav.prev,
2620
next: nav.next,
27-
};
21+
});
2822
});

packages/chronicle/src/server/api/search.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,21 +125,21 @@ export default defineHandler(async event => {
125125

126126
if (!query) {
127127
const docs = await getDocs(ctx);
128-
return docs
128+
return Response.json(docs
129129
.filter(d => d.type === 'page')
130130
.slice(0, 8)
131131
.map(d => ({
132132
id: d.id,
133133
url: d.url,
134134
type: d.type,
135135
content: d.title
136-
}));
136+
})));
137137
}
138138

139-
return index.search(query).map(r => ({
139+
return Response.json(index.search(query).map(r => ({
140140
id: r.id,
141141
url: r.url,
142142
type: r.type,
143143
content: r.title
144-
}));
144+
})));
145145
});

packages/chronicle/src/server/api/specs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default defineHandler(async event => {
1515
}
1616

1717
const apiConfigs = getApiConfigsForVersion(config, versionDir);
18-
if (!apiConfigs.length) return [];
18+
if (!apiConfigs.length) return Response.json([]);
1919

20-
return loadApiSpecs(apiConfigs);
20+
return Response.json(await loadApiSpecs(apiConfigs));
2121
});

packages/chronicle/src/server/routes/[...slug].md.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,5 @@ export default defineHandler(async event => {
3434
throw new HTTPError({ status: 404, message: 'Not Found' });
3535
}
3636

37-
event.res.headers.set('Content-Type', 'text/markdown; charset=utf-8');
38-
return matter(raw).content;
37+
return new Response(matter(raw).content, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } });
3938
});

0 commit comments

Comments
 (0)