orga-build 0.7.0 → 0.8.0

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/README.org CHANGED
@@ -93,14 +93,14 @@ export default {
93
93
 
94
94
  ** Configuration Options
95
95
 
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 |
104
104
 
105
105
  ** Syntax Highlighting Example
106
106
 
@@ -112,6 +112,47 @@ export default {
112
112
  }
113
113
  #+end_src
114
114
 
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
+
115
156
  * TypeScript Setup
116
157
 
117
158
  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.
@@ -40,6 +40,8 @@ This is a test page.
40
40
  Here's [[file:./docs/index.org][index page]].
41
41
 
42
42
  Here's [[file:more.org][another page]].
43
+
44
+ Here's [[mailto:hi@unclex.net][send me an email]].
43
45
  `
44
46
  )
45
47
  await fs.writeFile(
@@ -47,6 +49,19 @@ Here's [[file:more.org][another page]].
47
49
  'Docs index page.'
48
50
  )
49
51
  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
+ )
50
65
  await fs.writeFile(
51
66
  path.join(fixtureDir, 'style.css'),
52
67
  '.global-style-marker { color: rgb(1, 2, 3); }'
@@ -85,6 +100,10 @@ Here's [[file:more.org][another page]].
85
100
  'should rewrite docs/index.org to /docs'
86
101
  )
87
102
  assert.ok(html.includes('href="/more"'), 'should rewrite more.org to /more')
103
+ assert.ok(
104
+ html.includes('href="mailto:hi@unclex.net"'),
105
+ 'should keep mailto protocol in href'
106
+ )
88
107
  })
89
108
 
90
109
  test('generates assets directory', async () => {
@@ -163,4 +182,50 @@ This page verifies custom rehype plugins.`
163
182
  await fs.rm(fixtureDirRehype, { recursive: true, force: true })
164
183
  }
165
184
  })
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
+ })
166
231
  })
package/lib/app.jsx CHANGED
@@ -3,6 +3,13 @@ import * as components from '/@orga-build/components'
3
3
  import layouts from '/@orga-build/layouts'
4
4
  import pages from '/@orga-build/pages'
5
5
 
6
+ function SmartLink({ href, ...props }) {
7
+ if (!href || /^([a-z][a-z\d+\-.]*:|\/\/)/i.test(href)) {
8
+ return <a href={href} {...props} />
9
+ }
10
+ return <Link href={href} {...props} />
11
+ }
12
+
6
13
  export function App() {
7
14
  const _pages = Object.entries(pages).map(([path, page]) => {
8
15
  return {
@@ -16,7 +23,10 @@ export function App() {
16
23
  .filter((key) => path.startsWith(key))
17
24
  .sort((a, b) => -a.localeCompare(b))
18
25
  let element = (
19
- <page.default key={path} components={{ ...components, Link, a: Link }} />
26
+ <page.default
27
+ key={path}
28
+ components={{ ...components, Link, a: SmartLink }}
29
+ />
20
30
  )
21
31
  for (const layoutId of layoutIds) {
22
32
  const Layout = layouts[layoutId]
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["build.js"],"names":[],"mappings":"AAeA;;;GAGG;AACH,4FAHW,OAAO,aAAa,EAAE,MAAM,gBAC5B,MAAM,iBA8JhB;;sBA1K4C,aAAa"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["build.js"],"names":[],"mappings":"AAgBA;;;GAGG;AACH,4FAHW,OAAO,aAAa,EAAE,MAAM,gBAC5B,MAAM,iBAyLhB;;sBArM4C,aAAa"}
package/lib/build.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url'
4
4
  import { createBuilder } from 'vite'
5
+ import { resolveEndpointResponse } from './endpoint.js'
5
6
  import { emptyDir, ensureDir, exists } from './fs.js'
6
7
  import { alias, createOrgaBuildConfig } from './plugin.js'
7
8
  import { escapeHtml } from './util.js'
@@ -83,9 +84,11 @@ export async function build(
83
84
  console.log('preparing ssr bundle...')
84
85
  await builder.build(builder.environments.ssr)
85
86
 
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())
89
92
 
90
93
  // Build client bundle
91
94
  const _clientResult = await builder.build(builder.environments.client)
@@ -129,6 +132,31 @@ export async function build(
129
132
  })
130
133
  )
131
134
 
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
+
132
160
  await fs.rm(ssrOutDir, { recursive: true })
133
161
 
134
162
  return
@@ -0,0 +1,23 @@
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
+ * @param {Record<string, any>} endpointModule
10
+ * @param {EndpointContext} ctx
11
+ * @param {string} method
12
+ * @returns {Promise<Response>}
13
+ */
14
+ export function resolveEndpointResponse(endpointModule: Record<string, any>, ctx: EndpointContext, method?: string): Promise<Response>;
15
+ export type EndpointContext = {
16
+ url: URL;
17
+ params: Record<string, string>;
18
+ mode: "dev" | "build";
19
+ route: {
20
+ route: string;
21
+ };
22
+ };
23
+ //# sourceMappingURL=endpoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"endpoint.d.ts","sourceRoot":"","sources":["endpoint.js"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,wDALW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,OACnB,eAAe,WACf,MAAM,GACJ,OAAO,CAAC,QAAQ,CAAC,CAoC7B;;SA9Ca,GAAG;YACH,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;UACtB,KAAK,GAAG,OAAO;WACf;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE"}
@@ -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
+ }
package/lib/files.d.ts CHANGED
@@ -6,11 +6,24 @@
6
6
  export function setup(dir: string, { outDir }?: {
7
7
  outDir?: string | undefined;
8
8
  }): {
9
- pages: () => Promise<Record<string, Page>>;
9
+ pages: (() => Promise<Record<string, Page>>) & {
10
+ invalidate: () => void;
11
+ };
10
12
  page: (slug: string) => Promise<Page>;
11
- components: () => Promise<string | null>;
12
- layouts: () => Promise<Record<string, string>>;
13
- contentEntries: () => Promise<ContentEntry[]>;
13
+ endpoints: (() => Promise<Record<string, EndpointRoute>>) & {
14
+ invalidate: () => void;
15
+ };
16
+ endpoint: (route: string) => Promise<EndpointRoute>;
17
+ components: (() => Promise<string | null>) & {
18
+ invalidate: () => void;
19
+ };
20
+ layouts: (() => Promise<Record<string, string>>) & {
21
+ invalidate: () => void;
22
+ };
23
+ contentEntries: (() => Promise<ContentEntry[]>) & {
24
+ invalidate: () => void;
25
+ };
26
+ invalidate(): void;
14
27
  };
15
28
  /**
16
29
  * Convert a content file path (relative to content root) to the canonical page slug.
@@ -24,6 +37,10 @@ export type Page = {
24
37
  */
25
38
  title?: string;
26
39
  };
40
+ export type EndpointRoute = {
41
+ route: string;
42
+ dataPath: string;
43
+ };
27
44
  export type ContentEntry = {
28
45
  id: string;
29
46
  slug: string;
@@ -1 +1 @@
1
- {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["files.js"],"names":[],"mappings":"AA8EA;;;;GAIG;AACH,2BAJW,MAAM,eAEd;IAAyB,MAAM;CACjC;;iBAiIY,MAAM;;;;EAKlB;AAsBD;;;GAGG;AACH,4DAFW,MAAM,UAoBhB;;cA7Pa,MAAM;;;;YACN,MAAM;;;QAMN,MAAM;UACN,MAAM;UACN,MAAM;cACN,MAAM;SACN,KAAK,GAAG,KAAK,GAAG,KAAK;UACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC"}
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["files.js"],"names":[],"mappings":"AAoFA;;;;GAIG;AACH,2BAJW,MAAM,eAEd;IAAyB,MAAM;CACjC;;0BA0LqD,IAAI;;iBApB7C,MAAM;;0BAoBmC,IAAI;;sBAd7C,MAAM;;0BAcmC,IAAI;;;0BAAJ,IAAI;;;0BAAJ,IAAI;;;EATzD;AA8BD;;;GAGG;AACH,4DAFW,MAAM,UAoBhB;;cAtTa,MAAM;;;;YACN,MAAM;;;WAMN,MAAM;cACN,MAAM;;;QAKN,MAAM;UACN,MAAM;UACN,MAAM;cACN,MAAM;SACN,KAAK,GAAG,KAAK,GAAG,KAAK;UACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC"}
package/lib/files.js CHANGED
@@ -10,6 +10,12 @@ import { getSettings } from 'orga'
10
10
  * Path to the page data file
11
11
  */
12
12
 
13
+ /**
14
+ * @typedef {Object} EndpointRoute
15
+ * @property {string} route
16
+ * @property {string} dataPath
17
+ */
18
+
13
19
  /**
14
20
  * @typedef {Object} ContentEntry
15
21
  * @property {string} id
@@ -89,10 +95,10 @@ export function setup(dir, { outDir } = {}) {
89
95
  ? `!${outDirRelative}/**`
90
96
  : null
91
97
 
92
- const pages = cache(async function () {
98
+ const discoveredRoutes = cache(async function () {
93
99
  const files = await globby(
94
100
  [
95
- '**/*.{org,tsx,jsx}',
101
+ '**/*.{org,tsx,jsx,ts,js,mts,mjs}',
96
102
  '!**/_*/**',
97
103
  '!**/_*',
98
104
  '!**/.*/**',
@@ -105,14 +111,41 @@ export function setup(dir, { outDir } = {}) {
105
111
 
106
112
  /** @type {Record<string, Page>} */
107
113
  const pages = {}
114
+ /** @type {Record<string, EndpointRoute>} */
115
+ const endpoints = {}
116
+ /** @type {Map<string, { sourceType: 'page' | 'endpoint', filePath: string }>} */
117
+ const routeOwners = new Map()
118
+
108
119
  for (const file of files) {
109
- const pageSlug = getSlugFromContentFilePath(file)
110
- pages[pageSlug] = {
111
- dataPath: path.join(dir, file)
120
+ const absolutePath = path.join(dir, file)
121
+ const pageSlug = getPageSlugFromFilePath(file)
122
+ const endpointRoute = getEndpointRouteFromFilePath(file)
123
+
124
+ if (pageSlug) {
125
+ assertUniqueRoute(routeOwners, pageSlug, 'page', absolutePath)
126
+ pages[pageSlug] = { dataPath: absolutePath }
127
+ }
128
+
129
+ if (endpointRoute) {
130
+ assertUniqueRoute(routeOwners, endpointRoute, 'endpoint', absolutePath)
131
+ endpoints[endpointRoute] = {
132
+ route: endpointRoute,
133
+ dataPath: absolutePath
134
+ }
112
135
  }
113
136
  }
114
137
 
115
- return pages
138
+ return { pages, endpoints }
139
+ })
140
+
141
+ const pages = cache(async function () {
142
+ const routes = await discoveredRoutes()
143
+ return routes.pages
144
+ })
145
+
146
+ const endpoints = cache(async function () {
147
+ const routes = await discoveredRoutes()
148
+ return routes.endpoints
116
149
  })
117
150
 
118
151
  const layouts = cache(async function () {
@@ -202,9 +235,19 @@ export function setup(dir, { outDir } = {}) {
202
235
  const files = {
203
236
  pages,
204
237
  page,
238
+ endpoints,
239
+ endpoint,
205
240
  components,
206
241
  layouts,
207
- contentEntries
242
+ contentEntries,
243
+ invalidate() {
244
+ discoveredRoutes.invalidate()
245
+ pages.invalidate()
246
+ endpoints.invalidate()
247
+ layouts.invalidate()
248
+ components.invalidate()
249
+ contentEntries.invalidate()
250
+ }
208
251
  }
209
252
 
210
253
  return files
@@ -214,26 +257,40 @@ export function setup(dir, { outDir } = {}) {
214
257
  const all = await pages()
215
258
  return all[slug]
216
259
  }
260
+
261
+ /** @param {string} route */
262
+ async function endpoint(route) {
263
+ const all = await endpoints()
264
+ return all[route]
265
+ }
217
266
  }
218
267
 
219
268
  /**
220
269
  * Creates a cached version of an async function that will only execute once
221
- * and return the cached result on subsequent calls
270
+ * and return the cached result on subsequent calls. The returned function
271
+ * also has an `invalidate()` method to clear the cache.
222
272
  *
223
273
  * @template T
224
274
  * @param {() => Promise<T>} fn - The async function to cache
225
- * @returns {() => Promise<T>} - Cached function that returns the same type as the input function
275
+ * @returns {(() => Promise<T>) & { invalidate: () => void }}
226
276
  */
227
277
  function cache(fn) {
228
- /** @type {T | null} */
229
- let cache = null
230
- return async function () {
231
- if (cache) {
232
- return cache
278
+ let settled = false
279
+ /** @type {T | undefined} */
280
+ let value
281
+ /** @returns {Promise<T>} */
282
+ async function cached() {
283
+ if (!settled) {
284
+ value = await fn()
285
+ settled = true
233
286
  }
234
- cache = await fn()
235
- return cache
287
+ return /** @type {T} */ (value)
288
+ }
289
+ cached.invalidate = function () {
290
+ settled = false
291
+ value = undefined
236
292
  }
293
+ return cached
237
294
  }
238
295
 
239
296
  /**
@@ -259,3 +316,51 @@ export function getSlugFromContentFilePath(contentFilePath) {
259
316
 
260
317
  return slug
261
318
  }
319
+
320
+ /**
321
+ * @param {string} filePath
322
+ * @returns {string|null}
323
+ */
324
+ function getPageSlugFromFilePath(filePath) {
325
+ if (!/\.(org|tsx|jsx)$/.test(filePath)) {
326
+ return null
327
+ }
328
+ return getSlugFromContentFilePath(filePath)
329
+ }
330
+
331
+ /**
332
+ * @param {string} filePath
333
+ * @returns {string|null}
334
+ */
335
+ function getEndpointRouteFromFilePath(filePath) {
336
+ if (!/\.(ts|js|mts|mjs)$/.test(filePath)) {
337
+ return null
338
+ }
339
+
340
+ const normalizedFilePath = filePath.replace(/\\/g, '/')
341
+ const targetPath = normalizedFilePath.replace(/\.(ts|js|mts|mjs)$/, '')
342
+ const basename = path.posix.basename(targetPath)
343
+
344
+ // Endpoint files must carry a target extension: rss.xml.ts, data.json.ts, etc.
345
+ if (!basename.includes('.')) {
346
+ return null
347
+ }
348
+
349
+ return `/${targetPath.replace(/^\/+/, '')}`
350
+ }
351
+
352
+ /**
353
+ * @param {Map<string, { sourceType: 'page' | 'endpoint', filePath: string }>} routeOwners
354
+ * @param {string} route
355
+ * @param {'page' | 'endpoint'} sourceType
356
+ * @param {string} filePath
357
+ */
358
+ function assertUniqueRoute(routeOwners, route, sourceType, filePath) {
359
+ const existing = routeOwners.get(route)
360
+ if (existing) {
361
+ throw new Error(
362
+ `Route conflict detected for "${route}"\n- ${existing.sourceType}: ${existing.filePath}\n- ${sourceType}: ${filePath}`
363
+ )
364
+ }
365
+ routeOwners.set(route, { sourceType, filePath })
366
+ }
package/lib/orga.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"orga.d.ts","sourceRoot":"","sources":["orga.js"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,mEAJG;IAAiC,cAAc,EAAvC,MAAM,GAAC,MAAM,EAAE;IACC,IAAI,EAApB,MAAM;IACoC,aAAa;CACjE,mCAaA"}
1
+ {"version":3,"file":"orga.d.ts","sourceRoot":"","sources":["orga.js"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,mEAJG;IAAiC,cAAc,EAAvC,MAAM,GAAC,MAAM,EAAE;IACC,IAAI,EAApB,MAAM;IACoC,aAAa;CACjE,mCAUA"}
package/lib/orga.js CHANGED
@@ -19,10 +19,7 @@ export function setupOrga({ containerClass, root, rehypePlugins = [] }) {
19
19
  [rewriteOrgFileLinks, { root }],
20
20
  mediaAssets,
21
21
  ...rehypePlugins
22
- ],
23
- reorgRehypeOptions: {
24
- linkHref: (link) => link.path.value
25
- }
22
+ ]
26
23
  })
27
24
  }
28
25
 
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["plugin.js"],"names":[],"mappings":"AAuBA;;;;;;;GAOG;AAEH;;;;;;GAMG;AACH,yFAHW,sBAAsB,GACpB,OAAO,MAAM,EAAE,YAAY,EAAE,CAczC;AAED;;;;;;GAMG;AACH,8IAHW,sBAAsB,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,MAAM,EAAE,YAAY,EAAE,CAAC;IAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC5I;IAAE,OAAO,EAAE,OAAO,MAAM,EAAE,YAAY,EAAE,CAAC;IAAC,OAAO,EAAE;QAAE,KAAK,EAAE,OAAO,KAAK,CAAA;KAAE,CAAA;CAAE,CAyBxF;AAiBD;;;;;;;;;;;;;GAaG;AACH,gDAJW,MAAM,WACN,MAAM,EAAE,GACN,OAAO,MAAM,EAAE,MAAM,CAmFjC;AArLD;;GAEG;AACH;;;;EAIC;;;;;UAIa,MAAM;;;;aACN,MAAM,GAAG,SAAS;;;;qBAClB,MAAM,GAAC,MAAM,EAAE;;;;aACf,MAAM,EAAE;;;;oBACR,OAAO,SAAS,EAAE,aAAa"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["plugin.js"],"names":[],"mappings":"AAwBA;;;;;;;GAOG;AAEH;;;;;;GAMG;AACH,yFAHW,sBAAsB,GACpB,OAAO,MAAM,EAAE,YAAY,EAAE,CAczC;AAED;;;;;;GAMG;AACH,8IAHW,sBAAsB,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,MAAM,EAAE,YAAY,EAAE,CAAC;IAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC5I;IAAE,OAAO,EAAE,OAAO,MAAM,EAAE,YAAY,EAAE,CAAC;IAAC,OAAO,EAAE;QAAE,KAAK,EAAE,OAAO,KAAK,CAAA;KAAE,CAAA;CAAE,CAyBxF;AAiBD;;;;;;;;;;;;;GAaG;AACH,gDAJW,MAAM,WACN,MAAM,EAAE,GACN,OAAO,MAAM,EAAE,MAAM,CAsHjC;AAxND;;GAEG;AACH;;;;EAIC;;;;;UAIa,MAAM;;;;aACN,MAAM,GAAG,SAAS;;;;qBAClB,MAAM,GAAC,MAAM,EAAE;;;;aACf,MAAM,EAAE;;;;oBACR,OAAO,SAAS,EAAE,aAAa"}
package/lib/plugin.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import react from '@vitejs/plugin-react'
6
6
  import { createServerModuleRunner } from 'vite'
7
+ import { resolveEndpointResponse } from './endpoint.js'
7
8
  import { setupOrga } from './orga.js'
8
9
  import { escapeHtml } from './util.js'
9
10
  import { pluginFactory } from './vite.js'
@@ -134,6 +135,43 @@ export function htmlFallbackPlugin(projectRoot, styles = []) {
134
135
  return next()
135
136
  }
136
137
 
138
+ const url = req.url || '/'
139
+ const pathname = url.split('?')[0]
140
+
141
+ // Endpoint routes are handled first and bypass HTML fallback.
142
+ try {
143
+ const { endpoints } = await runner.import(ssrEntry)
144
+ const endpointModule = endpoints?.[pathname]
145
+ if (endpointModule) {
146
+ const ctx = {
147
+ url: new URL(url, `http://${req.headers.host || 'localhost'}`),
148
+ params: {},
149
+ mode: /** @type {'dev'} */ ('dev'),
150
+ route: { route: pathname }
151
+ }
152
+
153
+ const response = await resolveEndpointResponse(
154
+ endpointModule,
155
+ ctx,
156
+ req.method
157
+ )
158
+ res.statusCode = response.status
159
+ response.headers.forEach((headerValue, headerName) => {
160
+ res.setHeader(headerName, headerValue)
161
+ })
162
+ if (req.method === 'HEAD') {
163
+ res.end()
164
+ return
165
+ }
166
+ const bytes = Buffer.from(await response.arrayBuffer())
167
+ res.end(bytes)
168
+ return
169
+ }
170
+ } catch (e) {
171
+ next(e)
172
+ return
173
+ }
174
+
137
175
  // Only handle browser-like navigation requests.
138
176
  // Don't match generic */* accepts to avoid hijacking API requests.
139
177
  const accept = req.headers.accept || ''
@@ -142,8 +180,6 @@ export function htmlFallbackPlugin(projectRoot, styles = []) {
142
180
  }
143
181
 
144
182
  // Don't intercept asset requests (files with extensions)
145
- const url = req.url || '/'
146
- const pathname = url.split('?')[0]
147
183
  if (pathname !== '/' && /\.\w+$/.test(pathname)) {
148
184
  return next()
149
185
  }
package/lib/ssr.jsx CHANGED
@@ -1,9 +1,11 @@
1
1
  import { renderToString } from 'react-dom/server'
2
2
  import { Router } from 'wouter'
3
+ import endpoints from '/@orga-build/endpoints'
3
4
  import pages from '/@orga-build/pages'
4
5
  import { App } from './app.jsx'
5
6
 
6
7
  export { pages }
8
+ export { endpoints }
7
9
 
8
10
  /**
9
11
  * @param {string} url
package/lib/vite.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["vite.js"],"names":[],"mappings":"AASA;;;;;;GAMG;AACH,uDALG;IAAwB,GAAG,EAAnB,MAAM;IACW,MAAM;IACJ,MAAM;CACjC,GAAU,OAAO,MAAM,EAAE,MAAM,CAoJjC;AA7JD,gDAAuD"}
1
+ {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["vite.js"],"names":[],"mappings":"AAWA;;;;;;GAMG;AACH,uDALG;IAAwB,GAAG,EAAnB,MAAM;IACW,MAAM;IACJ,MAAM;CACjC,GAAU,OAAO,MAAM,EAAE,MAAM,CA4LjC;AAtMD,gDAAuD"}
package/lib/vite.js CHANGED
@@ -3,9 +3,11 @@ import { setup } from './files.js'
3
3
 
4
4
  const magicModulePrefix = '/@orga-build/'
5
5
  const pagesModuleId = `${magicModulePrefix}pages`
6
+ const endpointsModuleId = `${magicModulePrefix}endpoints`
6
7
  export const appEntryId = `${magicModulePrefix}main.js`
7
8
  const contentModuleId = 'orga-build:content'
8
9
  const contentModuleIdResolved = `\0${contentModuleId}`
10
+ const endpointModulePrefix = `${endpointsModuleId}/__route__/`
9
11
 
10
12
  /**
11
13
  * @param {Object} options
@@ -28,7 +30,15 @@ export function pluginFactory({ dir, outDir, styles = [] }) {
28
30
  }
29
31
  }),
30
32
 
33
+ async configureServer(_server) {
34
+ // Eagerly run file discovery so route conflicts surface at startup
35
+ await files.pages()
36
+ await files.endpoints()
37
+ },
38
+
31
39
  hotUpdate() {
40
+ // Invalidate in-memory file caches so added/removed routes are picked up
41
+ files.invalidate()
32
42
  // Invalidate content module when content files change
33
43
  const module = this.environment.moduleGraph.getModuleById(
34
44
  contentModuleIdResolved
@@ -64,6 +74,9 @@ export function pluginFactory({ dir, outDir, styles = [] }) {
64
74
  if (id === pagesModuleId) {
65
75
  return await renderPageList()
66
76
  }
77
+ if (id === endpointsModuleId) {
78
+ return await renderEndpointList()
79
+ }
67
80
  if (id.startsWith(pagesModuleId)) {
68
81
  const pageId = id.replace(pagesModuleId, '')
69
82
  const page = await files.page(pageId)
@@ -74,6 +87,14 @@ export {default} from '${page.dataPath}';
74
87
  `
75
88
  }
76
89
  }
90
+ if (id.startsWith(endpointModulePrefix)) {
91
+ const routeHex = id.slice(endpointModulePrefix.length)
92
+ const endpointId = Buffer.from(routeHex, 'hex').toString('utf-8')
93
+ const endpoint = await files.endpoint(endpointId)
94
+ if (endpoint) {
95
+ return `export * from '${endpoint.dataPath}';`
96
+ }
97
+ }
77
98
 
78
99
  if (id === `${magicModulePrefix}layouts`) {
79
100
  const layouts = await files.layouts()
@@ -99,19 +120,40 @@ export default layouts;
99
120
 
100
121
  async function renderPageList() {
101
122
  const pages = await files.pages()
102
- /** @type {string[]} */ const _imports = []
103
- /** @type {string[]} */ const _pages = []
104
- Object.entries(pages).forEach(([pageId, _value], i) => {
105
- const dataModulePath = path.join(magicModulePrefix, 'pages', pageId)
106
- _imports.push(`import * as page${i} from '${dataModulePath}'`)
107
- _pages.push(`pages['${pageId}'] = page${i}`)
123
+ return renderModuleMap('pages', pages, (id) =>
124
+ path.join(magicModulePrefix, 'pages', id)
125
+ )
126
+ }
127
+
128
+ async function renderEndpointList() {
129
+ const endpoints = await files.endpoints()
130
+ return renderModuleMap(
131
+ 'endpoints',
132
+ endpoints,
133
+ (route) => endpointModulePrefix + Buffer.from(route).toString('hex')
134
+ )
135
+ }
136
+
137
+ /**
138
+ * @param {string} name
139
+ * @param {Record<string, unknown>} entries
140
+ * @param {(key: string) => string} toModulePath
141
+ */
142
+ function renderModuleMap(name, entries, toModulePath) {
143
+ /** @type {string[]} */
144
+ const imports = []
145
+ /** @type {string[]} */
146
+ const assignments = []
147
+ Object.keys(entries).forEach((key, i) => {
148
+ imports.push(`import * as m${i} from '${toModulePath(key)}'`)
149
+ assignments.push(`${name}['${key}'] = m${i}`)
108
150
  })
109
- return `
110
- ${_imports.join('\n')}
111
- const pages = {};
112
- ${_pages.join('\n')}
113
- export default pages;
114
- `
151
+ return [
152
+ imports.join('\n'),
153
+ `const ${name} = {};`,
154
+ assignments.join('\n'),
155
+ `export default ${name};`
156
+ ].join('\n')
115
157
  }
116
158
 
117
159
  async function renderComponents() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orga-build",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A simple tool that builds org-mode files into a website",
5
5
  "type": "module",
6
6
  "engines": {