orga-build 0.2.7 → 0.3.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 ADDED
@@ -0,0 +1,220 @@
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
+ * Content Query API
12
+
13
+ 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.
14
+
15
+ ** Importing
16
+
17
+ #+begin_src typescript
18
+ import { getPages, getPage } from 'orga-build:content'
19
+ #+end_src
20
+
21
+ ** API Reference
22
+
23
+ *** =getPages(path?, filter?)=
24
+
25
+ Get all content entries matching a path pattern.
26
+
27
+ *Parameters:*
28
+ - =path= (optional): Path prefix to filter by (e.g., ='writing'=, ='content/writing/2025'=)
29
+ - =filter= (optional): Filter function to further refine results
30
+
31
+ *Returns:* Array of =ContentEntry= objects
32
+
33
+ *Examples:*
34
+
35
+ #+begin_src typescript
36
+ // Get all entries
37
+ const all = getPages()
38
+
39
+ // Get all entries in the 'writing' path
40
+ const writing = getPages('writing')
41
+
42
+ // Get entries in a nested path
43
+ const posts2025 = getPages('content/writing/2025')
44
+
45
+ // Filter out drafts
46
+ const published = getPages('writing', (entry) => {
47
+ return entry.data['draft'] !== 'true'
48
+ })
49
+ #+end_src
50
+
51
+ *** =getPage(idOrSlug, path?)=
52
+
53
+ Get a single content entry by id or slug.
54
+
55
+ *Parameters:*
56
+ - =idOrSlug=: The id or slug of the entry to find
57
+ - =path= (optional): Path prefix to search within
58
+
59
+ *Returns:* =ContentEntry | undefined=
60
+
61
+ *Examples:*
62
+
63
+ #+begin_src typescript
64
+ // Get by slug
65
+ const post = getPage('/writing/the-birth-of-emacsclient')
66
+
67
+ // Get by id within a path
68
+ const post = getPage('the-birth-of-emacsclient', 'writing')
69
+ #+end_src
70
+
71
+ *** =getEntries(refs)=
72
+
73
+ Get multiple content entries by reference.
74
+
75
+ *Parameters:*
76
+ - =refs=: Array of references with =id= and optional =path=
77
+
78
+ *Returns:* Array of =ContentEntry | undefined=
79
+
80
+ *Examples:*
81
+
82
+ #+begin_src typescript
83
+ const entries = getEntries([
84
+ { id: 'post-1', path: 'writing' },
85
+ { id: 'post-2', path: 'writing' }
86
+ ])
87
+ #+end_src
88
+
89
+ *** Aliases
90
+
91
+ For Astro familiarity:
92
+ - =getCollection= - Alias for =getPages=
93
+ - =getEntry= - Alias for =getPage=
94
+
95
+ ** ContentEntry Type
96
+
97
+ Each entry has the following structure:
98
+
99
+ #+begin_src typescript
100
+ interface ContentEntry {
101
+ id: string // e.g., 'post-name' or 'index'
102
+ slug: string // e.g., '/writing/post-name'
103
+ path: string // e.g., 'writing' or 'content/writing/2025'
104
+ filePath: string // absolute source file path
105
+ ext: 'org' | 'tsx' | 'jsx' // file extension
106
+ data: Record<string, unknown> // metadata from org headers
107
+ }
108
+ #+end_src
109
+
110
+ ** Metadata Extraction
111
+
112
+ For =.org= files, orga-build automatically extracts metadata from org-mode headers:
113
+
114
+ #+begin_example
115
+ #+title: My Post
116
+ #+date: 2025-01-15
117
+ #+draft: false
118
+ #+end_example
119
+
120
+ This becomes:
121
+
122
+ #+begin_src typescript
123
+ {
124
+ data: {
125
+ title: 'My Post',
126
+ date: '2025-01-15',
127
+ draft: 'false'
128
+ }
129
+ }
130
+ #+end_src
131
+
132
+ For =.tsx= and =.jsx= files, the =data= field is currently empty in v1.
133
+
134
+ ** Path Matching Behavior
135
+
136
+ Path matching works hierarchically:
137
+
138
+ - =getPages()= - Returns all entries
139
+ - =getPages('writing')= - Returns entries where:
140
+ - =path = 'writing'=, or
141
+ - =path= starts with ='writing/'=
142
+ - =getPages('content/writing/2025')= - Returns entries under that specific subtree
143
+
144
+ *Path Derivation Examples:*
145
+
146
+ | File Path | Slug | Path | ID |
147
+ |-----------+------+------+----|
148
+ | =pages/writing/foo.org= | =/writing/foo= | =writing= | =foo= |
149
+ | =pages/content/writing/2025/post.org= | =/content/writing/2025/post= | =content/writing/2025= | =post= |
150
+ | =pages/index.org= | =/= | =''= (empty) | =index= |
151
+ | =pages/about.org= | =/about= | =''= (empty) | =about= |
152
+
153
+ ** Example: Blog Index Page
154
+
155
+ #+begin_src tsx
156
+ import { getPages } from 'orga-build:content'
157
+
158
+ export default function BlogIndex() {
159
+ const posts = getPages('writing', (entry) => {
160
+ return entry.data['draft'] !== 'true'
161
+ }).sort((a, b) => {
162
+ // Sort by date descending
163
+ return String(b.data['date']).localeCompare(String(a.data['date']))
164
+ })
165
+
166
+ return (
167
+ <div>
168
+ <h1>Blog Posts</h1>
169
+ <ul>
170
+ {posts.map((post) => (
171
+ <li key={post.id}>
172
+ <a href={post.slug}>{String(post.data['title'])}</a>
173
+ <span>{String(post.data['date'])}</span>
174
+ </li>
175
+ ))}
176
+ </ul>
177
+ </div>
178
+ )
179
+ }
180
+ #+end_src
181
+
182
+ ** Example: Related Posts
183
+
184
+ #+begin_src tsx
185
+ import { getPages } from 'orga-build:content'
186
+
187
+ export default function Post({ slug }: { slug: string }) {
188
+ // Get current post
189
+ const currentPost = getPages().find((p) => p.slug === slug)
190
+
191
+ // Get related posts from same path
192
+ const related = getPages(currentPost?.path).filter(
193
+ (p) => p.slug !== slug
194
+ ).slice(0, 3)
195
+
196
+ return (
197
+ <div>
198
+ <h2>Related Posts</h2>
199
+ <ul>
200
+ {related.map((post) => (
201
+ <li key={post.id}>
202
+ <a href={post.slug}>{String(post.data['title'])}</a>
203
+ </li>
204
+ ))}
205
+ </ul>
206
+ </div>
207
+ )
208
+ }
209
+ #+end_src
210
+
211
+ * Development
212
+
213
+ ** TODO Items
214
+
215
+ - resolve relative path in links and images
216
+ - monitor file changes and cache properly
217
+
218
+ * License
219
+
220
+ 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.0",
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
+ "./content": {
25
+ "types": "./lib/content.d.ts"
26
+ }
23
27
  },
24
28
  "keywords": [
25
29
  "orgajs",