@eclipsa/content 0.0.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/vite.ts ADDED
@@ -0,0 +1,276 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import { pathToFileURL } from 'node:url'
3
+ import path from 'node:path'
4
+ import type { Plugin, PluginOption, ResolvedConfig, ViteDevServer } from 'vite'
5
+ import { createContentSearch } from './internal.ts'
6
+ import { generateContentSearchRuntimeModule, resolveContentSearchOptions } from './search.ts'
7
+ import type { ResolvedContentSearchOptions } from './types.ts'
8
+
9
+ const DEV_APP_INVALIDATORS_KEY = Symbol.for('eclipsa.dev-app-invalidators')
10
+ const CONTENT_HMR_EVENT = 'eclipsa:content-update'
11
+ const VIRTUAL_RUNTIME_ID = 'virtual:eclipsa-content:runtime'
12
+ const RESOLVED_VIRTUAL_RUNTIME_ID = '\0eclipsa-content:runtime'
13
+ const VIRTUAL_SEARCH_ID = 'virtual:eclipsa-content:search'
14
+ const RESOLVED_VIRTUAL_SEARCH_ID = '\0eclipsa-content:search'
15
+ const CONTENT_CONFIG_PATH = 'app/content.config.ts'
16
+ const CONTENT_COLLECTION_MARKER = '__eclipsa_content_collection__'
17
+ const CONTENT_SEARCH_ASSET = '__eclipsa_content_search__.json'
18
+
19
+ const normalizeSlashes = (value: string) => value.replaceAll('\\', '/')
20
+ const stripQuery = (id: string) => id.split('?', 1)[0] ?? id
21
+
22
+ const fileExists = async (filePath: string) => {
23
+ try {
24
+ await fs.access(filePath)
25
+ return true
26
+ } catch {
27
+ return false
28
+ }
29
+ }
30
+
31
+ const getConfigPath = (root: string) => path.join(root, CONTENT_CONFIG_PATH)
32
+ const getSearchAssetPath = (base: string) => {
33
+ const normalizedBase = base === '' ? '/' : base.endsWith('/') ? base : `${base}/`
34
+ return `${normalizedBase}${CONTENT_SEARCH_ASSET}`
35
+ }
36
+ const isContentConfigId = (root: string, id: string) =>
37
+ normalizeSlashes(path.resolve(stripQuery(id))) === normalizeSlashes(getConfigPath(root))
38
+
39
+ const getNamedCollectionExports = (source: string) =>
40
+ [...source.matchAll(/^\s*export\s+const\s+([A-Za-z_$][\w$]*)\s*=/gm)].map((match) => match[1]!)
41
+
42
+ const invalidateVirtualRuntime = (server: ViteDevServer) => {
43
+ const graphs = [
44
+ (server as ViteDevServer & { moduleGraph?: any }).moduleGraph,
45
+ ...Object.values(
46
+ (server as ViteDevServer & { environments?: Record<string, { moduleGraph?: any }> })
47
+ .environments ?? {},
48
+ ).map((environment) => environment.moduleGraph),
49
+ ]
50
+ for (const graph of graphs) {
51
+ if (!graph) {
52
+ continue
53
+ }
54
+ for (const id of [
55
+ VIRTUAL_RUNTIME_ID,
56
+ RESOLVED_VIRTUAL_RUNTIME_ID,
57
+ VIRTUAL_SEARCH_ID,
58
+ RESOLVED_VIRTUAL_SEARCH_ID,
59
+ ]) {
60
+ const mod = graph.getModuleById?.(id)
61
+ if (mod) {
62
+ graph.invalidateModule?.(mod)
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ const invalidateRegisteredDevApps = (server: ViteDevServer) => {
69
+ const invalidators = (
70
+ server as ViteDevServer & {
71
+ [DEV_APP_INVALIDATORS_KEY]?: Set<() => void>
72
+ }
73
+ )[DEV_APP_INVALIDATORS_KEY]
74
+ if (!invalidators) {
75
+ return
76
+ }
77
+ for (const invalidate of invalidators) {
78
+ invalidate()
79
+ }
80
+ }
81
+
82
+ const shouldInvalidateForFile = (root: string, filePath: string) => {
83
+ const normalizedFilePath = normalizeSlashes(path.resolve(filePath))
84
+ const normalizedRoot = normalizeSlashes(path.resolve(root))
85
+ if (!normalizedFilePath.startsWith(normalizedRoot)) {
86
+ return false
87
+ }
88
+ if (normalizedFilePath === normalizeSlashes(path.join(root, CONTENT_CONFIG_PATH))) {
89
+ return true
90
+ }
91
+ return normalizedFilePath.endsWith('.md')
92
+ }
93
+
94
+ const createMissingRuntimeModule = (root: string) => {
95
+ const message = `Missing ${CONTENT_CONFIG_PATH} in ${root}.`
96
+ return `
97
+ const error = new Error(${JSON.stringify(message)});
98
+ export const getCollection = async () => { throw error; };
99
+ export const getEntries = async () => { throw error; };
100
+ export const getEntry = async () => { throw error; };
101
+ export const render = async () => { throw error; };
102
+ `
103
+ }
104
+
105
+ const createClientRuntimeModule = () => `
106
+ const error = new Error("@eclipsa/content query APIs are server-only.");
107
+ export const getCollection = async () => { throw error; };
108
+ export const getEntries = async () => { throw error; };
109
+ export const getEntry = async () => { throw error; };
110
+ export const render = async () => { throw error; };
111
+ `
112
+
113
+ const createDisabledSearchModule = () => `
114
+ export const searchOptions = ${JSON.stringify(resolveContentSearchOptions(false))};
115
+ export const search = async () => [];
116
+ export default { search, searchOptions };
117
+ `
118
+
119
+ const createClientContentConfigModule = async (configPath: string) => {
120
+ const source = await fs.readFile(configPath, 'utf8')
121
+ const exportNames = getNamedCollectionExports(source)
122
+ if (exportNames.length === 0) {
123
+ return ''
124
+ }
125
+ return exportNames
126
+ .map(
127
+ (name) =>
128
+ `export const ${name} = Object.freeze({ ${JSON.stringify(CONTENT_COLLECTION_MARKER)}: true });`,
129
+ )
130
+ .join('\n')
131
+ }
132
+
133
+ const createRuntimeModule = (root: string, configPath: string) => `
134
+ import * as collectionsModule from ${JSON.stringify(normalizeSlashes(configPath))};
135
+ import { createContentRuntime } from '@eclipsa/content/internal';
136
+
137
+ const runtime = createContentRuntime({
138
+ collectionsModule,
139
+ configPath: ${JSON.stringify(normalizeSlashes(configPath))},
140
+ root: ${JSON.stringify(normalizeSlashes(root))},
141
+ });
142
+
143
+ export const getCollection = runtime.getCollection;
144
+ export const getEntries = runtime.getEntries;
145
+ export const getEntry = runtime.getEntry;
146
+ export const render = runtime.render;
147
+ `
148
+
149
+ const loadCollectionsModule = async (configPath: string) => {
150
+ const href = pathToFileURL(configPath).href
151
+ return import(`${href}?t=${Date.now()}`)
152
+ }
153
+
154
+ const handleInvalidation = (server: ViteDevServer, root: string, filePath: string) => {
155
+ if (!shouldInvalidateForFile(root, filePath)) {
156
+ return false
157
+ }
158
+ invalidateVirtualRuntime(server)
159
+ invalidateRegisteredDevApps(server)
160
+ ;(
161
+ server as ViteDevServer & { ws?: { send?: (event: string, payload?: unknown) => void } }
162
+ ).ws?.send?.(CONTENT_HMR_EVENT)
163
+ return true
164
+ }
165
+
166
+ const contentPlugin = (): Plugin => {
167
+ let config: ResolvedConfig
168
+ let searchStatePromise: Promise<{
169
+ indexJson: string
170
+ options: ResolvedContentSearchOptions
171
+ } | null> | null = null
172
+
173
+ const resolveSearchState = async () => {
174
+ if (searchStatePromise) {
175
+ return searchStatePromise
176
+ }
177
+ searchStatePromise = (async () => {
178
+ const configPath = getConfigPath(config.root)
179
+ if (!(await fileExists(configPath))) {
180
+ return null
181
+ }
182
+ const collectionsModule = await loadCollectionsModule(configPath)
183
+ const result = await createContentSearch({
184
+ base: config.base,
185
+ collectionsModule,
186
+ configPath,
187
+ root: config.root,
188
+ })
189
+ if (!result.options.enabled || result.index.documents.length === 0) {
190
+ return null
191
+ }
192
+ return {
193
+ indexJson: JSON.stringify(result.index),
194
+ options: result.options,
195
+ }
196
+ })()
197
+ return searchStatePromise
198
+ }
199
+
200
+ return {
201
+ enforce: 'pre',
202
+ name: 'vite-plugin-eclipsa-content',
203
+ configResolved(resolvedConfig) {
204
+ config = resolvedConfig
205
+ },
206
+ configureServer(server) {
207
+ const searchPath = getSearchAssetPath(config.base)
208
+ server.middlewares.use(async (req, res, next) => {
209
+ const requestPath = req.url?.split('?', 1)[0] ?? ''
210
+ if (requestPath !== searchPath) {
211
+ next()
212
+ return
213
+ }
214
+ const state = await resolveSearchState()
215
+ if (!state) {
216
+ res.statusCode = 404
217
+ res.end('Not found')
218
+ return
219
+ }
220
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
221
+ res.end(state.indexJson)
222
+ })
223
+ },
224
+ hotUpdate(options) {
225
+ searchStatePromise = null
226
+ if (handleInvalidation(options.server, config.root, options.file)) {
227
+ return []
228
+ }
229
+ },
230
+ resolveId(id) {
231
+ if (id === VIRTUAL_RUNTIME_ID) {
232
+ return RESOLVED_VIRTUAL_RUNTIME_ID
233
+ }
234
+ if (id === VIRTUAL_SEARCH_ID) {
235
+ return RESOLVED_VIRTUAL_SEARCH_ID
236
+ }
237
+ return null
238
+ },
239
+ async load(id) {
240
+ if (id === RESOLVED_VIRTUAL_SEARCH_ID) {
241
+ const state = await resolveSearchState()
242
+ if (!state) {
243
+ return createDisabledSearchModule()
244
+ }
245
+ return generateContentSearchRuntimeModule(getSearchAssetPath(config.base), state.options)
246
+ }
247
+ if (id !== RESOLVED_VIRTUAL_RUNTIME_ID) {
248
+ if (this.environment?.name === 'client' && isContentConfigId(config.root, id)) {
249
+ return createClientContentConfigModule(getConfigPath(config.root))
250
+ }
251
+ return null
252
+ }
253
+ if (this.environment?.name === 'client') {
254
+ return createClientRuntimeModule()
255
+ }
256
+ const configPath = getConfigPath(config.root)
257
+ if (!(await fileExists(configPath))) {
258
+ return createMissingRuntimeModule(config.root)
259
+ }
260
+ return createRuntimeModule(config.root, configPath)
261
+ },
262
+ async generateBundle() {
263
+ const state = await resolveSearchState()
264
+ if (!state) {
265
+ return
266
+ }
267
+ this.emitFile({
268
+ fileName: CONTENT_SEARCH_ASSET,
269
+ source: state.indexJson,
270
+ type: 'asset',
271
+ })
272
+ },
273
+ }
274
+ }
275
+
276
+ export const eclipsaContent = (): PluginOption => [contentPlugin()]