@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/.turbo/turbo-build.log +34 -0
- package/.turbo/turbo-test.log +13 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/dist/internal-h0upzIHm.mjs +644 -0
- package/dist/internal-h0upzIHm.mjs.map +1 -0
- package/dist/internal.d.mts +47 -0
- package/dist/internal.mjs +2 -0
- package/dist/mod-P8gKoDsz.d.mts +151 -0
- package/dist/mod.d.mts +2 -0
- package/dist/mod.mjs +34 -0
- package/dist/mod.mjs.map +1 -0
- package/dist/package.json +40 -0
- package/dist/types-rZ-wc23p.mjs +6 -0
- package/dist/types-rZ-wc23p.mjs.map +1 -0
- package/dist/virtual-runtime.d.ts +24 -0
- package/dist/vite.d.mts +7 -0
- package/dist/vite.mjs +195 -0
- package/dist/vite.mjs.map +1 -0
- package/highlight.ts +125 -0
- package/internal.test.ts +263 -0
- package/internal.ts +514 -0
- package/mod.ts +124 -0
- package/package.json +62 -0
- package/search.test.ts +56 -0
- package/search.ts +450 -0
- package/typecheck.ts +103 -0
- package/types.ts +172 -0
- package/virtual-runtime.d.ts +24 -0
- package/vite-config.test.ts +15 -0
- package/vite.config.ts +16 -0
- package/vite.test.ts +283 -0
- package/vite.ts +276 -0
package/internal.ts
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import fg from 'fast-glob'
|
|
2
|
+
import * as fs from 'node:fs/promises'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import YAML from 'yaml'
|
|
6
|
+
import type { StandardSchemaIssue, StandardSchemaV1 } from 'eclipsa'
|
|
7
|
+
import { highlightHtml } from './highlight.ts'
|
|
8
|
+
import { buildContentSearchIndex, resolveContentSearchOptions } from './search.ts'
|
|
9
|
+
import type { CollectionEntry, ContentFilter, ContentRuntimeModule } from './mod.ts'
|
|
10
|
+
import { CONTENT_COLLECTION_MARKER } from './types.ts'
|
|
11
|
+
import type {
|
|
12
|
+
AnyCollection,
|
|
13
|
+
BaseContentEntry,
|
|
14
|
+
ContentMarkdownOptions,
|
|
15
|
+
ContentComponentProps,
|
|
16
|
+
ContentEntryReference,
|
|
17
|
+
ContentHeading,
|
|
18
|
+
ContentLoader,
|
|
19
|
+
ContentLoaderContext,
|
|
20
|
+
ContentLoaderObject,
|
|
21
|
+
ContentSearchDocument,
|
|
22
|
+
ContentSearchIndex,
|
|
23
|
+
ResolvedContentSearchOptions,
|
|
24
|
+
ContentSourceEntry,
|
|
25
|
+
DefinedCollection,
|
|
26
|
+
GlobLoader,
|
|
27
|
+
RenderedContent,
|
|
28
|
+
ResolvedContentEntries,
|
|
29
|
+
} from './types.ts'
|
|
30
|
+
|
|
31
|
+
interface ParsedFrontmatter {
|
|
32
|
+
body: string
|
|
33
|
+
data: Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ResolvedManifest {
|
|
37
|
+
collections: Map<AnyCollection, BaseContentEntry[]>
|
|
38
|
+
markdownByCollectionName: Map<string, ContentMarkdownOptions | undefined>
|
|
39
|
+
searchByCollectionName: Map<string, ResolvedContentSearchOptions>
|
|
40
|
+
entriesByCollection: Map<AnyCollection, Map<string, BaseContentEntry>>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CreateContentRuntimeOptions {
|
|
44
|
+
collectionsModule: Record<string, unknown>
|
|
45
|
+
configPath: string
|
|
46
|
+
root: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MARKDOWN_EXTENSION_RE = /\.md$/i
|
|
50
|
+
const require = createRequire(import.meta.url)
|
|
51
|
+
let markdownTransform: typeof import('@ox-content/napi').transform | null = null
|
|
52
|
+
|
|
53
|
+
export class ContentCollectionError extends Error {
|
|
54
|
+
constructor(message: string) {
|
|
55
|
+
super(message)
|
|
56
|
+
this.name = 'ContentCollectionError'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const normalizeSlashes = (value: string) => value.replaceAll('\\', '/')
|
|
61
|
+
|
|
62
|
+
const formatIssuePath = (pathValue: StandardSchemaIssue['path']) => {
|
|
63
|
+
if (!pathValue || pathValue.length === 0) {
|
|
64
|
+
return ''
|
|
65
|
+
}
|
|
66
|
+
return pathValue
|
|
67
|
+
.map((segment) =>
|
|
68
|
+
typeof segment === 'object' && segment !== null && 'key' in segment
|
|
69
|
+
? String(segment.key)
|
|
70
|
+
: String(segment),
|
|
71
|
+
)
|
|
72
|
+
.join('.')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const createSchemaError = (
|
|
76
|
+
collection: string,
|
|
77
|
+
filePath: string,
|
|
78
|
+
issues: readonly StandardSchemaIssue[],
|
|
79
|
+
) => {
|
|
80
|
+
const detail = issues
|
|
81
|
+
.map((issue) => {
|
|
82
|
+
const issuePath = formatIssuePath(issue.path)
|
|
83
|
+
return issuePath === '' ? issue.message : `${issuePath}: ${issue.message}`
|
|
84
|
+
})
|
|
85
|
+
.join('; ')
|
|
86
|
+
return new ContentCollectionError(
|
|
87
|
+
`Invalid frontmatter in collection "${collection}" for ${filePath}: ${detail}`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const parseFrontmatter = (source: string): ParsedFrontmatter => {
|
|
92
|
+
if (!source.startsWith('---')) {
|
|
93
|
+
return {
|
|
94
|
+
body: source,
|
|
95
|
+
data: {},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u.exec(source)
|
|
99
|
+
if (!match) {
|
|
100
|
+
return {
|
|
101
|
+
body: source,
|
|
102
|
+
data: {},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const raw = YAML.parse(match[1] ?? '')
|
|
106
|
+
if (raw == null) {
|
|
107
|
+
return {
|
|
108
|
+
body: source.slice(match[0].length),
|
|
109
|
+
data: {},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
113
|
+
throw new ContentCollectionError('Markdown frontmatter must resolve to an object.')
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
body: source.slice(match[0].length),
|
|
117
|
+
data: { ...(raw as Record<string, unknown>) },
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const normalizeIdSegment = (segment: string) =>
|
|
122
|
+
segment
|
|
123
|
+
.trim()
|
|
124
|
+
.replaceAll(/\s+/g, '-')
|
|
125
|
+
.replaceAll(/[^a-zA-Z0-9/_-]+/g, '-')
|
|
126
|
+
.replaceAll(/-+/g, '-')
|
|
127
|
+
.replaceAll(/^[-/]+|[-/]+$/g, '')
|
|
128
|
+
|
|
129
|
+
const normalizeEntryId = (value: string) =>
|
|
130
|
+
normalizeSlashes(value).split('/').map(normalizeIdSegment).filter(Boolean).join('/')
|
|
131
|
+
|
|
132
|
+
const toEntryIdFromRelativePath = (relativePath: string) => {
|
|
133
|
+
const withoutExt = normalizeSlashes(relativePath).replace(MARKDOWN_EXTENSION_RE, '')
|
|
134
|
+
const segments = withoutExt.split('/').filter(Boolean)
|
|
135
|
+
if (segments[segments.length - 1] === 'index' && segments.length > 1) {
|
|
136
|
+
segments.pop()
|
|
137
|
+
}
|
|
138
|
+
return normalizeEntryId(segments.join('/')) || 'index'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const validateData = async (
|
|
142
|
+
collection: string,
|
|
143
|
+
schema: StandardSchemaV1<any, any> | undefined,
|
|
144
|
+
filePath: string,
|
|
145
|
+
data: Record<string, unknown>,
|
|
146
|
+
) => {
|
|
147
|
+
if (!schema) {
|
|
148
|
+
return data
|
|
149
|
+
}
|
|
150
|
+
const result = await schema['~standard'].validate(data)
|
|
151
|
+
if ('issues' in result && result.issues !== undefined) {
|
|
152
|
+
throw createSchemaError(collection, filePath, result.issues)
|
|
153
|
+
}
|
|
154
|
+
return result.value as Record<string, unknown>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const resolveGlobLoaderEntries = async (
|
|
158
|
+
collection: string,
|
|
159
|
+
loader: GlobLoader,
|
|
160
|
+
context: ContentLoaderContext,
|
|
161
|
+
): Promise<ContentSourceEntry[]> => {
|
|
162
|
+
const baseDir = path.resolve(path.dirname(context.configPath), loader.base)
|
|
163
|
+
const matches = await fg(loader.pattern, {
|
|
164
|
+
absolute: true,
|
|
165
|
+
cwd: baseDir,
|
|
166
|
+
onlyFiles: true,
|
|
167
|
+
})
|
|
168
|
+
return Promise.all(
|
|
169
|
+
matches.map(async (filePath) => {
|
|
170
|
+
const source = await fs.readFile(filePath, 'utf8')
|
|
171
|
+
const relativePath = normalizeSlashes(path.relative(baseDir, filePath))
|
|
172
|
+
const parsed = parseFrontmatter(source)
|
|
173
|
+
const slug = typeof parsed.data.slug === 'string' ? parsed.data.slug : undefined
|
|
174
|
+
delete parsed.data.slug
|
|
175
|
+
return {
|
|
176
|
+
body: parsed.body,
|
|
177
|
+
data: parsed.data,
|
|
178
|
+
filePath,
|
|
179
|
+
id: slug ? normalizeEntryId(slug) : toEntryIdFromRelativePath(relativePath),
|
|
180
|
+
} satisfies ContentSourceEntry
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const resolveLoaderEntries = async (
|
|
186
|
+
collection: string,
|
|
187
|
+
loader: ContentLoader,
|
|
188
|
+
context: ContentLoaderContext,
|
|
189
|
+
) => {
|
|
190
|
+
if ((loader as GlobLoader).kind === 'glob') {
|
|
191
|
+
return resolveGlobLoaderEntries(collection, loader as GlobLoader, context)
|
|
192
|
+
}
|
|
193
|
+
return [...(await (loader as ContentLoaderObject).load(context))]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const normalizeResolvedEntry = async (
|
|
197
|
+
collection: string,
|
|
198
|
+
schema: StandardSchemaV1<any, any> | undefined,
|
|
199
|
+
entry: ContentSourceEntry,
|
|
200
|
+
index: number,
|
|
201
|
+
) => {
|
|
202
|
+
const parsed =
|
|
203
|
+
entry.data === undefined
|
|
204
|
+
? parseFrontmatter(entry.body)
|
|
205
|
+
: {
|
|
206
|
+
body: entry.body,
|
|
207
|
+
data: { ...entry.data },
|
|
208
|
+
}
|
|
209
|
+
const filePath = entry.filePath ?? `${collection}:${entry.id ?? index}`
|
|
210
|
+
const slug = typeof parsed.data.slug === 'string' ? parsed.data.slug : undefined
|
|
211
|
+
delete parsed.data.slug
|
|
212
|
+
const id =
|
|
213
|
+
normalizeEntryId(entry.id ?? slug ?? `${collection}-${index}`) || `${collection}-${index}`
|
|
214
|
+
return {
|
|
215
|
+
body: parsed.body,
|
|
216
|
+
collection,
|
|
217
|
+
data: await validateData(collection, schema, filePath, parsed.data),
|
|
218
|
+
filePath,
|
|
219
|
+
id,
|
|
220
|
+
} satisfies BaseContentEntry
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const isDefinedCollection = (value: unknown): value is DefinedCollection<any> =>
|
|
224
|
+
typeof value === 'object' &&
|
|
225
|
+
value !== null &&
|
|
226
|
+
CONTENT_COLLECTION_MARKER in value &&
|
|
227
|
+
(value as Record<string, unknown>)[CONTENT_COLLECTION_MARKER] === true
|
|
228
|
+
|
|
229
|
+
export const resolveCollections = async ({
|
|
230
|
+
collectionsModule,
|
|
231
|
+
configPath,
|
|
232
|
+
root,
|
|
233
|
+
}: CreateContentRuntimeOptions): Promise<ResolvedManifest> => {
|
|
234
|
+
const byCollection = new Map<AnyCollection, BaseContentEntry[]>()
|
|
235
|
+
const entriesByCollection = new Map<AnyCollection, Map<string, BaseContentEntry>>()
|
|
236
|
+
const markdownByCollectionName = new Map<string, ContentMarkdownOptions | undefined>()
|
|
237
|
+
const searchByCollectionName = new Map<string, ResolvedContentSearchOptions>()
|
|
238
|
+
const definedCollections = Object.entries(collectionsModule).filter(
|
|
239
|
+
(entry): entry is [string, DefinedCollection<any>] => isDefinedCollection(entry[1]),
|
|
240
|
+
)
|
|
241
|
+
for (const [collectionName, definition] of definedCollections) {
|
|
242
|
+
const context: ContentLoaderContext = {
|
|
243
|
+
collection: collectionName,
|
|
244
|
+
configPath,
|
|
245
|
+
root,
|
|
246
|
+
}
|
|
247
|
+
const rawEntries = await resolveLoaderEntries(collectionName, definition.loader, context)
|
|
248
|
+
const resolvedEntries = await Promise.all(
|
|
249
|
+
rawEntries.map((entry, index) =>
|
|
250
|
+
normalizeResolvedEntry(collectionName, definition.schema, entry, index),
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
resolvedEntries.sort((left, right) => left.id.localeCompare(right.id))
|
|
254
|
+
const entriesById = new Map<string, BaseContentEntry>()
|
|
255
|
+
for (const entry of resolvedEntries) {
|
|
256
|
+
if (entriesById.has(entry.id)) {
|
|
257
|
+
throw new ContentCollectionError(
|
|
258
|
+
`Duplicate content id "${entry.id}" in collection "${collectionName}".`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
entriesById.set(entry.id, entry)
|
|
262
|
+
}
|
|
263
|
+
byCollection.set(definition, resolvedEntries)
|
|
264
|
+
entriesByCollection.set(definition, entriesById)
|
|
265
|
+
markdownByCollectionName.set(collectionName, definition.markdown)
|
|
266
|
+
searchByCollectionName.set(collectionName, resolveContentSearchOptions(definition.search))
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
collections: byCollection,
|
|
270
|
+
markdownByCollectionName,
|
|
271
|
+
searchByCollectionName,
|
|
272
|
+
entriesByCollection,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const createContentRenderer =
|
|
277
|
+
(html: string) =>
|
|
278
|
+
(props: Omit<ContentComponentProps, 'html'> = {}) => ({
|
|
279
|
+
isStatic: false,
|
|
280
|
+
props: {
|
|
281
|
+
...props,
|
|
282
|
+
dangerouslySetInnerHTML: html,
|
|
283
|
+
},
|
|
284
|
+
type: props.as ?? 'article',
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const resolveOxContentNapiPath = () => {
|
|
288
|
+
const resolvePaths = [
|
|
289
|
+
process.cwd(),
|
|
290
|
+
path.join(process.cwd(), 'node_modules', '@eclipsa', 'content'),
|
|
291
|
+
]
|
|
292
|
+
try {
|
|
293
|
+
return require.resolve('@ox-content/napi', { paths: resolvePaths })
|
|
294
|
+
} catch {
|
|
295
|
+
return '@ox-content/napi'
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const loadMarkdownTransform = async () => {
|
|
300
|
+
markdownTransform ??= (require(resolveOxContentNapiPath()) as typeof import('@ox-content/napi'))
|
|
301
|
+
.transform
|
|
302
|
+
return markdownTransform
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const decodeHtmlEntities = (value: string) =>
|
|
306
|
+
value
|
|
307
|
+
.replaceAll('&', '&')
|
|
308
|
+
.replaceAll('<', '<')
|
|
309
|
+
.replaceAll('>', '>')
|
|
310
|
+
.replaceAll('"', '"')
|
|
311
|
+
.replaceAll(''', "'")
|
|
312
|
+
.replaceAll(' ', ' ')
|
|
313
|
+
|
|
314
|
+
const stripHtml = (html: string) =>
|
|
315
|
+
decodeHtmlEntities(
|
|
316
|
+
html
|
|
317
|
+
.replaceAll(/<style[\s\S]*?<\/style>/gu, ' ')
|
|
318
|
+
.replaceAll(/<script[\s\S]*?<\/script>/gu, ' ')
|
|
319
|
+
.replaceAll(/<[^>]+>/gu, ' ')
|
|
320
|
+
.replaceAll(/\s+/gu, ' ')
|
|
321
|
+
.trim(),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const extractMarkdownCode = (source: string) => {
|
|
325
|
+
const codeBlocks = new Set<string>()
|
|
326
|
+
for (const match of source.matchAll(/```[\t ]*[^\n\r]*\r?\n([\s\S]*?)```/gu)) {
|
|
327
|
+
const code = match[1]?.trim()
|
|
328
|
+
if (code) {
|
|
329
|
+
codeBlocks.add(code)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const match of source.matchAll(/`([^`\n\r]+)`/gu)) {
|
|
333
|
+
const code = match[1]?.trim()
|
|
334
|
+
if (code) {
|
|
335
|
+
codeBlocks.add(code)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return [...codeBlocks]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const resolveSearchUrl = (base: string, entry: BaseContentEntry) => {
|
|
342
|
+
const normalizedBase = base === '' ? '/' : base.endsWith('/') ? base : `${base}/`
|
|
343
|
+
return `${normalizedBase}${entry.collection}/${entry.id}`.replaceAll(/\/+/g, '/')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const transformMarkdownEntry = async (entry: BaseContentEntry) => {
|
|
347
|
+
const transform = await loadMarkdownTransform()
|
|
348
|
+
const result = transform(entry.body, {
|
|
349
|
+
autolinks: true,
|
|
350
|
+
footnotes: true,
|
|
351
|
+
gfm: true,
|
|
352
|
+
sourcePath: entry.filePath,
|
|
353
|
+
strikethrough: true,
|
|
354
|
+
tables: true,
|
|
355
|
+
taskLists: true,
|
|
356
|
+
tocMaxDepth: 6,
|
|
357
|
+
})
|
|
358
|
+
if (result.errors.length > 0) {
|
|
359
|
+
throw new ContentCollectionError(
|
|
360
|
+
`Failed to render markdown for ${entry.filePath}: ${result.errors.join('; ')}`,
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
return result
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const createSearchDocument = async (
|
|
367
|
+
entry: BaseContentEntry,
|
|
368
|
+
base: string,
|
|
369
|
+
): Promise<ContentSearchDocument> => {
|
|
370
|
+
const result = await transformMarkdownEntry(entry)
|
|
371
|
+
const headings = result.toc.map((heading) => heading.text)
|
|
372
|
+
const title =
|
|
373
|
+
typeof entry.data.title === 'string'
|
|
374
|
+
? entry.data.title
|
|
375
|
+
: (result.toc.find((heading) => heading.depth === 1)?.text ?? entry.id)
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
body: stripHtml(result.html),
|
|
379
|
+
code: extractMarkdownCode(entry.body),
|
|
380
|
+
collection: entry.collection,
|
|
381
|
+
headings,
|
|
382
|
+
id: entry.id,
|
|
383
|
+
title,
|
|
384
|
+
url: resolveSearchUrl(base, entry),
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const renderMarkdown = async (
|
|
389
|
+
entry: BaseContentEntry,
|
|
390
|
+
markdownOptions: ContentMarkdownOptions | undefined,
|
|
391
|
+
): Promise<RenderedContent> => {
|
|
392
|
+
const result = await transformMarkdownEntry(entry)
|
|
393
|
+
const headings = result.toc.map(
|
|
394
|
+
(heading) =>
|
|
395
|
+
({
|
|
396
|
+
depth: heading.depth,
|
|
397
|
+
slug: heading.slug,
|
|
398
|
+
text: heading.text,
|
|
399
|
+
}) satisfies ContentHeading,
|
|
400
|
+
)
|
|
401
|
+
const html = await highlightHtml(result.html, markdownOptions?.highlight)
|
|
402
|
+
return {
|
|
403
|
+
Content: createContentRenderer(html),
|
|
404
|
+
headings,
|
|
405
|
+
html,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export const createContentSearch = async ({
|
|
410
|
+
collectionsModule,
|
|
411
|
+
configPath,
|
|
412
|
+
root,
|
|
413
|
+
base,
|
|
414
|
+
}: CreateContentRuntimeOptions & {
|
|
415
|
+
base: string
|
|
416
|
+
}): Promise<{
|
|
417
|
+
index: ContentSearchIndex
|
|
418
|
+
options: ResolvedContentSearchOptions
|
|
419
|
+
}> => {
|
|
420
|
+
const manifest = await resolveCollections({
|
|
421
|
+
collectionsModule,
|
|
422
|
+
configPath,
|
|
423
|
+
root,
|
|
424
|
+
})
|
|
425
|
+
const documents: ContentSearchDocument[] = []
|
|
426
|
+
let resolvedOptions = resolveContentSearchOptions(false)
|
|
427
|
+
|
|
428
|
+
for (const entries of manifest.collections.values()) {
|
|
429
|
+
const collectionName = entries[0]?.collection
|
|
430
|
+
if (!collectionName) {
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
const searchOptions = manifest.searchByCollectionName.get(collectionName)
|
|
434
|
+
if (!searchOptions?.enabled) {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
if (!resolvedOptions.enabled) {
|
|
438
|
+
resolvedOptions = searchOptions
|
|
439
|
+
}
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
documents.push(await createSearchDocument(entry, base))
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
index: buildContentSearchIndex(documents, resolvedOptions),
|
|
447
|
+
options: resolvedOptions,
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export const createContentRuntime = ({
|
|
452
|
+
collectionsModule,
|
|
453
|
+
configPath,
|
|
454
|
+
root,
|
|
455
|
+
}: CreateContentRuntimeOptions): ContentRuntimeModule => {
|
|
456
|
+
let manifestPromise: Promise<ResolvedManifest> | null = null
|
|
457
|
+
const renderCache = new Map<string, RenderedContent>()
|
|
458
|
+
const getManifest = () => {
|
|
459
|
+
manifestPromise ??= resolveCollections({
|
|
460
|
+
collectionsModule,
|
|
461
|
+
configPath,
|
|
462
|
+
root,
|
|
463
|
+
})
|
|
464
|
+
return manifestPromise
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
async getCollection<Collection extends AnyCollection>(
|
|
468
|
+
collection: Collection,
|
|
469
|
+
filter?: ContentFilter<Collection>,
|
|
470
|
+
) {
|
|
471
|
+
const manifest = await getManifest()
|
|
472
|
+
const entries = (manifest.collections.get(collection) ?? []) as CollectionEntry<Collection>[]
|
|
473
|
+
if (!filter) {
|
|
474
|
+
return [...entries]
|
|
475
|
+
}
|
|
476
|
+
const filtered: CollectionEntry<Collection>[] = []
|
|
477
|
+
for (const entry of entries) {
|
|
478
|
+
if (await filter(entry)) {
|
|
479
|
+
filtered.push(entry)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return filtered
|
|
483
|
+
},
|
|
484
|
+
async getEntries<Entries extends readonly ContentEntryReference<any>[]>(entries: Entries) {
|
|
485
|
+
const manifest = await getManifest()
|
|
486
|
+
return entries.map((entry) => {
|
|
487
|
+
const collectionEntries = manifest.entriesByCollection.get(entry.collection)
|
|
488
|
+
return collectionEntries?.get(entry.id)
|
|
489
|
+
}) as ResolvedContentEntries<Entries>
|
|
490
|
+
},
|
|
491
|
+
async getEntry<Collection extends AnyCollection>(collection: Collection, id: string) {
|
|
492
|
+
const manifest = await getManifest()
|
|
493
|
+
return manifest.entriesByCollection.get(collection)?.get(id) as
|
|
494
|
+
| CollectionEntry<Collection>
|
|
495
|
+
| undefined
|
|
496
|
+
},
|
|
497
|
+
async render<Collection extends AnyCollection>(entry: CollectionEntry<Collection>) {
|
|
498
|
+
const key = `${entry.collection}:${entry.id}`
|
|
499
|
+
const cached = renderCache.get(key)
|
|
500
|
+
if (cached) {
|
|
501
|
+
return cached
|
|
502
|
+
}
|
|
503
|
+
const manifest = await getManifest()
|
|
504
|
+
const rendered = await renderMarkdown(
|
|
505
|
+
entry,
|
|
506
|
+
manifest.markdownByCollectionName.get(entry.collection),
|
|
507
|
+
)
|
|
508
|
+
renderCache.set(key, rendered)
|
|
509
|
+
return rendered
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export { parseFrontmatter, toEntryIdFromRelativePath }
|
package/mod.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from 'eclipsa'
|
|
2
|
+
import { CONTENT_COLLECTION_MARKER } from './types.ts'
|
|
3
|
+
import type {
|
|
4
|
+
AnyCollection,
|
|
5
|
+
CollectionEntry,
|
|
6
|
+
ContentCollectionDefinition,
|
|
7
|
+
ContentComponentProps,
|
|
8
|
+
ContentFilter,
|
|
9
|
+
ContentHighlightOptions,
|
|
10
|
+
ContentMarkdownOptions,
|
|
11
|
+
ContentEntryReference,
|
|
12
|
+
DefinedCollection,
|
|
13
|
+
GlobLoader,
|
|
14
|
+
GlobLoaderOptions,
|
|
15
|
+
ResolvedContentEntries,
|
|
16
|
+
RenderedContent,
|
|
17
|
+
} from './types.ts'
|
|
18
|
+
|
|
19
|
+
const ensureServerOnly = () => {
|
|
20
|
+
if (typeof window !== 'undefined') {
|
|
21
|
+
throw new Error('@eclipsa/content query APIs are server-only.')
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const loadRuntime = async (): Promise<ContentRuntimeModule> => {
|
|
26
|
+
ensureServerOnly()
|
|
27
|
+
return import('virtual:eclipsa-content:runtime')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ContentRuntimeModule {
|
|
31
|
+
getCollection<Collection extends AnyCollection>(
|
|
32
|
+
collection: Collection,
|
|
33
|
+
filter?: ContentFilter<Collection>,
|
|
34
|
+
): Promise<CollectionEntry<Collection>[]>
|
|
35
|
+
getEntries<Entries extends readonly ContentEntryReference<any>[]>(
|
|
36
|
+
entries: Entries,
|
|
37
|
+
): Promise<ResolvedContentEntries<Entries>>
|
|
38
|
+
getEntry<Collection extends AnyCollection>(
|
|
39
|
+
collection: Collection,
|
|
40
|
+
id: string,
|
|
41
|
+
): Promise<CollectionEntry<Collection> | undefined>
|
|
42
|
+
render<Collection extends AnyCollection>(
|
|
43
|
+
entry: CollectionEntry<Collection>,
|
|
44
|
+
): Promise<RenderedContent>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type {
|
|
48
|
+
AnyCollection,
|
|
49
|
+
BaseContentEntry,
|
|
50
|
+
CollectionEntry,
|
|
51
|
+
ContentCollectionDefinition,
|
|
52
|
+
ContentComponentProps,
|
|
53
|
+
ContentFilter,
|
|
54
|
+
ContentHighlightOptions,
|
|
55
|
+
ContentMarkdownOptions,
|
|
56
|
+
ContentEntryReference,
|
|
57
|
+
ContentHeading,
|
|
58
|
+
ContentLoader,
|
|
59
|
+
ContentLoaderContext,
|
|
60
|
+
ContentLoaderObject,
|
|
61
|
+
ContentSearchDocument,
|
|
62
|
+
ContentSearchField,
|
|
63
|
+
ContentSearchIndex,
|
|
64
|
+
ContentSearchOptions,
|
|
65
|
+
ContentSearchPosting,
|
|
66
|
+
ContentSearchQueryOptions,
|
|
67
|
+
ContentSearchResult,
|
|
68
|
+
ContentSourceEntry,
|
|
69
|
+
DefinedCollection,
|
|
70
|
+
GlobLoader,
|
|
71
|
+
GlobLoaderOptions,
|
|
72
|
+
InferCollectionData,
|
|
73
|
+
ResolvedContentSearchOptions,
|
|
74
|
+
RenderedContent,
|
|
75
|
+
ResolvedContentEntries,
|
|
76
|
+
} from './types.ts'
|
|
77
|
+
|
|
78
|
+
export type { StandardSchemaV1 } from 'eclipsa'
|
|
79
|
+
|
|
80
|
+
export const defineCollection = <
|
|
81
|
+
Schema extends StandardSchemaV1<any, any> | undefined = StandardSchemaV1<any, any> | undefined,
|
|
82
|
+
>(
|
|
83
|
+
definition: ContentCollectionDefinition<Schema>,
|
|
84
|
+
): DefinedCollection<Schema> =>
|
|
85
|
+
({
|
|
86
|
+
...definition,
|
|
87
|
+
[CONTENT_COLLECTION_MARKER]: true,
|
|
88
|
+
}) as DefinedCollection<Schema>
|
|
89
|
+
|
|
90
|
+
export const glob = (options: GlobLoaderOptions): GlobLoader => ({
|
|
91
|
+
base: options.base,
|
|
92
|
+
kind: 'glob',
|
|
93
|
+
pattern: options.pattern,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
export const Content = ({ as = 'article', html, ...props }: ContentComponentProps) => ({
|
|
97
|
+
isStatic: false,
|
|
98
|
+
props: {
|
|
99
|
+
...props,
|
|
100
|
+
dangerouslySetInnerHTML: html,
|
|
101
|
+
},
|
|
102
|
+
type: as,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export const getCollection = async <Collection extends AnyCollection>(
|
|
106
|
+
collection: Collection,
|
|
107
|
+
filter?: ContentFilter<Collection>,
|
|
108
|
+
): Promise<CollectionEntry<Collection>[]> =>
|
|
109
|
+
(await loadRuntime()).getCollection(collection, filter) as Promise<CollectionEntry<Collection>[]>
|
|
110
|
+
|
|
111
|
+
export const getEntry = async <Collection extends AnyCollection>(
|
|
112
|
+
collection: Collection,
|
|
113
|
+
id: string,
|
|
114
|
+
): Promise<CollectionEntry<Collection> | undefined> =>
|
|
115
|
+
(await loadRuntime()).getEntry(collection, id) as Promise<CollectionEntry<Collection> | undefined>
|
|
116
|
+
|
|
117
|
+
export const getEntries = async <Entries extends readonly ContentEntryReference<any>[]>(
|
|
118
|
+
entries: Entries,
|
|
119
|
+
): Promise<ResolvedContentEntries<Entries>> =>
|
|
120
|
+
(await loadRuntime()).getEntries(entries) as Promise<ResolvedContentEntries<Entries>>
|
|
121
|
+
|
|
122
|
+
export const render = async <Collection extends AnyCollection>(
|
|
123
|
+
entry: CollectionEntry<Collection>,
|
|
124
|
+
): Promise<RenderedContent> => (await loadRuntime()).render(entry)
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eclipsa/content",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"homepage": "https://github.com/pnsk-lab/eclipsa",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/pnsk-lab/eclipsa/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/pnsk-lab/eclipsa.git",
|
|
13
|
+
"directory": "packages/content"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./mod.ts",
|
|
19
|
+
"import": "./mod.ts"
|
|
20
|
+
},
|
|
21
|
+
"./vite": {
|
|
22
|
+
"types": "./vite.ts",
|
|
23
|
+
"import": "./vite.ts"
|
|
24
|
+
},
|
|
25
|
+
"./internal": {
|
|
26
|
+
"types": "./internal.ts",
|
|
27
|
+
"import": "./internal.ts"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./mod.d.mts",
|
|
34
|
+
"import": "./mod.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./vite": {
|
|
37
|
+
"types": "./vite.d.mts",
|
|
38
|
+
"import": "./vite.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./internal": {
|
|
41
|
+
"types": "./internal.d.mts",
|
|
42
|
+
"import": "./internal.mjs"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "vp pack && bun ../../scripts/release/write-dist-package-json.ts",
|
|
48
|
+
"pack": "vp pack && bun ../../scripts/release/write-dist-package-json.ts",
|
|
49
|
+
"test": "vp test --run",
|
|
50
|
+
"typecheck": "bun x tsc -p ../../tsconfig.json --noEmit"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@ox-content/napi": "^0.17.0",
|
|
54
|
+
"eclipsa": "0.2.0-alpha.0",
|
|
55
|
+
"fast-glob": "^3.3.2",
|
|
56
|
+
"shiki": "^4.0.2",
|
|
57
|
+
"yaml": "^2.8.1"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"vite": "*"
|
|
61
|
+
}
|
|
62
|
+
}
|