@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/types.ts ADDED
@@ -0,0 +1,172 @@
1
+ import type { InferStandardSchemaOutput, StandardSchemaV1 } from 'eclipsa'
2
+
3
+ export const CONTENT_COLLECTION_MARKER = '__eclipsa_content_collection__'
4
+
5
+ export interface ContentSourceEntry {
6
+ body: string
7
+ data?: Record<string, unknown>
8
+ filePath?: string
9
+ id?: string
10
+ }
11
+
12
+ export interface ContentLoaderContext {
13
+ collection: string
14
+ configPath: string
15
+ root: string
16
+ }
17
+
18
+ export interface ContentLoaderObject {
19
+ load(
20
+ context: ContentLoaderContext,
21
+ ): ContentSourceEntry[] | Promise<ContentSourceEntry[]> | readonly ContentSourceEntry[]
22
+ }
23
+
24
+ export interface ContentHighlightOptions {
25
+ theme?: string
26
+ }
27
+
28
+ export interface ContentMarkdownOptions {
29
+ highlight?: boolean | ContentHighlightOptions
30
+ }
31
+
32
+ export interface ContentSearchOptions {
33
+ enabled?: boolean
34
+ hotkey?: string
35
+ limit?: number
36
+ placeholder?: string
37
+ prefix?: boolean
38
+ }
39
+
40
+ export interface ResolvedContentSearchOptions {
41
+ enabled: boolean
42
+ hotkey: string
43
+ limit: number
44
+ placeholder: string
45
+ prefix: boolean
46
+ }
47
+
48
+ export type ContentSearchField = 'body' | 'code' | 'heading' | 'title'
49
+
50
+ export interface ContentSearchDocument {
51
+ body: string
52
+ code: string[]
53
+ collection: string
54
+ headings: string[]
55
+ id: string
56
+ title: string
57
+ url: string
58
+ }
59
+
60
+ export interface ContentSearchPosting {
61
+ docIdx: number
62
+ field: ContentSearchField
63
+ tf: number
64
+ }
65
+
66
+ export interface ContentSearchIndex {
67
+ avgDl: number
68
+ df: Record<string, number>
69
+ docCount: number
70
+ documents: ContentSearchDocument[]
71
+ index: Record<string, ContentSearchPosting[]>
72
+ options: ResolvedContentSearchOptions
73
+ }
74
+
75
+ export interface ContentSearchQueryOptions {
76
+ limit?: number
77
+ prefix?: boolean
78
+ }
79
+
80
+ export interface ContentSearchResult {
81
+ collection: string
82
+ id: string
83
+ matches: string[]
84
+ score: number
85
+ snippet: string
86
+ title: string
87
+ url: string
88
+ }
89
+
90
+ export interface GlobLoaderOptions {
91
+ base: string
92
+ pattern: string
93
+ }
94
+
95
+ export interface GlobLoader {
96
+ readonly base: string
97
+ readonly kind: 'glob'
98
+ readonly pattern: string
99
+ }
100
+
101
+ export type ContentLoader = GlobLoader | ContentLoaderObject
102
+
103
+ export interface ContentCollectionDefinition<
104
+ Schema extends StandardSchemaV1<any, any> | undefined,
105
+ > {
106
+ loader: ContentLoader
107
+ markdown?: ContentMarkdownOptions
108
+ search?: boolean | ContentSearchOptions
109
+ schema?: Schema
110
+ }
111
+
112
+ export interface DefinedCollection<
113
+ Schema extends StandardSchemaV1<any, any> | undefined = StandardSchemaV1<any, any> | undefined,
114
+ > extends ContentCollectionDefinition<Schema> {
115
+ readonly [CONTENT_COLLECTION_MARKER]: true
116
+ }
117
+
118
+ export type AnyCollection = DefinedCollection<StandardSchemaV1<any, any> | undefined>
119
+
120
+ export type InferCollectionData<Collection extends AnyCollection> =
121
+ Collection extends DefinedCollection<infer Schema>
122
+ ? Schema extends StandardSchemaV1<any, any>
123
+ ? InferStandardSchemaOutput<Schema>
124
+ : Record<string, unknown>
125
+ : Record<string, unknown>
126
+
127
+ export interface BaseContentEntry<
128
+ Data = Record<string, unknown>,
129
+ Collection extends string = string,
130
+ > {
131
+ body: string
132
+ collection: Collection
133
+ data: Data
134
+ filePath: string
135
+ id: string
136
+ }
137
+
138
+ export interface ContentHeading {
139
+ depth: number
140
+ slug: string
141
+ text: string
142
+ }
143
+
144
+ export interface ContentComponentProps extends Record<string, unknown> {
145
+ as?: string
146
+ html: string
147
+ }
148
+
149
+ export interface RenderedContent {
150
+ Content: (props?: Omit<ContentComponentProps, 'html'>) => any
151
+ headings: ContentHeading[]
152
+ html: string
153
+ }
154
+
155
+ export type CollectionEntry<Collection extends AnyCollection = AnyCollection> = BaseContentEntry<
156
+ InferCollectionData<Collection>
157
+ >
158
+
159
+ export type ContentFilter<Collection extends AnyCollection> = (
160
+ entry: CollectionEntry<Collection>,
161
+ ) => boolean | Promise<boolean>
162
+
163
+ export interface ContentEntryReference<Collection extends AnyCollection = AnyCollection> {
164
+ collection: Collection
165
+ id: string
166
+ }
167
+
168
+ export type ResolvedContentEntries<Entries extends readonly ContentEntryReference<any>[]> = {
169
+ [Index in keyof Entries]: Entries[Index] extends ContentEntryReference<infer Collection>
170
+ ? CollectionEntry<Collection> | undefined
171
+ : never
172
+ }
@@ -0,0 +1,24 @@
1
+ declare module 'virtual:eclipsa-content:runtime' {
2
+ export function getCollection<Collection extends import('./types.ts').AnyCollection>(
3
+ collection: Collection,
4
+ filter?: import('./mod.ts').ContentFilter<Collection>,
5
+ ): Promise<import('./mod.ts').CollectionEntry<Collection>[]>
6
+ export function getEntries<
7
+ Entries extends readonly import('./types.ts').ContentEntryReference<any>[],
8
+ >(entries: Entries): Promise<import('./types.ts').ResolvedContentEntries<Entries>>
9
+ export function getEntry<Collection extends import('./types.ts').AnyCollection>(
10
+ collection: Collection,
11
+ id: string,
12
+ ): Promise<import('./mod.ts').CollectionEntry<Collection> | undefined>
13
+ export function render<Collection extends import('./types.ts').AnyCollection>(
14
+ entry: import('./mod.ts').CollectionEntry<Collection>,
15
+ ): Promise<import('./types.ts').RenderedContent>
16
+ }
17
+
18
+ declare module 'virtual:eclipsa-content:search' {
19
+ export const searchOptions: import('./types.ts').ResolvedContentSearchOptions
20
+ export function search(
21
+ query: string,
22
+ options?: import('./types.ts').ContentSearchQueryOptions,
23
+ ): Promise<import('./types.ts').ContentSearchResult[]>
24
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import config from './vite.config.ts'
3
+
4
+ describe('@eclipsa/content vite pack config', () => {
5
+ it('builds every published entrypoint with declarations', () => {
6
+ expect(config.pack).toMatchObject({
7
+ clean: true,
8
+ copy: ['virtual-runtime.d.ts'],
9
+ dts: true,
10
+ entry: ['mod.ts', 'vite.ts', 'internal.ts'],
11
+ format: ['esm'],
12
+ sourcemap: true,
13
+ })
14
+ })
15
+ })
package/vite.config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vite-plus'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['*.test.ts', '*.test.tsx'],
6
+ environment: 'node',
7
+ },
8
+ pack: {
9
+ copy: ['virtual-runtime.d.ts'],
10
+ entry: ['mod.ts', 'vite.ts', 'internal.ts'],
11
+ dts: true,
12
+ format: ['esm'],
13
+ clean: true,
14
+ sourcemap: true,
15
+ },
16
+ })
package/vite.test.ts ADDED
@@ -0,0 +1,283 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import type { Plugin } from 'vite'
6
+ import { eclipsaContent } from './vite.ts'
7
+
8
+ const DEV_APP_INVALIDATORS_KEY = Symbol.for('eclipsa.dev-app-invalidators')
9
+ const createdRoots: string[] = []
10
+ const contentEntryImportPath = JSON.stringify(path.resolve(__dirname, 'mod.ts'))
11
+
12
+ const createTempRoot = async () => {
13
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'eclipsa-content-vite-'))
14
+ createdRoots.push(root)
15
+ await fs.mkdir(path.join(root, 'app'), {
16
+ recursive: true,
17
+ })
18
+ return root
19
+ }
20
+
21
+ const getPlugin = (): Plugin => {
22
+ const plugin = eclipsaContent()
23
+ if (!Array.isArray(plugin)) {
24
+ throw new Error('Expected eclipsaContent() to return a plugin array')
25
+ }
26
+ return plugin[0] as Plugin
27
+ }
28
+
29
+ const getHotUpdate = (plugin: Plugin) => {
30
+ const hook = plugin.hotUpdate
31
+ if (typeof hook === 'function') {
32
+ return hook
33
+ }
34
+ return hook?.handler
35
+ }
36
+
37
+ afterEach(async () => {
38
+ await Promise.all(
39
+ createdRoots.splice(0).map((root) => fs.rm(root, { force: true, recursive: true })),
40
+ )
41
+ })
42
+
43
+ describe('@eclipsa/content vite plugin', () => {
44
+ it('returns a failing runtime module when content config is missing', async () => {
45
+ const root = await createTempRoot()
46
+ const plugin = getPlugin()
47
+ const configResolved =
48
+ typeof plugin.configResolved === 'function'
49
+ ? plugin.configResolved
50
+ : plugin.configResolved?.handler
51
+ await configResolved?.call(
52
+ {} as any,
53
+ {
54
+ root,
55
+ } as any,
56
+ )
57
+
58
+ const load = typeof plugin.load === 'function' ? plugin.load : plugin.load?.handler
59
+ const code = await load?.call({} as any, '\0eclipsa-content:runtime')
60
+
61
+ expect(code).toContain('Missing app/content.config.ts')
62
+ })
63
+
64
+ it('builds a runtime module that imports the app content config', async () => {
65
+ const root = await createTempRoot()
66
+ await fs.writeFile(path.join(root, 'app', 'content.config.ts'), 'export const docs = {}')
67
+ const plugin = getPlugin()
68
+ const configResolved =
69
+ typeof plugin.configResolved === 'function'
70
+ ? plugin.configResolved
71
+ : plugin.configResolved?.handler
72
+ await configResolved?.call(
73
+ {} as any,
74
+ {
75
+ root,
76
+ } as any,
77
+ )
78
+
79
+ const load = typeof plugin.load === 'function' ? plugin.load : plugin.load?.handler
80
+ const code = await load?.call({} as any, '\0eclipsa-content:runtime')
81
+
82
+ expect(code).toContain('@eclipsa/content/internal')
83
+ expect(code).toContain('app/content.config.ts')
84
+ })
85
+
86
+ it('builds a search runtime module when search is enabled', async () => {
87
+ const root = await createTempRoot()
88
+ await fs.mkdir(path.join(root, 'app', 'content', 'docs'), {
89
+ recursive: true,
90
+ })
91
+ await fs.writeFile(
92
+ path.join(root, 'app', 'content.config.ts'),
93
+ `
94
+ import { defineCollection, glob } from ${contentEntryImportPath}
95
+
96
+ export const docs = defineCollection({
97
+ loader: glob({
98
+ base: './content/docs',
99
+ pattern: '**/*.md',
100
+ }),
101
+ search: {
102
+ placeholder: 'Search docs',
103
+ },
104
+ })
105
+ `,
106
+ )
107
+ await fs.writeFile(
108
+ path.join(root, 'app', 'content', 'docs', 'page.md'),
109
+ `---
110
+ title: Search Page
111
+ ---
112
+ # Search Page
113
+
114
+ Find the comet needle.
115
+ `,
116
+ )
117
+
118
+ const plugin = getPlugin()
119
+ const configResolved =
120
+ typeof plugin.configResolved === 'function'
121
+ ? plugin.configResolved
122
+ : plugin.configResolved?.handler
123
+ await configResolved?.call(
124
+ {} as any,
125
+ {
126
+ base: '/',
127
+ root,
128
+ } as any,
129
+ )
130
+
131
+ const load = typeof plugin.load === 'function' ? plugin.load : plugin.load?.handler
132
+ const code = await load?.call(
133
+ { environment: { name: 'client' } } as any,
134
+ '\0eclipsa-content:search',
135
+ )
136
+
137
+ expect(code).toContain('__eclipsa_content_search__.json')
138
+ expect(code).toContain('searchOptions')
139
+ expect(code).not.toContain('import type')
140
+ expect(code).not.toContain(': Promise<')
141
+ })
142
+
143
+ it('stubs content config collections in the client environment', async () => {
144
+ const root = await createTempRoot()
145
+ const configPath = path.join(root, 'app', 'content.config.ts')
146
+ await fs.writeFile(
147
+ configPath,
148
+ `
149
+ import { defineCollection } from '@eclipsa/content'
150
+ import { z } from 'zod'
151
+
152
+ export const docs = defineCollection({ loader: { load: () => [] }, schema: z.object({ title: z.string() }) })
153
+ export const posts = defineCollection({ loader: { load: () => [] } })
154
+ `,
155
+ )
156
+ const plugin = getPlugin()
157
+ const configResolved =
158
+ typeof plugin.configResolved === 'function'
159
+ ? plugin.configResolved
160
+ : plugin.configResolved?.handler
161
+ await configResolved?.call(
162
+ {} as any,
163
+ {
164
+ root,
165
+ } as any,
166
+ )
167
+
168
+ const load = typeof plugin.load === 'function' ? plugin.load : plugin.load?.handler
169
+ const code = await load?.call(
170
+ {
171
+ environment: {
172
+ name: 'client',
173
+ },
174
+ } as any,
175
+ configPath,
176
+ )
177
+
178
+ expect(code).toContain('export const docs = Object.freeze')
179
+ expect(code).toContain('export const posts = Object.freeze')
180
+ expect(code).not.toContain('zod')
181
+ })
182
+
183
+ it('invalidates registered dev apps and emits a content HMR event for markdown changes', async () => {
184
+ const root = await createTempRoot()
185
+ const plugin = getPlugin()
186
+ const configResolved =
187
+ typeof plugin.configResolved === 'function'
188
+ ? plugin.configResolved
189
+ : plugin.configResolved?.handler
190
+ await configResolved?.call(
191
+ {} as any,
192
+ {
193
+ root,
194
+ } as any,
195
+ )
196
+
197
+ const invalidate = vi.fn()
198
+ const send = vi.fn()
199
+ const hotUpdate = getHotUpdate(plugin)
200
+
201
+ const result = await hotUpdate?.call(
202
+ {} as any,
203
+ {
204
+ file: path.join(root, 'app', 'content', 'docs', 'guide', 'page.md'),
205
+ modules: [],
206
+ read: () => '',
207
+ server: {
208
+ [DEV_APP_INVALIDATORS_KEY]: new Set([invalidate]),
209
+ environments: {
210
+ ssr: {
211
+ moduleGraph: {
212
+ getModuleById: vi.fn(),
213
+ invalidateModule: vi.fn(),
214
+ },
215
+ },
216
+ },
217
+ ws: {
218
+ send,
219
+ },
220
+ },
221
+ timestamp: Date.now(),
222
+ type: 'update',
223
+ } as any,
224
+ )
225
+
226
+ expect(invalidate).toHaveBeenCalledTimes(1)
227
+ expect(send).toHaveBeenCalledWith('eclipsa:content-update')
228
+ expect(result).toEqual([])
229
+ })
230
+
231
+ it('emits a search index asset during bundle generation when search is enabled', async () => {
232
+ const root = await createTempRoot()
233
+ await fs.mkdir(path.join(root, 'app', 'content', 'docs'), {
234
+ recursive: true,
235
+ })
236
+ await fs.writeFile(
237
+ path.join(root, 'app', 'content.config.ts'),
238
+ `
239
+ import { defineCollection, glob } from ${contentEntryImportPath}
240
+
241
+ export const docs = defineCollection({
242
+ loader: glob({
243
+ base: './content/docs',
244
+ pattern: '**/*.md',
245
+ }),
246
+ search: true,
247
+ })
248
+ `,
249
+ )
250
+ await fs.writeFile(
251
+ path.join(root, 'app', 'content', 'docs', 'page.md'),
252
+ '# Search Asset\n\nMeteor shard token.',
253
+ )
254
+
255
+ const plugin = getPlugin()
256
+ const configResolved =
257
+ typeof plugin.configResolved === 'function'
258
+ ? plugin.configResolved
259
+ : plugin.configResolved?.handler
260
+ await configResolved?.call(
261
+ {} as any,
262
+ {
263
+ base: '/',
264
+ root,
265
+ } as any,
266
+ )
267
+
268
+ const emitFile = vi.fn()
269
+ const generateBundle =
270
+ typeof plugin.generateBundle === 'function'
271
+ ? plugin.generateBundle
272
+ : plugin.generateBundle?.handler
273
+
274
+ await generateBundle?.call({ emitFile } as any, {} as any, {} as any, false)
275
+
276
+ expect(emitFile).toHaveBeenCalledWith(
277
+ expect.objectContaining({
278
+ fileName: '__eclipsa_content_search__.json',
279
+ type: 'asset',
280
+ }),
281
+ )
282
+ })
283
+ })