orga-build 0.2.7 → 0.3.1

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 ADDED
@@ -0,0 +1,251 @@
1
+ #+TITLE: orga-build
2
+
3
+ A simple tool that builds org-mode files into a website.
4
+
5
+ * Installation
6
+
7
+ #+begin_src bash
8
+ npm install orga-build
9
+ #+end_src
10
+
11
+ * TypeScript Setup
12
+
13
+ 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.
14
+
15
+ ** Minimal Setup
16
+
17
+ 1. Create a =types.d.ts= file in your project root (or any location):
18
+
19
+ #+begin_src typescript
20
+ /// <reference types="orga-build/client" />
21
+ #+end_src
22
+
23
+ 2. Ensure your =tsconfig.json= includes this file:
24
+
25
+ #+begin_src json
26
+ {
27
+ "compilerOptions": {
28
+ "module": "esnext",
29
+ "moduleResolution": "bundler",
30
+ "jsx": "react-jsx"
31
+ },
32
+ "include": ["types.d.ts", "**/*"]
33
+ }
34
+ #+end_src
35
+
36
+ That's it! TypeScript will now recognize imports from =orga-build:content=.
37
+
38
+ ** Why This is Needed
39
+
40
+ The =orga-build:content= module is a "virtual module" - it doesn't exist as a physical file but is generated at build time by Vite. The =/// <reference types="orga-build/client" />= directive tells TypeScript to load the type definitions for this virtual module.
41
+
42
+ * Content Query API
43
+
44
+ orga-build provides an Astro-inspired content query API via the =orga-build:content= virtual module. This allows you to safely query content entries from any page or layout without circular imports.
45
+
46
+ ** Importing
47
+
48
+ #+begin_src typescript
49
+ import { getPages, getPage } from 'orga-build:content'
50
+ #+end_src
51
+
52
+ ** API Reference
53
+
54
+ *** =getPages(path?, filter?)=
55
+
56
+ Get all content entries matching a path pattern.
57
+
58
+ *Parameters:*
59
+ - =path= (optional): Path prefix to filter by (e.g., ='writing'=, ='content/writing/2025'=)
60
+ - =filter= (optional): Filter function to further refine results
61
+
62
+ *Returns:* Array of =ContentEntry= objects
63
+
64
+ *Examples:*
65
+
66
+ #+begin_src typescript
67
+ // Get all entries
68
+ const all = getPages()
69
+
70
+ // Get all entries in the 'writing' path
71
+ const writing = getPages('writing')
72
+
73
+ // Get entries in a nested path
74
+ const posts2025 = getPages('content/writing/2025')
75
+
76
+ // Filter out drafts
77
+ const published = getPages('writing', (entry) => {
78
+ return entry.data['draft'] !== 'true'
79
+ })
80
+ #+end_src
81
+
82
+ *** =getPage(idOrSlug, path?)=
83
+
84
+ Get a single content entry by id or slug.
85
+
86
+ *Parameters:*
87
+ - =idOrSlug=: The id or slug of the entry to find
88
+ - =path= (optional): Path prefix to search within
89
+
90
+ *Returns:* =ContentEntry | undefined=
91
+
92
+ *Examples:*
93
+
94
+ #+begin_src typescript
95
+ // Get by slug
96
+ const post = getPage('/writing/the-birth-of-emacsclient')
97
+
98
+ // Get by id within a path
99
+ const post = getPage('the-birth-of-emacsclient', 'writing')
100
+ #+end_src
101
+
102
+ *** =getEntries(refs)=
103
+
104
+ Get multiple content entries by reference.
105
+
106
+ *Parameters:*
107
+ - =refs=: Array of references with =id= and optional =path=
108
+
109
+ *Returns:* Array of =ContentEntry | undefined=
110
+
111
+ *Examples:*
112
+
113
+ #+begin_src typescript
114
+ const entries = getEntries([
115
+ { id: 'post-1', path: 'writing' },
116
+ { id: 'post-2', path: 'writing' }
117
+ ])
118
+ #+end_src
119
+
120
+ *** Aliases
121
+
122
+ For Astro familiarity:
123
+ - =getCollection= - Alias for =getPages=
124
+ - =getEntry= - Alias for =getPage=
125
+
126
+ ** ContentEntry Type
127
+
128
+ Each entry has the following structure:
129
+
130
+ #+begin_src typescript
131
+ interface ContentEntry {
132
+ id: string // e.g., 'post-name' or 'index'
133
+ slug: string // e.g., '/writing/post-name'
134
+ path: string // e.g., 'writing' or 'content/writing/2025'
135
+ filePath: string // absolute source file path
136
+ ext: 'org' | 'tsx' | 'jsx' // file extension
137
+ data: Record<string, unknown> // metadata from org headers
138
+ }
139
+ #+end_src
140
+
141
+ ** Metadata Extraction
142
+
143
+ For =.org= files, orga-build automatically extracts metadata from org-mode headers:
144
+
145
+ #+begin_example
146
+ #+title: My Post
147
+ #+date: 2025-01-15
148
+ #+draft: false
149
+ #+end_example
150
+
151
+ This becomes:
152
+
153
+ #+begin_src typescript
154
+ {
155
+ data: {
156
+ title: 'My Post',
157
+ date: '2025-01-15',
158
+ draft: 'false'
159
+ }
160
+ }
161
+ #+end_src
162
+
163
+ For =.tsx= and =.jsx= files, the =data= field is currently empty in v1.
164
+
165
+ ** Path Matching Behavior
166
+
167
+ Path matching works hierarchically:
168
+
169
+ - =getPages()= - Returns all entries
170
+ - =getPages('writing')= - Returns entries where:
171
+ - =path = 'writing'=, or
172
+ - =path= starts with ='writing/'=
173
+ - =getPages('content/writing/2025')= - Returns entries under that specific subtree
174
+
175
+ *Path Derivation Examples:*
176
+
177
+ | File Path | Slug | Path | ID |
178
+ |-----------+------+------+----|
179
+ | =pages/writing/foo.org= | =/writing/foo= | =writing= | =foo= |
180
+ | =pages/content/writing/2025/post.org= | =/content/writing/2025/post= | =content/writing/2025= | =post= |
181
+ | =pages/index.org= | =/= | =''= (empty) | =index= |
182
+ | =pages/about.org= | =/about= | =''= (empty) | =about= |
183
+
184
+ ** Example: Blog Index Page
185
+
186
+ #+begin_src tsx
187
+ import { getPages } from 'orga-build:content'
188
+
189
+ export default function BlogIndex() {
190
+ const posts = getPages('writing', (entry) => {
191
+ return entry.data['draft'] !== 'true'
192
+ }).sort((a, b) => {
193
+ // Sort by date descending
194
+ return String(b.data['date']).localeCompare(String(a.data['date']))
195
+ })
196
+
197
+ return (
198
+ <div>
199
+ <h1>Blog Posts</h1>
200
+ <ul>
201
+ {posts.map((post) => (
202
+ <li key={post.id}>
203
+ <a href={post.slug}>{String(post.data['title'])}</a>
204
+ <span>{String(post.data['date'])}</span>
205
+ </li>
206
+ ))}
207
+ </ul>
208
+ </div>
209
+ )
210
+ }
211
+ #+end_src
212
+
213
+ ** Example: Related Posts
214
+
215
+ #+begin_src tsx
216
+ import { getPages } from 'orga-build:content'
217
+
218
+ export default function Post({ slug }: { slug: string }) {
219
+ // Get current post
220
+ const currentPost = getPages().find((p) => p.slug === slug)
221
+
222
+ // Get related posts from same path
223
+ const related = getPages(currentPost?.path).filter(
224
+ (p) => p.slug !== slug
225
+ ).slice(0, 3)
226
+
227
+ return (
228
+ <div>
229
+ <h2>Related Posts</h2>
230
+ <ul>
231
+ {related.map((post) => (
232
+ <li key={post.id}>
233
+ <a href={post.slug}>{String(post.data['title'])}</a>
234
+ </li>
235
+ ))}
236
+ </ul>
237
+ </div>
238
+ )
239
+ }
240
+ #+end_src
241
+
242
+ * Development
243
+
244
+ ** TODO Items
245
+
246
+ - resolve relative path in links and images
247
+ - monitor file changes and cache properly
248
+
249
+ * License
250
+
251
+ MIT
@@ -0,0 +1,51 @@
1
+ declare module 'orga-build:content' {
2
+ export interface ContentEntry {
3
+ id: string
4
+ slug: string
5
+ path: string
6
+ filePath: string
7
+ ext: 'org' | 'tsx' | 'jsx'
8
+ data: Record<string, unknown>
9
+ }
10
+
11
+ /**
12
+ * Get all content entries matching a path pattern
13
+ * @param path - Optional path prefix to filter by (e.g., 'writing', 'content/writing/2025')
14
+ * @param filter - Optional filter function to further refine results
15
+ * @returns Array of matching content entries
16
+ */
17
+ export function getPages(
18
+ path?: string,
19
+ filter?: (entry: ContentEntry) => boolean
20
+ ): ContentEntry[]
21
+
22
+ /**
23
+ * Get a single content entry by id or slug
24
+ * @param idOrSlug - The id or slug of the entry to find
25
+ * @param path - Optional path prefix to search within
26
+ * @returns The matching content entry or undefined
27
+ */
28
+ export function getPage(
29
+ idOrSlug: string,
30
+ path?: string
31
+ ): ContentEntry | undefined
32
+
33
+ /**
34
+ * Get multiple content entries by reference
35
+ * @param refs - Array of references with id and optional path
36
+ * @returns Array of matching content entries (may include undefined)
37
+ */
38
+ export function getEntries(
39
+ refs: Array<{ path?: string; id: string }>
40
+ ): Array<ContentEntry | undefined>
41
+
42
+ /**
43
+ * Alias for getPages
44
+ */
45
+ export const getCollection: typeof getPages
46
+
47
+ /**
48
+ * Alias for getPage
49
+ */
50
+ export const getEntry: typeof getPage
51
+ }
package/lib/files.d.ts CHANGED
@@ -1,9 +1,3 @@
1
- /**
2
- * @typedef {Object} Page
3
- * @property {string} dataPath
4
- * @property {string} [title]
5
- * Path to the page data file
6
- */
7
1
  /**
8
2
  * @param {string} dir
9
3
  */
@@ -12,6 +6,7 @@ export function setup(dir: string): {
12
6
  page: (id: string) => Promise<Page>;
13
7
  components: () => Promise<string | null>;
14
8
  layouts: () => Promise<Record<string, string>>;
9
+ contentEntries: () => Promise<ContentEntry[]>;
15
10
  };
16
11
  export type Page = {
17
12
  dataPath: string;
@@ -20,4 +15,12 @@ export type Page = {
20
15
  */
21
16
  title?: string;
22
17
  };
18
+ export type ContentEntry = {
19
+ id: string;
20
+ slug: string;
21
+ path: string;
22
+ filePath: string;
23
+ ext: "org" | "tsx" | "jsx";
24
+ data: Record<string, unknown>;
25
+ };
23
26
  //# sourceMappingURL=files.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["files.js"],"names":[],"mappings":"AAGA;;;;;GAKG;AAEH;;GAEG;AACH,2BAFW,MAAM;;eA4EJ,MAAM;;;EAKlB;;cAvFa,MAAM;;;;YACN,MAAM"}
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["files.js"],"names":[],"mappings":"AA8EA;;GAEG;AACH,2BAFW,MAAM;;eA2HJ,MAAM;;;;EAKlB;;cAxMa,MAAM;;;;YACN,MAAM;;;QAMN,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
@@ -1,5 +1,7 @@
1
1
  import { globby } from 'globby'
2
2
  import path from 'node:path'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { getSettings } from 'orga'
3
5
 
4
6
  /**
5
7
  * @typedef {Object} Page
@@ -8,6 +10,72 @@ import path from 'node:path'
8
10
  * Path to the page data file
9
11
  */
10
12
 
13
+ /**
14
+ * @typedef {Object} ContentEntry
15
+ * @property {string} id
16
+ * @property {string} slug
17
+ * @property {string} path
18
+ * @property {string} filePath
19
+ * @property {'org' | 'tsx' | 'jsx'} ext
20
+ * @property {Record<string, unknown>} data
21
+ */
22
+
23
+ /**
24
+ * Extract file extension from file path
25
+ * @param {string} filePath
26
+ * @returns {'org' | 'tsx' | 'jsx'}
27
+ */
28
+ function getFileExtension(filePath) {
29
+ const match = filePath.match(/\.(org|tsx|jsx)$/)
30
+ return /** @type {'org' | 'tsx' | 'jsx'} */ (match ? match[1] : 'org')
31
+ }
32
+
33
+ /**
34
+ * Derive content path from slug
35
+ * @param {string} slug - e.g. '/writing/foo', '/content/writing/2025/post', '/'
36
+ * @returns {string} - e.g. 'writing', 'content/writing/2025', ''
37
+ */
38
+ function getContentPath(slug) {
39
+ // Remove leading slash
40
+ let normalized = slug.replace(/^\/+/, '')
41
+ // Remove trailing slash
42
+ normalized = normalized.replace(/\/+$/, '')
43
+
44
+ // If root page, return empty string
45
+ if (!normalized) {
46
+ return ''
47
+ }
48
+
49
+ // Get all segments except the last one (the file name)
50
+ const segments = normalized.split('/')
51
+ if (segments.length === 1) {
52
+ // Single segment like '/about' -> path is empty (root level)
53
+ return ''
54
+ }
55
+
56
+ // Multiple segments like '/writing/foo' -> path is 'writing'
57
+ return segments.slice(0, -1).join('/')
58
+ }
59
+
60
+ /**
61
+ * Derive content id from slug
62
+ * @param {string} slug - e.g. '/writing/foo', '/writing', '/'
63
+ * @returns {string} - e.g. 'foo', 'writing', 'index'
64
+ */
65
+ function getContentId(slug) {
66
+ // Remove leading and trailing slashes
67
+ let normalized = slug.replace(/^\/+/, '').replace(/\/+$/, '')
68
+
69
+ // If root page, return 'index'
70
+ if (!normalized) {
71
+ return 'index'
72
+ }
73
+
74
+ // Get last segment
75
+ const segments = normalized.split('/')
76
+ return segments[segments.length - 1] || 'index'
77
+ }
78
+
11
79
  /**
12
80
  * @param {string} dir
13
81
  */
@@ -78,13 +146,60 @@ export function setup(dir) {
78
146
  return files[0] ? path.join(dir, files[0]) : null
79
147
  })
80
148
 
81
- return {
149
+ const contentEntries = cache(async function () {
150
+ const allPages = await pages()
151
+ /** @type {ContentEntry[]} */
152
+ const entries = []
153
+
154
+ for (const [slug, pageData] of Object.entries(allPages)) {
155
+ const filePath = pageData.dataPath
156
+ const ext = getFileExtension(filePath)
157
+
158
+ // Derive path from directory structure
159
+ const derivedPath = getContentPath(slug)
160
+
161
+ // Derive id from the slug (last segment or 'index')
162
+ const id = getContentId(slug)
163
+
164
+ /** @type {Record<string, unknown>} */
165
+ let data = {}
166
+
167
+ // Extract metadata from .org files
168
+ if (ext === 'org') {
169
+ try {
170
+ const content = await readFile(filePath, 'utf-8')
171
+ data = getSettings(content)
172
+ } catch (/** @type {any} */ error) {
173
+ console.warn(
174
+ `Failed to read metadata from ${filePath}:`,
175
+ error?.message || error
176
+ )
177
+ }
178
+ }
179
+
180
+ entries.push({
181
+ id,
182
+ slug,
183
+ path: derivedPath,
184
+ filePath,
185
+ ext,
186
+ data
187
+ })
188
+ }
189
+
190
+ return entries
191
+ })
192
+
193
+ const files = {
82
194
  pages,
83
195
  page,
84
196
  components,
85
- layouts
197
+ layouts,
198
+ contentEntries
86
199
  }
87
200
 
201
+ return files
202
+
88
203
  /** @param {string} id */
89
204
  async function page(id) {
90
205
  const all = await pages()
package/lib/vite.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["vite.js"],"names":[],"mappings":"AAOA;;;;GAIG;AACH,uCAHG;IAAwB,GAAG,EAAnB,MAAM;CACd,GAAU,OAAO,MAAM,EAAE,MAAM,CA2GjC"}
1
+ {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["vite.js"],"names":[],"mappings":"AASA;;;;GAIG;AACH,uCAHG;IAAwB,GAAG,EAAnB,MAAM;CACd,GAAU,OAAO,MAAM,EAAE,MAAM,CA6JjC"}
package/lib/vite.js CHANGED
@@ -4,6 +4,8 @@ import path from 'node:path'
4
4
  const magicModulePrefix = '/@orga-build/'
5
5
  const pagesModuleId = magicModulePrefix + 'pages'
6
6
  const appEntryId = `${magicModulePrefix}main.js`
7
+ const contentModuleId = 'orga-build:content'
8
+ const contentModuleIdResolved = '\0' + contentModuleId
7
9
 
8
10
  /**
9
11
  * @param {Object} options
@@ -39,6 +41,11 @@ export function pluginFactory({ dir }) {
39
41
  }
40
42
 
41
43
  reloadVirtualModule('/')
44
+
45
+ // Invalidate content module on file changes
46
+ watcher.on('change', () => {
47
+ reloadVirtualModule(contentModuleIdResolved)
48
+ })
42
49
  },
43
50
 
44
51
  buildStart() {},
@@ -47,6 +54,9 @@ export function pluginFactory({ dir }) {
47
54
  if (id === appEntryId) {
48
55
  return appEntryId
49
56
  }
57
+ if (id === contentModuleId) {
58
+ return contentModuleIdResolved
59
+ }
50
60
  if (id.startsWith(magicModulePrefix)) {
51
61
  return id
52
62
  }
@@ -55,6 +65,9 @@ export function pluginFactory({ dir }) {
55
65
  if (id === appEntryId) {
56
66
  return `import "orga-build/csr";`
57
67
  }
68
+ if (id === contentModuleIdResolved) {
69
+ return await renderContentModule()
70
+ }
58
71
  if (id === pagesModuleId) {
59
72
  return await renderPageList()
60
73
  }
@@ -115,4 +128,43 @@ export default pages;
115
128
  }
116
129
  return ''
117
130
  }
131
+
132
+ async function renderContentModule() {
133
+ const entries = await files.contentEntries()
134
+ const manifest = JSON.stringify(entries, null, 2)
135
+
136
+ return `
137
+ const __entries = ${manifest}
138
+
139
+ function normalizePath(path = '') {
140
+ return String(path).replace(/^\\/+|\\/+$/g, '')
141
+ }
142
+
143
+ function pathMatches(entryPath, queryPath) {
144
+ const e = normalizePath(entryPath)
145
+ const q = normalizePath(queryPath)
146
+ if (!q) return true
147
+ return e === q || e.startsWith(q + '/')
148
+ }
149
+
150
+ export function getPages(path = '', filter) {
151
+ const list = __entries.filter((e) => pathMatches(e.path, path))
152
+ return typeof filter === 'function' ? list.filter(filter) : list
153
+ }
154
+
155
+ export function getPage(idOrSlug, path = '') {
156
+ return __entries.find((e) => {
157
+ if (!pathMatches(e.path, path)) return false
158
+ return e.id === idOrSlug || e.slug === idOrSlug
159
+ })
160
+ }
161
+
162
+ export function getEntries(refs) {
163
+ return refs.map((r) => getPage(r.id, r.path || ''))
164
+ }
165
+
166
+ export const getCollection = getPages
167
+ export const getEntry = getPage
168
+ `
169
+ }
118
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orga-build",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "description": "A simple tool that builds org-mode files into a website",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "cli.js",
12
12
  "index.js",
13
13
  "index.d.ts",
14
- "index.d.ts.map"
14
+ "index.d.ts.map",
15
+ "lib/content.d.ts"
15
16
  ],
16
17
  "exports": {
17
18
  ".": {
@@ -19,7 +20,10 @@
19
20
  "import": "./index.js"
20
21
  },
21
22
  "./csr": "./lib/csr.jsx",
22
- "./components": "./lib/components.js"
23
+ "./components": "./lib/components.js",
24
+ "./client": {
25
+ "types": "./lib/content.d.ts"
26
+ }
23
27
  },
24
28
  "keywords": [
25
29
  "orgajs",