orga-build 0.7.1 → 0.9.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
@@ -78,29 +78,35 @@ export default {
78
78
  // CSS class(es) to wrap rendered org content
79
79
  containerClass: ['prose', 'prose-lg'],
80
80
 
81
- // Global stylesheet URLs (explicit, no magic files)
82
- // These are injected in dev SSR <head> and imported by the client entry.
83
- styles: ['/style.css'],
81
+ // Global stylesheets paths relative to orga.config.js (leading / optional).
82
+ // Injected in dev SSR <head> and imported by the client entry.
83
+ styles: ['pages/style.css'],
84
84
 
85
85
  // Extra rehype plugins appended to orga-build defaults
86
86
  // Useful for syntax highlighting (e.g. rehype-pretty-code).
87
87
  rehypePlugins: [],
88
88
 
89
89
  // Additional Vite plugins
90
- vitePlugins: []
90
+ vitePlugins: [],
91
+
92
+ // Glob patterns (relative to root) to exclude from content scanning.
93
+ // Useful for generated or declaration files that must live inside root
94
+ // but should not be treated as pages or endpoints.
95
+ exclude: ['**/*.d.ts']
91
96
  }
92
97
  #+end_src
93
98
 
94
99
  ** Configuration Options
95
100
 
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 |
101
+ | Option | Type | Default | Description |
102
+ |----------------+-------------------+---------+-----------------------------------------------------------------|
103
+ | =root= | =string= | ='pages'= | Directory containing content files |
104
+ | =outDir= | =string= | ='out'= | Output directory for production build |
105
+ | =containerClass= | =string \vert string[]= | =[]= | CSS class(es) for content wrapper |
106
+ | =styles= | =string[]= | =[]= | Stylesheets to inject/import; paths relative to =orga.config.js= |
107
+ | =exclude= | =string[]= | =[]= | Glob patterns (relative to =root=) excluded from content scanning |
108
+ | =rehypePlugins= | =PluggableList= | =[]= | Extra rehype plugins appended to orga-build defaults |
109
+ | =vitePlugins= | =PluginOption[]= | =[]= | Additional Vite plugins |
104
110
 
105
111
  ** Syntax Highlighting Example
106
112
 
@@ -112,6 +118,47 @@ export default {
112
118
  }
113
119
  #+end_src
114
120
 
121
+ * Routing
122
+
123
+ orga-build supports two route types: *page routes* and *endpoint routes*.
124
+
125
+ ** Page Routes
126
+
127
+ Page routes are discovered from =.org=, =.tsx=, and =.jsx= files.
128
+
129
+ - =index.org= -> =/=
130
+ - =about.org= -> =/about=
131
+ - =docs/getting-started.tsx= -> =/docs/getting-started=
132
+
133
+ At build time, page routes are emitted as HTML:
134
+
135
+ - =/about= -> =out/about/index.html=
136
+
137
+ ** Endpoint Routes
138
+
139
+ 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=).
140
+
141
+ - =rss.xml.ts= -> =/rss.xml=
142
+ - =nested/feed.xml.ts= -> =/nested/feed.xml=
143
+ - =api/data.json.ts= -> =/api/data.json=
144
+
145
+ Endpoint modules must export:
146
+
147
+ #+begin_src ts
148
+ export async function GET(ctx) {
149
+ return new Response('ok', {
150
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
151
+ })
152
+ }
153
+ #+end_src
154
+
155
+ At build time, endpoint routes are emitted to exact filenames:
156
+
157
+ - =/rss.xml= -> =out/rss.xml=
158
+ - =/api/data.json= -> =out/api/data.json=
159
+
160
+ Route conflicts (same final route path) fail fast during dev/build startup.
161
+
115
162
  * TypeScript Setup
116
163
 
117
164
  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); }'
@@ -103,11 +116,13 @@ Here's [[mailto:hi@unclex.net][send me an email]].
103
116
  })
104
117
 
105
118
  test('processes configured global styles through vite and injects built css', async () => {
119
+ const styleUrl =
120
+ '/' + path.relative(process.cwd(), path.join(fixtureDir, 'style.css'))
106
121
  await build({
107
122
  root: fixtureDir,
108
123
  outDir: outDir,
109
124
  containerClass: [],
110
- styles: ['/style.css'],
125
+ styles: [styleUrl],
111
126
  vitePlugins: [],
112
127
  preBuild: [],
113
128
  postBuild: []
@@ -169,4 +184,50 @@ This page verifies custom rehype plugins.`
169
184
  await fs.rm(fixtureDirRehype, { recursive: true, force: true })
170
185
  }
171
186
  })
187
+
188
+ test('emits endpoint routes with exact output filenames', async () => {
189
+ await build({
190
+ root: fixtureDir,
191
+ outDir: outDir,
192
+ containerClass: [],
193
+ vitePlugins: [],
194
+ preBuild: [],
195
+ postBuild: []
196
+ })
197
+
198
+ const rss = await fs.readFile(path.join(outDir, 'rss.xml'), 'utf-8')
199
+ assert.ok(
200
+ rss.includes('<rss>') && rss.includes('<count>'),
201
+ 'should emit rss.xml from GET endpoint'
202
+ )
203
+ })
204
+
205
+ test('fails on duplicate route conflicts', async () => {
206
+ const fixtureDirConflict = path.join(__dirname, 'fixtures-conflict')
207
+ const outDirConflict = path.join(__dirname, '.test-output-conflict')
208
+ try {
209
+ await fs.mkdir(fixtureDirConflict, { recursive: true })
210
+ await fs.writeFile(path.join(fixtureDirConflict, 'index.org'), 'Home')
211
+ await fs.writeFile(
212
+ path.join(fixtureDirConflict, 'index.tsx'),
213
+ 'export default function Page() { return <div>Index</div> }'
214
+ )
215
+
216
+ await assert.rejects(
217
+ () =>
218
+ build({
219
+ root: fixtureDirConflict,
220
+ outDir: outDirConflict,
221
+ containerClass: [],
222
+ vitePlugins: [],
223
+ preBuild: [],
224
+ postBuild: []
225
+ }),
226
+ /Route conflict detected/
227
+ )
228
+ } finally {
229
+ await fs.rm(outDirConflict, { recursive: true, force: true })
230
+ await fs.rm(fixtureDirConflict, { recursive: true, force: true })
231
+ }
232
+ })
172
233
  })
package/lib/build.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * @param {import('./config.js').Config} config
3
3
  * @param {string} [projectRoot]
4
4
  */
5
- export function build({ outDir, root, containerClass, styles, rehypePlugins, vitePlugins }: import("./config.js").Config, projectRoot?: string): Promise<void>;
5
+ export function build({ outDir, root, containerClass, styles, rehypePlugins, vitePlugins, exclude }: import("./config.js").Config, projectRoot?: string): Promise<void>;
6
6
  export { alias };
7
7
  import { alias } from './plugin.js';
8
8
  //# sourceMappingURL=build.d.ts.map
@@ -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,qGAHW,OAAO,aAAa,EAAE,MAAM,gBAC5B,MAAM,iBA0LhB;;sBAtM4C,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'
@@ -24,7 +25,8 @@ export async function build(
24
25
  containerClass,
25
26
  styles = [],
26
27
  rehypePlugins = [],
27
- vitePlugins = []
28
+ vitePlugins = [],
29
+ exclude = []
28
30
  },
29
31
  projectRoot = process.cwd()
30
32
  ) {
@@ -38,12 +40,12 @@ export async function build(
38
40
  containerClass,
39
41
  styles,
40
42
  rehypePlugins,
41
- vitePlugins
43
+ vitePlugins,
44
+ exclude
42
45
  })
43
46
 
44
47
  // Shared config with environment-specific build settings
45
48
  const builder = await createBuilder({
46
- root,
47
49
  plugins,
48
50
  resolve,
49
51
  ssr: { noExternal: true },
@@ -83,9 +85,11 @@ export async function build(
83
85
  console.log('preparing ssr bundle...')
84
86
  await builder.build(builder.environments.ssr)
85
87
 
86
- const { render, pages } = await import(
87
- pathToFileURL(path.join(ssrOutDir, 'ssr.mjs')).toString()
88
- )
88
+ const {
89
+ render,
90
+ pages,
91
+ endpoints = {}
92
+ } = await import(pathToFileURL(path.join(ssrOutDir, 'ssr.mjs')).toString())
89
93
 
90
94
  // Build client bundle
91
95
  const _clientResult = await builder.build(builder.environments.client)
@@ -129,6 +133,31 @@ export async function build(
129
133
  })
130
134
  )
131
135
 
136
+ const endpointPaths = Object.keys(endpoints)
137
+ await Promise.all(
138
+ endpointPaths.map(async (route) => {
139
+ const endpointModule = endpoints[route]
140
+ const ctx = {
141
+ url: new URL(`http://localhost${route}`),
142
+ params: {},
143
+ mode: /** @type {'build'} */ ('build'),
144
+ route: { route }
145
+ }
146
+
147
+ const response = await resolveEndpointResponse(endpointModule, ctx, 'GET')
148
+ if (response.status < 200 || response.status >= 300) {
149
+ throw new Error(
150
+ `Endpoint route "${route}" returned non-2xx status during build: ${response.status}`
151
+ )
152
+ }
153
+
154
+ const bytes = Buffer.from(await response.arrayBuffer())
155
+ const writePath = path.join(clientOutDir, route.replace(/^\//, ''))
156
+ await ensureDir(path.dirname(writePath))
157
+ await fs.writeFile(writePath, bytes)
158
+ })
159
+ )
160
+
132
161
  await fs.rm(ssrOutDir, { recursive: true })
133
162
 
134
163
  return
package/lib/config.d.ts CHANGED
@@ -24,5 +24,9 @@ export type Config = {
24
24
  * - Extra rehype plugins appended to orga-build defaults
25
25
  */
26
26
  rehypePlugins?: import("unified").PluggableList;
27
+ /**
28
+ * - Glob patterns for files to exclude from content scanning
29
+ */
30
+ exclude?: string[];
27
31
  };
28
32
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["config.js"],"names":[],"mappings":"AA2BA;;;GAGG;AACH,qCAHW,MAAM,EAAE,GACN,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CA+C5D;;YAvEa,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"}
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"}
package/lib/config.js CHANGED
@@ -11,6 +11,7 @@ import path from 'node:path'
11
11
  * @property {string[]|string} containerClass
12
12
  * @property {string[]} [styles] - Global stylesheet URLs injected in dev SSR and imported by client entry
13
13
  * @property {import('unified').PluggableList} [rehypePlugins] - Extra rehype plugins appended to orga-build defaults
14
+ * @property {string[]} [exclude] - Glob patterns for files to exclude from content scanning
14
15
  */
15
16
 
16
17
  /** @type {Config} */
@@ -22,7 +23,8 @@ const defaultConfig = {
22
23
  vitePlugins: [],
23
24
  containerClass: [],
24
25
  styles: [],
25
- rehypePlugins: []
26
+ rehypePlugins: [],
27
+ exclude: []
26
28
  }
27
29
 
28
30
  /**
@@ -68,7 +70,9 @@ export async function loadConfig(...files) {
68
70
  result.outDir = resolveConfigPath(result.outDir)
69
71
  const styles = result.styles
70
72
  result.styles = Array.isArray(styles)
71
- ? styles.filter((v) => typeof v === 'string')
73
+ ? styles
74
+ .filter((v) => typeof v === 'string')
75
+ .map((v) => '/' + v.replace(/^\/+/, ''))
72
76
  : []
73
77
  return {
74
78
  config: result,
@@ -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
@@ -2,15 +2,30 @@
2
2
  * @param {string} dir
3
3
  * @param {object} [options]
4
4
  * @param {string} [options.outDir] - Output directory to exclude from file discovery
5
+ * @param {string[]} [options.exclude] - Additional glob patterns to exclude from file discovery
5
6
  */
6
- export function setup(dir: string, { outDir }?: {
7
+ export function setup(dir: string, { outDir, exclude }?: {
7
8
  outDir?: string | undefined;
9
+ exclude?: string[] | undefined;
8
10
  }): {
9
- pages: () => Promise<Record<string, Page>>;
11
+ pages: (() => Promise<Record<string, Page>>) & {
12
+ invalidate: () => void;
13
+ };
10
14
  page: (slug: string) => Promise<Page>;
11
- components: () => Promise<string | null>;
12
- layouts: () => Promise<Record<string, string>>;
13
- contentEntries: () => Promise<ContentEntry[]>;
15
+ endpoints: (() => Promise<Record<string, EndpointRoute>>) & {
16
+ invalidate: () => void;
17
+ };
18
+ endpoint: (route: string) => Promise<EndpointRoute>;
19
+ components: (() => Promise<string | null>) & {
20
+ invalidate: () => void;
21
+ };
22
+ layouts: (() => Promise<Record<string, string>>) & {
23
+ invalidate: () => void;
24
+ };
25
+ contentEntries: (() => Promise<ContentEntry[]>) & {
26
+ invalidate: () => void;
27
+ };
28
+ invalidate(): void;
14
29
  };
15
30
  /**
16
31
  * Convert a content file path (relative to content root) to the canonical page slug.
@@ -24,6 +39,10 @@ export type Page = {
24
39
  */
25
40
  title?: string;
26
41
  };
42
+ export type EndpointRoute = {
43
+ route: string;
44
+ dataPath: string;
45
+ };
27
46
  export type ContentEntry = {
28
47
  id: string;
29
48
  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;;;;;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"}
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
@@ -80,8 +86,9 @@ function getContentId(slug) {
80
86
  * @param {string} dir
81
87
  * @param {object} [options]
82
88
  * @param {string} [options.outDir] - Output directory to exclude from file discovery
89
+ * @param {string[]} [options.exclude] - Additional glob patterns to exclude from file discovery
83
90
  */
84
- export function setup(dir, { outDir } = {}) {
91
+ export function setup(dir, { outDir, exclude = [] } = {}) {
85
92
  const outDirRelative = outDir ? path.relative(dir, outDir) : null
86
93
  // Only exclude outDir if it's inside the root (not an external path like ../out)
87
94
  const outDirExclude =
@@ -89,30 +96,58 @@ export function setup(dir, { outDir } = {}) {
89
96
  ? `!${outDirRelative}/**`
90
97
  : null
91
98
 
92
- const pages = cache(async function () {
99
+ const discoveredRoutes = cache(async function () {
93
100
  const files = await globby(
94
101
  [
95
- '**/*.{org,tsx,jsx}',
102
+ '**/*.{org,tsx,jsx,ts,js,mts,mjs}',
96
103
  '!**/_*/**',
97
104
  '!**/_*',
98
105
  '!**/.*/**',
99
106
  '!**/.*',
100
107
  '!node_modules/**',
101
- ...(outDirExclude ? [outDirExclude] : [])
108
+ ...(outDirExclude ? [outDirExclude] : []),
109
+ ...exclude.map((p) => `!${p}`)
102
110
  ],
103
111
  { cwd: dir }
104
112
  )
105
113
 
106
114
  /** @type {Record<string, Page>} */
107
115
  const pages = {}
116
+ /** @type {Record<string, EndpointRoute>} */
117
+ const endpoints = {}
118
+ /** @type {Map<string, { sourceType: 'page' | 'endpoint', filePath: string }>} */
119
+ const routeOwners = new Map()
120
+
108
121
  for (const file of files) {
109
- const pageSlug = getSlugFromContentFilePath(file)
110
- pages[pageSlug] = {
111
- dataPath: path.join(dir, file)
122
+ const absolutePath = path.join(dir, file)
123
+ const pageSlug = getPageSlugFromFilePath(file)
124
+ const endpointRoute = getEndpointRouteFromFilePath(file)
125
+
126
+ if (pageSlug) {
127
+ assertUniqueRoute(routeOwners, pageSlug, 'page', absolutePath)
128
+ pages[pageSlug] = { dataPath: absolutePath }
129
+ }
130
+
131
+ if (endpointRoute) {
132
+ assertUniqueRoute(routeOwners, endpointRoute, 'endpoint', absolutePath)
133
+ endpoints[endpointRoute] = {
134
+ route: endpointRoute,
135
+ dataPath: absolutePath
136
+ }
112
137
  }
113
138
  }
114
139
 
115
- return pages
140
+ return { pages, endpoints }
141
+ })
142
+
143
+ const pages = cache(async function () {
144
+ const routes = await discoveredRoutes()
145
+ return routes.pages
146
+ })
147
+
148
+ const endpoints = cache(async function () {
149
+ const routes = await discoveredRoutes()
150
+ return routes.endpoints
116
151
  })
117
152
 
118
153
  const layouts = cache(async function () {
@@ -122,7 +157,8 @@ export function setup(dir, { outDir } = {}) {
122
157
  '!**/.*/**',
123
158
  '!**/.*',
124
159
  '!node_modules/**',
125
- ...(outDirExclude ? [outDirExclude] : [])
160
+ ...(outDirExclude ? [outDirExclude] : []),
161
+ ...exclude.map((p) => `!${p}`)
126
162
  ],
127
163
  {
128
164
  cwd: dir
@@ -146,7 +182,8 @@ export function setup(dir, { outDir } = {}) {
146
182
  '!**/.*/**',
147
183
  '!**/.*',
148
184
  '!node_modules/**',
149
- ...(outDirExclude ? [outDirExclude] : [])
185
+ ...(outDirExclude ? [outDirExclude] : []),
186
+ ...exclude.map((p) => `!${p}`)
150
187
  ],
151
188
  {
152
189
  cwd: dir
@@ -202,9 +239,19 @@ export function setup(dir, { outDir } = {}) {
202
239
  const files = {
203
240
  pages,
204
241
  page,
242
+ endpoints,
243
+ endpoint,
205
244
  components,
206
245
  layouts,
207
- contentEntries
246
+ contentEntries,
247
+ invalidate() {
248
+ discoveredRoutes.invalidate()
249
+ pages.invalidate()
250
+ endpoints.invalidate()
251
+ layouts.invalidate()
252
+ components.invalidate()
253
+ contentEntries.invalidate()
254
+ }
208
255
  }
209
256
 
210
257
  return files
@@ -214,26 +261,40 @@ export function setup(dir, { outDir } = {}) {
214
261
  const all = await pages()
215
262
  return all[slug]
216
263
  }
264
+
265
+ /** @param {string} route */
266
+ async function endpoint(route) {
267
+ const all = await endpoints()
268
+ return all[route]
269
+ }
217
270
  }
218
271
 
219
272
  /**
220
273
  * Creates a cached version of an async function that will only execute once
221
- * and return the cached result on subsequent calls
274
+ * and return the cached result on subsequent calls. The returned function
275
+ * also has an `invalidate()` method to clear the cache.
222
276
  *
223
277
  * @template T
224
278
  * @param {() => Promise<T>} fn - The async function to cache
225
- * @returns {() => Promise<T>} - Cached function that returns the same type as the input function
279
+ * @returns {(() => Promise<T>) & { invalidate: () => void }}
226
280
  */
227
281
  function cache(fn) {
228
- /** @type {T | null} */
229
- let cache = null
230
- return async function () {
231
- if (cache) {
232
- return cache
282
+ let settled = false
283
+ /** @type {T | undefined} */
284
+ let value
285
+ /** @returns {Promise<T>} */
286
+ async function cached() {
287
+ if (!settled) {
288
+ value = await fn()
289
+ settled = true
233
290
  }
234
- cache = await fn()
235
- return cache
291
+ return /** @type {T} */ (value)
292
+ }
293
+ cached.invalidate = function () {
294
+ settled = false
295
+ value = undefined
236
296
  }
297
+ return cached
237
298
  }
238
299
 
239
300
  /**
@@ -259,3 +320,51 @@ export function getSlugFromContentFilePath(contentFilePath) {
259
320
 
260
321
  return slug
261
322
  }
323
+
324
+ /**
325
+ * @param {string} filePath
326
+ * @returns {string|null}
327
+ */
328
+ function getPageSlugFromFilePath(filePath) {
329
+ if (!/\.(org|tsx|jsx)$/.test(filePath)) {
330
+ return null
331
+ }
332
+ return getSlugFromContentFilePath(filePath)
333
+ }
334
+
335
+ /**
336
+ * @param {string} filePath
337
+ * @returns {string|null}
338
+ */
339
+ function getEndpointRouteFromFilePath(filePath) {
340
+ if (!/\.(ts|js|mts|mjs)$/.test(filePath)) {
341
+ return null
342
+ }
343
+
344
+ const normalizedFilePath = filePath.replace(/\\/g, '/')
345
+ const targetPath = normalizedFilePath.replace(/\.(ts|js|mts|mjs)$/, '')
346
+ const basename = path.posix.basename(targetPath)
347
+
348
+ // Endpoint files must carry a target extension: rss.xml.ts, data.json.ts, etc.
349
+ if (!basename.includes('.')) {
350
+ return null
351
+ }
352
+
353
+ return `/${targetPath.replace(/^\/+/, '')}`
354
+ }
355
+
356
+ /**
357
+ * @param {Map<string, { sourceType: 'page' | 'endpoint', filePath: string }>} routeOwners
358
+ * @param {string} route
359
+ * @param {'page' | 'endpoint'} sourceType
360
+ * @param {string} filePath
361
+ */
362
+ function assertUniqueRoute(routeOwners, route, sourceType, filePath) {
363
+ const existing = routeOwners.get(route)
364
+ if (existing) {
365
+ throw new Error(
366
+ `Route conflict detected for "${route}"\n- ${existing.sourceType}: ${existing.filePath}\n- ${sourceType}: ${filePath}`
367
+ )
368
+ }
369
+ routeOwners.set(route, { sourceType, filePath })
370
+ }
package/lib/plugin.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * @property {string|string[]} [containerClass] - CSS class(es) to wrap rendered content
6
6
  * @property {string[]} [styles] - Global stylesheet URLs to import/inject
7
7
  * @property {import('unified').PluggableList} [rehypePlugins] - Extra rehype plugins appended to orga-build defaults
8
+ * @property {string[]} [exclude] - Glob patterns for files to exclude from content scanning
8
9
  */
9
10
  /**
10
11
  * Creates the canonical orga-build plugin preset.
@@ -13,7 +14,7 @@
13
14
  * @param {OrgaBuildPluginOptions} options
14
15
  * @returns {import('vite').PluginOption[]}
15
16
  */
16
- export function orgaBuildPlugin({ root, outDir, containerClass, styles, rehypePlugins }: OrgaBuildPluginOptions): import("vite").PluginOption[];
17
+ export function orgaBuildPlugin({ root, outDir, containerClass, styles, rehypePlugins, exclude }: OrgaBuildPluginOptions): import("vite").PluginOption[];
17
18
  /**
18
19
  * Creates the full Vite config options for orga-build.
19
20
  * Includes plugins, resolve aliases, and other shared config.
@@ -21,7 +22,7 @@ export function orgaBuildPlugin({ root, outDir, containerClass, styles, rehypePl
21
22
  * @param {OrgaBuildPluginOptions & { outDir?: string, vitePlugins?: import('vite').PluginOption[], includeFallbackHtml?: boolean, projectRoot?: string }} options
22
23
  * @returns {{ plugins: import('vite').PluginOption[], resolve: { alias: typeof alias } }}
23
24
  */
24
- export function createOrgaBuildConfig({ root, outDir, containerClass, styles, rehypePlugins, vitePlugins, includeFallbackHtml, projectRoot }: OrgaBuildPluginOptions & {
25
+ export function createOrgaBuildConfig({ root, outDir, containerClass, styles, rehypePlugins, vitePlugins, includeFallbackHtml, projectRoot, exclude }: OrgaBuildPluginOptions & {
25
26
  outDir?: string;
26
27
  vitePlugins?: import("vite").PluginOption[];
27
28
  includeFallbackHtml?: boolean;
@@ -76,5 +77,9 @@ export type OrgaBuildPluginOptions = {
76
77
  * - Extra rehype plugins appended to orga-build defaults
77
78
  */
78
79
  rehypePlugins?: import("unified").PluggableList;
80
+ /**
81
+ * - Glob patterns for files to exclude from content scanning
82
+ */
83
+ exclude?: string[];
79
84
  };
80
85
  //# sourceMappingURL=plugin.d.ts.map
@@ -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;;;;;;;;GAQG;AAEH;;;;;;GAMG;AACH,kGAHW,sBAAsB,GACpB,OAAO,MAAM,EAAE,YAAY,EAAE,CAezC;AAED;;;;;;GAMG;AACH,uJAHW,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,CAiCxF;AAiBD;;;;;;;;;;;;;GAaG;AACH,gDAJW,MAAM,WACN,MAAM,EAAE,GACN,OAAO,MAAM,EAAE,MAAM,CAsHjC;AAlOD;;GAEG;AACH;;;;EAIC;;;;;UAIa,MAAM;;;;aACN,MAAM,GAAG,SAAS;;;;qBAClB,MAAM,GAAC,MAAM,EAAE;;;;aACf,MAAM,EAAE;;;;oBACR,OAAO,SAAS,EAAE,aAAa;;;;cAC/B,MAAM,EAAE"}
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'
@@ -28,6 +29,7 @@ export const alias = {
28
29
  * @property {string|string[]} [containerClass] - CSS class(es) to wrap rendered content
29
30
  * @property {string[]} [styles] - Global stylesheet URLs to import/inject
30
31
  * @property {import('unified').PluggableList} [rehypePlugins] - Extra rehype plugins appended to orga-build defaults
32
+ * @property {string[]} [exclude] - Glob patterns for files to exclude from content scanning
31
33
  */
32
34
 
33
35
  /**
@@ -42,12 +44,13 @@ export function orgaBuildPlugin({
42
44
  outDir,
43
45
  containerClass = [],
44
46
  styles = [],
45
- rehypePlugins = []
47
+ rehypePlugins = [],
48
+ exclude = []
46
49
  }) {
47
50
  return [
48
51
  setupOrga({ containerClass, root, rehypePlugins }),
49
52
  react(),
50
- pluginFactory({ dir: root, outDir, styles })
53
+ pluginFactory({ dir: root, outDir, styles, exclude })
51
54
  ]
52
55
  }
53
56
 
@@ -66,11 +69,19 @@ export function createOrgaBuildConfig({
66
69
  rehypePlugins = [],
67
70
  vitePlugins = [],
68
71
  includeFallbackHtml = false,
69
- projectRoot = process.cwd()
72
+ projectRoot = process.cwd(),
73
+ exclude = []
70
74
  }) {
71
75
  const plugins = [
72
76
  ...vitePlugins,
73
- ...orgaBuildPlugin({ root, outDir, containerClass, styles, rehypePlugins })
77
+ ...orgaBuildPlugin({
78
+ root,
79
+ outDir,
80
+ containerClass,
81
+ styles,
82
+ rehypePlugins,
83
+ exclude
84
+ })
74
85
  ]
75
86
  if (includeFallbackHtml) {
76
87
  // HTML fallback must be first so it can handle HTML navigation requests
@@ -134,6 +145,43 @@ export function htmlFallbackPlugin(projectRoot, styles = []) {
134
145
  return next()
135
146
  }
136
147
 
148
+ const url = req.url || '/'
149
+ const pathname = url.split('?')[0]
150
+
151
+ // Endpoint routes are handled first and bypass HTML fallback.
152
+ try {
153
+ const { endpoints } = await runner.import(ssrEntry)
154
+ const endpointModule = endpoints?.[pathname]
155
+ if (endpointModule) {
156
+ const ctx = {
157
+ url: new URL(url, `http://${req.headers.host || 'localhost'}`),
158
+ params: {},
159
+ mode: /** @type {'dev'} */ ('dev'),
160
+ route: { route: pathname }
161
+ }
162
+
163
+ const response = await resolveEndpointResponse(
164
+ endpointModule,
165
+ ctx,
166
+ req.method
167
+ )
168
+ res.statusCode = response.status
169
+ response.headers.forEach((headerValue, headerName) => {
170
+ res.setHeader(headerName, headerValue)
171
+ })
172
+ if (req.method === 'HEAD') {
173
+ res.end()
174
+ return
175
+ }
176
+ const bytes = Buffer.from(await response.arrayBuffer())
177
+ res.end(bytes)
178
+ return
179
+ }
180
+ } catch (e) {
181
+ next(e)
182
+ return
183
+ }
184
+
137
185
  // Only handle browser-like navigation requests.
138
186
  // Don't match generic */* accepts to avoid hijacking API requests.
139
187
  const accept = req.headers.accept || ''
@@ -142,8 +190,6 @@ export function htmlFallbackPlugin(projectRoot, styles = []) {
142
190
  }
143
191
 
144
192
  // Don't intercept asset requests (files with extensions)
145
- const url = req.url || '/'
146
- const pathname = url.split('?')[0]
147
193
  if (pathname !== '/' && /\.\w+$/.test(pathname)) {
148
194
  return next()
149
195
  }
package/lib/serve.js CHANGED
@@ -18,11 +18,11 @@ export async function serve(config, port = 3000, projectRoot = process.cwd()) {
18
18
  rehypePlugins: config.rehypePlugins ?? [],
19
19
  vitePlugins: config.vitePlugins,
20
20
  includeFallbackHtml: true,
21
- projectRoot
21
+ projectRoot,
22
+ exclude: config.exclude ?? []
22
23
  })
23
24
 
24
25
  const server = await createServer({
25
- root: config.root,
26
26
  plugins,
27
27
  appType: 'custom',
28
28
  // Aliases are scoped to the client environment only.
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 CHANGED
@@ -3,12 +3,14 @@
3
3
  * @param {string} options.dir
4
4
  * @param {string} [options.outDir]
5
5
  * @param {string[]} [options.styles]
6
+ * @param {string[]} [options.exclude]
6
7
  * @returns {import('vite').Plugin}
7
8
  */
8
- export function pluginFactory({ dir, outDir, styles }: {
9
+ export function pluginFactory({ dir, outDir, styles, exclude }: {
9
10
  dir: string;
10
11
  outDir?: string | undefined;
11
12
  styles?: string[] | undefined;
13
+ exclude?: string[] | undefined;
12
14
  }): import("vite").Plugin;
13
15
  export const appEntryId: "/@orga-build/main.js";
14
16
  //# sourceMappingURL=vite.d.ts.map
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;;;;;;;GAOG;AACH,gEANG;IAAwB,GAAG,EAAnB,MAAM;IACW,MAAM;IACJ,MAAM;IACN,OAAO;CAClC,GAAU,OAAO,MAAM,EAAE,MAAM,CA4LjC;AAvMD,gDAAuD"}
package/lib/vite.js CHANGED
@@ -3,19 +3,22 @@ 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
12
14
  * @param {string} options.dir
13
15
  * @param {string} [options.outDir]
14
16
  * @param {string[]} [options.styles]
17
+ * @param {string[]} [options.exclude]
15
18
  * @returns {import('vite').Plugin}
16
19
  */
17
- export function pluginFactory({ dir, outDir, styles = [] }) {
18
- const files = setup(dir, { outDir })
20
+ export function pluginFactory({ dir, outDir, styles = [], exclude = [] }) {
21
+ const files = setup(dir, { outDir, exclude })
19
22
 
20
23
  return {
21
24
  name: 'vite-plugin-orga-pages',
@@ -28,7 +31,15 @@ export function pluginFactory({ dir, outDir, styles = [] }) {
28
31
  }
29
32
  }),
30
33
 
34
+ async configureServer(_server) {
35
+ // Eagerly run file discovery so route conflicts surface at startup
36
+ await files.pages()
37
+ await files.endpoints()
38
+ },
39
+
31
40
  hotUpdate() {
41
+ // Invalidate in-memory file caches so added/removed routes are picked up
42
+ files.invalidate()
32
43
  // Invalidate content module when content files change
33
44
  const module = this.environment.moduleGraph.getModuleById(
34
45
  contentModuleIdResolved
@@ -64,6 +75,9 @@ export function pluginFactory({ dir, outDir, styles = [] }) {
64
75
  if (id === pagesModuleId) {
65
76
  return await renderPageList()
66
77
  }
78
+ if (id === endpointsModuleId) {
79
+ return await renderEndpointList()
80
+ }
67
81
  if (id.startsWith(pagesModuleId)) {
68
82
  const pageId = id.replace(pagesModuleId, '')
69
83
  const page = await files.page(pageId)
@@ -74,6 +88,14 @@ export {default} from '${page.dataPath}';
74
88
  `
75
89
  }
76
90
  }
91
+ if (id.startsWith(endpointModulePrefix)) {
92
+ const routeHex = id.slice(endpointModulePrefix.length)
93
+ const endpointId = Buffer.from(routeHex, 'hex').toString('utf-8')
94
+ const endpoint = await files.endpoint(endpointId)
95
+ if (endpoint) {
96
+ return `export * from '${endpoint.dataPath}';`
97
+ }
98
+ }
77
99
 
78
100
  if (id === `${magicModulePrefix}layouts`) {
79
101
  const layouts = await files.layouts()
@@ -99,19 +121,40 @@ export default layouts;
99
121
 
100
122
  async function renderPageList() {
101
123
  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}`)
124
+ return renderModuleMap('pages', pages, (id) =>
125
+ path.join(magicModulePrefix, 'pages', id)
126
+ )
127
+ }
128
+
129
+ async function renderEndpointList() {
130
+ const endpoints = await files.endpoints()
131
+ return renderModuleMap(
132
+ 'endpoints',
133
+ endpoints,
134
+ (route) => endpointModulePrefix + Buffer.from(route).toString('hex')
135
+ )
136
+ }
137
+
138
+ /**
139
+ * @param {string} name
140
+ * @param {Record<string, unknown>} entries
141
+ * @param {(key: string) => string} toModulePath
142
+ */
143
+ function renderModuleMap(name, entries, toModulePath) {
144
+ /** @type {string[]} */
145
+ const imports = []
146
+ /** @type {string[]} */
147
+ const assignments = []
148
+ Object.keys(entries).forEach((key, i) => {
149
+ imports.push(`import * as m${i} from '${toModulePath(key)}'`)
150
+ assignments.push(`${name}['${key}'] = m${i}`)
108
151
  })
109
- return `
110
- ${_imports.join('\n')}
111
- const pages = {};
112
- ${_pages.join('\n')}
113
- export default pages;
114
- `
152
+ return [
153
+ imports.join('\n'),
154
+ `const ${name} = {};`,
155
+ assignments.join('\n'),
156
+ `export default ${name};`
157
+ ].join('\n')
115
158
  }
116
159
 
117
160
  async function renderComponents() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orga-build",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "A simple tool that builds org-mode files into a website",
5
5
  "type": "module",
6
6
  "engines": {