orga-build 0.9.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 +282 -33
  2. package/cli.js +4 -4
  3. package/lib/__tests__/build.test.js +441 -22
  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 +10 -175
  14. package/lib/config.d.ts +5 -3
  15. package/lib/config.d.ts.map +1 -1
  16. package/lib/config.js +8 -16
  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 +8 -3
  25. package/lib/files.d.ts.map +1 -1
  26. package/lib/files.js +104 -20
  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 +20 -32
  45. package/lib/plugin.d.ts.map +1 -1
  46. package/lib/plugin.js +128 -178
  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 +5 -6
  57. package/lib/vite.d.ts.map +1 -1
  58. package/lib/vite.js +74 -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
package/lib/config.d.ts CHANGED
@@ -9,15 +9,13 @@ export function loadConfig(...files: string[]): Promise<{
9
9
  export type Config = {
10
10
  outDir: string;
11
11
  root: string;
12
- preBuild: string[];
13
- postBuild: string[];
14
12
  /**
15
13
  * - Array of Vite plugins
16
14
  */
17
15
  vitePlugins: import("vite").PluginOption[];
18
16
  containerClass: string[] | string;
19
17
  /**
20
- * - Global stylesheet URLs injected in dev SSR and imported by client entry
18
+ * - Global stylesheet URLs linked from the HTML shell
21
19
  */
22
20
  styles?: string[];
23
21
  /**
@@ -28,5 +26,9 @@ export type Config = {
28
26
  * - Glob patterns for files to exclude from content scanning
29
27
  */
30
28
  exclude?: string[];
29
+ /**
30
+ * - Absolute URL the site is served from, e.g. `https://example.com`
31
+ */
32
+ site?: string | undefined;
31
33
  };
32
34
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["config.js"],"names":[],"mappings":"AA6BA;;;GAGG;AACH,qCAHW,MAAM,EAAE,GACN,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CAiD5D;;YA3Ea,MAAM;UACN,MAAM;cACN,MAAM,EAAE;eACR,MAAM,EAAE;;;;iBACR,OAAO,MAAM,EAAE,YAAY,EAAE;oBAC7B,MAAM,EAAE,GAAC,MAAM;;;;aACf,MAAM,EAAE;;;;oBACR,OAAO,SAAS,EAAE,aAAa;;;;cAC/B,MAAM,EAAE"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["config.js"],"names":[],"mappings":"AA0BA;;;GAGG;AACH,qCAHW,MAAM,EAAE,GACN,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CA4C5D;;YAnEa,MAAM;UACN,MAAM;;;;iBACN,OAAO,MAAM,EAAE,YAAY,EAAE;oBAC7B,MAAM,EAAE,GAAC,MAAM;;;;aACf,MAAM,EAAE;;;;oBACR,OAAO,SAAS,EAAE,aAAa;;;;cAC/B,MAAM,EAAE;;;;WACR,MAAM,GAAG,SAAS"}
package/lib/config.js CHANGED
@@ -5,21 +5,18 @@ import path from 'node:path'
5
5
  * @typedef {Object} Config
6
6
  * @property {string} outDir
7
7
  * @property {string} root
8
- * @property {string[]} preBuild
9
- * @property {string[]} postBuild
10
8
  * @property {import('vite').PluginOption[]} vitePlugins - Array of Vite plugins
11
9
  * @property {string[]|string} containerClass
12
- * @property {string[]} [styles] - Global stylesheet URLs injected in dev SSR and imported by client entry
10
+ * @property {string[]} [styles] - Global stylesheet URLs linked from the HTML shell
13
11
  * @property {import('unified').PluggableList} [rehypePlugins] - Extra rehype plugins appended to orga-build defaults
14
12
  * @property {string[]} [exclude] - Glob patterns for files to exclude from content scanning
13
+ * @property {string | undefined} [site] - Absolute URL the site is served from, e.g. `https://example.com`
15
14
  */
16
15
 
17
16
  /** @type {Config} */
18
17
  const defaultConfig = {
19
18
  outDir: '.out',
20
19
  root: '.',
21
- preBuild: [],
22
- postBuild: [],
23
20
  vitePlugins: [],
24
21
  containerClass: [],
25
22
  styles: [],
@@ -53,17 +50,12 @@ export async function loadConfig(...files) {
53
50
  continue
54
51
  }
55
52
 
56
- try {
57
- const module = await import(filePath)
58
- // Support both default export (recommended) and named exports
59
- const config = module.default || module
60
- result = { ...defaultConfig, ...config }
61
- configPath = filePath
62
- break
63
- } catch (err) {
64
- // Config file exists but has errors
65
- console.error(`Error loading config from ${file}:`, err)
66
- }
53
+ // A broken config file fails the command rather than falling back to defaults.
54
+ const module = await import(filePath)
55
+ // Support both default export (recommended) and named exports
56
+ result = { ...defaultConfig, ...(module.default || module) }
57
+ configPath = filePath
58
+ break
67
59
  }
68
60
 
69
61
  result.root = resolveConfigPath(result.root)
package/lib/content.d.ts CHANGED
@@ -8,6 +8,12 @@ declare module 'orga-build:content' {
8
8
  data: Record<string, unknown>
9
9
  }
10
10
 
11
+ /**
12
+ * The configured `site` (absolute URL, no trailing slash), or `undefined`.
13
+ * A page's absolute URL is `site + page.slug`.
14
+ */
15
+ export const site: string | undefined
16
+
11
17
  /**
12
18
  * Get all content entries matching a path pattern
13
19
  * @param path - Optional path prefix to filter by (e.g., 'writing', 'content/writing/2025')
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Serves pages and endpoints in dev, rendering each request on the server
3
+ * (matching Astro/SvelteKit behaviour):
4
+ * - Endpoint routes are answered from their `GET`/`HEAD` handlers
5
+ * - Pages are SSR-rendered through the `ssr` environment's module runner, so
6
+ * edits are picked up without restarting
7
+ * - Unknown routes fall through to Vite, which answers 404 like the build
8
+ * - Only GET/HEAD requests that accept HTML are rendered; assets pass through
9
+ *
10
+ * @param {string | undefined} site - Absolute site URL, passed to endpoints
11
+ * @returns {import('vite').Plugin}
12
+ */
13
+ export function devSsrPlugin(site: string | undefined): import("vite").Plugin;
14
+ //# sourceMappingURL=dev-ssr.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dev-ssr.d.ts","sourceRoot":"","sources":["dev-ssr.js"],"names":[],"mappings":"AASA;;;;;;;;;;;GAWG;AACH,mCAHW,MAAM,GAAG,SAAS,GAChB,OAAO,MAAM,EAAE,MAAM,CAqHjC"}
package/lib/dev-ssr.js ADDED
@@ -0,0 +1,137 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { isCSSRequest, isRunnableDevEnvironment, normalizePath } from 'vite'
4
+ import { resolveEndpointResponse } from './endpoint.js'
5
+ import { readIndexHtml, renderPageHtml } from './html.js'
6
+ import { islandRuntimeId } from './vite.js'
7
+
8
+ const ssrEntry = fileURLToPath(new URL('./ssr.jsx', import.meta.url))
9
+
10
+ /**
11
+ * Serves pages and endpoints in dev, rendering each request on the server
12
+ * (matching Astro/SvelteKit behaviour):
13
+ * - Endpoint routes are answered from their `GET`/`HEAD` handlers
14
+ * - Pages are SSR-rendered through the `ssr` environment's module runner, so
15
+ * edits are picked up without restarting
16
+ * - Unknown routes fall through to Vite, which answers 404 like the build
17
+ * - Only GET/HEAD requests that accept HTML are rendered; assets pass through
18
+ *
19
+ * @param {string | undefined} site - Absolute site URL, passed to endpoints
20
+ * @returns {import('vite').Plugin}
21
+ */
22
+ export function devSsrPlugin(site) {
23
+ return {
24
+ name: 'orga-build:dev-ssr',
25
+ // Run before other plugins' middlewares (e.g. Cloudflare) so HTML
26
+ // navigation requests are not answered with a 404 first.
27
+ enforce: 'pre',
28
+
29
+ configureServer(server) {
30
+ const ssr = server.environments.ssr
31
+ if (!isRunnableDevEnvironment(ssr)) {
32
+ server.config.logger.warn(
33
+ '[orga-build] the "ssr" environment is not runnable in this process; dev SSR is disabled'
34
+ )
35
+ return
36
+ }
37
+
38
+ // The browser imports islands straight from source, served by Vite
39
+ // under its `base`. The shell's own URLs are rebased by Vite in
40
+ // `transformIndexHtml`; these are injected afterwards.
41
+ const { root, base } = server.config
42
+ /** @param {string} src */
43
+ const islandUrl = (src) =>
44
+ // Outside the root (`..`, or another drive on Windows): Vite's /@fs/ form.
45
+ src.startsWith('..') || path.isAbsolute(src)
46
+ ? `${base}@fs/${normalizePath(path.resolve(root, src)).replace(/^\//, '')}`
47
+ : base + src
48
+ const islandScript = base + islandRuntimeId.slice(1)
49
+ // CSS imported by server-rendered code never reaches the browser's
50
+ // module graph: link each file, which Vite serves as plain CSS.
51
+ const styles = () =>
52
+ [...ssr.moduleGraph.idToModuleMap.keys()]
53
+ .filter(
54
+ (id) => isCSSRequest(id) && path.isAbsolute(id) && !id.includes('?')
55
+ )
56
+ .map((id) => islandUrl(normalizePath(path.relative(root, id))))
57
+
58
+ server.middlewares.use(async (req, res, next) => {
59
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
60
+ return next()
61
+ }
62
+
63
+ // This runs before Vite's own middlewares, so `base` is still in the URL.
64
+ const url = req.url || '/'
65
+ const requestPath = url.split('?')[0]
66
+ if (!requestPath.startsWith(base)) return next()
67
+ // Directory-style URLs (`/docs/`) are the same page as `/docs`.
68
+ const pathname = requestPath
69
+ .slice(base.length - 1)
70
+ .replace(/(.)\/+$/, '$1')
71
+
72
+ try {
73
+ // The runner follows the module graph, so stale modules are never served.
74
+ const { render, pages, endpoints } = await ssr.runner.import(ssrEntry)
75
+
76
+ // Endpoint routes are handled first and bypass HTML rendering.
77
+ const endpointModule = endpoints?.[pathname]
78
+ if (endpointModule) {
79
+ const ctx = {
80
+ url: new URL(url, `http://${req.headers.host || 'localhost'}`),
81
+ params: {},
82
+ mode: /** @type {'dev'} */ ('dev'),
83
+ route: { route: pathname },
84
+ site
85
+ }
86
+ const response = await resolveEndpointResponse(
87
+ endpointModule,
88
+ ctx,
89
+ req.method
90
+ )
91
+ res.statusCode = response.status
92
+ response.headers.forEach((headerValue, headerName) => {
93
+ res.setHeader(headerName, headerValue)
94
+ })
95
+ if (req.method === 'HEAD') {
96
+ res.end()
97
+ return
98
+ }
99
+ res.end(Buffer.from(await response.arrayBuffer()))
100
+ return
101
+ }
102
+
103
+ // Only handle browser-like navigation requests.
104
+ // Don't match generic */* accepts to avoid hijacking API requests.
105
+ const accept = req.headers.accept || ''
106
+ if (!accept.includes('text/html')) {
107
+ return next()
108
+ }
109
+
110
+ // Don't intercept asset requests (files with extensions)
111
+ if (pathname !== '/' && /\.\w+$/.test(pathname)) {
112
+ return next()
113
+ }
114
+
115
+ if (!pages[pathname]) return next()
116
+
117
+ const template = await server.transformIndexHtml(
118
+ url,
119
+ await readIndexHtml(server.config.root)
120
+ )
121
+ const html = renderPageHtml(template, {
122
+ content: render(pathname, islandUrl),
123
+ page: pages[pathname],
124
+ islandScript,
125
+ styles: styles()
126
+ })
127
+
128
+ res.statusCode = 200
129
+ res.setHeader('Content-Type', 'text/html')
130
+ res.end(html)
131
+ } catch (e) {
132
+ next(e)
133
+ }
134
+ })
135
+ }
136
+ }
137
+ }
package/lib/endpoint.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * @property {Record<string, string>} params
5
5
  * @property {'dev' | 'build'} mode
6
6
  * @property {{ route: string }} route
7
+ * @property {string | undefined} site - The configured `site`, without a trailing slash
7
8
  */
8
9
  /**
9
10
  * @param {Record<string, any>} endpointModule
@@ -19,5 +20,9 @@ export type EndpointContext = {
19
20
  route: {
20
21
  route: string;
21
22
  };
23
+ /**
24
+ * - The configured `site`, without a trailing slash
25
+ */
26
+ site: string | undefined;
22
27
  };
23
28
  //# sourceMappingURL=endpoint.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"endpoint.d.ts","sourceRoot":"","sources":["endpoint.js"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;GAKG;AACH,wDALW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,OACnB,eAAe,WACf,MAAM,GACJ,OAAO,CAAC,QAAQ,CAAC,CAoC7B;;SA/Ca,GAAG;YACH,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;UACtB,KAAK,GAAG,OAAO;WACf;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE;;;;UACjB,MAAM,GAAG,SAAS"}
package/lib/endpoint.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * @property {Record<string, string>} params
5
5
  * @property {'dev' | 'build'} mode
6
6
  * @property {{ route: string }} route
7
+ * @property {string | undefined} site - The configured `site`, without a trailing slash
7
8
  */
8
9
 
9
10
  /**
package/lib/files.d.ts CHANGED
@@ -3,10 +3,12 @@
3
3
  * @param {object} [options]
4
4
  * @param {string} [options.outDir] - Output directory to exclude from file discovery
5
5
  * @param {string[]} [options.exclude] - Additional glob patterns to exclude from file discovery
6
+ * @param {boolean} [options.drafts] - Include pages marked `#+draft:` (for previews in dev)
6
7
  */
7
- export function setup(dir: string, { outDir, exclude }?: {
8
+ export function setup(dir: string, { outDir, exclude, drafts }?: {
8
9
  outDir?: string | undefined;
9
10
  exclude?: string[] | undefined;
11
+ drafts?: boolean | undefined;
10
12
  }): {
11
13
  pages: (() => Promise<Record<string, Page>>) & {
12
14
  invalidate: () => void;
@@ -33,11 +35,14 @@ export function setup(dir: string, { outDir, exclude }?: {
33
35
  */
34
36
  export function getSlugFromContentFilePath(contentFilePath: string): string;
35
37
  export type Page = {
38
+ /**
39
+ * Path to the page data file
40
+ */
36
41
  dataPath: string;
37
42
  /**
38
- * Path to the page data file
43
+ * Metadata: an org page's keywords, or a TSX/JSX page's literal named exports
39
44
  */
40
- title?: string;
45
+ data: Record<string, unknown>;
41
46
  };
42
47
  export type EndpointRoute = {
43
48
  route: string;
@@ -1 +1 @@
1
- {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["files.js"],"names":[],"mappings":"AAoFA;;;;;GAKG;AACH,2BALW,MAAM,wBAEd;IAAyB,MAAM;IACJ,OAAO;CACpC;;0BA6LqD,IAAI;;iBApB7C,MAAM;;0BAoBmC,IAAI;;sBAd7C,MAAM;;0BAcmC,IAAI;;;0BAAJ,IAAI;;;0BAAJ,IAAI;;;EATzD;AA8BD;;;GAGG;AACH,4DAFW,MAAM,UAoBhB;;cA1Ta,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"}
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["files.js"],"names":[],"mappings":"AAsFA;;;;;;GAMG;AACH,2BANW,MAAM,gCAEd;IAAyB,MAAM;IACJ,OAAO;IACR,MAAM;CAClC;;0BA8QqD,IAAI;;iBAlH7C,MAAM;;0BAkHmC,IAAI;;sBA5G7C,MAAM;;0BA4GmC,IAAI;;;0BAAJ,IAAI;;;0BAAJ,IAAI;;;EAvGzD;AA4HD;;;GAGG;AACH,4DAFW,MAAM,UAoBhB;;;;;cA7Ya,MAAM;;;;UAEN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;WAMvB,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
@@ -2,12 +2,14 @@ import { readFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { globby } from 'globby'
4
4
  import { getSettings } from 'orga'
5
+ import { parseSync } from 'vite'
5
6
 
6
7
  /**
7
8
  * @typedef {Object} Page
8
9
  * @property {string} dataPath
9
- * @property {string} [title]
10
10
  * Path to the page data file
11
+ * @property {Record<string, unknown>} data
12
+ * Metadata: an org page's keywords, or a TSX/JSX page's literal named exports
11
13
  */
12
14
 
13
15
  /**
@@ -87,8 +89,9 @@ function getContentId(slug) {
87
89
  * @param {object} [options]
88
90
  * @param {string} [options.outDir] - Output directory to exclude from file discovery
89
91
  * @param {string[]} [options.exclude] - Additional glob patterns to exclude from file discovery
92
+ * @param {boolean} [options.drafts] - Include pages marked `#+draft:` (for previews in dev)
90
93
  */
91
- export function setup(dir, { outDir, exclude = [] } = {}) {
94
+ export function setup(dir, { outDir, exclude = [], drafts = false } = {}) {
92
95
  const outDirRelative = outDir ? path.relative(dir, outDir) : null
93
96
  // Only exclude outDir if it's inside the root (not an external path like ../out)
94
97
  const outDirExclude =
@@ -125,7 +128,10 @@ export function setup(dir, { outDir, exclude = [] } = {}) {
125
128
 
126
129
  if (pageSlug) {
127
130
  assertUniqueRoute(routeOwners, pageSlug, 'page', absolutePath)
128
- pages[pageSlug] = { dataPath: absolutePath }
131
+ const data = await readMetadata(absolutePath)
132
+ if (drafts || !isDraft(data)) {
133
+ pages[pageSlug] = { dataPath: absolutePath, data }
134
+ }
129
135
  }
130
136
 
131
137
  if (endpointRoute) {
@@ -207,29 +213,13 @@ export function setup(dir, { outDir, exclude = [] } = {}) {
207
213
  // Derive id from the slug (last segment or 'index')
208
214
  const id = getContentId(slug)
209
215
 
210
- /** @type {Record<string, unknown>} */
211
- let data = {}
212
-
213
- // Extract metadata from .org files
214
- if (ext === 'org') {
215
- try {
216
- const content = await readFile(filePath, 'utf-8')
217
- data = getSettings(content)
218
- } catch (/** @type {any} */ error) {
219
- console.warn(
220
- `Failed to read metadata from ${filePath}:`,
221
- error?.message || error
222
- )
223
- }
224
- }
225
-
226
216
  entries.push({
227
217
  id,
228
218
  slug,
229
219
  path: derivedPath,
230
220
  filePath,
231
221
  ext,
232
- data
222
+ data: pageData.data
233
223
  })
234
224
  }
235
225
 
@@ -269,6 +259,100 @@ export function setup(dir, { outDir, exclude = [] } = {}) {
269
259
  }
270
260
  }
271
261
 
262
+ /**
263
+ * Read a page's metadata: an org file's keywords, or the named exports of a
264
+ * TSX/JSX page whose values are literals (`export const title = 'Hi'`).
265
+ * @param {string} filePath
266
+ * @returns {Promise<Record<string, unknown>>}
267
+ */
268
+ async function readMetadata(filePath) {
269
+ try {
270
+ const text = await readFile(filePath, 'utf-8')
271
+ return filePath.endsWith('.org')
272
+ ? getSettings(text)
273
+ : readLiteralExports(filePath, text)
274
+ } catch (/** @type {any} */ error) {
275
+ console.warn(
276
+ `Failed to read metadata from ${filePath}:`,
277
+ error?.message || error
278
+ )
279
+ return {}
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Named exports whose values can be read without running the module. Others
285
+ * are skipped; Vite reports syntax errors when it builds the page.
286
+ * @param {string} filePath
287
+ * @param {string} text
288
+ */
289
+ function readLiteralExports(filePath, text) {
290
+ const { program, errors } = parseSync(filePath, text)
291
+ /** @type {Record<string, unknown>} */
292
+ const data = {}
293
+ if (errors.length) return data
294
+ for (const node of program.body) {
295
+ if (
296
+ node.type !== 'ExportNamedDeclaration' ||
297
+ node.declaration?.type !== 'VariableDeclaration'
298
+ ) {
299
+ continue
300
+ }
301
+ for (const { id, init } of node.declaration.declarations) {
302
+ if (id.type !== 'Identifier' || !init) continue
303
+ const value = literalValue(init)
304
+ if (value !== notLiteral) data[id.name] = value
305
+ }
306
+ }
307
+ return data
308
+ }
309
+
310
+ const notLiteral = Symbol('notLiteral')
311
+
312
+ /**
313
+ * The value of a literal expression: a string, number (signed too), boolean
314
+ * or `null`, a template without placeholders, or an array of those.
315
+ * @param {any} node
316
+ * @returns {unknown}
317
+ */
318
+ function literalValue(node) {
319
+ switch (node.type) {
320
+ case 'Literal':
321
+ // Regexes and bigints have no JSON form.
322
+ return node.regex || node.bigint ? notLiteral : node.value
323
+ case 'TemplateLiteral':
324
+ return node.expressions.length ? notLiteral : node.quasis[0].value.cooked
325
+ case 'TSAsExpression':
326
+ case 'TSSatisfiesExpression':
327
+ return literalValue(node.expression)
328
+ case 'UnaryExpression': {
329
+ const value = literalValue(node.argument)
330
+ if (typeof value !== 'number') return notLiteral
331
+ return node.operator === '-'
332
+ ? -value
333
+ : node.operator === '+'
334
+ ? value
335
+ : notLiteral
336
+ }
337
+ case 'ArrayExpression': {
338
+ const values = node.elements.map((/** @type {any} */ element) =>
339
+ element ? literalValue(element) : notLiteral
340
+ )
341
+ return values.includes(notLiteral) ? notLiteral : values
342
+ }
343
+ default:
344
+ return notLiteral
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Whether a page is marked `#+draft: t` (or `true`, `yes`).
350
+ * @param {Record<string, unknown>} data
351
+ */
352
+ function isDraft(data) {
353
+ return /^(t|true|yes)$/i.test(String(data.draft ?? '').trim())
354
+ }
355
+
272
356
  /**
273
357
  * Creates a cached version of an async function that will only execute once
274
358
  * and return the cached result on subsequent calls. The returned function
package/lib/fs.d.ts CHANGED
@@ -9,11 +9,6 @@ export function resolvePath(rootPath: string): string;
9
9
  * @returns {Promise<boolean>}
10
10
  */
11
11
  export function exists(path: string): Promise<boolean>;
12
- /**
13
- * @param {string} dir
14
- * @returns {Promise<void>}
15
- */
16
- export function emptyDir(dir: string): Promise<void>;
17
12
  /**
18
13
  * @param {string} path
19
14
  */
package/lib/fs.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["fs.js"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,sCAHW,MAAM,GACJ,MAAM,CAclB;AAED;;;GAGG;AACH,6BAHW,MAAM,GACJ,OAAO,CAAC,OAAO,CAAC,CAS5B;AAED;;;GAGG;AACH,8BAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAczB;AAED;;GAEG;AACH,gCAFW,MAAM,iBAUhB;AAED;;;GAGG;AACH,0BAHW,MAAM,QACN,MAAM,iBAKhB"}
1
+ {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["fs.js"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,sCAHW,MAAM,GACJ,MAAM,CAclB;AAED;;;GAGG;AACH,6BAHW,MAAM,GACJ,OAAO,CAAC,OAAO,CAAC,CAS5B;AAED;;GAEG;AACH,gCAFW,MAAM,iBAUhB;AAED;;;GAGG;AACH,0BAHW,MAAM,QACN,MAAM,iBAKhB"}
package/lib/fs.js CHANGED
@@ -33,24 +33,6 @@ export async function exists(path) {
33
33
  }
34
34
  }
35
35
 
36
- /**
37
- * @param {string} dir
38
- * @returns {Promise<void>}
39
- */
40
- export async function emptyDir(dir) {
41
- /** @type {string[]} */
42
- let items = []
43
- try {
44
- items = await fs.readdir(dir)
45
- } catch {
46
- await fs.mkdir(dir, { recursive: true })
47
- }
48
-
49
- await Promise.all(
50
- items.map((item) => fs.rm(`${dir}/${item}`, { recursive: true }))
51
- )
52
- }
53
-
54
36
  /**
55
37
  * @param {string} path
56
38
  */
package/lib/html.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read the HTML shell: the user's `index.html` in the Vite root, or the
3
+ * default one shipped with orga-build.
4
+ *
5
+ * @param {string} root - Vite root
6
+ */
7
+ export function readIndexHtml(root: string): Promise<string>;
8
+ /**
9
+ * Makes `<root>/index.html` always loadable, falling back to the default shell,
10
+ * so Vite can use it as the client build entry. Vite then bundles its scripts
11
+ * and stylesheets and injects the hashed asset tags itself.
12
+ *
13
+ * Global styles are added as `<link>` tags, which Vite serves (with HMR) in
14
+ * dev and bundles in build.
15
+ *
16
+ * @param {string[]} [styles]
17
+ * @returns {import('vite').Plugin}
18
+ */
19
+ export function htmlShellPlugin(styles?: string[]): import("vite").Plugin;
20
+ /**
21
+ * Fill a processed HTML shell with a server-rendered page. The island runtime
22
+ * is only linked when the page has an island, so other pages ship no script.
23
+ *
24
+ * @param {string} template - HTML shell, already transformed by Vite
25
+ * @param {Object} options
26
+ * @param {string | undefined} options.content - Rendered page markup
27
+ * @param {Record<string, unknown> | undefined} options.page - Page module exports, used for `%orga.*%` placeholders
28
+ * @param {string | undefined} [options.islandScript] - URL of the island runtime
29
+ * @param {string[]} [options.styles] - URLs of stylesheets imported by server-rendered code
30
+ */
31
+ export function renderPageHtml(template: string, { content, page, islandScript, styles }: {
32
+ content: string | undefined;
33
+ page: Record<string, unknown> | undefined;
34
+ islandScript?: string | undefined;
35
+ styles?: string[] | undefined;
36
+ }): string;
37
+ //# sourceMappingURL=html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["html.js"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,oCAFW,MAAM,mBAMhB;AAED;;;;;;;;;;GAUG;AACH,yCAHW,MAAM,EAAE,GACN,OAAO,MAAM,EAAE,MAAM,CAqCjC;AAED;;;;;;;;;;GAUG;AACH,yCAPW,MAAM,2CAEd;IAAoC,OAAO,EAAnC,MAAM,GAAG,SAAS;IAC2B,IAAI,EAAjD,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS;IACN,YAAY,GAAzC,MAAM,GAAG,SAAS;IACC,MAAM;CACnC,UAsBA"}
package/lib/html.js ADDED
@@ -0,0 +1,101 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { exists } from './fs.js'
5
+ import { escapeHtml } from './util.js'
6
+
7
+ const defaultIndexHtml = fileURLToPath(new URL('./index.html', import.meta.url))
8
+
9
+ /**
10
+ * Read the HTML shell: the user's `index.html` in the Vite root, or the
11
+ * default one shipped with orga-build.
12
+ *
13
+ * @param {string} root - Vite root
14
+ */
15
+ export async function readIndexHtml(root) {
16
+ const userIndexHtml = path.join(root, 'index.html')
17
+ const file = (await exists(userIndexHtml)) ? userIndexHtml : defaultIndexHtml
18
+ return fs.readFile(file, 'utf-8')
19
+ }
20
+
21
+ /**
22
+ * Makes `<root>/index.html` always loadable, falling back to the default shell,
23
+ * so Vite can use it as the client build entry. Vite then bundles its scripts
24
+ * and stylesheets and injects the hashed asset tags itself.
25
+ *
26
+ * Global styles are added as `<link>` tags, which Vite serves (with HMR) in
27
+ * dev and bundles in build.
28
+ *
29
+ * @param {string[]} [styles]
30
+ * @returns {import('vite').Plugin}
31
+ */
32
+ export function htmlShellPlugin(styles = []) {
33
+ /** @type {string} */
34
+ let root
35
+ /** @type {string} */
36
+ let indexHtmlPath
37
+
38
+ return {
39
+ name: 'orga-build:html-shell',
40
+ enforce: 'pre',
41
+ configResolved(config) {
42
+ root = config.root
43
+ indexHtmlPath = path.join(root, 'index.html')
44
+ },
45
+ resolveId(id, importer) {
46
+ // Build entries arrive relative to the root, without an importer.
47
+ if (!importer && path.resolve(root, id) === indexHtmlPath) {
48
+ return indexHtmlPath
49
+ }
50
+ },
51
+ async load(id) {
52
+ if (id === indexHtmlPath) {
53
+ return readIndexHtml(root)
54
+ }
55
+ },
56
+ transformIndexHtml: {
57
+ order: 'pre',
58
+ handler() {
59
+ return [...new Set(styles)].map((href) => ({
60
+ tag: 'link',
61
+ attrs: { rel: 'stylesheet', href },
62
+ injectTo: 'head'
63
+ }))
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Fill a processed HTML shell with a server-rendered page. The island runtime
71
+ * is only linked when the page has an island, so other pages ship no script.
72
+ *
73
+ * @param {string} template - HTML shell, already transformed by Vite
74
+ * @param {Object} options
75
+ * @param {string | undefined} options.content - Rendered page markup
76
+ * @param {Record<string, unknown> | undefined} options.page - Page module exports, used for `%orga.*%` placeholders
77
+ * @param {string | undefined} [options.islandScript] - URL of the island runtime
78
+ * @param {string[]} [options.styles] - URLs of stylesheets imported by server-rendered code
79
+ */
80
+ export function renderPageHtml(
81
+ template,
82
+ { content, page, islandScript, styles = [] }
83
+ ) {
84
+ let html = template
85
+ // Unknown routes render nothing (`undefined`); a page may render ''.
86
+ if (content !== undefined) {
87
+ html = html.replace(
88
+ '<div id="root"></div>',
89
+ `<div id="root">${content}</div>`
90
+ )
91
+ const head = styles.map((href) => `<link rel="stylesheet" href="${href}">`)
92
+ if (islandScript && content.includes('<orga-island')) {
93
+ head.push(`<script type="module" src="${islandScript}"></script>`)
94
+ }
95
+ if (head.length) html = html.replace(/<\/head>/i, `${head.join('')}$&`)
96
+ }
97
+ // Unknown routes have no page: their placeholders resolve to empty strings.
98
+ return html.replace(/%orga\.(\w+)%/g, (_, key) =>
99
+ escapeHtml(String(page?.[key] ?? ''))
100
+ )
101
+ }