@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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.mjs","names":[],"sources":["../vite.ts"],"sourcesContent":["import * as fs from 'node:fs/promises'\nimport { pathToFileURL } from 'node:url'\nimport path from 'node:path'\nimport type { Plugin, PluginOption, ResolvedConfig, ViteDevServer } from 'vite'\nimport { createContentSearch } from './internal.ts'\nimport { generateContentSearchRuntimeModule, resolveContentSearchOptions } from './search.ts'\nimport type { ResolvedContentSearchOptions } from './types.ts'\n\nconst DEV_APP_INVALIDATORS_KEY = Symbol.for('eclipsa.dev-app-invalidators')\nconst CONTENT_HMR_EVENT = 'eclipsa:content-update'\nconst VIRTUAL_RUNTIME_ID = 'virtual:eclipsa-content:runtime'\nconst RESOLVED_VIRTUAL_RUNTIME_ID = '\\0eclipsa-content:runtime'\nconst VIRTUAL_SEARCH_ID = 'virtual:eclipsa-content:search'\nconst RESOLVED_VIRTUAL_SEARCH_ID = '\\0eclipsa-content:search'\nconst CONTENT_CONFIG_PATH = 'app/content.config.ts'\nconst CONTENT_COLLECTION_MARKER = '__eclipsa_content_collection__'\nconst CONTENT_SEARCH_ASSET = '__eclipsa_content_search__.json'\n\nconst normalizeSlashes = (value: string) => value.replaceAll('\\\\', '/')\nconst stripQuery = (id: string) => id.split('?', 1)[0] ?? id\n\nconst fileExists = async (filePath: string) => {\n try {\n await fs.access(filePath)\n return true\n } catch {\n return false\n }\n}\n\nconst getConfigPath = (root: string) => path.join(root, CONTENT_CONFIG_PATH)\nconst getSearchAssetPath = (base: string) => {\n const normalizedBase = base === '' ? '/' : base.endsWith('/') ? base : `${base}/`\n return `${normalizedBase}${CONTENT_SEARCH_ASSET}`\n}\nconst isContentConfigId = (root: string, id: string) =>\n normalizeSlashes(path.resolve(stripQuery(id))) === normalizeSlashes(getConfigPath(root))\n\nconst getNamedCollectionExports = (source: string) =>\n [...source.matchAll(/^\\s*export\\s+const\\s+([A-Za-z_$][\\w$]*)\\s*=/gm)].map((match) => match[1]!)\n\nconst invalidateVirtualRuntime = (server: ViteDevServer) => {\n const graphs = [\n (server as ViteDevServer & { moduleGraph?: any }).moduleGraph,\n ...Object.values(\n (server as ViteDevServer & { environments?: Record<string, { moduleGraph?: any }> })\n .environments ?? {},\n ).map((environment) => environment.moduleGraph),\n ]\n for (const graph of graphs) {\n if (!graph) {\n continue\n }\n for (const id of [\n VIRTUAL_RUNTIME_ID,\n RESOLVED_VIRTUAL_RUNTIME_ID,\n VIRTUAL_SEARCH_ID,\n RESOLVED_VIRTUAL_SEARCH_ID,\n ]) {\n const mod = graph.getModuleById?.(id)\n if (mod) {\n graph.invalidateModule?.(mod)\n }\n }\n }\n}\n\nconst invalidateRegisteredDevApps = (server: ViteDevServer) => {\n const invalidators = (\n server as ViteDevServer & {\n [DEV_APP_INVALIDATORS_KEY]?: Set<() => void>\n }\n )[DEV_APP_INVALIDATORS_KEY]\n if (!invalidators) {\n return\n }\n for (const invalidate of invalidators) {\n invalidate()\n }\n}\n\nconst shouldInvalidateForFile = (root: string, filePath: string) => {\n const normalizedFilePath = normalizeSlashes(path.resolve(filePath))\n const normalizedRoot = normalizeSlashes(path.resolve(root))\n if (!normalizedFilePath.startsWith(normalizedRoot)) {\n return false\n }\n if (normalizedFilePath === normalizeSlashes(path.join(root, CONTENT_CONFIG_PATH))) {\n return true\n }\n return normalizedFilePath.endsWith('.md')\n}\n\nconst createMissingRuntimeModule = (root: string) => {\n const message = `Missing ${CONTENT_CONFIG_PATH} in ${root}.`\n return `\nconst error = new Error(${JSON.stringify(message)});\nexport const getCollection = async () => { throw error; };\nexport const getEntries = async () => { throw error; };\nexport const getEntry = async () => { throw error; };\nexport const render = async () => { throw error; };\n`\n}\n\nconst createClientRuntimeModule = () => `\nconst error = new Error(\"@eclipsa/content query APIs are server-only.\");\nexport const getCollection = async () => { throw error; };\nexport const getEntries = async () => { throw error; };\nexport const getEntry = async () => { throw error; };\nexport const render = async () => { throw error; };\n`\n\nconst createDisabledSearchModule = () => `\nexport const searchOptions = ${JSON.stringify(resolveContentSearchOptions(false))};\nexport const search = async () => [];\nexport default { search, searchOptions };\n`\n\nconst createClientContentConfigModule = async (configPath: string) => {\n const source = await fs.readFile(configPath, 'utf8')\n const exportNames = getNamedCollectionExports(source)\n if (exportNames.length === 0) {\n return ''\n }\n return exportNames\n .map(\n (name) =>\n `export const ${name} = Object.freeze({ ${JSON.stringify(CONTENT_COLLECTION_MARKER)}: true });`,\n )\n .join('\\n')\n}\n\nconst createRuntimeModule = (root: string, configPath: string) => `\nimport * as collectionsModule from ${JSON.stringify(normalizeSlashes(configPath))};\nimport { createContentRuntime } from '@eclipsa/content/internal';\n\nconst runtime = createContentRuntime({\n collectionsModule,\n configPath: ${JSON.stringify(normalizeSlashes(configPath))},\n root: ${JSON.stringify(normalizeSlashes(root))},\n});\n\nexport const getCollection = runtime.getCollection;\nexport const getEntries = runtime.getEntries;\nexport const getEntry = runtime.getEntry;\nexport const render = runtime.render;\n`\n\nconst loadCollectionsModule = async (configPath: string) => {\n const href = pathToFileURL(configPath).href\n return import(`${href}?t=${Date.now()}`)\n}\n\nconst handleInvalidation = (server: ViteDevServer, root: string, filePath: string) => {\n if (!shouldInvalidateForFile(root, filePath)) {\n return false\n }\n invalidateVirtualRuntime(server)\n invalidateRegisteredDevApps(server)\n ;(\n server as ViteDevServer & { ws?: { send?: (event: string, payload?: unknown) => void } }\n ).ws?.send?.(CONTENT_HMR_EVENT)\n return true\n}\n\nconst contentPlugin = (): Plugin => {\n let config: ResolvedConfig\n let searchStatePromise: Promise<{\n indexJson: string\n options: ResolvedContentSearchOptions\n } | null> | null = null\n\n const resolveSearchState = async () => {\n if (searchStatePromise) {\n return searchStatePromise\n }\n searchStatePromise = (async () => {\n const configPath = getConfigPath(config.root)\n if (!(await fileExists(configPath))) {\n return null\n }\n const collectionsModule = await loadCollectionsModule(configPath)\n const result = await createContentSearch({\n base: config.base,\n collectionsModule,\n configPath,\n root: config.root,\n })\n if (!result.options.enabled || result.index.documents.length === 0) {\n return null\n }\n return {\n indexJson: JSON.stringify(result.index),\n options: result.options,\n }\n })()\n return searchStatePromise\n }\n\n return {\n enforce: 'pre',\n name: 'vite-plugin-eclipsa-content',\n configResolved(resolvedConfig) {\n config = resolvedConfig\n },\n configureServer(server) {\n const searchPath = getSearchAssetPath(config.base)\n server.middlewares.use(async (req, res, next) => {\n const requestPath = req.url?.split('?', 1)[0] ?? ''\n if (requestPath !== searchPath) {\n next()\n return\n }\n const state = await resolveSearchState()\n if (!state) {\n res.statusCode = 404\n res.end('Not found')\n return\n }\n res.setHeader('Content-Type', 'application/json; charset=utf-8')\n res.end(state.indexJson)\n })\n },\n hotUpdate(options) {\n searchStatePromise = null\n if (handleInvalidation(options.server, config.root, options.file)) {\n return []\n }\n },\n resolveId(id) {\n if (id === VIRTUAL_RUNTIME_ID) {\n return RESOLVED_VIRTUAL_RUNTIME_ID\n }\n if (id === VIRTUAL_SEARCH_ID) {\n return RESOLVED_VIRTUAL_SEARCH_ID\n }\n return null\n },\n async load(id) {\n if (id === RESOLVED_VIRTUAL_SEARCH_ID) {\n const state = await resolveSearchState()\n if (!state) {\n return createDisabledSearchModule()\n }\n return generateContentSearchRuntimeModule(getSearchAssetPath(config.base), state.options)\n }\n if (id !== RESOLVED_VIRTUAL_RUNTIME_ID) {\n if (this.environment?.name === 'client' && isContentConfigId(config.root, id)) {\n return createClientContentConfigModule(getConfigPath(config.root))\n }\n return null\n }\n if (this.environment?.name === 'client') {\n return createClientRuntimeModule()\n }\n const configPath = getConfigPath(config.root)\n if (!(await fileExists(configPath))) {\n return createMissingRuntimeModule(config.root)\n }\n return createRuntimeModule(config.root, configPath)\n },\n async generateBundle() {\n const state = await resolveSearchState()\n if (!state) {\n return\n }\n this.emitFile({\n fileName: CONTENT_SEARCH_ASSET,\n source: state.indexJson,\n type: 'asset',\n })\n },\n }\n}\n\nexport const eclipsaContent = (): PluginOption => [contentPlugin()]\n"],"mappings":"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"}
package/highlight.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { createHighlighter, type Highlighter } from 'shiki'
2
+ import type { ContentHighlightOptions } from './types.ts'
3
+
4
+ const DEFAULT_THEME = 'github-dark'
5
+ const CODE_BLOCK_RE = /<pre\b[^>]*>\s*<code\b([^>]*)>([\s\S]*?)<\/code>\s*<\/pre>/giu
6
+ const CLASS_ATTR_RE = /\bclass=(['"])(.*?)\1/iu
7
+ const HTML_ENTITY_RE = /&(?:#(\d+)|#x([\da-fA-F]+)|amp|lt|gt|quot|#39);/g
8
+ const highlighterCache = new Map<string, Promise<Highlighter>>()
9
+ const loadedLanguagesByTheme = new Map<string, Set<string>>()
10
+
11
+ const decodeHtmlEntities = (value: string) =>
12
+ value.replace(HTML_ENTITY_RE, (entity, decimal, hex) => {
13
+ if (decimal) {
14
+ return String.fromCodePoint(Number(decimal))
15
+ }
16
+ if (hex) {
17
+ return String.fromCodePoint(Number.parseInt(hex, 16))
18
+ }
19
+ switch (entity) {
20
+ case '&amp;':
21
+ return '&'
22
+ case '&lt;':
23
+ return '<'
24
+ case '&gt;':
25
+ return '>'
26
+ case '&quot;':
27
+ return '"'
28
+ case '&#39;':
29
+ return "'"
30
+ default:
31
+ return entity
32
+ }
33
+ })
34
+
35
+ const getLanguageFromCodeAttributes = (attributes: string) => {
36
+ const classAttr = CLASS_ATTR_RE.exec(attributes)?.[2]
37
+ if (!classAttr) {
38
+ return null
39
+ }
40
+ for (const token of classAttr.split(/\s+/)) {
41
+ if (token.startsWith('language-')) {
42
+ return token.slice('language-'.length)
43
+ }
44
+ }
45
+ return null
46
+ }
47
+
48
+ const resolveTheme = (options: boolean | ContentHighlightOptions | undefined) => {
49
+ if (!options) {
50
+ return null
51
+ }
52
+ return options === true ? DEFAULT_THEME : (options.theme ?? DEFAULT_THEME)
53
+ }
54
+
55
+ const getHighlighter = (theme: string) => {
56
+ const cached = highlighterCache.get(theme)
57
+ if (cached) {
58
+ return cached
59
+ }
60
+ const next = createHighlighter({
61
+ langs: [],
62
+ themes: [theme],
63
+ })
64
+ highlighterCache.set(theme, next)
65
+ loadedLanguagesByTheme.set(theme, new Set())
66
+ return next
67
+ }
68
+
69
+ const ensureLanguageLoaded = async (theme: string, language: string) => {
70
+ const loadedLanguages = loadedLanguagesByTheme.get(theme) ?? new Set<string>()
71
+ loadedLanguagesByTheme.set(theme, loadedLanguages)
72
+ if (loadedLanguages.has(language)) {
73
+ return
74
+ }
75
+ const highlighter = await getHighlighter(theme)
76
+ await highlighter.loadLanguage(language as any)
77
+ loadedLanguages.add(language)
78
+ }
79
+
80
+ export const highlightHtml = async (
81
+ html: string,
82
+ options: boolean | ContentHighlightOptions | undefined,
83
+ ) => {
84
+ const theme = resolveTheme(options)
85
+ if (!theme) {
86
+ return html
87
+ }
88
+
89
+ const highlighter = await getHighlighter(theme)
90
+ let highlightedHtml = ''
91
+ let lastIndex = 0
92
+
93
+ for (const match of html.matchAll(CODE_BLOCK_RE)) {
94
+ const index = match.index ?? 0
95
+ const fullMatch = match[0]
96
+ const codeAttributes = match[1] ?? ''
97
+ const encodedCode = match[2] ?? ''
98
+ const language = getLanguageFromCodeAttributes(codeAttributes)
99
+
100
+ highlightedHtml += html.slice(lastIndex, index)
101
+ lastIndex = index + fullMatch.length
102
+
103
+ if (!language) {
104
+ highlightedHtml += fullMatch
105
+ continue
106
+ }
107
+
108
+ try {
109
+ await ensureLanguageLoaded(theme, language)
110
+ highlightedHtml += highlighter.codeToHtml(decodeHtmlEntities(encodedCode), {
111
+ lang: language,
112
+ theme,
113
+ })
114
+ } catch {
115
+ highlightedHtml += fullMatch
116
+ }
117
+ }
118
+
119
+ if (lastIndex === 0) {
120
+ return html
121
+ }
122
+
123
+ highlightedHtml += html.slice(lastIndex)
124
+ return highlightedHtml
125
+ }
@@ -0,0 +1,263 @@
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 } from 'vitest'
5
+ import {
6
+ createContentRuntime,
7
+ createContentSearch,
8
+ parseFrontmatter,
9
+ toEntryIdFromRelativePath,
10
+ } from './internal.ts'
11
+ import { defineCollection, glob } from './mod.ts'
12
+
13
+ const createdRoots: string[] = []
14
+
15
+ const createTempRoot = async () => {
16
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'eclipsa-content-'))
17
+ createdRoots.push(root)
18
+ await fs.mkdir(path.join(root, 'app', 'content', 'docs', 'guide'), {
19
+ recursive: true,
20
+ })
21
+ return root
22
+ }
23
+
24
+ afterEach(async () => {
25
+ await Promise.all(
26
+ createdRoots.splice(0).map((root) => fs.rm(root, { force: true, recursive: true })),
27
+ )
28
+ })
29
+
30
+ describe('@eclipsa/content internals', () => {
31
+ it('parses YAML frontmatter and strips the delimiter block', () => {
32
+ expect(
33
+ parseFrontmatter(`---
34
+ title: Hello
35
+ order: 1
36
+ ---
37
+ # Heading
38
+ `),
39
+ ).toEqual({
40
+ body: '# Heading\n',
41
+ data: {
42
+ order: 1,
43
+ title: 'Hello',
44
+ },
45
+ })
46
+ })
47
+
48
+ it('normalizes file paths into stable entry ids', () => {
49
+ expect(toEntryIdFromRelativePath('guide/getting started.md')).toBe('guide/getting-started')
50
+ expect(toEntryIdFromRelativePath('guide/index.md')).toBe('guide')
51
+ })
52
+
53
+ it('loads markdown entries, honors slug overrides, and renders headings', async () => {
54
+ const root = await createTempRoot()
55
+ const configPath = path.join(root, 'app', 'content.config.ts')
56
+ await fs.writeFile(
57
+ path.join(root, 'app', 'content', 'docs', 'guide', 'getting-started.md'),
58
+ `---
59
+ title: Getting Started
60
+ description: Intro page
61
+ order: 2
62
+ slug: guide/start-here
63
+ ---
64
+ # Getting Started
65
+
66
+ Welcome to **content**.
67
+
68
+ \`\`\`ts
69
+ const answer = 42
70
+ \`\`\`
71
+ `,
72
+ )
73
+ await fs.writeFile(
74
+ path.join(root, 'app', 'content', 'docs', 'guide', 'overview.md'),
75
+ `---
76
+ title: Overview
77
+ description: Overview page
78
+ order: 1
79
+ ---
80
+ # Overview
81
+ `,
82
+ )
83
+ const schema = {
84
+ '~standard': {
85
+ types: undefined as unknown as {
86
+ input: {
87
+ description: string
88
+ order: number
89
+ title: string
90
+ }
91
+ output: {
92
+ description: string
93
+ order: number
94
+ title: string
95
+ }
96
+ },
97
+ validate(value: unknown) {
98
+ return {
99
+ value: value as {
100
+ description: string
101
+ order: number
102
+ title: string
103
+ },
104
+ }
105
+ },
106
+ vendor: 'test',
107
+ version: 1 as const,
108
+ },
109
+ }
110
+ const docs = defineCollection({
111
+ loader: glob({
112
+ base: './content/docs',
113
+ pattern: '**/*.md',
114
+ }),
115
+ markdown: {
116
+ highlight: {
117
+ theme: 'github-dark',
118
+ },
119
+ },
120
+ schema,
121
+ })
122
+ const runtime = createContentRuntime({
123
+ collectionsModule: {
124
+ docs,
125
+ },
126
+ configPath,
127
+ root,
128
+ })
129
+
130
+ const entries = await runtime.getCollection(docs)
131
+
132
+ expect(entries.map((entry) => entry.id)).toEqual(['guide/overview', 'guide/start-here'])
133
+ expect(entries[1]?.data).toEqual({
134
+ description: 'Intro page',
135
+ order: 2,
136
+ title: 'Getting Started',
137
+ })
138
+
139
+ const entry = await runtime.getEntry(docs, 'guide/start-here')
140
+ expect(entry?.body).toContain('Welcome to **content**.')
141
+
142
+ const rendered = await runtime.render(entry!)
143
+ expect(rendered.html).toContain('<h1>Getting Started</h1>')
144
+ expect(rendered.html).toContain('class="shiki')
145
+ expect(rendered.html).toContain('answer')
146
+ expect(rendered.headings).toEqual([
147
+ {
148
+ depth: 1,
149
+ slug: 'getting-started',
150
+ text: 'Getting Started',
151
+ },
152
+ ])
153
+ })
154
+
155
+ it('surfaces frontmatter validation errors with file context', async () => {
156
+ const root = await createTempRoot()
157
+ const configPath = path.join(root, 'app', 'content.config.ts')
158
+ await fs.writeFile(
159
+ path.join(root, 'app', 'content', 'docs', 'guide', 'bad.md'),
160
+ `---
161
+ title: Bad
162
+ ---
163
+ # Bad
164
+ `,
165
+ )
166
+ const schema = {
167
+ '~standard': {
168
+ types: undefined as unknown as {
169
+ input: {
170
+ title: string
171
+ }
172
+ output: {
173
+ title: string
174
+ }
175
+ },
176
+ validate() {
177
+ return {
178
+ issues: [
179
+ {
180
+ message: 'description is required',
181
+ path: ['description'],
182
+ },
183
+ ],
184
+ } as const
185
+ },
186
+ vendor: 'test',
187
+ version: 1 as const,
188
+ },
189
+ }
190
+ const docs = defineCollection({
191
+ loader: glob({
192
+ base: './content/docs',
193
+ pattern: '**/*.md',
194
+ }),
195
+ schema,
196
+ })
197
+ const runtime = createContentRuntime({
198
+ collectionsModule: {
199
+ docs,
200
+ },
201
+ configPath,
202
+ root,
203
+ })
204
+
205
+ await expect(runtime.getCollection(docs)).rejects.toThrow(
206
+ /Invalid frontmatter in collection "docs".*description is required/u,
207
+ )
208
+ })
209
+
210
+ it('builds a search index from searchable markdown collections', async () => {
211
+ const root = await createTempRoot()
212
+ const configPath = path.join(root, 'app', 'content.config.ts')
213
+ await fs.writeFile(
214
+ path.join(root, 'app', 'content', 'docs', 'guide', 'quick-start.md'),
215
+ `---
216
+ title: Quick Start
217
+ ---
218
+ # Quick Start
219
+
220
+ Searchable nebula token.
221
+
222
+ \`\`\`ts
223
+ const searchNeedle = 'nebula'
224
+ \`\`\`
225
+ `,
226
+ )
227
+ const docs = defineCollection({
228
+ loader: glob({
229
+ base: './content/docs',
230
+ pattern: '**/*.md',
231
+ }),
232
+ search: {
233
+ hotkey: 'k',
234
+ limit: 5,
235
+ placeholder: 'Search docs',
236
+ },
237
+ })
238
+
239
+ const result = await createContentSearch({
240
+ base: '/',
241
+ collectionsModule: { docs },
242
+ configPath,
243
+ root,
244
+ })
245
+
246
+ expect(result.options).toMatchObject({
247
+ enabled: true,
248
+ hotkey: 'k',
249
+ limit: 5,
250
+ placeholder: 'Search docs',
251
+ })
252
+ expect(result.index.documents).toHaveLength(1)
253
+ expect(result.index.documents[0]).toMatchObject({
254
+ collection: 'docs',
255
+ id: 'guide/quick-start',
256
+ title: 'Quick Start',
257
+ url: '/docs/guide/quick-start',
258
+ })
259
+ expect(result.index.documents[0]?.body).toContain('Searchable nebula token.')
260
+ expect(result.index.documents[0]?.code).toContain("const searchNeedle = 'nebula'")
261
+ expect(result.index.documents[0]?.headings).toContain('Quick Start')
262
+ })
263
+ })