Drop-in Mintlify replacement as a Vite plugin. Users point their vite.config.ts at this plugin and get a full documentation site from MDX files + a holocron.jsonc (or docs.json) config file.
Read the spiceflow skill before editing any code in this package. Run playwriter skill or load the spiceflow skill to get the latest API reference.
Read the Emil design-engineering skill before adding or changing animations/transitions in this package: https://raw.githubusercontent.com/emilkowalski/skill/refs/heads/main/skills/emil-design-eng/SKILL.md
Supports two config file names (first found wins):
docs.json(preferred)holocron.jsonc
both follow the same schema.
Schema: the source of truth is vite/src/schema.ts — Zod schemas that describe the supported input shape. The JSON Schema at vite/schema.json is GENERATED from it via pnpm -F @holocron.so/vite generate-schema (runs automatically on build). Do not hand-edit schema.json — edit src/schema.ts and regenerate.
The schema follows the Mintlify docs.json shape (https://mintlify.com/docs.json) for the subset Holocron consumes. Unknown Mintlify fields pass through .passthrough() so users can paste a full docs.json without validation errors.
To read mintlify docs curl https://www.mintlify.com/docs/llms-full.txt into a file and grep it. notice this file is very large. this is useful to find out specific mintlify behaviour, supported components, etc
Fetch those docs every time we need to find out some info about Mintlify
navbar.links→ simple text links in the logo bar (top-right, next to logo)navigation.global.anchors→ rendered as tabs in the tab bar (can be external URLs like GitHub, Changelog)navigation.tabs→ also rendered as tabs; clicking switches the sidebar contentnavigation.versions→ native<select>dropdown in the header (right of logo). Each version wraps its own inner navigation (tabs/groups/pages). Selecting a version navigates to its first page; sidebar updates to show that version's groups. The version markeddefault: truedetermines the/redirect target.navigation.dropdowns→ native<select>dropdown in the header (next to version select). Same as versions but can also be link-only (hrefwithout content → opens external URL).navigation.productsis normalized into dropdowns at config time.navigationgroups → sidebar sections with collapsible pages
All version/dropdown inner tabs are flattened into the main navigation.tabs array so every page gets a route. But buildTabItems() in data.ts must exclude switcher-owned tabs from the header tab bar — the <select> dropdowns replace that role. Only anchors (global links) appear in the tab bar when switchers are active. The switchers metadata (enriched inner nav trees) is serialized alongside config and navigation in the virtual:holocron-config module.
The navigation tree is the central data structure. It mirrors the docs.json shape exactly (tabs → groups → pages) but enriches page slug strings into NavPage objects with parsed metadata (title, headings, gitSha).
This enriched tree is written to dist/holocron-cache.json after each sync. On the next build, the cache is read back and pages with matching gitSha are reused without re-parsing. This makes builds as fast as possible — only changed MDX files get processed. On CI, caching dist/ between runs gives near-instant rebuilds.
Types are intentionally kept close to docs.json to minimize transformations. Utility functions (getTabs, getActiveGroups, findPage, buildSidebarTree) take the tree directly as input.
Holocron's CSS variables follow the standard shadcn/ui v2 naming convention. This means users who already have a shadcn theme can port it directly — just override --foreground, --primary, --border, --muted-foreground, etc. in their own CSS and holocron adapts.
The full shadcn token set is defined in globals.css :root with editorial defaults, and registered in @theme inline for Tailwind utility generation (text-foreground, bg-primary, border-border, etc.).
Three layers of variables:
- shadcn standard —
--background,--foreground,--primary,--muted-foreground,--border,--accent,--sidebar-foreground,--sidebar-primary, etc. Users override these to theme holocron. - Holocron extras —
--text-tertiary,--border-subtle,--divider. Things shadcn doesn't cover. No prefix, same naming style. - Semantic colors —
--blue,--green,--yellow,--orange,--red,--purple. Dark-mode-aware color tokens for callouts/badges. Use astext-blue,bg-red/10,border-green/20in Tailwind. Don't conflict with Tailwind's numbered palette (text-blue-500).
Do NOT introduce prefixed variable namespaces (no --hc-*, no --fd-*, no --editorial-*). Keep everything in the flat shadcn naming style. If a new variable is needed, pick a descriptive name that could plausibly be a shadcn extension.
Any container-like MDX component (Callout, Accordion, Expandable, Panel, Card, Frame, Prompt, API fields/examples, tiles, tree wrappers, etc.) must own its inner vertical rhythm with flex flex-col gap-* on the container body, just like the root page/layout wrappers do. Do not rely on paragraph margins inside containers — many editorial nodes render with margins stripped, so raw MDX children will visually collapse unless the container explicitly provides gap spacing.
When a container can receive arbitrary MDX children, also add no-bleed on that container/body so nested code blocks, lists, and images do not leak outside the card frame.
A CSS variable is only justified if it is used in many places and serves to deduplicate an otherwise-repeated hardcoded value. If a variable is referenced only once (or never), inline the value directly and delete the variable.
Rules:
- Many call sites → define a CSS var (e.g.
--foregroundused in dozens of components,--sticky-topshared by sidebar + aside). - Single call site → inline the value. A
--fade-top: 81pxthat's only read by one::beforerule should just betop: 81px. - Zero call sites → delete immediately. Dead variables clutter
globals.cssand mislead future readers into thinking a token layer exists.
When auditing, grep the repo for var(--name) references plus Tailwind arbitrary-value patterns (gap-(--name), text-(color:--name), [var(--name)]). Remember that refs inside /* ... */ CSS comments look live but aren't. See MEMORY.md ("CSS variable audit") for the grep commands and the last full audit.
CSS variables can also be used to change a color in dark/light mode. or change the value in desktop or mobile. for example we do this for negative margins in bleed images/code line numbers/lists. this use case is justified and desired.
if possible add as few hard coded colors & values as possible. instead use opacity to create variations of colors for example to add a background to a callout you would only define the fg color and derive the bg and border from the fg one using opacity
you can change alpha with <div class="bg-sky-500/10"></div>
or in css with --alpha
:root {
--new-variable: --alpha(var(--color-gray-950) / 10%);
}derive colors from existing tokens with color-mix() instead of hardcoding new shades:
:root {
/* 90% neutral-500 mixed with black → slightly darker muted text */
--muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));
/* 64% foreground mixed with sidebar bg → sidebar text that adapts to surface */
--sidebar-foreground: color-mix(in srgb, var(--foreground) 64%, var(--sidebar));
/* 98% background mixed with white → slightly lighter card surface */
--card: color-mix(in srgb, var(--background) 98%, var(--color-white));
}--alpha() and color-mix() both produce computed colors that auto-adapt in dark mode when their input tokens change. Prefer these over hardcoded hex/oklch values — one definition works for both modes.
prefer our own CSS variables over Tailwind's dark: variant. dark mode values should be changed using CSS variables instead of dark:
using something like
@variant dark {
/* shadcn/ui dark */
--background: oklch(0.21 0.006 285.885);
}you can also change variables based on breakpoints with
@variant lg {
--bleed: 32px;
}SVG icons rendered inline (as <svg> elements in the DOM) inherit currentColor from the parent's CSS color property — this is how our <Icon> component works and why icons respond to dark mode. But SVG used as a CSS background-image data URI (url("data:image/svg+xml,...")) does NOT inherit currentColor. The data-URI SVG is not part of the document tree, so currentColor resolves to black regardless of the parent's color. Always use inline SVG elements (not background-image) for icons or decorations that need to adapt to light/dark mode.
- Vite plugin (
vite-plugin.ts) — wraps spiceflowPlugin, tailwind, tsconfig-paths. Generates two virtual modules:virtual:holocron-pages(import.meta.glob for lazy MDX loading) andvirtual:holocron-config(serialized config + navigation tree). - App entry (
app.tsx) — Spiceflow app that imports from virtual modules. Rendering logic is inapp-factory.tsx. - Sync engine (
lib/sync.ts) — walks the config navigation, computes git blob SHAs, diffs against cache, parses only changed files. - Components (
components/) — editorial UI copied fromwebsite/src/components/. Same styles, same design tokens.
The editorial page layout is built from a minimal set of CSS Grids. Understand these before touching EditorialPage in components/markdown.tsx.
slot-page (flex flex-col gap-(--layout-gap))
├── slot-navbar (logo + tab bar)
├── Hero mini-grid (3-col, only when hero prop is set)
│ └── hero content (col 2, aligned with page grid's content col)
└── Page grid — the ONLY explicit 3-col grid
grid-template-columns: 210px 520px 210px (toc, content, sidebar)
gap-x: 50px, gap-y: 48px (--section-gap between rows)
justify-between (distributes extra width)
├── .slot-sidebar-left (col 1, row 1/span 100, sticky TOC)
├── Inner per-section wrapper (subgrid, col-[2/-1], grid-row: 1)
│ ├── slot-main (col 1 = content, H1 + paragraphs + …)
│ └── per-section aside (col 2 = sidebar, sticky-scoped to wrapper)
├── Inner per-section wrapper (subgrid, col-[2/-1], grid-row: 2)
│ ├── slot-main
│ └── per-section aside
├── Shared full aside (col 3, grid-row: N / span M via CSS var)
├── Inner per-section wrapper (subgrid, col-[2/-1], grid-row: 3)
│ └── slot-main
└── .slot-sidebar-right (col 3, flat-layout only)
1. Page grid (markdown.tsx EditorialPage) — the only explicit 3-col grid. Defines column widths and is the single source of truth. Every other grid inherits from it.
- Column widths live only here:
--grid-toc-width(210),--grid-content-width(520),--grid-sidebar-width(210),--grid-gap(50). justify-betweendistributes extra width up to--grid-max-width(1100px), so actual column gaps are50px + distributed.gap-y-(--section-gap)(48px) gives uniform rhythm between section rows.- On mobile: collapses to
grid-cols-1, sidebars godisplay: none, everything stacks.
2. Inner per-section wrapper (subgrid, lg:col-[2/-1], one per section) — pairs content with its per-section aside.
- Spans both page-grid cols 2-3 via subgrid inheritance → content in inner-col 1 (page col 2), aside in inner-col 2 (page col 3).
- Key responsibility: sticky scoping. Per-section asides inside this wrapper have a containing block = this wrapper = one section's bounds. Scrolling past the section unsticks its aside before the next section's aside sticks. No overlap between asides.
- On mobile: becomes
flex flex-col gap-y-(--prose-gap)→ content + aside stack tightly (20px gap).
3. Hero mini-grid (only when hero prop is set) — replicates the page grid's 3-col definition explicitly to align hero content with the page grid's content column. Not a subgrid because hero lives OUTSIDE the page grid in DOM (sibling in the flex flow).
The markdown document is parsed into an AST and split into sections (MdastSection) at every heading level. This split dictates the CSS grid rows in the main content area.
There are three ways content can exist in the right sidebar:
- Per-section
<Aside>: Sticky only for the bounds of its specific section (the content between two headings). Handled via CSS subgrid row spanning. - Shared
<Aside full>: Spans multiple sections. It is sticky for the section it is placed in, and all subsequent sections until the next<Aside full>or the end of the document. Handled by calculating the grid row span across multiple sub-sections. - AI Widget (
<SidebarAssistant>viaHolocronAIAssistantWidget): Acts exactly like an<Aside full>but must never overlap with another aside. To achieve this without complex React rendering logic, the widget is injected during AST processing (buildSections). If the first section has an<Aside>, the widget is prepended into its children (rendering as a flex column above it). If there are no asides in the first section, it is wrapped in an<Aside full>and inserted at the very top of the document.
Column alignment contract. Every grid in this page uses the same 3 column widths defined via CSS vars. Subgrids inherit tracks through grid-cols-subgrid. The hero mini-grid redeclares the column template explicitly.
Gap inheritance chain (column-gap, through subgrid):
Page grid: 50px (explicit --grid-gap)
→ Inner subgrid: normal → inherits from page grid → 50px
Axis rule: use gap-y-(--token) on subgrid wrappers (not gap-(--token)) so the column-gap inherits and isn't clobbered.
Row placement. Each inner wrapper gets an explicit style={{ gridRow: i + 1 }}. Shared <Aside full> gets style={{ '--shared-row': '${start} / span ${N}' }} plus class lg:[grid-row:var(--shared-row)] — grid-row is ONLY read at lg, so on mobile the aside gets grid-row: auto and auto-places at the end of its range instead of forcing an implicit second column in grid-cols-1.
Sticky scoping via containing blocks. position: sticky is bounded by its grid cell:
- TOC sidebar → page grid cell at col 1, row 1/span 100 (whole page).
- Per-section aside → inner subgrid cell (one section).
- Shared
<Aside full>→ multi-row grid area viagrid-row: start / span N(its range of sections).
NEVER use display: contents on a wrapper whose children need sticky scoping — it removes the wrapper from layout, so descendants inherit the grand-parent as their containing block, collapsing all sticky scopes together. This was the cause of a multi-aside overlap bug (see MEMORY.md).
- Mobile (< lg / 1080px): page grid is
grid-cols-1. All items stack in DOM order. Inner wrappers become flex-col (20px prose-gap between content + aside). Sidebars hidden. Shared aside auto-places at end of its range. - Desktop (≥ lg): full 3-col grid. Subgrids inherit columns. Sticky asides scoped by containing block.
- Page grid owns column widths. No other grid re-declares them (except the hero mini-grid, deliberately).
- Use
gap-y-...on subgrids, nevergap-(--token)(breaks column-gap inheritance). - Never
display: contentsaround sticky-scoped children. - Inline
gridRowstyle applies at ALL breakpoints — if you only want it at lg, use a CSS custom property +lg:[grid-row:var(--x)]class. - Axis ownership: page grid owns horizontal (via
--grid-*); section rhythm owns vertical (via--section-gap,--prose-gap).
MDX files are loaded lazily via import.meta.glob('?raw'). Content stays on disk until a page is requested. At request time, the MDX is parsed with safe-mdx, split into sections, and rendered with the editorial components.
Never use <p> tags in components other than the P component itself (the MDX p mapping in app-factory.tsx). In the editorial component system, safe-mdx wraps text children in paragraph nodes that map to P. If any other component (e.g. Caption, Hero, custom wrappers) also renders a <p>, the text inside it will get wrapped in another P → <p>, creating invalid <p> inside <p> nesting. This violates the HTML spec and causes React hydration mismatches.
Use <div> instead of <p> in all editorial components. Style it identically with inline styles — the visual output is the same, and <div> can nest any element without spec violations.
This rule is not a concern for container components that receive {children} from MDX (like Callout, Accordion, Expandable, Panel, Card, Frame, Prompt, Badge, Steps, Update, View, Tile, etc.). Those containers render {children} directly and safe-mdx handles wrapping text into P nodes. The rule only applies when a component explicitly renders a <p> tag in its own JSX — that <p> would nest inside the P that safe-mdx wraps around the component call, producing <P><p>…</p></P>. As long as all text in your component JSX uses <div> or <span> (never <p>), you're safe.
When a page renders but client behavior is dead (tree rows do not collapse, search input does nothing, title does not update on navigation), debug hydration in this order:
-
Check whether the client tree hydrated at all
- Use Playwriter in a wide viewport.
- Inspect TOC DOM nodes for React markers like
__reactFiber*/__reactProps*. - If they are missing, the issue is not the TOC logic; the client boundary never mounted.
-
Check the browser resource graph
- Compare Holocron against a known-good Spiceflow app (the
playwriter/websiteproject is a good reference). - If the page never requests a
virtual:vite-rsc/client-package-proxy/...module for Holocron components, the package client boundary is not being treated as package source.
- Compare Holocron against a known-good Spiceflow app (the
-
Common root causes for missing hydration in Holocron
- Package externalization:
@vitejs/plugin-rscmust keep@holocron.so/vite/...subpaths inside the RSC transform pipeline.- Symptom: no React markers on the TOC DOM.
- Fix area:
vite/src/vite-plugin.tsclient/ssr/rsc config, especiallyresolve.noExternal.
- Symlink resolution escaping
node_modules: when workspace symlinks are real-pathed,@vitejs/plugin-rscmay stop treating Holocron imports as package sources.- Symptom: server rendering works, but interactive collapse regresses.
- Fix area:
resolve.preserveSymlinks.
- Browser entry failing before startup: if
spiceflow's browser entry never reacheshydrateRoot, the whole page stays static.- Symptom: no React markers, no client behavior, often with browser
unhandledrejectionerrors. - Fix area: dep optimization for wrapper-package transitive deps.
- Symptom: no React markers, no client behavior, often with browser
- Package externalization:
-
Specific error messages and likely causes
SyntaxError: ... prism.js does not provide an export named 'default'- Cause: browser package client chunk imported
prismjswith default-import interop that only worked on the server side. - Fix area:
vite/src/components/markdown.tsxPrism import shape.
- Cause: browser package client chunk imported
Error: Calling require for "scheduler" in an environment that doesn't expose the require function- Cause: the browser dep optimizer left a raw
require("scheduler")path in the React DOM client graph. - Fix area: wrapper-package client optimize deps and resolution/aliasing for
scheduler.
- Cause: the browser dep optimizer left a raw
ReferenceError: module is not definedfrom@vitejs/plugin-rsc/dist/vendor/react-server-dom/...- Cause: the vendored browser client path is reaching the browser as raw CommonJS instead of going through the correct optimized package chain.
- Fix area: ensure the browser client dep graph is optimized through the wrapper package path, not only from the app root.
Failed to resolve dependency: @holocron.so/vite > spiceflow > @vitejs/plugin-rsc/vendor/react-server-dom/client.browser- Cause: Vite cannot resolve that exact nested include from the app root even though the runtime graph may still work.
- Treat as a signal while debugging, not automatically as the root bug.
-
Title debugging
- If
document.titleis empty but server markup looks correct, inspect the serialized flight payload and confirmroot.headcontains a real<title>tag. - The stable fix was to derive
headandtitlefrom the actual page/layout tree (getHeadSnapshot) on the server, then syncdocument.titlefrom that payload on the client.
- If
-
Best comparison target
- The extracted editorial UI originally worked in
playwriter/website. - When Hydration breaks in Holocron, compare:
- loaded browser resources
- presence of
client-package-proxyrequests - React markers on TOC DOM
- startup browser errors / unhandled rejections
- The extracted editorial UI originally worked in
holocron docs website generator uses spiceflow deeply. I am also the author of spiceflow so if there is any issues there and we need to change code there clearly say so and create a plan and present it to me. the spiceflow source code can be downloaded with chamber to be read, then you can use the kimaki cli to find the source code to modify after plan is approved
after you make changes to holocron vite you will have to run pnpm build again inside vite so that the example and integration tests can use the updated code from dist.
The integration-tests/ package is organized as a set of fixtures, one per configuration shape. Each fixture is a self-contained mini-site with its own holocron.jsonc (or docs.json) + pages/, and its own matching test directory. Each fixture exercises a different permutation of Holocron config fields so we can cover every config shape a user might actually write.
integration-tests/
├── fixtures/
│ ├── basic/ # navigation: [{group, pages}] shorthand
│ │ ├── holocron.jsonc
│ │ └── pages/*.mdx
│ ├── tabs/ # navigation.tabs with groups + external link tabs
│ │ ├── holocron.jsonc
│ │ └── pages/*.mdx
│ └── <name>/ # one folder per config variation
├── e2e/
│ ├── basic/ # tests for the basic fixture
│ │ ├── basic.test.ts
│ │ └── config-hmr.test.ts
│ └── tabs/ # tests for the tabs fixture
│ └── tabs.test.ts
├── scripts/
│ ├── fixtures.ts # discovers fixtures/* subdirs with a config file
│ └── build-fixtures.ts # runs `vite build` once per fixture
├── vite.config.ts # shared by every fixture
└── playwright.config.ts # one webServer + one project per fixture
How it works: scripts/fixtures.ts walks fixtures/ and returns every subdirectory containing a holocron.jsonc or docs.json. playwright.config.ts allocates one free port per fixture (persisted via E2E_PORT_<NAME> env vars so re-imports get stable ports), then spawns one webServer per fixture (via vite <fixtureRoot> --config vite.config.ts --port <N> in dev, or node <fixtureRoot>/dist/rsc/index.js in build mode) and one Playwright project per fixture with testDir: e2e/<name> and use.baseURL: http://localhost:<N>.
Tests use Playwright's request fixture (not raw fetch()) so per-project baseURL is picked up automatically.
When you hit a config bug — add a fixture: if a user reports that some combination of navigation, navbar, anchors, redirects, footer.socials, logo, or any other config fields misbehaves, add a new fixture under fixtures/<descriptive-name>/ with the minimal reproduction config + MDX pages, add a matching e2e/<name>/<name>.test.ts, reproduce the bug as a failing test, then fix the bug in vite/src/.
Adding a fixture, step by step:
- Create
fixtures/<name>/holocron.jsonc(ordocs.json) - Create
fixtures/<name>/pages/*.mdxwith whatever pages the config references - Create
e2e/<name>/<name>.test.tswith assertions on the rendered output - Done —
playwright.config.tsdiscovers the fixture automatically. No other changes needed.
Running tests:
pnpm test-e2e— runs every fixture in dev mode (one Vite server per fixture)pnpm test-e2e-start— builds every fixture, runs every fixture in prod modepnpm test-e2e --project=<name>— runs one specific fixture
Playwright waits: avoid fixed sleeps like page.waitForTimeout(2000) in integration tests. Prefer condition-based waits such as expect(...).toBeVisible(), page.waitForLoadState('networkidle'), expect.poll(...), or a concrete DOM/state change tied to the behavior under test.
After changing vite/src/ you must run pnpm build in the vite/ package before re-running integration tests.
takumi is the library used to generate images for example for og images
if needed read docs with curl https://takumi.kane.tw/llms-full.txt
remark plugins are very useful to change the AST of the mdx, for example to convert tags into our own image component with support for placeholder and static non layout shift size props.
another use case is to add a compat layer to support Mintlify patterns
to test remark plugins use vitest tests with inline snapshots, input is mdx string and output should be mdx string too. see existing tests for examples