orga-build 0.8.0 → 0.10.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.
Files changed (66) hide show
  1. package/README.org +296 -41
  2. package/cli.js +4 -4
  3. package/lib/__tests__/build.test.js +444 -23
  4. package/lib/__tests__/dev.test.d.ts +2 -0
  5. package/lib/__tests__/dev.test.d.ts.map +1 -0
  6. package/lib/__tests__/dev.test.js +295 -0
  7. package/lib/__tests__/fixtures.d.ts +8 -0
  8. package/lib/__tests__/fixtures.d.ts.map +1 -0
  9. package/lib/__tests__/fixtures.js +61 -0
  10. package/lib/app.jsx +23 -50
  11. package/lib/build.d.ts +4 -1
  12. package/lib/build.d.ts.map +1 -1
  13. package/lib/build.js +12 -176
  14. package/lib/config.d.ts +9 -3
  15. package/lib/config.d.ts.map +1 -1
  16. package/lib/config.js +14 -18
  17. package/lib/content.d.ts +6 -0
  18. package/lib/dev-ssr.d.ts +14 -0
  19. package/lib/dev-ssr.d.ts.map +1 -0
  20. package/lib/dev-ssr.js +137 -0
  21. package/lib/endpoint.d.ts +5 -0
  22. package/lib/endpoint.d.ts.map +1 -1
  23. package/lib/endpoint.js +1 -0
  24. package/lib/files.d.ts +10 -3
  25. package/lib/files.d.ts.map +1 -1
  26. package/lib/files.js +111 -23
  27. package/lib/fs.d.ts +0 -5
  28. package/lib/fs.d.ts.map +1 -1
  29. package/lib/fs.js +0 -18
  30. package/lib/html.d.ts +37 -0
  31. package/lib/html.d.ts.map +1 -0
  32. package/lib/html.js +101 -0
  33. package/lib/index.html +0 -1
  34. package/lib/island-client.d.ts +5 -0
  35. package/lib/island-client.d.ts.map +1 -0
  36. package/lib/island-client.js +41 -0
  37. package/lib/island.d.ts +18 -0
  38. package/lib/island.d.ts.map +1 -0
  39. package/lib/island.js +146 -0
  40. package/lib/island.jsx +108 -0
  41. package/lib/orga.d.ts +1 -1
  42. package/lib/orga.d.ts.map +1 -1
  43. package/lib/orga.js +54 -16
  44. package/lib/plugin.d.ts +25 -32
  45. package/lib/plugin.d.ts.map +1 -1
  46. package/lib/plugin.js +130 -170
  47. package/lib/prerender.d.ts +11 -0
  48. package/lib/prerender.d.ts.map +1 -0
  49. package/lib/prerender.js +184 -0
  50. package/lib/serve.d.ts.map +1 -1
  51. package/lib/serve.js +5 -23
  52. package/lib/ssr.jsx +9 -14
  53. package/lib/util.d.ts +0 -22
  54. package/lib/util.d.ts.map +1 -1
  55. package/lib/util.js +0 -62
  56. package/lib/vite.d.ts +7 -6
  57. package/lib/vite.d.ts.map +1 -1
  58. package/lib/vite.js +75 -23
  59. package/package.json +5 -8
  60. package/lib/components.d.ts +0 -2
  61. package/lib/components.d.ts.map +0 -1
  62. package/lib/components.js +0 -1
  63. package/lib/csr.jsx +0 -11
  64. package/lib/watch.d.ts +0 -10
  65. package/lib/watch.d.ts.map +0 -1
  66. package/lib/watch.js +0 -53
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["plugin.js"],"names":[],"mappings":"AA2BA;;;;;;;;;GASG;AAEH;;;;;;;GAOG;AACH,wGAHW,sBAAsB,GACpB,OAAO,MAAM,EAAE,YAAY,EAAE,CAyBzC;AAkBD;;;;;GAKG;AACH,mEAHW,sBAAsB,GAAG;IAAE,WAAW,CAAC,EAAE,OAAO,MAAM,EAAE,YAAY,EAAE,CAAA;CAAE,GACtE;IAAE,OAAO,EAAE,OAAO,MAAM,EAAE,YAAY,EAAE,CAAA;CAAE,CAMtD;AArFD;;;GAGG;AACH,6BAA8B,qBAAqB,CAAA;AAGnD;;GAEG;AACH;;;EAGC;;;;;UAIa,MAAM;;;;aACN,MAAM,GAAG,SAAS;;;;qBAClB,MAAM,GAAC,MAAM,EAAE;;;;aACf,MAAM,EAAE;;;;oBACR,OAAO,SAAS,EAAE,aAAa;;;;cAC/B,MAAM,EAAE;;;;WACR,MAAM,GAAG,SAAS"}
package/lib/plugin.js CHANGED
@@ -1,39 +1,45 @@
1
- import fs from 'node:fs/promises'
2
1
  import { createRequire } from 'node:module'
3
2
  import path from 'node:path'
4
3
  import { fileURLToPath } from 'node:url'
5
4
  import react from '@vitejs/plugin-react'
6
- import { createServerModuleRunner } from 'vite'
7
- import { resolveEndpointResponse } from './endpoint.js'
5
+ import { devSsrPlugin } from './dev-ssr.js'
6
+ import { htmlShellPlugin } from './html.js'
7
+ import { islandPlugin } from './island.js'
8
8
  import { setupOrga } from './orga.js'
9
- import { escapeHtml } from './util.js'
9
+ import { prerenderPlugin } from './prerender.js'
10
10
  import { pluginFactory } from './vite.js'
11
11
 
12
12
  const ssrEntry = fileURLToPath(new URL('./ssr.jsx', import.meta.url))
13
+ /**
14
+ * Prefix of emitted asset paths in server-rendered markup, replaced per page
15
+ * while prerendering (see `prerender.js`).
16
+ */
17
+ export const assetUrlMarker = '/@orga-build/asset/'
13
18
 
14
19
  const require = createRequire(import.meta.url)
15
- const defaultIndexHtml = fileURLToPath(new URL('./index.html', import.meta.url))
16
20
  /**
17
- * Alias map for React and wouter to ensure consistent resolution
21
+ * Alias map for React to ensure a single copy is bundled
18
22
  */
19
23
  export const alias = {
20
24
  react: path.dirname(require.resolve('react/package.json')),
21
- 'react-dom': path.dirname(require.resolve('react-dom/package.json')),
22
- wouter: path.dirname(require.resolve('wouter'))
25
+ 'react-dom': path.dirname(require.resolve('react-dom/package.json'))
23
26
  }
24
27
 
25
28
  /**
26
29
  * @typedef {Object} OrgaBuildPluginOptions
27
30
  * @property {string} root - Root directory for content files
28
- * @property {string | undefined} [outDir] - Output directory (excluded from file discovery)
31
+ * @property {string | undefined} [outDir] - Output directory (default: Vite's `build.outDir`)
29
32
  * @property {string|string[]} [containerClass] - CSS class(es) to wrap rendered content
30
- * @property {string[]} [styles] - Global stylesheet URLs to import/inject
33
+ * @property {string[]} [styles] - Global stylesheet URLs to link from the HTML shell
31
34
  * @property {import('unified').PluggableList} [rehypePlugins] - Extra rehype plugins appended to orga-build defaults
35
+ * @property {string[]} [exclude] - Glob patterns for files to exclude from content scanning
36
+ * @property {string | undefined} [site] - Absolute URL the site is served from, e.g. `https://example.com`
32
37
  */
33
38
 
34
39
  /**
35
- * Creates the canonical orga-build plugin preset.
36
- * This is the single composition path used by both dev and build.
40
+ * Creates the orga-build plugin preset: everything needed for `vite` (dev SSR)
41
+ * and `vite build` (static site) to work, used by the CLI and usable directly
42
+ * in a `vite.config.js`.
37
43
  *
38
44
  * @param {OrgaBuildPluginOptions} options
39
45
  * @returns {import('vite').PluginOption[]}
@@ -43,190 +49,144 @@ export function orgaBuildPlugin({
43
49
  outDir,
44
50
  containerClass = [],
45
51
  styles = [],
46
- rehypePlugins = []
52
+ rehypePlugins = [],
53
+ exclude = [],
54
+ site
47
55
  }) {
56
+ // Virtual modules import content files by path, so it must be absolute.
57
+ root = path.resolve(root)
58
+ site = normalizeSite(site)
59
+ const islands = islandPlugin()
48
60
  return [
61
+ configPlugin({ root, outDir }),
62
+ htmlShellPlugin(styles),
63
+ devSsrPlugin(site),
64
+ prerenderPlugin(islands, site),
65
+ islands,
49
66
  setupOrga({ containerClass, root, rehypePlugins }),
50
67
  react(),
51
- pluginFactory({ dir: root, outDir, styles })
68
+ pluginFactory({ dir: root, exclude, site })
52
69
  ]
53
70
  }
54
71
 
55
72
  /**
56
- * Creates the full Vite config options for orga-build.
57
- * Includes plugins, resolve aliases, and other shared config.
58
- *
59
- * @param {OrgaBuildPluginOptions & { outDir?: string, vitePlugins?: import('vite').PluginOption[], includeFallbackHtml?: boolean, projectRoot?: string }} options
60
- * @returns {{ plugins: import('vite').PluginOption[], resolve: { alias: typeof alias } }}
73
+ * Without a trailing slash, so `site + page.slug` is a page's URL.
74
+ * @param {string | undefined} site
61
75
  */
62
- export function createOrgaBuildConfig({
63
- root,
64
- outDir,
65
- containerClass = [],
66
- styles = [],
67
- rehypePlugins = [],
68
- vitePlugins = [],
69
- includeFallbackHtml = false,
70
- projectRoot = process.cwd()
71
- }) {
72
- const plugins = [
73
- ...vitePlugins,
74
- ...orgaBuildPlugin({ root, outDir, containerClass, styles, rehypePlugins })
75
- ]
76
- if (includeFallbackHtml) {
77
- // HTML fallback must be first so it can handle HTML navigation requests
78
- // before runtime plugins (e.g. Cloudflare) potentially return 404.
79
- plugins.unshift(htmlFallbackPlugin(projectRoot, styles))
80
- }
81
- return {
82
- plugins,
83
- resolve: { alias }
76
+ function normalizeSite(site) {
77
+ if (site === undefined) return
78
+ // `localhost:3000` parses too, as a URL with the scheme `localhost:`.
79
+ const url = URL.canParse(site) ? new URL(site) : undefined
80
+ if (url?.protocol !== 'http:' && url?.protocol !== 'https:') {
81
+ throw new Error(
82
+ `orga-build: "site" must be an absolute http(s) URL, e.g. "https://example.com", got "${site}"`
83
+ )
84
84
  }
85
+ return url.href.replace(/\/+$/, '')
85
86
  }
86
87
 
87
88
  /**
88
- * Checks if a user-provided index.html exists in the project root.
89
+ * Creates the full Vite config options for orga-build.
89
90
  *
90
- * @param {string} root - Project root directory
91
- * @returns {Promise<boolean>}
91
+ * @param {OrgaBuildPluginOptions & { vitePlugins?: import('vite').PluginOption[] }} options
92
+ * @returns {{ plugins: import('vite').PluginOption[] }}
92
93
  */
93
- async function hasUserIndexHtml(root) {
94
- try {
95
- await fs.access(path.join(root, 'index.html'), fs.constants.F_OK)
96
- return true
97
- } catch {
98
- return false
94
+ export function createOrgaBuildConfig({ vitePlugins = [], ...options }) {
95
+ return {
96
+ plugins: [...vitePlugins, ...orgaBuildPlugin(options)]
99
97
  }
100
98
  }
101
99
 
102
100
  /**
103
- * Creates an HTML serving plugin that handles index.html for dev mode.
104
- *
105
- * This plugin performs per-request SSR in dev mode (matching Astro/SvelteKit behaviour):
106
- * - SSR-renders each page on every request using Vite's server module runner
107
- * - Injects rendered content and page metadata (%orga.*% placeholders) into the template
108
- * - Falls back to the shell HTML for unknown routes (client-side router handles 404)
109
- * - Only handles GET/HEAD requests that accept HTML
110
- * - Does not intercept asset requests
111
- *
112
- * @param {string} projectRoot - Project root directory (where orga.config.js lives)
113
- * @param {string[]} [styles]
101
+ * @param {Object} options
102
+ * @param {string} options.root - Content root
103
+ * @param {string | undefined} options.outDir
114
104
  * @returns {import('vite').Plugin}
115
105
  */
116
- export function htmlFallbackPlugin(projectRoot, styles = []) {
106
+ function configPlugin({ root, outDir }) {
117
107
  return {
118
- name: 'orga-build:html-fallback',
119
-
120
- async configureServer(server) {
121
- // Determine which index.html to use at startup
122
- // Look for user's index.html in project root (where orga.config.js lives)
123
- const userIndexPath = path.join(projectRoot, 'index.html')
124
- const userHasIndex = await hasUserIndexHtml(projectRoot)
125
- const indexHtmlPath = userHasIndex ? userIndexPath : defaultIndexHtml
126
-
127
- // CJS compatibility here depends on Vite externalizing bare-specifier deps
128
- // (e.g. react/*) to native Node import(). If aliases rewrite specifiers
129
- // to absolute paths, modules can be inlined and evaluated without CJS globals.
130
- const runner = createServerModuleRunner(server.environments.ssr)
131
-
132
- server.middlewares.use(async (req, res, next) => {
133
- // Only handle GET/HEAD requests
134
- if (req.method !== 'GET' && req.method !== 'HEAD') {
135
- return next()
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 }
108
+ name: 'orga-build:config',
109
+ config(config, { command }) {
110
+ const clientOutDir = outDir ?? config.build?.outDir ?? 'dist'
111
+ const isBuild = command === 'build'
112
+ const contentDir = path
113
+ .relative(path.resolve(config.root ?? ''), path.resolve(root))
114
+ .split(path.sep)
115
+ .join('/')
116
+ return {
117
+ // HTML is served by orga-build:dev-ssr, not Vite's SPA fallback.
118
+ appType: 'custom',
119
+ // Asset URLs in server-rendered markup are resolved per page while
120
+ // prerendering, so a relative `base` works for nested pages. Left
121
+ // alone when the user renders URLs themselves.
122
+ experimental: config.experimental?.renderBuiltUrl
123
+ ? {}
124
+ : {
125
+ renderBuiltUrl(filename, { hostType, ssr }) {
126
+ if (ssr && hostType === 'js') return assetUrlMarker + filename
127
+ }
128
+ },
129
+ // Make `vite build` build every environment through buildApp.
130
+ builder: {},
131
+ // `resolve.alias` is global, not per-environment, so it is only set
132
+ // for build. In dev it would also reach the SSR module runner: the
133
+ // aliases turn bare specifiers (e.g. 'react') into absolute paths,
134
+ // which bypasses Vite's externalization and evaluates CJS packages
135
+ // inline, without 'module'/'require' globals.
136
+ resolve: isBuild ? { alias } : {},
137
+ build: {
138
+ outDir: clientOutDir,
139
+ // Also tells Vite to stop watching outDir in dev.
140
+ emptyOutDir: true,
141
+ cssCodeSplit: false
142
+ },
143
+ server: { forwardConsole: true },
144
+ environments: {
145
+ client: {
146
+ input: 'index.html',
147
+ // Islands are extra client chunks; prerendering reads their
148
+ // hashed names from the manifest.
149
+ build: { manifest: true },
150
+ optimizeDeps: {
151
+ // Scan pages, layouts and components up front so their deps
152
+ // are pre-bundled at startup instead of triggering a reload
153
+ // when first visited.
154
+ entries: [
155
+ '**/*.html',
156
+ path.posix.join(contentDir, '**/*.{jsx,tsx}')
157
+ ],
158
+ // The island runtime lives in orga-build itself (in
159
+ // node_modules once installed), which the scanner never
160
+ // crawls. Pre-bundle its CJS deps explicitly or the browser
161
+ // gets raw CommonJS.
162
+ include: ['react', 'react-dom/client']
151
163
  }
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
164
+ },
165
+ ssr: {
166
+ input: ssrEntry,
167
+ // The built SSR bundle is self-contained so it can be imported
168
+ // from its outDir, which sits outside the site's outDir because
169
+ // it must survive the client build.
170
+ resolve: isBuild ? { noExternal: true } : {},
171
+ build: {
172
+ outDir: path.join(
173
+ path.resolve(config.root ?? ''),
174
+ 'node_modules/.orga-build/ssr'
175
+ ),
176
+ // Pages live only in this graph now, so their images and CSS
177
+ // must be emitted here; prerendering copies them to the site.
178
+ emitAssets: true,
179
+ minify: false,
180
+ rolldownOptions: {
181
+ output: {
182
+ entryFileNames: '[name].mjs',
183
+ chunkFileNames: '[name]-[hash].mjs'
184
+ }
185
+ }
165
186
  }
166
- const bytes = Buffer.from(await response.arrayBuffer())
167
- res.end(bytes)
168
- return
169
187
  }
170
- } catch (e) {
171
- next(e)
172
- return
173
- }
174
-
175
- // Only handle browser-like navigation requests.
176
- // Don't match generic */* accepts to avoid hijacking API requests.
177
- const accept = req.headers.accept || ''
178
- if (!accept.includes('text/html')) {
179
- return next()
180
- }
181
-
182
- // Don't intercept asset requests (files with extensions)
183
- if (pathname !== '/' && /\.\w+$/.test(pathname)) {
184
- return next()
185
- }
186
-
187
- try {
188
- // Import via the runner on each request — the module graph handles
189
- // HMR invalidation so stale modules are never served.
190
- const { render, pages } = await runner.import(ssrEntry)
191
- const content = render(pathname)
192
-
193
- let html = await fs.readFile(indexHtmlPath, 'utf-8')
194
- html = await server.transformIndexHtml(url, html)
195
-
196
- const uniqueCssUrls = [...new Set(styles)]
197
- if (uniqueCssUrls.length > 0) {
198
- const cssLinks = uniqueCssUrls
199
- .map((u) => `<link rel="stylesheet" href="${escapeHtml(u)}">`)
200
- .join('')
201
- html = html.replace('</head>', `${cssLinks}</head>`)
202
- }
203
-
204
- if (content) {
205
- const ssr = { routePath: pathname }
206
- html = html.replace(
207
- '<div id="root"></div>',
208
- `<script>window._ssr=${JSON.stringify(ssr)};</script><div id="root">${content}</div>`
209
- )
210
- }
211
-
212
- // Replace %orga.*% placeholders with page metadata
213
- const page = pages[pathname]
214
- if (page) {
215
- html = html.replace(/%orga\.(\w+)%/g, (_, key) => {
216
- const value = page[key] ?? ''
217
- return escapeHtml(String(value))
218
- })
219
- }
220
- // Strip any remaining unresolved placeholders (unknown route)
221
- html = html.replace(/%orga\.\w+%/g, '')
222
-
223
- res.statusCode = 200
224
- res.setHeader('Content-Type', 'text/html')
225
- res.end(html)
226
- } catch (e) {
227
- next(e)
228
188
  }
229
- })
189
+ }
230
190
  }
231
191
  }
232
192
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Turns `vite build` into a static site build: builds the SSR and client
3
+ * environments, then renders every page and endpoint into the client outDir.
4
+ *
5
+ * @param {import('vite').Plugin} islands - The island plugin, whose `api`
6
+ * tells whether the client build ran before islands were discovered
7
+ * @param {string | undefined} site - Absolute site URL, without a trailing slash
8
+ * @returns {import('vite').Plugin}
9
+ */
10
+ export function prerenderPlugin(islands: import("vite").Plugin, site: string | undefined): import("vite").Plugin;
11
+ //# sourceMappingURL=prerender.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prerender.d.ts","sourceRoot":"","sources":["prerender.js"],"names":[],"mappings":"AAUA;;;;;;;;GAQG;AACH,yCALW,OAAO,MAAM,EAAE,MAAM,QAErB,MAAM,GAAG,SAAS,GAChB,OAAO,MAAM,EAAE,MAAM,CAsBjC"}
@@ -0,0 +1,184 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { normalizePath } from 'vite'
5
+ import { resolveEndpointResponse } from './endpoint.js'
6
+ import { ensureDir } from './fs.js'
7
+ import { renderPageHtml } from './html.js'
8
+ import { clientRuntime } from './island.js'
9
+ import { assetUrlMarker } from './plugin.js'
10
+
11
+ /**
12
+ * Turns `vite build` into a static site build: builds the SSR and client
13
+ * environments, then renders every page and endpoint into the client outDir.
14
+ *
15
+ * @param {import('vite').Plugin} islands - The island plugin, whose `api`
16
+ * tells whether the client build ran before islands were discovered
17
+ * @param {string | undefined} site - Absolute site URL, without a trailing slash
18
+ * @returns {import('vite').Plugin}
19
+ */
20
+ export function prerenderPlugin(islands, site) {
21
+ return {
22
+ name: 'orga-build:prerender',
23
+
24
+ buildApp: {
25
+ // Run after any configured `builder.buildApp`: a client build empties
26
+ // outDir, so prerendered files must be written last.
27
+ order: 'post',
28
+ async handler(builder) {
29
+ const { client, ssr } = builder.environments
30
+ // Islands are discovered while building `ssr`, so it goes first. A
31
+ // client built earlier (e.g. by another plugin's buildApp) lacks them.
32
+ if (!ssr.isBuilt) await builder.build(ssr)
33
+ if (!client.isBuilt || islands.api?.clientIsStale) {
34
+ await builder.build(client)
35
+ }
36
+ await prerender(client, ssr, islands, site)
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {import('vite').BuildEnvironment} client
44
+ * @param {import('vite').BuildEnvironment} ssr
45
+ * @param {import('vite').Plugin} islands - Its `api.ssrAssets` lists the files the SSR build emitted
46
+ * @param {string | undefined} site
47
+ */
48
+ async function prerender(client, ssr, islands, site) {
49
+ const outDir = resolveOutDir(client)
50
+ const ssrOutDir = resolveOutDir(ssr)
51
+ const { root, base } = client.config
52
+
53
+ // Images and CSS imported by pages are emitted by the SSR build next to its
54
+ // bundle: move them into the site and link the CSS (one file, see
55
+ // `cssCodeSplit`).
56
+ /** @type {string[]} */
57
+ const emitted = [.../** @type {Set<string>} */ (islands.api?.ssrAssets ?? [])]
58
+ await Promise.all(
59
+ emitted.map(async (file) => {
60
+ await ensureDir(path.dirname(path.join(outDir, file)))
61
+ await fs.copyFile(path.join(ssrOutDir, file), path.join(outDir, file))
62
+ })
63
+ )
64
+ const styles = emitted.filter((file) => file.endsWith('.css'))
65
+
66
+ /**
67
+ * URL of a file in outDir from a page `depth` directories deep. With a
68
+ * relative `base`, URLs are relative to the page's directory.
69
+ * @param {number} depth
70
+ */
71
+ const urlAt =
72
+ (depth) =>
73
+ /** @param {string} file */
74
+ (file) =>
75
+ base === './'
76
+ ? `${depth ? '../'.repeat(depth) : './'}${file}`
77
+ : base + file
78
+
79
+ // Cache-bust so repeated builds in one process load the fresh bundle.
80
+ const ssrModuleUrl = pathToFileURL(path.join(ssrOutDir, 'ssr.mjs'))
81
+ ssrModuleUrl.search = `t=${Date.now()}`
82
+ const { render, pages, endpoints = {} } = await import(ssrModuleUrl.href)
83
+
84
+ // Vite has processed index.html into the shell: stylesheets are already
85
+ // injected with their hashed names.
86
+ const shellPath = path.join(outDir, 'index.html')
87
+ const template = await fs.readFile(shellPath, 'utf-8')
88
+ if (!pages['/']) await fs.rm(shellPath)
89
+
90
+ // Islands are built as extra client chunks; the manifest has their names.
91
+ /** @type {Record<string, { file: string }>} */
92
+ const manifest = JSON.parse(
93
+ await fs.readFile(path.join(outDir, '.vite/manifest.json'), 'utf-8')
94
+ )
95
+ const runtimeSrc = normalizePath(path.relative(root, clientRuntime))
96
+
97
+ await Promise.all(
98
+ Object.keys(pages).map(async (pathname) => {
99
+ const toUrl = urlAt(pathname.split('/').filter(Boolean).length)
100
+ /** @param {string} src */
101
+ const url = (src) => {
102
+ const file = manifest[src]?.file
103
+ if (!file) {
104
+ throw new Error(
105
+ `island "${src}" is missing from the client build: the client environment must be built after ssr`
106
+ )
107
+ }
108
+ return toUrl(file)
109
+ }
110
+ const html = renderPageHtml(rebaseRelativeUrls(template, pathname), {
111
+ // Emitted assets are marked in the markup (see `renderBuiltUrl`).
112
+ content: render(pathname, url)?.replaceAll(assetUrlMarker, toUrl('')),
113
+ page: pages[pathname],
114
+ islandScript: manifest[runtimeSrc] && url(runtimeSrc),
115
+ styles: styles.map(toUrl)
116
+ })
117
+ const writePath = path.join(
118
+ outDir,
119
+ pathname.replace(/^\//, ''),
120
+ 'index.html'
121
+ )
122
+ await ensureDir(path.dirname(writePath))
123
+ await fs.writeFile(writePath, html)
124
+ })
125
+ )
126
+
127
+ await Promise.all(
128
+ Object.keys(endpoints).map(async (route) => {
129
+ const ctx = {
130
+ url: new URL((site ?? 'http://localhost') + route),
131
+ params: {},
132
+ mode: /** @type {'build'} */ ('build'),
133
+ route: { route },
134
+ site
135
+ }
136
+
137
+ const response = await resolveEndpointResponse(
138
+ endpoints[route],
139
+ ctx,
140
+ 'GET'
141
+ )
142
+ if (response.status < 200 || response.status >= 300) {
143
+ throw new Error(
144
+ `Endpoint route "${route}" returned non-2xx status during build: ${response.status}`
145
+ )
146
+ }
147
+
148
+ // Responses may carry emitted asset URLs too (see `renderBuiltUrl`).
149
+ // The marker is ASCII, so the substitution is done on a byte-preserving
150
+ // latin1 view: any charset survives, and untouched bodies stay as is.
151
+ let body = Buffer.from(await response.arrayBuffer())
152
+ if (body.includes(assetUrlMarker)) {
153
+ const toUrl = urlAt(route.split('/').filter(Boolean).length - 1)
154
+ body = Buffer.from(
155
+ body.toString('latin1').replaceAll(assetUrlMarker, toUrl('')),
156
+ 'latin1'
157
+ )
158
+ }
159
+ const writePath = path.join(outDir, route.replace(/^\//, ''))
160
+ await ensureDir(path.dirname(writePath))
161
+ await fs.writeFile(writePath, body)
162
+ })
163
+ )
164
+ }
165
+
166
+ /**
167
+ * With a relative `base`, Vite writes the shell's URLs relative to the root
168
+ * `index.html` (`./assets/…`). Rebase them for pages in nested directories.
169
+ *
170
+ * @param {string} html
171
+ * @param {string} pathname
172
+ */
173
+ function rebaseRelativeUrls(html, pathname) {
174
+ const depth = pathname.split('/').filter(Boolean).length
175
+ if (!depth) return html
176
+ return html.replace(/(\s(?:src|href)=["'])\.\//g, `$1${'../'.repeat(depth)}`)
177
+ }
178
+
179
+ /**
180
+ * @param {import('vite').BuildEnvironment} environment
181
+ */
182
+ function resolveOutDir(environment) {
183
+ return path.resolve(environment.config.root, environment.config.build.outDir)
184
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["serve.js"],"names":[],"mappings":"AAIA;;;;;;GAMG;AACH,8BAJW,OAAO,aAAa,EAAE,MAAM,SAC5B,MAAM,gBACN,MAAM,iBAuChB"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["serve.js"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,8BAJW,OAAO,aAAa,EAAE,MAAM,SAC5B,MAAM,gBACN,MAAM,iBAsBhB"}
package/lib/serve.js CHANGED
@@ -1,6 +1,5 @@
1
- import path from 'node:path'
2
1
  import { createServer } from 'vite'
3
- import { alias, createOrgaBuildConfig } from './plugin.js'
2
+ import { createOrgaBuildConfig } from './plugin.js'
4
3
 
5
4
  /**
6
5
  * Start the development server using native Vite.
@@ -17,31 +16,14 @@ export async function serve(config, port = 3000, projectRoot = process.cwd()) {
17
16
  styles: config.styles ?? [],
18
17
  rehypePlugins: config.rehypePlugins ?? [],
19
18
  vitePlugins: config.vitePlugins,
20
- includeFallbackHtml: true,
21
- projectRoot
19
+ exclude: config.exclude ?? [],
20
+ site: config.site
22
21
  })
23
22
 
24
23
  const server = await createServer({
25
- root: config.root,
24
+ root: projectRoot,
26
25
  plugins,
27
- appType: 'custom',
28
- // Aliases are scoped to the client environment only.
29
- // The SSR environment must NOT have these aliases: they convert bare specifiers
30
- // (e.g. 'react') into absolute paths, which bypasses Vite's fetchModule
31
- // externalization branch and causes CJS packages to be evaluated inline by
32
- // ESModulesEvaluator (which has no 'module'/'require' globals).
33
- environments: {
34
- client: {
35
- resolve: /** @type {any} */ ({ alias })
36
- }
37
- },
38
- server: {
39
- port,
40
- strictPort: false,
41
- watch: {
42
- ignored: [`${path.resolve(config.outDir)}/**`]
43
- }
44
- }
26
+ server: { port, strictPort: false }
45
27
  })
46
28
 
47
29
  await server.listen()
package/lib/ssr.jsx CHANGED
@@ -1,26 +1,21 @@
1
1
  import { renderToString } from 'react-dom/server'
2
- import { Router } from 'wouter'
3
2
  import endpoints from '/@orga-build/endpoints'
4
3
  import pages from '/@orga-build/pages'
5
4
  import { App } from './app.jsx'
5
+ import { beginPage } from './island.jsx'
6
6
 
7
7
  export { pages }
8
8
  export { endpoints }
9
9
 
10
10
  /**
11
+ * Render the page at `url` to static HTML.
12
+ *
11
13
  * @param {string} url
14
+ * @param {(src: string) => string} resolveIslandUrl - Maps an island's source
15
+ * path (relative to the Vite root) to the URL the browser imports it from
12
16
  */
13
- export function render(url) {
14
- const page = pages[url]
15
- if (!page) {
16
- console.log(`no page found for ${url}`)
17
- return
18
- }
19
- const ssrContext = {}
20
- console.log(`rendering ${url}`)
21
- return renderToString(
22
- <Router ssrPath={url} ssrContext={ssrContext}>
23
- <App />
24
- </Router>
25
- )
17
+ export function render(url, resolveIslandUrl) {
18
+ if (!pages[url]) return
19
+ beginPage(resolveIslandUrl)
20
+ return renderToString(<App path={url} />)
26
21
  }