Skip to content

Commit f4b8394

Browse files
authored
Orga build (#318)
* [orga-build] add endpoint route support * address some issues * cleanup * update README * add changeset * fix build
1 parent 0bcbbf4 commit f4b8394

File tree

9 files changed

+408
-41
lines changed

9 files changed

+408
-41
lines changed

.changeset/green-poems-yell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'orga-build': minor
3+
---
4+
5+
add data endpoint

packages/orga-build/README.org

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,14 @@ export default {
9393

9494
** Configuration Options
9595

96-
| Option | Type | Default | Description |
97-
|--------+------+---------+-------------|
98-
| =root= | =string= | ='pages'= | Directory containing content files |
99-
| =outDir= | =string= | ='out'= | Output directory for production build |
100-
| =containerClass= | =string \vert string[]= | =[]= | CSS class(es) for content wrapper |
101-
| =styles= | =string[]= | =[]= | Global stylesheet URLs injected/imported explicitly |
102-
| =rehypePlugins= | =PluggableList= | =[]= | Extra rehype plugins appended to orga-build defaults |
103-
| =vitePlugins= | =PluginOption[]= | =[]= | Additional Vite plugins |
96+
| Option | Type | Default | Description |
97+
|----------------+-------------------+---------+------------------------------------------------------|
98+
| =root= | =string= | ='pages'= | Directory containing content files |
99+
| =outDir= | =string= | ='out'= | Output directory for production build |
100+
| =containerClass= | =string \vert string[]= | =[]= | CSS class(es) for content wrapper |
101+
| =styles= | =string[]= | =[]= | Global stylesheet URLs injected/imported explicitly |
102+
| =rehypePlugins= | =PluggableList= | =[]= | Extra rehype plugins appended to orga-build defaults |
103+
| =vitePlugins= | =PluginOption[]= | =[]= | Additional Vite plugins |
104104

105105
** Syntax Highlighting Example
106106

@@ -112,6 +112,47 @@ export default {
112112
}
113113
#+end_src
114114

115+
* Routing
116+
117+
orga-build supports two route types: *page routes* and *endpoint routes*.
118+
119+
** Page Routes
120+
121+
Page routes are discovered from =.org=, =.tsx=, and =.jsx= files.
122+
123+
- =index.org= -> =/=
124+
- =about.org= -> =/about=
125+
- =docs/getting-started.tsx= -> =/docs/getting-started=
126+
127+
At build time, page routes are emitted as HTML:
128+
129+
- =/about= -> =out/about/index.html=
130+
131+
** Endpoint Routes
132+
133+
Endpoint routes are discovered from =.ts=, =.js=, =.mts=, and =.mjs= files where the basename already includes a target extension (for example =rss.xml.ts= or =data.json.ts=).
134+
135+
- =rss.xml.ts= -> =/rss.xml=
136+
- =nested/feed.xml.ts= -> =/nested/feed.xml=
137+
- =api/data.json.ts= -> =/api/data.json=
138+
139+
Endpoint modules must export:
140+
141+
#+begin_src ts
142+
export async function GET(ctx) {
143+
return new Response('ok', {
144+
headers: { 'content-type': 'text/plain; charset=utf-8' }
145+
})
146+
}
147+
#+end_src
148+
149+
At build time, endpoint routes are emitted to exact filenames:
150+
151+
- =/rss.xml= -> =out/rss.xml=
152+
- =/api/data.json= -> =out/api/data.json=
153+
154+
Route conflicts (same final route path) fail fast during dev/build startup.
155+
115156
* TypeScript Setup
116157

117158
If you're using TypeScript and want type support for the =orga-build:content= virtual module, you need to add a reference to the type definitions.

packages/orga-build/lib/__tests__/build.test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ Here's [[mailto:hi@unclex.net][send me an email]].
4949
'Docs index page.'
5050
)
5151
await fs.writeFile(path.join(fixtureDir, 'more.org'), 'Another page.')
52+
await fs.writeFile(
53+
path.join(fixtureDir, 'rss.xml.ts'),
54+
`import { getPages } from 'orga-build:content'
55+
56+
export function GET() {
57+
const pages = getPages()
58+
return new Response(
59+
'<?xml version="1.0" encoding="UTF-8"?><rss><count>' + pages.length + '</count></rss>',
60+
{ headers: { 'content-type': 'application/xml; charset=utf-8' } }
61+
)
62+
}
63+
`
64+
)
5265
await fs.writeFile(
5366
path.join(fixtureDir, 'style.css'),
5467
'.global-style-marker { color: rgb(1, 2, 3); }'
@@ -169,4 +182,50 @@ This page verifies custom rehype plugins.`
169182
await fs.rm(fixtureDirRehype, { recursive: true, force: true })
170183
}
171184
})
185+
186+
test('emits endpoint routes with exact output filenames', async () => {
187+
await build({
188+
root: fixtureDir,
189+
outDir: outDir,
190+
containerClass: [],
191+
vitePlugins: [],
192+
preBuild: [],
193+
postBuild: []
194+
})
195+
196+
const rss = await fs.readFile(path.join(outDir, 'rss.xml'), 'utf-8')
197+
assert.ok(
198+
rss.includes('<rss>') && rss.includes('<count>'),
199+
'should emit rss.xml from GET endpoint'
200+
)
201+
})
202+
203+
test('fails on duplicate route conflicts', async () => {
204+
const fixtureDirConflict = path.join(__dirname, 'fixtures-conflict')
205+
const outDirConflict = path.join(__dirname, '.test-output-conflict')
206+
try {
207+
await fs.mkdir(fixtureDirConflict, { recursive: true })
208+
await fs.writeFile(path.join(fixtureDirConflict, 'index.org'), 'Home')
209+
await fs.writeFile(
210+
path.join(fixtureDirConflict, 'index.tsx'),
211+
'export default function Page() { return <div>Index</div> }'
212+
)
213+
214+
await assert.rejects(
215+
() =>
216+
build({
217+
root: fixtureDirConflict,
218+
outDir: outDirConflict,
219+
containerClass: [],
220+
vitePlugins: [],
221+
preBuild: [],
222+
postBuild: []
223+
}),
224+
/Route conflict detected/
225+
)
226+
} finally {
227+
await fs.rm(outDirConflict, { recursive: true, force: true })
228+
await fs.rm(fixtureDirConflict, { recursive: true, force: true })
229+
}
230+
})
172231
})

packages/orga-build/lib/build.js

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
22
import path from 'node:path'
33
import { fileURLToPath, pathToFileURL } from 'node:url'
44
import { createBuilder } from 'vite'
5+
import { resolveEndpointResponse } from './endpoint.js'
56
import { emptyDir, ensureDir, exists } from './fs.js'
67
import { alias, createOrgaBuildConfig } from './plugin.js'
78
import { escapeHtml } from './util.js'
@@ -83,9 +84,11 @@ export async function build(
8384
console.log('preparing ssr bundle...')
8485
await builder.build(builder.environments.ssr)
8586

86-
const { render, pages } = await import(
87-
pathToFileURL(path.join(ssrOutDir, 'ssr.mjs')).toString()
88-
)
87+
const {
88+
render,
89+
pages,
90+
endpoints = {}
91+
} = await import(pathToFileURL(path.join(ssrOutDir, 'ssr.mjs')).toString())
8992

9093
// Build client bundle
9194
const _clientResult = await builder.build(builder.environments.client)
@@ -129,6 +132,31 @@ export async function build(
129132
})
130133
)
131134

135+
const endpointPaths = Object.keys(endpoints)
136+
await Promise.all(
137+
endpointPaths.map(async (route) => {
138+
const endpointModule = endpoints[route]
139+
const ctx = {
140+
url: new URL(`http://localhost${route}`),
141+
params: {},
142+
mode: /** @type {'build'} */ ('build'),
143+
route: { route }
144+
}
145+
146+
const response = await resolveEndpointResponse(endpointModule, ctx, 'GET')
147+
if (response.status < 200 || response.status >= 300) {
148+
throw new Error(
149+
`Endpoint route "${route}" returned non-2xx status during build: ${response.status}`
150+
)
151+
}
152+
153+
const bytes = Buffer.from(await response.arrayBuffer())
154+
const writePath = path.join(clientOutDir, route.replace(/^\//, ''))
155+
await ensureDir(path.dirname(writePath))
156+
await fs.writeFile(writePath, bytes)
157+
})
158+
)
159+
132160
await fs.rm(ssrOutDir, { recursive: true })
133161

134162
return
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @typedef {Object} EndpointContext
3+
* @property {URL} url
4+
* @property {Record<string, string>} params
5+
* @property {'dev' | 'build'} mode
6+
* @property {{ route: string }} route
7+
*/
8+
9+
/**
10+
* @param {Record<string, any>} endpointModule
11+
* @param {EndpointContext} ctx
12+
* @param {string} method
13+
* @returns {Promise<Response>}
14+
*/
15+
export async function resolveEndpointResponse(
16+
endpointModule,
17+
ctx,
18+
method = 'GET'
19+
) {
20+
const route = ctx.route.route
21+
22+
if (method === 'HEAD' && typeof endpointModule.HEAD === 'function') {
23+
const res = await endpointModule.HEAD(ctx)
24+
if (!(res instanceof Response))
25+
throw new Error(`Endpoint route "${route}" HEAD must return Response`)
26+
return res
27+
}
28+
29+
if (typeof endpointModule.GET !== 'function') {
30+
throw new Error(
31+
`Endpoint route "${route}" must export GET(ctx) returning Response`
32+
)
33+
}
34+
35+
const res = await endpointModule.GET(ctx)
36+
if (!(res instanceof Response)) {
37+
throw new Error(`Endpoint route "${route}" GET must return Response`)
38+
}
39+
40+
if (method === 'HEAD') {
41+
return new Response(null, {
42+
status: res.status,
43+
statusText: res.statusText,
44+
headers: new Headers(res.headers)
45+
})
46+
}
47+
48+
return res
49+
}

0 commit comments

Comments
 (0)