import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig, type Plugin, searchForWorkspaceRoot } from 'vite'; import { shisoMdx } from './mdx.config.ts'; import { generateIconRegistry } from './scripts/generate-icon-registry.mjs'; import { shisoLastModified } from './scripts/generate-last-modified.mjs'; import { generateOpenApiModule } from './scripts/generate-openapi.mjs'; import { generateSearchIndex } from './scripts/generate-search-index.mjs'; import { createDocsConfigModule } from './scripts/vite-docs-config.mjs'; import { resolveCodeBlockConfig } from './src/lib/code-blocks.ts'; import type { DocsConfig, ResolvedShisoConfig } from './src/lib/types.ts'; /** * Keeps src/lib/icon-registry.generated.ts in sync with the `icon="name"` values * used in content, so string icon names resolve without bundling all of lucide. */ function shisoIconRegistry(getDocsConfig: () => DocsConfig, root: string, output: string): Plugin { return { name: 'shiso-icon-registry', async buildStart() { const { unknown } = await generateIconRegistry({ config: getDocsConfig(), root, output }); if (unknown.length) { this.warn(`Unknown icon names (not in lucide): ${unknown.join(', ')}`); } }, async handleHotUpdate({ file }) { if (/\.(md|mdx|tsx)$/.test(file) || file.endsWith('docs.json')) { await generateIconRegistry({ config: getDocsConfig(), root, output }); } }, }; } /** * Keeps src/lib/search-index.generated.ts in sync with content, so the search * dialog can query page text without a server. */ function shisoOpenApi( getDocsConfig: () => DocsConfig, getSpecPath: () => string | undefined, root: string, output: string, ): Plugin { const generate = () => generateOpenApiModule({ root, config: getDocsConfig(), theme: resolveCodeBlockConfig(getDocsConfig().styling).theme, output, }); return { name: 'shiso-openapi', async buildStart() { await generate(); }, async handleHotUpdate({ file }) { const specPath = getSpecPath(); if ((specPath && path.resolve(file) === specPath) || file.endsWith('docs.json')) { await generate(); } }, }; } function shisoSearchIndex( getDocsConfig: () => DocsConfig, getShisoConfig: () => ResolvedShisoConfig, root: string, output: string, ): Plugin { return { name: 'shiso-search-index', async buildStart() { await generateSearchIndex({ config: getDocsConfig(), shiso: getShisoConfig(), root, output }); }, async handleHotUpdate({ file }) { if ( /\.(md|mdx)$/.test(file) || file.endsWith('docs.json') || /shiso\.config\.\w+$/.test(file) ) { await generateSearchIndex({ config: getDocsConfig(), shiso: getShisoConfig(), root, output, }); } }, }; } /** * Injects site-level metadata from docs.json into index.html: the fallback * title, favicon, and theme color CSS variables. * * The title/description defaults are wrapped in marker comments because * prerendered pages supply their own (src/lib/head.ts) and the duplicates have * to be removed. Deleting a delimited block is deterministic; the regex over * `` this replaced was not. */ const DEFAULT_HEAD_OPEN = '<!--shiso-default-head-->'; const DEFAULT_HEAD_CLOSE = '<!--/shiso-default-head-->'; interface FontSpec { family?: string; weight?: number; source?: string; format?: string; } interface FontsOption extends FontSpec { heading?: FontSpec; body?: FontSpec; } /** Stylesheet URL for every configured Google Font (any spec without a `source`). */ function googleFontsUrl(specs: FontSpec[]): string | null { const families = new Map<string, Set<number>>(); for (const spec of specs) { if (!spec.family || spec.source) { continue; } const weights = families.get(spec.family) || new Set([400, 700]); if (spec.weight) { weights.add(spec.weight); } families.set(spec.family, weights); } if (!families.size) { return null; } const query = [...families] .map(([family, weights]) => { const sorted = [...weights].sort((a, b) => a - b); return `family=${family.trim().replace(/ +/g, '+')}:wght@${sorted.join(';')}`; }) .join('&'); return `https://fonts.googleapis.com/css2?${query}&display=swap`; } /** @font-face rule for a self-hosted font spec. */ function fontFaceRule(spec: FontSpec): string { const format = spec.format || (spec.source?.endsWith('.woff') ? 'woff' : 'woff2'); return ( `@font-face{font-family:'${spec.family}';src:url(${spec.source}) format('${format}');` + `font-weight:${spec.weight || 400};font-display:swap;}` ); } /** CSS custom-property overrides from `colors`, `fonts`, and `background`. */ function buildThemeCss(config: DocsConfig): string { const { colors, background } = config as { colors?: { primary?: string; light?: string; dark?: string }; background?: { image?: string | { light?: string; dark?: string }; color?: { light?: string; dark?: string }; }; }; const fonts = (config as { fonts?: FontsOption }).fonts; const root: string[] = []; const dark: string[] = []; const extra: string[] = []; const lightModePrimary = colors?.primary || colors?.dark; if (lightModePrimary) { root.push(`--primary:${lightModePrimary};`); root.push(`--ring:${lightModePrimary};`); root.push(`--sidebar-primary:${lightModePrimary};`); root.push(`--sidebar-ring:${lightModePrimary};`); } // Shadcn uses one semantic primary token for high-emphasis actions and // active accents. `dark` supplies the accent when `primary` is omitted. const darkModeAccent = colors?.light || colors?.primary || colors?.dark; if (darkModeAccent) { dark.push(`--primary:${darkModeAccent};`); dark.push(`--ring:${darkModeAccent};`); dark.push(`--sidebar-primary:${darkModeAccent};`); dark.push(`--sidebar-ring:${darkModeAccent};`); } if (fonts) { const body = fonts.body?.family ? fonts.body : fonts; const heading = fonts.heading?.family ? fonts.heading : fonts; for (const spec of [fonts, fonts.heading, fonts.body]) { if (spec?.family && spec.source) { extra.push(fontFaceRule(spec)); } } if (body.family) { root.push(`--font-sans:'${body.family}',system-ui,-apple-system,'Segoe UI',sans-serif;`); } if (body.weight) { root.push(`--font-body-weight:${body.weight};`); } if (heading.family) { root.push( `--font-heading:'${heading.family}',system-ui,-apple-system,'Segoe UI',sans-serif;`, ); } if (heading.weight) { root.push(`--font-heading-weight:${heading.weight};`); } } if (background?.color?.light) { root.push(`--background:${background.color.light};`); } if (background?.color?.dark) { dark.push(`--background:${background.color.dark};`); } if (background?.image) { const image = background.image; const base = 'background-size:cover;background-attachment:fixed;background-position:center;'; if (typeof image === 'string') { extra.push(`body{background-image:url(${image});${base}}`); } else { if (image.light) { extra.push(`body{background-image:url(${image.light});${base}}`); } if (image.dark) { extra.push(`[data-theme="dark"] body{background-image:url(${image.dark});${base}}`); } } } return [ // App styles are loaded by the client entry after this head style in dev. // Use a more specific selector than the default token declarations so // configured theme values win regardless of stylesheet load order. root.length ? `html:root{${root.join('')}}` : '', dark.length ? `html[data-theme="dark"]{${dark.join('')}}` : '', ...extra, ] .filter(Boolean) .join(''); } function shisoHtml(getDocsConfig: () => DocsConfig): Plugin { return { name: 'shiso-html', transformIndexHtml(html) { const docsConfig = getDocsConfig(); const name = docsConfig.name?.trim(); const description = docsConfig.description; const favicon = docsConfig.favicon; const appearance = docsConfig.appearance; const fonts = docsConfig.fonts; const defaults = [ DEFAULT_HEAD_OPEN, name ? `<title>${name}` : '', description ? `` : '', DEFAULT_HEAD_CLOSE, ] .filter(Boolean) .join(''); const tags: unknown[] = []; if (favicon) { tags.push({ tag: 'link', attrs: { rel: 'icon', href: favicon }, injectTo: 'head' }); } const fontsUrl = googleFontsUrl( [fonts, fonts?.heading, fonts?.body].filter(Boolean) as FontSpec[], ); if (fontsUrl) { tags.push( { tag: 'link', attrs: { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, injectTo: 'head', }, { tag: 'link', attrs: { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, injectTo: 'head', }, { tag: 'link', attrs: { rel: 'stylesheet', href: fontsUrl }, injectTo: 'head' }, ); } const themeCss = buildThemeCss(docsConfig); if (themeCss) { tags.push({ tag: 'style', children: themeCss, injectTo: 'head' }); } // Fill the appearance placeholders in the theme init script. const withAppearance = html .replace('__SHISO_APPEARANCE_DEFAULT__', appearance?.default || 'system') .replace('__SHISO_APPEARANCE_STRICT__', appearance?.strict === true ? 'true' : 'false'); // The marker block goes in as raw HTML rather than as a `tags` entry so // the comments survive: Vite's tag descriptors cannot express them. return { html: withAppearance.replace('', `${defaults}\n`), tags: tags as never, }; }, }; } /** * Serves the raw markdown source for `.md` URLs during development, * mirroring the `.md` copies the prerenderer publishes next to Markdown and * MDX pages in production builds. TSX standalone pages return 404 because * their source is not a Markdown representation. */ function shisoMarkdownDev( getDocsConfig: () => DocsConfig, getShisoConfig: () => ResolvedShisoConfig, root: string, ): Plugin { return { name: 'shiso-markdown-dev', apply: 'serve', configureServer(server) { server.middlewares.use(async (req, res, next) => { const url = (req.url || '').split('?')[0]; if ((req.method !== 'GET' && req.method !== 'HEAD') || !url.endsWith('.md')) { return next(); } // Values arrive with defaults applied and already normalized. const { contentDir, docsPrefix } = getShisoConfig(); let route = decodeURIComponent(url).slice(0, -'.md'.length); const base = server.config.base.replace(/\/+$/, ''); if (base && route.startsWith(base)) { route = route.slice(base.length); } // Standalone pages (top-level `pages` key) live under content/pages, // outside the docs prefix. "/index.md" maps to the "/" entry. const routeKey = (route.replace(/\/+$/, '') || '/').replace(/^\/index$/, '/'); const standalone = (getDocsConfig().pages || []).find( item => (item?.path?.trim().replace(/\/+$/, '') || '/') === routeKey, ); if (standalone?.page) { const pagesRoot = path.resolve(root, 'content/pages'); const pageSlug = standalone.page .trim() .replace(/^\/+/, '') .replace(/\.(?:mdx?|tsx)$/, ''); for (const candidate of [`${pageSlug}.mdx`, `${pageSlug}.md`]) { const filePath = path.resolve(pagesRoot, candidate); // Never read outside the pages directory. if (!filePath.startsWith(pagesRoot + path.sep)) { break; } try { const source = await readFile(filePath, 'utf8'); res.setHeader('Content-Type', 'text/markdown; charset=utf-8'); res.end(source); return; } catch { // Try the next candidate. } } // Component pages intentionally have no raw Markdown representation. res.statusCode = 404; res.end('Not found'); return; } if (docsPrefix && route.startsWith(docsPrefix)) { route = route.slice(docsPrefix.length); } const slug = route.replace(/^\/+/, '') || 'index'; const contentRoot = path.resolve(root, contentDir); const candidates = [`${slug}.mdx`, `${slug}.md`, `${slug}/index.mdx`, `${slug}/index.md`]; for (const candidate of candidates) { const filePath = path.resolve(contentRoot, candidate); // Never read outside the content directory. if (!filePath.startsWith(contentRoot + path.sep)) { break; } try { const source = await readFile(filePath, 'utf8'); res.setHeader('Content-Type', 'text/markdown; charset=utf-8'); res.end(source); return; } catch { // Try the next candidate. } } next(); }); }, }; } const MDX_REACT_ENTRY = fileURLToPath(import.meta.resolve('@mdx-js/react')); const LUCIDE_REACT_ENTRY = path.join( path.dirname(fileURLToPath(import.meta.resolve('lucide-react/package.json'))), 'dist/esm/lucide-react.mjs', ); export default defineConfig(async () => { const projectRoot = process.cwd(); const generatedRoot = path.join(projectRoot, '.shiso'); const configModule = await createDocsConfigModule({ root: projectRoot, }); const getDocsConfig = configModule.getConfig as () => DocsConfig; const getShisoConfig = configModule.getShisoConfig as () => ResolvedShisoConfig; // Shiso may be installed via link:/file: from a directory outside the // project's workspace root. Allow the framework's real location (and the // repository root above it, which holds its dependency store) so raw assets // like fonts remain servable in dev instead of returning 403. const shisoRoot = path.dirname(fileURLToPath(import.meta.url)); return { server: { fs: { allow: [ searchForWorkspaceRoot(process.cwd()), shisoRoot, path.resolve(shisoRoot, '..', '..'), ], }, }, optimizeDeps: { // The published runtime is already bundled ESM and contains project-time // virtual imports that are resolved by the plugins below. exclude: ['@umami/shiso'], }, ssr: { // App code may import framework entry points directly (e.g. // "@umami/shiso/components" from TSX standalone pages). Bundle them in // the SSR build so project-time virtual imports resolve through the // aliases below instead of failing in Node during prerender. noExternal: ['@umami/shiso'], }, plugins: [ configModule.plugin, tailwindcss(), shisoIconRegistry( getDocsConfig, projectRoot, path.join(generatedRoot, 'icon-registry.generated.ts'), ), shisoLastModified({ root: projectRoot, output: path.join(generatedRoot, 'last-modified.ts'), }), shisoSearchIndex( getDocsConfig, getShisoConfig, projectRoot, path.join(generatedRoot, 'search-index.generated.ts'), ), shisoOpenApi( getDocsConfig, () => configModule.getSpecPath?.(), projectRoot, path.join(generatedRoot, 'openapi.generated.ts'), ), shisoHtml(getDocsConfig), shisoMarkdownDev(getDocsConfig, getShisoConfig, projectRoot), shisoMdx({ ...getShisoConfig().mdx, codeBlocks: resolveCodeBlockConfig(getDocsConfig().styling), }), react({ include: /\.(mdx|md|tsx|ts|jsx|js)$/ }), ], resolve: { alias: [ { find: '@mdx-js/react', replacement: MDX_REACT_ENTRY }, { find: 'lucide-react', replacement: LUCIDE_REACT_ENTRY }, { find: '@/lib/icon-registry.generated', replacement: path.join(generatedRoot, 'icon-registry.generated.ts'), }, { find: '@/lib/search-index.generated', replacement: path.join(generatedRoot, 'search-index.generated.ts'), }, { find: '@/lib/openapi.generated', replacement: path.join(generatedRoot, 'openapi.generated.ts'), }, { find: '@/generated/last-modified', replacement: path.join(generatedRoot, 'last-modified.ts'), }, ], dedupe: ['react', 'react-dom'], }, }; });