Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions src/app/hooks/useConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { deserializeConfig, type ParsedConfig, serializeConfig } from '../../con

const ConfigContext = createContext(virtualConfig)

export const configHash = import.meta.env.DEV
? bytesToHex(sha256(serializeConfig(virtualConfig))).slice(0, 8)
: ''
function getConfigHash(config: ParsedConfig): string {
return import.meta.env.DEV ? bytesToHex(sha256(serializeConfig(config))).slice(0, 8) : ''
}

export function getConfig(): ParsedConfig {
if (typeof window !== 'undefined' && import.meta.env.DEV) {
const storedConfig = window.localStorage.getItem(`vocs.config.${configHash}`)
const hash = getConfigHash(virtualConfig)
const storedConfig = window.localStorage.getItem(`vocs.config.${hash}`)
if (storedConfig) return deserializeConfig(storedConfig)
}
return virtualConfig
Expand All @@ -31,12 +32,37 @@ export function ConfigProvider({
})

useEffect(() => {
if (import.meta.hot) import.meta.hot.on('vocs:config', setConfig)
if (import.meta.hot) {
import.meta.hot.on('vocs:config', (newConfig) => {
try {
// check first that we received a config object
if (!newConfig || typeof newConfig !== 'object') {
console.error('Received invalid config update:', newConfig)
return
}

setConfig(newConfig)

// clear any error overlay if config update succeeded
if (import.meta.hot) import.meta.hot.send('vocs:config-updated')
} catch (error) {
console.error('Failed to apply config update:', error)
// Keep using current config on error
}
})
}
}, [])

useEffect(() => {
if (typeof window !== 'undefined' && import.meta.env.DEV)
window.localStorage.setItem(`vocs.config.${configHash}`, serializeConfig(config))
if (typeof window !== 'undefined' && import.meta.env.DEV) {
try {
const hash = getConfigHash(config)
window.localStorage.setItem(`vocs.config.${hash}`, serializeConfig(config))
} catch (error) {
console.error('Failed to cache config in localStorage:', error)
// Continue without caching - not critical
}
}
}, [config])

return <ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
Expand Down
132 changes: 92 additions & 40 deletions src/vite/plugins/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,51 +115,103 @@ export async function search(): Promise<Plugin> {

return `export const getSearchIndex = async () => JSON.stringify(await ((await fetch("${config.basePath}/.vocs/search-index-${hash}.json")).json()))`
},
async handleHotUpdate({ file }) {
async handleHotUpdate({ file, server }) {
if (!file.endsWith('.md') && !file.endsWith('.mdx')) return

const fileId = getDocId(config.rootDir, file)
if (!existsSync(file)) return

const mdx = readFileSync(file, 'utf-8')
const rehypePlugins = getRehypePlugins({
cacheDir: config.cacheDir,
markdown: config.markdown,
rootDir: config.rootDir,
twoslash: false,
})

const { html: rendered, frontmatter } = await processMdx(file, mdx, {
rehypePlugins,
})

if (frontmatter.searchable === false) return

const sections = splitPageIntoSections(rendered)
if (sections.length === 0) return

const pagesDirPath = resolve(config.rootDir, 'pages')
const relativePagesDirPath = relative(config.rootDir, pagesDirPath)

for (const section of sections) {
const id = `${fileId}#${section.anchor}`
if (index.has(id)) index.discard(id)
const relFile = slash(relative(config.rootDir, fileId))
const href = relFile.replace(relativePagesDirPath, '').replace(/\.(.*)/, '')
index.add({
href: `${href}#${section.anchor}`,
html: section.html,
id,
isPage: section.isPage,
text: section.text,
title: section.titles.at(-1)!,
titles: section.titles.slice(0, -1),
try {
const fileId = getDocId(config.rootDir, file)

// check if file still exists (might have been deleted)
if (!existsSync(file)) {
// remove all sections for this file from the index
if (index) {
const pagesDirPath = resolve(config.rootDir, 'pages')
const relativePagesDirPath = relative(config.rootDir, pagesDirPath)
const relFile = slash(relative(config.rootDir, fileId))
const href = relFile.replace(relativePagesDirPath, '').replace(/\.(.*)/, '')

// try to remove potential sections
for (const doc of (index.toJSON() as any) || []) {
if (doc.href?.startsWith(href)) index.discard(doc.id)
}

onIndexUpdated()
}
return
}

const mdx = readFileSync(file, 'utf-8')
const rehypePlugins = getRehypePlugins({
cacheDir: config.cacheDir,
markdown: config.markdown,
rootDir: config.rootDir,
twoslash: false,
})
}

debug('vocs:search > updated', file)
const { html: rendered, frontmatter } = await processMdx(file, mdx, {
rehypePlugins,
})

if (frontmatter.searchable === false) {
// remove from index if marked as not searchable
if (index) {
for (const doc of (index.toJSON() as any) || []) {
if (doc.id?.startsWith(fileId)) index.discard(doc.id)
}
onIndexUpdated()
}
return
}

const sections = splitPageIntoSections(rendered)
if (sections.length === 0) return

const pagesDirPath = resolve(config.rootDir, 'pages')
const relativePagesDirPath = relative(config.rootDir, pagesDirPath)

onIndexUpdated()
// remove old sections for this file
if (index) {
for (const doc of (index.toJSON() as any) || []) {
if (doc.id?.startsWith(fileId)) index.discard(doc.id)
}
}

// add new sections
for (const section of sections) {
const id = `${fileId}#${section.anchor}`
const relFile = slash(relative(config.rootDir, fileId))
const href = relFile.replace(relativePagesDirPath, '').replace(/\.(.*)/, '')
index.add({
href: `${href}#${section.anchor}`,
html: section.html,
id,
isPage: section.isPage,
text: section.text,
title: section.titles.at(-1)!,
titles: section.titles.slice(0, -1),
})
}

debug('vocs:search > updated', file)

onIndexUpdated()
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))

if (server) {
server.ws.send({
type: 'error',
err: {
message: `Failed to process MDX file for search indexing:\n${file}\n\n${err.message}`,
stack: err.stack || '',
plugin: 'vocs:search',
},
})
}

// don't throw - keep server running with old index
return
}
},
}
}
37 changes: 35 additions & 2 deletions src/vite/plugins/virtual-blog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs'
import { glob } from 'node:fs/promises'
import { relative, resolve } from 'node:path'
import { default as fs } from 'fs-extra'
Expand Down Expand Up @@ -101,8 +102,40 @@ export function virtualBlog(): PluginOption {
}
return
},
handleHotUpdate() {
// TODO: handle changes
async handleHotUpdate({ file, server }) {
if (!file.endsWith('.md') && !file.endsWith('.mdx')) return

try {
const { config } = await resolveVocsConfig()
const { blogDir, rootDir } = config
const blogDirResolved = resolve(rootDir, blogDir)

// check if file is in blog directory
if (!file.startsWith(blogDirResolved)) return

// skip index file
if (file.startsWith(`${blogDirResolved}/index`)) return

// invalidate module if file is deleted
if (!existsSync(file)) {
const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId)
if (mod) {
server.moduleGraph.invalidateModule(mod)
return [mod]
}
return
}

// invalidate virtual mod to trigger reload
const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId)
if (mod) {
server.moduleGraph.invalidateModule(mod)
return [mod]
}
} catch (_error) {
// errors already handled by resolveVocsConfig
}

return
},
}
Expand Down
86 changes: 76 additions & 10 deletions src/vite/plugins/virtual-config.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,73 @@
import type { PluginOption } from 'vite'
import { createLogger, type PluginOption } from 'vite'

import { deserializeFunctionsStringified, serializeConfig } from '../../config.js'
import { resolveVocsConfig } from '../utils/resolveVocsConfig.js'
import { clearConfigCache, resolveVocsConfig } from '../utils/resolveVocsConfig.js'

const logger = createLogger()

export function virtualConfig(): PluginOption {
const virtualModuleId = 'virtual:config'
const resolvedVirtualModuleId = `\0${virtualModuleId}`

let configPath: string | undefined
let debounceTimer: NodeJS.Timeout | undefined
let cleanupHandlers: Array<() => void> = []

return {
name: 'vocs-config',
async configureServer(server) {
const { configPath } = await resolveVocsConfig()
const resolved = await resolveVocsConfig()
configPath = resolved.configPath

if (configPath) {
server.watcher.add(configPath)
server.watcher.on('change', async (path) => {

const changeHandler = async (path: string) => {
if (path !== configPath) return
try {
const { config } = await resolveVocsConfig()
server.ws.send('vocs:config', config)
} catch {}

if (debounceTimer) clearTimeout(debounceTimer)

// debounce config changes to handle rapid edits
debounceTimer = setTimeout(async () => {
try {
clearConfigCache()

const { config } = await resolveVocsConfig()
const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId)
if (mod) {
server.moduleGraph.invalidateModule(mod)
}

server.ws.send('vocs:config', config)

logger.info('Config updated successfully', { timestamp: true })
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))

server.ws.send({
type: 'error',
err: {
message: `Vocs config error: ${err.message}`,
stack: err.stack || '',
plugin: 'vocs-config',
},
})
}
}, 100)
}

server.watcher.on('change', changeHandler)

cleanupHandlers.push(() => {
server.watcher.off('change', changeHandler)
if (debounceTimer) clearTimeout(debounceTimer)
})

server.httpServer?.on('close', () => {
cleanupHandlers.forEach((cleanup) => {
cleanup()
})
cleanupHandlers = []
})
}
},
Expand All @@ -36,9 +85,26 @@ export function virtualConfig(): PluginOption {
}
return
},
handleHotUpdate() {
// TODO: handle changes
async handleHotUpdate({ server, file }) {
if (file !== configPath) return

clearConfigCache()

// invalidate virtual mod
const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId)
if (mod) {
server.moduleGraph.invalidateModule(mod)
return [mod]
}

return
},
buildEnd() {
cleanupHandlers.forEach((cleanup) => {
cleanup()
})
cleanupHandlers = []
if (debounceTimer) clearTimeout(debounceTimer)
},
}
}
Loading
Loading