orga-build 0.7.1 → 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 +49 -8
- package/lib/__tests__/build.test.js +59 -0
- package/lib/build.d.ts.map +1 -1
- package/lib/build.js +31 -3
- package/lib/endpoint.d.ts +23 -0
- package/lib/endpoint.d.ts.map +1 -0
- package/lib/endpoint.js +49 -0
- package/lib/files.d.ts +21 -4
- package/lib/files.d.ts.map +1 -1
- package/lib/files.js +121 -16
- package/lib/plugin.d.ts.map +1 -1
- package/lib/plugin.js +38 -2
- package/lib/ssr.jsx +2 -0
- package/lib/vite.d.ts.map +1 -1
- package/lib/vite.js +54 -12
- package/package.json +1 -1
package/README.org
CHANGED
|
@@ -93,14 +93,14 @@ export default {
|
|
|
93
93
|
|
|
94
94
|
** Configuration Options
|
|
95
95
|
|
|
96
|
-
| Option
|
|
97
|
-
|
|
98
|
-
| =root=
|
|
99
|
-
| =outDir=
|
|
100
|
-
| =containerClass= | =string \vert string[]= | =[]=
|
|
101
|
-
| =styles=
|
|
102
|
-
| =rehypePlugins=
|
|
103
|
-
| =vitePlugins=
|
|
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.
|
|
@@ -49,6 +49,19 @@ Here's [[mailto:hi@unclex.net][send me an email]].
|
|
|
49
49
|
'Docs index page.'
|
|
50
50
|
)
|
|
51
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
|
+
)
|
|
52
65
|
await fs.writeFile(
|
|
53
66
|
path.join(fixtureDir, 'style.css'),
|
|
54
67
|
'.global-style-marker { color: rgb(1, 2, 3); }'
|
|
@@ -169,4 +182,50 @@ This page verifies custom rehype plugins.`
|
|
|
169
182
|
await fs.rm(fixtureDirRehype, { recursive: true, force: true })
|
|
170
183
|
}
|
|
171
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
|
+
})
|
|
172
231
|
})
|
package/lib/build.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build.d.ts","sourceRoot":"","sources":["build.js"],"names":[],"mappings":"
|
|
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 {
|
|
87
|
-
|
|
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"}
|
package/lib/endpoint.js
ADDED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
package/lib/files.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["files.js"],"names":[],"mappings":"
|
|
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
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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>
|
|
275
|
+
* @returns {(() => Promise<T>) & { invalidate: () => void }}
|
|
226
276
|
*/
|
|
227
277
|
function cache(fn) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["plugin.js"],"names":[],"mappings":"
|
|
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":"
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
export default
|
|
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() {
|