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 = '';
const DEFAULT_HEAD_CLOSE = '';
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>();
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 ? `${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'],
},
};
});