@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
|
@@ -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 '&':
|
|
21
|
+
return '&'
|
|
22
|
+
case '<':
|
|
23
|
+
return '<'
|
|
24
|
+
case '>':
|
|
25
|
+
return '>'
|
|
26
|
+
case '"':
|
|
27
|
+
return '"'
|
|
28
|
+
case ''':
|
|
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
|
+
}
|
package/internal.test.ts
ADDED
|
@@ -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
|
+
})
|