Skip to content

@ox-content/vite-plugin-solid#

View Markdown

Solid integration for Ox Content - embed Solid components in Markdown.

Installation#

vp install @ox-content/vite-plugin-solid solid-js@next @solidjs/web@next @solidjs/vite-plugin

This 3.x beta adapter targets Solid 2 and @solidjs/vite-plugin. It does not preserve the Solid 1 + vite-plugin-solid peer dependency path; keep the older adapter release if your app has to stay on Solid 1.

Usage#

// vite.config.ts
import { defineConfig } from "vite";
import solid from "@solidjs/vite-plugin";
import { oxContentSolid } from "@ox-content/vite-plugin-solid";

export default defineConfig({
  plugins: [
    oxContentSolid({
      srcDir: "docs",
      // Auto-discover components with glob pattern
      components: "./src/components/*.tsx",
    }),
    solid({ extensions: [".md", ".markdown", ".mdx"], compiler: "native" }),
  ],
});

Plugin Order and extensions#

Unlike the Vue, React, and Svelte integrations, this plugin has two setup rules that are not optional. Both follow from the same fact: Solid's JSX is compile-time only. There is no runtime element factory like React's createElement or Vue's h to fall back on, so Markdown is emitted as Solid JSX and @solidjs/vite-plugin is what turns it into DOM or SSR instructions.

  1. oxContentSolid() must come before solid() in the plugins array. Both plugins are enforce: "pre", so array order decides which one sees the Markdown file first. If solid() runs first, the Solid compiler sees raw Markdown instead of the generated JSX.
  2. solid() must be given the Markdown extensions. Use compiler: "native" for the Solid 2 native OXC compiler path. Without the extensions, generated Markdown modules would be handed to the browser as uncompiled JSX.

Both mistakes are checked for you and reported with the fix — see verifySolidPlugin.

Options#

components#

  • Type: string | string[] | Record<string, string>

Components to register for use in Markdown. Supports:

// Single pattern
components: "./src/components/*.tsx";

// Multiple patterns
components: ["./src/components/*.tsx", "./src/ui/*.tsx"];

Component names are derived from file names in PascalCase:

  • counter.tsxCounter
  • my-button.tsxMyButton

Explicit Map#

components: {
  Counter: './src/components/Counter.tsx',
  Alert: './src/components/Alert.tsx',
}

verifySolidPlugin#

  • Type: boolean
  • Default: true

Fail fast on the two setup mistakes described above instead of letting them surface as an unrelated syntax error.

The plugin check runs when the config resolves; the extensions check runs the first time a Markdown module comes out of the pipeline still uncompiled. Both throw a message naming the fix.

Turn it off when Solid's JSX is compiled by something other than @solidjs/vite-plugin.

Using Components in Markdown#

Register shared components in the global components map, or import a component from the document that uses it.

# My Page

Here's an interactive counter:

<Counter start={5} />

And an alert:

<Alert type="warning">
  This is a warning message!
</Alert>

On .mdx, a relative import is local to that file and overrides the global map when the names match:

import GtvChart from './gtv-chart/GtvChart.tsx'

<GtvChart title="ok" />

Optional renderIsland(name, props, filePath) can replace island inner HTML at transform time. The hook belongs on the Solid adapter; @ox-content/vite-plugin does not import @solidjs/web for SSR.

Example Component#

// src/components/Counter.tsx
import { createSignal } from "solid-js";

export default function Counter(props: { start?: number }) {
  const [count, setCount] = createSignal(props.start ?? 0);

  return (
    <div class="counter">
      <button onClick={() => setCount(count() - 1)}>-</button>
      <span>{count()}</span>
      <button onClick={() => setCount(count() + 1)}>+</button>
    </div>
  );
}

Note that props is not destructured. Solid props are a reactive proxy, and destructuring them reads every value once at setup time, which is what breaks reactivity in components ported from React.

Virtual Modules#

  • virtual:ox-content-solid/components - Registered components
import components from "virtual:ox-content-solid/components";

There is no virtual:ox-content-solid/runtime counterpart to the Svelte integration's: Solid mounts through render from @solidjs/web, which the generated modules import directly.

Islands#

Markdown that uses a registered or document-imported component is emitted with island markers and hydrated through @ox-content/islands, the same runtime the other framework integrations use. Each island is mounted with render from @solidjs/web and disposed when the Markdown component unmounts.

Markdown without any registered or document-imported component skips the island runtime entirely and compiles to a single innerHTML binding.

HTML-string custom hosts#

Hosts that call renderMarkdown() and then place the returned HTML inside their own Solid page shell can use the Solid adapter without importing each Markdown document as a Vite module. renderSolidHtmlHost() resolves document-local MDX imports, loads the matching server modules through a host-supplied loader, and replaces island bodies with Solid SSR HTML.

import { renderSolidHtmlHost, type MdxImport } from "@ox-content/vite-plugin-solid";

const imports: MdxImport[] = [
  { source: "./Chart.tsx", specifiers: [{ imported: "default", local: "Chart", kind: "default" }] },
];

const rendered = await renderSolidHtmlHost({
  html: markdown.html,
  documentPath: "/repo/docs/report.mdx",
  root: "/repo",
  srcDir: "docs",
  imports,
  components: { Badge: "./src/components/Badge.tsx" },
  loadModule: (moduleId) => viteDevServer.ssrLoadModule(moduleId),
  resolveClientModule: (module) =>
    module.source === "document" ? `./docs/${module.name}.tsx` : `./components/${module.name}.tsx`,
});

The module cache is scoped to one render call, so development edits should trigger a fresh call after the host invalidates its page state. resolveClientModule() writes the returned identity to each island as data-ox-module, along with data-ox-export. Use the same keys in the browser loader map so two documents can reuse a local component name without downstream HTML replacement.

Diagnostics report missing components, module load failures, missing exports, SSR errors, and unsupported document-local import forms with document/component context. The supported document-local forms are the same ones the Markdown-module adapter understands: default imports and named imports with local bindings.

The browser client lives on a separate subpath so custom hosts can bundle it without importing Vite, Node helpers, or native optional dependencies: @ox-content/vite-plugin-solid/html-host/client. It is a fresh-mount bridge, not Solid hydration. It reads the existing Ox Content island payload and authored slot HTML, clears the target, and lets the caller mount with @solidjs/web. When SSR produced HTML for a self-closing island, that rendered output is not passed back as children.

For production custom hosts, generate the module map from the same site-selected document set instead of a broad source-directory glob. Keep the publication rule in your site code and share that selected list with both the custom host routes and the registry plugin.

// vite.config.ts
import { defineConfig } from "vite";
import solid from "@solidjs/vite-plugin";
import { oxContentCustomHost } from "@ox-content/vite-plugin";
import { createSolidHtmlHostIslandRegistry } from "@ox-content/vite-plugin-solid";
import { selectedDocuments } from "./src/site-content";

const solidIslands = createSolidHtmlHostIslandRegistry({
  documents: async () =>
    (await selectedDocuments()).map((document) => ({
      documentPath: document.file,
      source: document.source,
    })),
});

export default defineConfig({
  appType: "custom",
  plugins: [
    solidIslands.plugin,
    oxContentCustomHost({ host: "./src/site-host.ts" }),
    solid({ compiler: "native" }),
  ],
});
// src/site-host.ts
import { renderMarkdown, type MdxImport } from "@ox-content/vite-plugin";
import { renderSolidHtmlHost, toSolidHtmlHostClientModuleId } from "@ox-content/vite-plugin-solid";
import { selectedDocuments } from "./site-content";

export default {
  async routes() {
    return (await selectedDocuments()).map((document) => ({
      path: document.url,
      inputPath: document.file,
      source: document.source,
      async render(ctx) {
        const markdown = await renderMarkdown(document.source, document.file, {
          srcDir: "content",
        });
        const rendered = await renderSolidHtmlHost({
          html: markdown.html,
          imports: markdown.imports as MdxImport[],
          documentPath: document.file,
          root: ctx.root,
          srcDir: "content",
          loadModule: ctx.loadModule,
          resolveClientModule: (module) =>
            toSolidHtmlHostClientModuleId(module.serverModuleId, ctx.root),
        });
        return { html: rendered.html, source: document.source };
      },
    }));
  },
};

The browser imports the generated virtual module. Its static dynamic-import roots are the selected island modules only, so Vite does not create entry chunks for unpublished documents that were omitted by your site policy. Dependencies reachable from those selected modules are still bundled normally.

import { initIslands } from "@ox-content/islands";
import { render } from "@solidjs/web";
import { initSolidHtmlHost } from "@ox-content/vite-plugin-solid/html-host/client";
import modules from "virtual:ox-content-solid/html-host/modules";

initSolidHtmlHost({
  initIslands,
  modules,
  render({ component: Component, props, element, slotHtml }) {
    const dispose = render(
      () =>
        slotHtml ? (
          <Component {...props}>
            <div innerHTML={slotHtml} />
          </Component>
        ) : (
          <Component {...props} />
        ),
      element,
    );
    return dispose;
  },
  onError(error) {
    console.error(error.message);
  },
  options: { selector: ".ox-content [data-ox-island]" },
});

initSolidHtmlHost() calls the initIslands() function supplied by the host and returns its controller. If you already manage the island runtime yourself, use createSolidHtmlHostLazyHydrate() from the same subpath and pass the returned synchronous hydrate function to initIslands(). The adapter keeps pending lazy imports cancellable: disposal before a module resolves prevents stale mount, mounted cleanup runs exactly once, and unknown module, loader, runtime, export, and render failures are reported to onError.

Island stylesheets for custom hosts#

Server-rendered islands often need their CSS before the client module mounts. When you use oxContentCustomHost(), prefer the public host assets context so the host never touches a Vite manifest or development module graph directly.

const styles = ctx.assets.stylesheets({
  modules: rendered.clientModules.map((module) => module.moduleId),
});

const assets = ctx.assets.document({
  islandStyles: styles.stylesheets,
  clientEntries: ["src/main.ts"],
});

return {
  html: `<!doctype html><html><head>${assets.headHtml}</head><body>${rendered.html}</body></html>`,
  dependencies: styles.dependencies,
};

The assets context resolves direct and transitive island CSS before the client module script, deduplicates through ctx.assets.document(), preserves dev CSS query strings, and returns dependency paths that keep cached custom-host routes fresh after stylesheet edits. Build mode uses the Vite manifest behind the same method and returns emitted hashed hrefs for the same module identities. The lower-level resolveSolidIslandStylesheets() helper remains available for non-custom hosts that intentionally own the manifest or module graph.

HMR#

Components are hot-reloaded when modified. Markdown modules that use a changed component are invalidated alongside it.

Rust and N-API Codegen#

The Rust renderer can also emit Solid code directly from rendered Markdown HTML, without the Vite pipeline:

import { renderFrameworkComponentCode } from "@ox-content/napi";

renderFrameworkComponentCode("<p>Hello</p>", "solid", [], "component");

That path targets solid-js/h, Solid's hyperscript entry point, because it has to produce code that runs without the JSX compiler. The Vite plugin emits JSX instead, which compiles to faster, finer-grained output.

Last updated: