@@ -179,7 +179,7 @@ export function DirectiveContainer(
name: `tab-group:${props.title}`,
sync: messageSync(new BroadcastChannel("tab-group")),
storage: cookieStorage.withOptions({
- expires: new Date(+new Date() + 3e10),
+ expires: new Date(Date.now() + 3e10),
}),
});
diff --git a/src/default-theme/utils.ts b/src/default-theme/utils.ts
index 4aa84568..cb66c028 100644
--- a/src/default-theme/utils.ts
+++ b/src/default-theme/utils.ts
@@ -1,8 +1,8 @@
-import type { DefaultThemeConfig } from ".";
import {
useRouteSolidBaseConfig as _useRouteConfig,
useSolidBaseContext as _useSolidBaseContext,
-} from "../client";
+} from "../client/index.jsx";
+import type { DefaultThemeConfig } from "./index.js";
export function useSolidBaseContext() {
return _useSolidBaseContext
();
diff --git a/src/mdx.ts b/src/mdx.ts
new file mode 100644
index 00000000..c63df00b
--- /dev/null
+++ b/src/mdx.ts
@@ -0,0 +1,413 @@
+import {
+ createComponent,
+ createContext,
+ type JSX,
+ mergeProps,
+ type ParentProps,
+ useContext,
+} from "solid-js";
+import { Dynamic } from "solid-js/web";
+
+const HTMLElements = [
+ "html",
+ "base",
+ "head",
+ "link",
+ "meta",
+ "style",
+ "title",
+ "body",
+ "address",
+ "article",
+ "aside",
+ "footer",
+ "header",
+ "main",
+ "nav",
+ "section",
+ "body",
+ "blockquote",
+ "dd",
+ "div",
+ "dl",
+ "dt",
+ "figcaption",
+ "figure",
+ "hr",
+ "li",
+ "ol",
+ "p",
+ "pre",
+ "ul",
+ "a",
+ "abbr",
+ "b",
+ "bdi",
+ "bdo",
+ "br",
+ "cite",
+ "code",
+ "data",
+ "dfn",
+ "em",
+ "i",
+ "kbd",
+ "mark",
+ "q",
+ "rp",
+ "rt",
+ "ruby",
+ "s",
+ "samp",
+ "small",
+ "span",
+ "strong",
+ "sub",
+ "sup",
+ "time",
+ "u",
+ "var",
+ "wbr",
+ "area",
+ "audio",
+ "img",
+ "map",
+ "track",
+ "video",
+ "embed",
+ "iframe",
+ "object",
+ "param",
+ "picture",
+ "portal",
+ "source",
+ "svg",
+ "math",
+ "canvas",
+ "noscript",
+ "script",
+ "del",
+ "ins",
+ "caption",
+ "col",
+ "colgroup",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "button",
+ "datalist",
+ "fieldset",
+ "form",
+ "input",
+ "label",
+ "legend",
+ "meter",
+ "optgroup",
+ "option",
+ "output",
+ "progress",
+ "select",
+ "textarea",
+ "details",
+ "dialog",
+ "menu",
+ "summary",
+ "details",
+ "slot",
+ "template",
+ "acronym",
+ "applet",
+ "basefont",
+ "bgsound",
+ "big",
+ "blink",
+ "center",
+ "content",
+ "dir",
+ "font",
+ "frame",
+ "frameset",
+ "hgroup",
+ "image",
+ "keygen",
+ "marquee",
+ "menuitem",
+ "nobr",
+ "noembed",
+ "noframes",
+ "plaintext",
+ "rb",
+ "rtc",
+ "shadow",
+ "spacer",
+ "strike",
+ "tt",
+ "xmp",
+ "a",
+ "abbr",
+ "acronym",
+ "address",
+ "applet",
+ "area",
+ "article",
+ "aside",
+ "audio",
+ "b",
+ "base",
+ "basefont",
+ "bdi",
+ "bdo",
+ "bgsound",
+ "big",
+ "blink",
+ "blockquote",
+ "body",
+ "br",
+ "button",
+ "canvas",
+ "caption",
+ "center",
+ "cite",
+ "code",
+ "col",
+ "colgroup",
+ "content",
+ "data",
+ "datalist",
+ "dd",
+ "del",
+ "details",
+ "dfn",
+ "dialog",
+ "dir",
+ "div",
+ "dl",
+ "dt",
+ "em",
+ "embed",
+ "fieldset",
+ "figcaption",
+ "figure",
+ "font",
+ "footer",
+ "form",
+ "frame",
+ "frameset",
+ "head",
+ "header",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hgroup",
+ "hr",
+ "html",
+ "i",
+ "iframe",
+ "image",
+ "img",
+ "input",
+ "ins",
+ "kbd",
+ "keygen",
+ "label",
+ "legend",
+ "li",
+ "link",
+ "main",
+ "map",
+ "mark",
+ "marquee",
+ "menu",
+ "menuitem",
+ "meta",
+ "meter",
+ "nav",
+ "nobr",
+ "noembed",
+ "noframes",
+ "noscript",
+ "object",
+ "ol",
+ "optgroup",
+ "option",
+ "output",
+ "p",
+ "param",
+ "picture",
+ "plaintext",
+ "portal",
+ "pre",
+ "progress",
+ "q",
+ "rb",
+ "rp",
+ "rt",
+ "rtc",
+ "ruby",
+ "s",
+ "samp",
+ "script",
+ "section",
+ "select",
+ "shadow",
+ "slot",
+ "small",
+ "source",
+ "spacer",
+ "span",
+ "strike",
+ "strong",
+ "style",
+ "sub",
+ "summary",
+ "sup",
+ "table",
+ "tbody",
+ "td",
+ "template",
+ "textarea",
+ "tfoot",
+ "th",
+ "thead",
+ "time",
+ "title",
+ "tr",
+ "track",
+ "tt",
+ "u",
+ "ul",
+ "var",
+ "video",
+ "wbr",
+ "xmp",
+ "input",
+];
+
+const SVGElements = new Set([
+ // "a",
+ "altGlyph",
+ "altGlyphDef",
+ "altGlyphItem",
+ "animate",
+ "animateColor",
+ "animateMotion",
+ "animateTransform",
+ "circle",
+ "clipPath",
+ "color-profile",
+ "cursor",
+ "defs",
+ "desc",
+ "ellipse",
+ "feBlend",
+ "feColorMatrix",
+ "feComponentTransfer",
+ "feComposite",
+ "feConvolveMatrix",
+ "feDiffuseLighting",
+ "feDisplacementMap",
+ "feDistantLight",
+ "feFlood",
+ "feFuncA",
+ "feFuncB",
+ "feFuncG",
+ "feFuncR",
+ "feGaussianBlur",
+ "feImage",
+ "feMerge",
+ "feMergeNode",
+ "feMorphology",
+ "feOffset",
+ "fePointLight",
+ "feSpecularLighting",
+ "feSpotLight",
+ "feTile",
+ "feTurbulence",
+ "filter",
+ "font",
+ "font-face",
+ "font-face-format",
+ "font-face-name",
+ "font-face-src",
+ "font-face-uri",
+ "foreignObject",
+ "g",
+ "glyph",
+ "glyphRef",
+ "hkern",
+ "image",
+ "line",
+ "linearGradient",
+ "marker",
+ "mask",
+ "metadata",
+ "missing-glyph",
+ "mpath",
+ "path",
+ "pattern",
+ "polygon",
+ "polyline",
+ "radialGradient",
+ "rect",
+ // "script",
+ "set",
+ "stop",
+ // "style",
+ "svg",
+ "switch",
+ "symbol",
+ "text",
+ "textPath",
+ // "title",
+ "tref",
+ "tspan",
+ "use",
+ "view",
+ "vkern",
+]);
+
+export const MDXContext = createContext(
+ Object.fromEntries(
+ [...HTMLElements, ...SVGElements.keys()].map((el) => [
+ el,
+ (_props: any) => {
+ const props = mergeProps(_props, {
+ component: el,
+ });
+ return createComponent(Dynamic, props);
+ },
+ ]),
+ ),
+);
+
+export const MDXProvider = (
+ props: ParentProps<{
+ components: {
+ [k: string]: (props: any) => JSX.Element;
+ };
+ }>,
+) => {
+ const context = useContext(MDXContext);
+ return createComponent(MDXContext.Provider, {
+ get value() {
+ return {
+ ...context,
+ ...(props.components ?? {}),
+ };
+ },
+ get children() {
+ return props.children;
+ },
+ });
+};
+
+export const useMDXComponents = () => {
+ return useContext(MDXContext);
+};
diff --git a/src/server.ts b/src/server.tsx
similarity index 64%
rename from src/server.ts
rename to src/server.tsx
index edf84875..ccbdc9a0 100644
--- a/src/server.ts
+++ b/src/server.tsx
@@ -1,4 +1,4 @@
-import { getLocale, getTheme } from "./client";
+import { getLocale, getTheme } from "./client/index.jsx";
export function getHtmlProps() {
return {
diff --git a/src/solid-mdx.ts b/src/solid-mdx.ts
deleted file mode 100644
index 64ddc1ff..00000000
--- a/src/solid-mdx.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "solid-mdx";
diff --git a/src/types/solid-mdx.d.ts b/src/types/solid-mdx.d.ts
deleted file mode 100644
index 4dcef8e6..00000000
--- a/src/types/solid-mdx.d.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-declare module "solid-mdx" {
- import type { PropsWithChildren, JSX } from "solid-js";
-
- export declare const MDXContext: import("solid-js").Context<{
- [k: string]: (props: any) => JSX.Element;
- }>;
- export declare const MDXProvider: (
- props: PropsWithChildren<{
- components: {
- [k: string]: (props: any) => JSX.Element;
- };
- }>,
- ) => JSX.Element;
- export declare const useMDXComponents: () => {
- [k: string]: (props: any) => JSX.Element;
- };
-}
diff --git a/src/virtual.d.ts b/src/virtual.d.ts
index d11f8217..c02fc5b9 100644
--- a/src/virtual.d.ts
+++ b/src/virtual.d.ts
@@ -1,5 +1,7 @@
declare module "virtual:solidbase/config" {
- export const solidBaseConfig: import("./config").SolidBaseResolvedConfig;
+ export const solidBaseConfig: import(
+ "./config/index.js"
+ ).SolidBaseResolvedConfig;
}
declare module "virtual:solidbase/components" {
@@ -12,3 +14,7 @@ declare module "virtual:solidbase/components" {
declare module "virtual:solidbase/default-theme/fonts" {
export const preloadFonts: Array<{ path: string; type: string }>;
}
+
+declare module "virtual:solidbase/mdx" {
+ export const MDXProvider: typeof import("./mdx.ts").MDXProvider;
+}
diff --git a/src/vite-mdx/imports.ts b/src/vite-mdx/imports.ts
index f740abd4..c13079d1 100644
--- a/src/vite-mdx/imports.ts
+++ b/src/vite-mdx/imports.ts
@@ -38,7 +38,7 @@ export function resolveImport(
if (!importCache[cacheKey]) {
try {
importCache[cacheKey] = resolve.sync(name, { basedir: cwd });
- } catch (e) {
+ } catch (_e) {
if (throwOnMissing) {
throw new Error(`[vite-plugin-mdx] "${name}" must be installed`);
}
diff --git a/src/vite-mdx/index.ts b/src/vite-mdx/index.ts
index 5691e78f..18508187 100644
--- a/src/vite-mdx/index.ts
+++ b/src/vite-mdx/index.ts
@@ -1,8 +1,7 @@
// ty vinxi :)
-import type { Plugin } from "vite";
-
import { VFile, type VFileCompatible } from "vfile";
+import type { Plugin } from "vite";
import { mergeArrays } from "./common.js";
import type { NamedImports } from "./imports.js";
import { createTransformer } from "./transform.js";
@@ -67,7 +66,7 @@ function createPlugin(
},
async transform(_code, id, ssr) {
let code = _code;
- const [path, query] = id.split("?");
+ const [path, _query] = id.split("?");
if (/\.mdx?$/.test(path)) {
if (!transformMdx)
throw new Error(
@@ -82,8 +81,8 @@ function createPlugin(
const input = new VFile({ value: code, path });
code = await transformMdx(input, { ...mdxOptions });
- // @ts-ignore
- const refreshResult = await reactRefresh?.transform!.call(
+ // @ts-expect-error
+ const refreshResult = await reactRefresh?.transform?.call(
this,
code,
`${path}.js`,
diff --git a/src/vite-mdx/viteMdxTransclusion/index.ts b/src/vite-mdx/viteMdxTransclusion/index.ts
index 4e4e17e1..0ef681c7 100644
--- a/src/vite-mdx/viteMdxTransclusion/index.ts
+++ b/src/vite-mdx/viteMdxTransclusion/index.ts
@@ -3,15 +3,15 @@ import { isAbsolute } from "node:path";
import LRUCache from "@alloc/quick-lru";
import {
type FSWatcher,
+ normalizePath,
type Plugin,
type ResolvedConfig,
- normalizePath,
} from "vite";
import { mergeArrays } from "../common.js";
import type { MdxOptions, MdxPlugin } from "../types.js";
-import { ImportMap } from "./ImportMap.js";
import { createMdxAstCompiler } from "./createMdxAstCompiler.js";
+import { ImportMap } from "./ImportMap.js";
import { type MdxAstCache, remarkTransclusion } from "./remarkTransclusion.js";
/**
@@ -68,7 +68,6 @@ export function viteMdxTransclusion(
if (importers) {
astCache.delete(filePath);
for (const importer of importers) {
- // @ts-ignore
watcher!.emit("change", importer);
}
}
diff --git a/tests/client/config.test.ts b/tests/client/config.test.ts
new file mode 100644
index 00000000..209010f3
--- /dev/null
+++ b/tests/client/config.test.ts
@@ -0,0 +1,57 @@
+import { createRoot } from "solid-js";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+function setSolidBaseConfig(value: Record) {
+ const store = ((globalThis as any).__solidBaseConfig ??= {}) as Record<
+ string,
+ unknown
+ >;
+ for (const key of Object.keys(store)) delete store[key];
+ Object.assign(store, value);
+}
+
+vi.mock("../../src/client/locale.ts", () => ({
+ useLocale: () => ({
+ currentLocale: () => ({
+ config: {
+ themeConfig: {
+ nav: { title: "Localized" },
+ },
+ },
+ }),
+ }),
+}));
+
+describe("route config helper", () => {
+ afterEach(() => {
+ setSolidBaseConfig({});
+ vi.resetModules();
+ });
+
+ it("merges locale theme config over the base config", async () => {
+ setSolidBaseConfig({
+ title: "Docs",
+ themeConfig: {
+ nav: { title: "Default" },
+ sidebar: { "/": [] },
+ },
+ });
+
+ const { useRouteSolidBaseConfig } = await import(
+ "../../src/client/config.ts"
+ );
+
+ createRoot((dispose) => {
+ const config = useRouteSolidBaseConfig();
+
+ expect(config()).toMatchObject({
+ title: "Docs",
+ themeConfig: {
+ nav: { title: "Localized" },
+ sidebar: { "/": [] },
+ },
+ });
+ dispose();
+ });
+ });
+});
diff --git a/tests/client/locale.test.ts b/tests/client/locale.test.ts
new file mode 100644
index 00000000..b1bae52e
--- /dev/null
+++ b/tests/client/locale.test.ts
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+
+import { createRoot } from "solid-js";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const pathname = vi.fn<() => string>(() => "/fr/guide/install");
+const navigate = vi.fn<(to: string) => Promise>(() => Promise.resolve());
+const matchRest = vi.fn<() => string | undefined>(() => "guide/install");
+
+function setSolidBaseConfig(value: Record) {
+ const store = ((globalThis as any).__solidBaseConfig ??= {}) as Record<
+ string,
+ unknown
+ >;
+ for (const key of Object.keys(store)) delete store[key];
+ Object.assign(store, value);
+}
+
+vi.mock("@solidjs/router", () => ({
+ useLocation: () => ({
+ get pathname() {
+ return pathname();
+ },
+ }),
+ useNavigate: () => navigate,
+ useMatch: () => () => ({ params: { rest: matchRest() } }),
+}));
+
+vi.mock("solid-js", async () => {
+ const actual = await vi.importActual("solid-js");
+ return {
+ ...actual,
+ startTransition: (fn: () => void) => Promise.resolve(fn()),
+ };
+});
+
+vi.mock("solid-js/web", () => ({
+ getRequestEvent: vi.fn(),
+ isServer: false,
+}));
+
+describe("locale client helpers", () => {
+ afterEach(() => {
+ pathname.mockReset();
+ pathname.mockReturnValue("/fr/guide/install");
+ navigate.mockReset();
+ navigate.mockReturnValue(Promise.resolve());
+ matchRest.mockReset();
+ matchRest.mockReturnValue("guide/install");
+ setSolidBaseConfig({});
+ vi.resetModules();
+ });
+
+ it("selects the current locale, prefixes paths, and navigates when switching", async () => {
+ document.documentElement.lang = "";
+ setSolidBaseConfig({
+ lang: "en-US",
+ locales: {
+ root: { label: "English" },
+ fr: { label: "Francais" },
+ },
+ });
+
+ const { LocaleContextProvider, getLocaleLink, useLocale } = await import(
+ "../../src/client/locale.ts"
+ );
+
+ createRoot((dispose) => {
+ let value: ReturnType | undefined;
+
+ LocaleContextProvider({
+ get children() {
+ value = useLocale();
+ return null;
+ },
+ } as any);
+
+ expect(value?.currentLocale().code).toBe("fr");
+ expect(value?.routePath()).toBe("/guide/install");
+ expect(value?.applyPathPrefix("reference")).toBe("/fr/reference");
+ expect(getLocaleLink(value!.locales[0]!)).toBe("/");
+ expect(getLocaleLink(value!.locales[1]!)).toBe("/fr/");
+
+ void value?.setLocale(value!.locales[0]!);
+ dispose();
+ });
+
+ await Promise.resolve();
+ expect(navigate).toHaveBeenCalledWith("/guide/install");
+ expect(document.documentElement.lang).toBe("en-US");
+ });
+
+ it("supports custom locale links and root fallback resolution", async () => {
+ setSolidBaseConfig({
+ lang: "en-US",
+ locales: {
+ root: { label: "English", link: "/docs/" },
+ de: { label: "Deutsch", link: "/de/docs/" },
+ },
+ });
+
+ const { getLocale, getLocaleLink } = await import(
+ "../../src/client/locale.ts"
+ );
+
+ expect(getLocale("/de/docs/setup").code).toBe("de");
+ expect(getLocale("/docs/setup").code).toBe("en-US");
+ expect(getLocaleLink(getLocale("/de/docs/setup"))).toBe("/de/docs/");
+ });
+});
diff --git a/tests/client/page-data.test.ts b/tests/client/page-data.test.ts
new file mode 100644
index 00000000..d366ef30
--- /dev/null
+++ b/tests/client/page-data.test.ts
@@ -0,0 +1,79 @@
+// @vitest-environment jsdom
+
+import { createRoot } from "solid-js";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const useCurrentMatches = vi.fn();
+
+vi.mock("@solidjs/router", () => ({
+ useCurrentMatches,
+}));
+
+vi.mock("solid-js", async () => {
+ const actual = await vi.importActual("solid-js");
+ return {
+ ...actual,
+ createResource: (source: any, fetcher: (value: any) => Promise) => {
+ let value: any;
+ Promise.resolve(typeof source === "function" ? source() : source)
+ .then((resolved) => fetcher(resolved))
+ .then((resolved) => {
+ value = resolved;
+ });
+ return [() => value] as const;
+ },
+ };
+});
+
+describe("page data helpers", () => {
+ const pagePath = "tests/fixtures/page.mdx";
+
+ afterEach(() => {
+ useCurrentMatches.mockReset();
+ vi.resetModules();
+ (window as any).$$SolidBase_page_data = undefined;
+ });
+
+ it("reads current page data from the window cache in dev", async () => {
+ useCurrentMatches.mockReturnValue([
+ {
+ route: { key: { $component: { src: `${pagePath}?import` } } },
+ },
+ ]);
+ (window as any).$$SolidBase_page_data = {
+ [pagePath]: {
+ frontmatter: { title: "Hello", description: "World" },
+ llmText: "Hello world",
+ },
+ };
+
+ const { CurrentPageDataProvider, useCurrentPageData, useFrontmatter } =
+ await import("../../src/client/page-data.ts");
+
+ let pageData: ReturnType | undefined;
+ let frontmatter: ReturnType> | undefined;
+
+ const dispose = createRoot((dispose) => {
+ CurrentPageDataProvider({
+ get children() {
+ pageData = useCurrentPageData();
+ frontmatter = useFrontmatter();
+ return null;
+ },
+ } as any);
+ return dispose;
+ });
+
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect((pageData?.() as any)?.llmText).toBe("Hello world");
+ expect(frontmatter?.()).toEqual({
+ title: "Hello",
+ description: "World",
+ });
+ dispose();
+ });
+});
diff --git a/tests/client/sidebar.test.ts b/tests/client/sidebar.test.ts
new file mode 100644
index 00000000..3f98cec4
--- /dev/null
+++ b/tests/client/sidebar.test.ts
@@ -0,0 +1,131 @@
+import { createRoot } from "solid-js";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const pathname = vi.fn<() => string>(() => "/guide/install");
+const routePath = vi.fn<() => string>(() => "/guide/install");
+
+vi.mock("@solidjs/router", () => ({
+ useLocation: () => ({
+ get pathname() {
+ return pathname();
+ },
+ }),
+}));
+
+vi.mock("../../src/client/locale.ts", () => ({
+ useLocale: () => ({
+ routePath,
+ }),
+}));
+
+describe("client sidebar helpers", () => {
+ afterEach(() => {
+ pathname.mockReset();
+ pathname.mockReturnValue("/guide/install");
+ routePath.mockReset();
+ routePath.mockReturnValue("/guide/install");
+ vi.resetModules();
+ });
+
+ it("selects the longest matching sidebar prefix", async () => {
+ const { SidebarProvider, useSidebar } = await import(
+ "../../src/client/sidebar.ts"
+ );
+
+ createRoot((dispose) => {
+ let value: ReturnType> | undefined;
+ SidebarProvider({
+ config: {
+ "/guide": [{ title: "Guide", link: "/intro" }],
+ "/guide/install": [{ title: "Install", link: "/" }],
+ },
+ get children() {
+ value = useSidebar()();
+ return null;
+ },
+ } as any);
+
+ expect(value).toEqual({
+ prefix: "/guide/install",
+ items: [{ title: "Install", link: "/" }],
+ });
+ dispose();
+ });
+ });
+
+ it("flattens nested sections and computes prev/next links", async () => {
+ pathname.mockReturnValue("/guide/nested/install");
+ routePath.mockReturnValue("/guide/nested/install");
+
+ const { SidebarProvider, usePrevNext } = await import(
+ "../../src/client/sidebar.ts"
+ );
+
+ createRoot((dispose) => {
+ let value: ReturnType | undefined;
+ SidebarProvider({
+ config: {
+ "/guide": [
+ { title: "Intro", link: "/intro" },
+ {
+ title: "Section",
+ base: "/nested",
+ items: [
+ { title: "Install", link: "/install" },
+ { title: "External", link: "https://example.com" },
+ ],
+ },
+ ],
+ },
+ get children() {
+ value = usePrevNext() as any;
+ return null;
+ },
+ } as any);
+
+ expect(value).toBeDefined();
+ expect(value!.prevLink()).toMatchObject({
+ title: "Intro",
+ link: "/guide/intro",
+ depth: 0,
+ });
+ expect(value!.nextLink()).toMatchObject({
+ title: "External",
+ link: "https://example.com",
+ target: "_blank",
+ rel: "noopener noreferrer",
+ depth: 1,
+ });
+ dispose();
+ });
+ });
+
+ it("supports legacy section-shaped sidebar config objects", async () => {
+ const { SidebarProvider, useSidebar } = await import(
+ "../../src/client/sidebar.ts"
+ );
+
+ createRoot((dispose) => {
+ let value: ReturnType> | undefined;
+
+ SidebarProvider({
+ config: {
+ "/guide": {
+ title: "Guide",
+ items: [{ title: "Intro", link: "/intro" }],
+ },
+ },
+ get children() {
+ value = useSidebar()();
+ return null;
+ },
+ } as any);
+
+ expect(value).toEqual({
+ prefix: "/guide",
+ items: [{ title: "Intro", link: "/intro" }],
+ });
+ dispose();
+ });
+ });
+});
diff --git a/tests/client/theme.test.ts b/tests/client/theme.test.ts
new file mode 100644
index 00000000..914f9692
--- /dev/null
+++ b/tests/client/theme.test.ts
@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+
+import { createRoot } from "solid-js";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const prefersDarkValue = vi.fn<() => boolean>(() => false);
+const useHead = vi.fn();
+
+vi.mock("@solid-primitives/media", () => ({
+ usePrefersDark: () => prefersDarkValue,
+}));
+
+vi.mock("solid-js", async () => {
+ const actual = await vi.importActual("solid-js");
+ return {
+ ...actual,
+ createEffect: (fn: () => void) => fn(),
+ createUniqueId: () => "theme-script",
+ };
+});
+
+vi.mock("@solidjs/meta", () => ({
+ useHead,
+}));
+
+vi.mock("solid-js/web", () => ({
+ getRequestEvent: vi.fn(),
+ isServer: false,
+}));
+
+vi.mock("../../src/client/read-theme-cookie.js?raw", () => ({
+ default: "window.__theme = document.cookie",
+}));
+
+describe("theme client helpers", () => {
+ afterEach(async () => {
+ prefersDarkValue.mockReset();
+ prefersDarkValue.mockReturnValue(false);
+ useHead.mockReset();
+ vi.resetModules();
+ document.cookie = "";
+ });
+
+ it("derives raw theme, variant, and theme from cookies and system preference", async () => {
+ document.cookie = "theme=system";
+ prefersDarkValue.mockReturnValue(true);
+
+ const { getRawTheme, getTheme, getThemeVariant, setTheme } = await import(
+ "../../src/client/theme.ts"
+ );
+
+ expect(getRawTheme()).toBe("sdark");
+ expect(getTheme()).toBe("dark");
+ expect(getThemeVariant()).toBe("system");
+
+ setTheme("light");
+ expect(getRawTheme()).toBe("light");
+ expect(getThemeVariant()).toBe("light");
+ });
+
+ it("writes theme side effects and injects the theme cookie script", async () => {
+ const setAttribute = vi.spyOn(document.documentElement, "setAttribute");
+ document.cookie = "theme=dark";
+
+ const { setTheme, useThemeListener } = await import(
+ "../../src/client/theme.ts"
+ );
+
+ const dispose = createRoot((dispose) => {
+ setTheme("dark");
+ useThemeListener();
+ return dispose;
+ });
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(setAttribute).toHaveBeenCalledWith("data-theme", "dark");
+ expect(document.cookie).toContain("theme=dark");
+ expect(useHead).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tag: "script",
+ props: expect.objectContaining({
+ children: "window.__theme = document.cookie",
+ }),
+ }),
+ );
+ setAttribute.mockRestore();
+ dispose();
+ });
+});
diff --git a/tests/config/document-markdown.test.ts b/tests/config/document-markdown.test.ts
new file mode 100644
index 00000000..ec8ec156
--- /dev/null
+++ b/tests/config/document-markdown.test.ts
@@ -0,0 +1,198 @@
+import { describe, expect, it } from "vitest";
+
+import { toDocumentMarkdown } from "../../src/config/document-markdown.ts";
+import { routeFixturePath } from "../helpers/fixtures.ts";
+
+describe("toDocumentMarkdown", () => {
+ it("renders arbitrary frontmatter expressions across markdown content", async () => {
+ const source = [
+ "---",
+ "title: Hello",
+ "product: SolidBase",
+ "tagline: Fast docs",
+ "release: 3",
+ "---",
+ "",
+ "# {frontmatter.title}",
+ "",
+ "Welcome to {frontmatter.product}.",
+ "",
+ "> {frontmatter.tagline}",
+ "",
+ "- v{frontmatter.release}",
+ "",
+ '{frontmatter["product"]}',
+ ].join("\n");
+
+ expect(
+ await toDocumentMarkdown(source, {
+ config: {},
+ filePath: routeFixturePath("index.mdx"),
+ }),
+ ).toBe(
+ [
+ "# Hello",
+ "",
+ "Welcome to SolidBase.",
+ "",
+ "> Fast docs",
+ "",
+ "* v3",
+ "",
+ "SolidBase",
+ ].join("\n"),
+ );
+ });
+
+ it("strips frontmatter metadata and keeps code fences untouched", async () => {
+ const source = [
+ "---",
+ "title: Hello",
+ "product: SolidBase",
+ "---",
+ "",
+ "# {frontmatter.title}",
+ "",
+ "```md",
+ "{frontmatter.product}",
+ "```",
+ ].join("\n");
+
+ const markdown = await toDocumentMarkdown(source, {
+ config: {},
+ filePath: routeFixturePath("index.mdx"),
+ });
+
+ expect(markdown).toContain("# Hello");
+ expect(markdown).toContain("```md\n{frontmatter.product}\n```");
+ expect(markdown).not.toContain("title: Hello");
+ expect(markdown).not.toContain("export const frontmatter");
+ });
+
+ it("renders scalar values and removes empty flow expressions", async () => {
+ const source = [
+ "---",
+ "count: 3",
+ "published: false",
+ "tags:",
+ " - docs",
+ " - llms",
+ "---",
+ "",
+ "Count: {frontmatter.count}",
+ "",
+ "{frontmatter.published}",
+ "",
+ "{frontmatter.tags}",
+ ].join("\n");
+
+ expect(
+ await toDocumentMarkdown(source, {
+ config: {},
+ filePath: routeFixturePath("index.mdx"),
+ }),
+ ).toBe(["Count: 3", "", "docsllms"].join("\n"));
+ });
+
+ it("keeps object expressions intact and renders missing values as empty text", async () => {
+ const source = [
+ "---",
+ "meta:",
+ " nested: value",
+ "---",
+ "",
+ "Object: {frontmatter.meta}",
+ "",
+ "Broken: {frontmatter.missing?.value}",
+ ].join("\n");
+
+ expect(
+ await toDocumentMarkdown(source, {
+ config: {},
+ filePath: routeFixturePath("index.mdx"),
+ }),
+ ).toBe(["Object: {frontmatter.meta}", "", "Broken:"].join("\n"));
+ });
+
+ it("matches a representative rendered-markdown fixture", async () => {
+ const source = [
+ "---",
+ "title: Home",
+ "product: SolidBase",
+ "tagline: Fast docs",
+ "release: 3",
+ "---",
+ "",
+ "# {frontmatter.title}",
+ "",
+ "Welcome to {frontmatter.product}.",
+ "",
+ "> {frontmatter.tagline}",
+ "",
+ "- v{frontmatter.release}",
+ "",
+ '{frontmatter["product"]}',
+ "",
+ "```md",
+ "{frontmatter.product}",
+ "```",
+ ].join("\n");
+
+ expect(
+ await toDocumentMarkdown(source, {
+ config: {},
+ filePath: routeFixturePath("index.mdx"),
+ }),
+ ).toBe(
+ [
+ "# Home",
+ "",
+ "Welcome to SolidBase.",
+ "",
+ "> Fast docs",
+ "",
+ "* v3",
+ "",
+ "SolidBase",
+ "",
+ "```md",
+ "{frontmatter.product}",
+ "```",
+ ].join("\n"),
+ );
+ });
+
+ it("applies TOC, imported code, and github alert transforms together", async () => {
+ const codePath = routeFixturePath("..", "..", "code", "example.ts");
+ const source = [
+ "---",
+ "title: Home",
+ "product: SolidBase",
+ "---",
+ "",
+ "# {frontmatter.title}",
+ "",
+ "[[toc]]",
+ "",
+ "## Install",
+ "",
+ "> [!NOTE]",
+ "> Welcome to {frontmatter.product}.",
+ "",
+ `\`\`\`ts file="${codePath}#L2-L3"`,
+ "```",
+ ].join("\n");
+
+ const markdown = await toDocumentMarkdown(source, {
+ config: { markdown: { toc: {} } },
+ filePath: routeFixturePath("index.mdx"),
+ });
+
+ expect(markdown).toContain("# Home");
+ expect(markdown).toContain("1. [Install](#install)");
+ expect(markdown).toContain(' ({
+ default: {
+ existsSync,
+ },
+}));
+
+vi.mock("cross-spawn", () => ({
+ spawn,
+}));
+
+function createChild(output: string) {
+ const child = new EventEmitter() as EventEmitter & {
+ stdout: EventEmitter;
+ };
+ child.stdout = new EventEmitter();
+
+ queueMicrotask(() => {
+ child.stdout.emit("data", output);
+ child.emit("close");
+ });
+
+ return child;
+}
+
+describe("getGitTimestamp", () => {
+ const missingPath = "tests/fixtures/missing.mdx";
+ const docPath = "tests/fixtures/doc.mdx";
+ const errorPath = "tests/fixtures/error.mdx";
+
+ beforeEach(() => {
+ vi.resetModules();
+ existsSync.mockReset();
+ spawn.mockReset();
+ });
+
+ it("returns 0 for missing files without spawning git", async () => {
+ existsSync.mockReturnValue(false);
+ const { getGitTimestamp } = await import("../../src/config/git.ts");
+
+ expect(getGitTimestamp(missingPath)).toBe(0);
+ expect(spawn).not.toHaveBeenCalled();
+ });
+
+ it("spawns git once and caches successful timestamps", async () => {
+ existsSync.mockReturnValue(true);
+ spawn.mockImplementation(() => createChild("2024-01-02 03:04:05 +0000"));
+ const { getGitTimestamp } = await import("../../src/config/git.ts");
+
+ await expect(getGitTimestamp(docPath)).resolves.toBe(
+ +new Date("2024-01-02 03:04:05 +0000"),
+ );
+ expect(getGitTimestamp(docPath)).toBe(
+ +new Date("2024-01-02 03:04:05 +0000"),
+ );
+ expect(spawn).toHaveBeenCalledTimes(1);
+ expect(spawn).toHaveBeenCalledWith(
+ "git",
+ ["log", "-1", '--pretty="%ai"', "doc.mdx"],
+ { cwd: dirname(docPath) },
+ );
+ });
+
+ it("rejects when git spawning errors", async () => {
+ existsSync.mockReturnValue(true);
+ spawn.mockImplementation(() => {
+ const child = new EventEmitter() as EventEmitter & {
+ stdout: EventEmitter;
+ };
+ child.stdout = new EventEmitter();
+ queueMicrotask(() => child.emit("error", new Error("git failed")));
+ return child;
+ });
+ const { getGitTimestamp } = await import("../../src/config/git.ts");
+
+ await expect(getGitTimestamp(errorPath)).rejects.toThrow("git failed");
+ });
+});
diff --git a/tests/config/import-code-file.test.ts b/tests/config/import-code-file.test.ts
new file mode 100644
index 00000000..7dd7f599
--- /dev/null
+++ b/tests/config/import-code-file.test.ts
@@ -0,0 +1,71 @@
+import remarkParse from "remark-parse";
+import { unified } from "unified";
+import { describe, expect, it } from "vitest";
+
+import { remarkImportCodeFile } from "../../src/config/remark-plugins/import-code-file.ts";
+import { routeFixturePath } from "../helpers/fixtures.ts";
+
+async function transform(
+ markdown: string,
+ filePath = routeFixturePath("index.mdx"),
+) {
+ const processor = unified().use(remarkParse).use(remarkImportCodeFile);
+ const tree = processor.parse(markdown);
+ return processor.run(tree, { path: filePath, value: markdown });
+}
+
+describe("remarkImportCodeFile", () => {
+ it("inlines imported code and annotates title metadata", async () => {
+ const codePath = routeFixturePath("..", "..", "code", "example.ts");
+ const tree: any = await transform(`\`\`\`ts file="${codePath}"\n\`\`\``);
+ const code = tree.children[0];
+
+ expect(code.lang).toBe("ts");
+ expect(code.meta).toContain('title="example.ts"');
+ expect(code.value).toContain('console.log("hi");');
+ expect(code.value).toContain('console.log("bye");');
+ });
+
+ it("supports line ranges and strips redundant indentation", async () => {
+ const codePath = routeFixturePath("..", "..", "code", "example.ts");
+ const tree: any = await transform(
+ `\`\`\`ts file="${codePath}#L2-L3"\n\`\`\``,
+ );
+ const code = tree.children[0];
+
+ expect(code.value).toBe('console.log("hi");\nconsole.log("bye");');
+ });
+
+ it("allows custom transforms before line extraction", async () => {
+ const codePath = routeFixturePath("..", "..", "code", "example.ts");
+ const processor = unified()
+ .use(remarkParse)
+ .use(remarkImportCodeFile, {
+ transform: (code) => code.replace("bye", "see ya"),
+ });
+ const markdown = `\`\`\`ts file="${codePath}#L2-L3"\n\`\`\``;
+ const tree: any = await processor.run(processor.parse(markdown), {
+ path: routeFixturePath("index.mdx"),
+ value: markdown,
+ });
+
+ expect(tree.children[0].value).toContain('console.log("see ya");');
+ });
+
+ it("throws for missing files so broken imports are visible", async () => {
+ const missingPath = routeFixturePath("..", "..", "code", "missing.ts");
+
+ await expect(
+ transform(`\`\`\`ts file="${missingPath}"\n\`\`\``),
+ ).rejects.toThrow(/missing\.ts|ENOENT/);
+ });
+
+ it("returns an empty snippet for out-of-range line selections", async () => {
+ const codePath = routeFixturePath("..", "..", "code", "example.ts");
+ const tree: any = await transform(
+ `\`\`\`ts file="${codePath}#L99-L100"\n\`\`\``,
+ );
+
+ expect(tree.children[0].value).toBe("");
+ });
+});
diff --git a/tests/config/index.test.ts b/tests/config/index.test.ts
new file mode 100644
index 00000000..2d6aefbd
--- /dev/null
+++ b/tests/config/index.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from "vitest";
+
+const solidBaseMdx = vi.fn();
+const solidBaseVitePlugin = vi.fn();
+
+vi.mock("../../src/config/mdx.ts", () => ({
+ solidBaseMdx,
+}));
+
+vi.mock("../../src/config/vite-plugin/index.ts", () => ({
+ default: solidBaseVitePlugin,
+}));
+
+vi.mock("../../src/default-theme/index.js", () => ({
+ default: {
+ componentsPath: "/themes/default",
+ },
+}));
+
+describe("createSolidBase", () => {
+ it("applies defaults and returns mdx, core, and theme plugins", async () => {
+ solidBaseMdx.mockReset();
+ solidBaseVitePlugin.mockReset();
+ solidBaseMdx.mockReturnValue("mdx-plugin");
+ solidBaseVitePlugin.mockReturnValue("solidbase-plugin");
+
+ const { createSolidBase } = await import("../../src/config/index.ts");
+ const theme = {
+ componentsPath: "/themes/custom",
+ config: vi.fn(),
+ vite: vi.fn(() => "theme-plugin"),
+ } as any;
+
+ const solidBase = createSolidBase(theme);
+ const config = solidBase.startConfig({
+ extensions: ["tsx", "md"],
+ });
+ const plugins = solidBase.plugin({ title: "Docs", llms: true });
+
+ expect(config.extensions).toEqual(["tsx", "md", "mdx"]);
+ expect(config.ssr).toBe(true);
+ expect(theme.config).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "Docs",
+ llms: true,
+ lang: "en-US",
+ issueAutolink: false,
+ }),
+ );
+ expect(plugins).toEqual(["mdx-plugin", "solidbase-plugin", "theme-plugin"]);
+ expect(solidBaseMdx).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Docs", llms: true }),
+ );
+ expect(solidBaseVitePlugin).toHaveBeenCalledWith(
+ theme,
+ expect.objectContaining({ title: "Docs", llms: true }),
+ );
+ });
+
+ it("merges inherited theme config hooks and reverses theme vite order", async () => {
+ solidBaseMdx.mockReset();
+ solidBaseVitePlugin.mockReset();
+ solidBaseMdx.mockReturnValue("mdx-plugin");
+ solidBaseVitePlugin.mockReturnValue("solidbase-plugin");
+
+ const { createSolidBase } = await import("../../src/config/index.ts");
+ const parent = {
+ componentsPath: "/themes/parent",
+ config: vi.fn(),
+ vite: vi.fn(() => "parent-plugin"),
+ } as any;
+ const child = {
+ componentsPath: "/themes/child",
+ extends: parent,
+ config: vi.fn(),
+ vite: vi.fn(() => "child-plugin"),
+ } as any;
+
+ const solidBase = createSolidBase(child);
+ const plugins = solidBase.plugin();
+
+ expect(child.config.mock.invocationCallOrder[0]).toBeLessThan(
+ parent.config.mock.invocationCallOrder[0],
+ );
+ expect(parent.config).toHaveBeenCalled();
+ expect(child.vite.mock.invocationCallOrder[0]).toBeLessThan(
+ parent.vite.mock.invocationCallOrder[0],
+ );
+ expect((plugins as any[]).slice(-2)).toEqual([
+ "parent-plugin",
+ "child-plugin",
+ ]);
+ });
+});
diff --git a/tests/config/llms-index.test.ts b/tests/config/llms-index.test.ts
new file mode 100644
index 00000000..2545c466
--- /dev/null
+++ b/tests/config/llms-index.test.ts
@@ -0,0 +1,335 @@
+import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ buildLlmsIndex,
+ getDocumentByPath,
+ getLlmDocuments,
+} from "../../src/config/llms-index.ts";
+import { fixtureSiteRoot } from "../helpers/fixtures.ts";
+
+const config = {
+ title: "SolidBase Docs",
+ description: "Documentation for SolidBase",
+ llms: true,
+ themeConfig: {
+ sidebar: {
+ "/": [
+ {
+ title: "Guide",
+ items: [{ title: "Getting Started", link: "/guide/getting-started" }],
+ },
+ ],
+ },
+ },
+ markdown: {},
+} as any;
+
+describe("getLlmDocuments", () => {
+ it("collects transformed markdown documents and skips excluded files", async () => {
+ const documents = await getLlmDocuments(fixtureSiteRoot, config);
+
+ expect(documents.map((document) => document.routePath)).toEqual([
+ "/guide/getting-started",
+ "/",
+ "/plain",
+ ]);
+
+ expect(documents[1]).toMatchObject({
+ title: "Home",
+ description: "Welcome home",
+ markdownPath: "/index.md",
+ });
+ expect(documents[1]?.content).toContain("Welcome to SolidBase.");
+ expect(documents[1]?.content).toContain(
+ "```md\n{frontmatter.product}\n```",
+ );
+ });
+
+ it("builds llms index content from sidebar metadata", async () => {
+ const documents = await getLlmDocuments(fixtureSiteRoot, config);
+ const index = buildLlmsIndex("https://solidbase.dev", config, documents);
+
+ expect(index).toContain("# SolidBase Docs");
+ expect(index).toContain("## Guide");
+ expect(index).toContain(
+ "- [Getting Started](https://solidbase.dev/guide/getting-started.md): Learn the basics",
+ );
+ });
+
+ it("looks up emitted documents by markdown path", async () => {
+ const documents = await getLlmDocuments(fixtureSiteRoot, config);
+
+ expect(
+ getDocumentByPath(documents, "/guide/getting-started.md"),
+ ).toMatchObject({
+ routePath: "/guide/getting-started",
+ title: "Getting Started",
+ });
+ });
+
+ it("falls back to a flat index when no sidebar is configured", async () => {
+ const documents = await getLlmDocuments(fixtureSiteRoot, config);
+ const index = buildLlmsIndex(
+ undefined,
+ { ...config, themeConfig: {} },
+ documents,
+ );
+
+ expect(index).toContain(
+ "- [Getting Started](/guide/getting-started.md): Learn the basics",
+ );
+ expect(index).toContain("- [Home](/index.md): Welcome home");
+ });
+
+ it("defaults llms.txt to root locale documents when localized routes exist", () => {
+ const index = buildLlmsIndex(
+ undefined,
+ {
+ ...config,
+ themeConfig: {},
+ locales: {
+ root: { label: "English" },
+ fr: { label: "Francais" },
+ },
+ },
+ [
+ {
+ title: "Home",
+ description: "Welcome home",
+ routePath: "/",
+ markdownPath: "/index.md",
+ content: "Home",
+ },
+ {
+ title: "Guide",
+ description: "English docs",
+ routePath: "/guide/getting-started",
+ markdownPath: "/guide/getting-started.md",
+ content: "Guide",
+ },
+ {
+ title: "Accueil",
+ description: "French docs",
+ routePath: "/fr",
+ markdownPath: "/fr.md",
+ content: "Accueil",
+ },
+ {
+ title: "Demarrage",
+ description: "French getting started",
+ routePath: "/fr/guide/getting-started",
+ markdownPath: "/fr/guide/getting-started.md",
+ content: "Demarrage",
+ },
+ ],
+ );
+
+ expect(index).toContain("- [Home](/index.md): Welcome home");
+ expect(index).toContain(
+ "- [Guide](/guide/getting-started.md): English docs",
+ );
+ expect(index).not.toContain("/fr.md");
+ expect(index).not.toContain("/fr/guide/getting-started.md");
+ });
+
+ it("renders nav sections and nested sidebar groups as headings", () => {
+ const index = buildLlmsIndex(
+ undefined,
+ {
+ ...config,
+ themeConfig: {
+ nav: [
+ { text: "Guide", link: "/guide" },
+ { text: "Reference", link: "/reference" },
+ ],
+ sidebar: {
+ "/guide": [
+ { title: "About", link: "/" },
+ {
+ title: "Customization",
+ items: [
+ {
+ title: "Custom Themes",
+ link: "/customization/custom-themes",
+ },
+ ],
+ },
+ {
+ title: "Features",
+ items: [
+ {
+ title: "LLMs.txt",
+ link: "/features/llms",
+ },
+ ],
+ },
+ ],
+ "/reference": [
+ { title: "Index", link: "/" },
+ { title: "Frontmatter", link: "/frontmatter" },
+ { title: "Runtime API", link: "/runtime-api" },
+ {
+ title: "Default Theme",
+ items: [
+ {
+ title: "Landing",
+ link: "/default-theme/landing",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ [
+ {
+ title: "About",
+ routePath: "/guide",
+ markdownPath: "/guide.md",
+ content: "About",
+ },
+ {
+ title: "Custom Themes",
+ routePath: "/guide/customization/custom-themes",
+ markdownPath: "/guide/customization/custom-themes.md",
+ content: "Custom Themes",
+ },
+ {
+ title: "LLMs.txt",
+ routePath: "/guide/features/llms",
+ markdownPath: "/guide/features/llms.md",
+ content: "LLMs",
+ },
+ {
+ title: "Index",
+ routePath: "/reference",
+ markdownPath: "/reference.md",
+ content: "Reference",
+ },
+ {
+ title: "Frontmatter",
+ routePath: "/reference/frontmatter",
+ markdownPath: "/reference/frontmatter.md",
+ content: "Frontmatter",
+ },
+ {
+ title: "Runtime API",
+ routePath: "/reference/runtime-api",
+ markdownPath: "/reference/runtime-api.md",
+ content: "Runtime API",
+ },
+ {
+ title: "Landing",
+ routePath: "/reference/default-theme/landing",
+ markdownPath: "/reference/default-theme/landing.md",
+ content: "Landing",
+ },
+ ],
+ );
+
+ expect(index).toContain("- [About](/guide.md)");
+ expect(index).toContain("## Guide");
+ expect(index).toContain("## Reference");
+ expect(index).toContain("- [About](/guide.md)");
+ expect(index).toContain(
+ "### Customization\n\n- [Custom Themes](/guide/customization/custom-themes.md)",
+ );
+ expect(index).toContain(
+ "### Features\n\n- [LLMs.txt](/guide/features/llms.md)",
+ );
+ expect(index).toContain("- [Index](/reference.md)");
+ expect(index).toContain("- [Frontmatter](/reference/frontmatter.md)");
+ expect(index).toContain("- [Runtime API](/reference/runtime-api.md)");
+ expect(index).toContain(
+ "### Default Theme\n\n- [Landing](/reference/default-theme/landing.md)",
+ );
+ });
+
+ it("uses route path as a title fallback and respects nested llms exclusion", async () => {
+ const root = await mkdtemp(join(tmpdir(), "solidbase-llms-"));
+ const routesDir = join(root, "src", "routes", "guide");
+ await mkdir(routesDir, { recursive: true });
+
+ await writeFile(
+ join(root, "src", "routes", "guide", "no-title.mdx"),
+ "Paragraph only.",
+ );
+ await writeFile(
+ join(root, "src", "routes", "guide", "excluded.mdx"),
+ ["---", "llms:", " exclude: true", "---", "", "Should not appear."].join(
+ "\n",
+ ),
+ );
+
+ const documents = await getLlmDocuments(root, {
+ ...config,
+ themeConfig: {},
+ });
+
+ expect(documents).toEqual([
+ expect.objectContaining({
+ title: "/guide/no-title",
+ routePath: "/guide/no-title",
+ markdownPath: "/guide/no-title.md",
+ content: "Paragraph only.",
+ }),
+ ]);
+ });
+
+ it("skips not-found route files from llms output", async () => {
+ const root = await mkdtemp(join(tmpdir(), "solidbase-llms-404-"));
+ await mkdir(join(root, "src", "routes", "fr"), { recursive: true });
+
+ await writeFile(
+ join(root, "src", "routes", "index.mdx"),
+ ["---", "title: Home", "---", "", "Welcome home."].join("\n"),
+ );
+ await writeFile(
+ join(root, "src", "routes", "[...404].mdx"),
+ ["---", "title: Not Found", "---", "", "Missing page."].join("\n"),
+ );
+ await writeFile(
+ join(root, "src", "routes", "fr", "[...404].mdx"),
+ ["---", "title: Introuvable", "---", "", "Page manquante."].join("\n"),
+ );
+
+ const documents = await getLlmDocuments(root, {
+ ...config,
+ themeConfig: {},
+ });
+
+ expect(documents.map((document) => document.routePath)).toEqual(["/"]);
+ });
+
+ it("strips numeric ordering prefixes from llms route paths", async () => {
+ const root = await mkdtemp(join(tmpdir(), "solidbase-llms-ordering-"));
+ await mkdir(join(root, "src", "routes", "guide", "features"), {
+ recursive: true,
+ });
+
+ await writeFile(
+ join(root, "src", "routes", "guide", "(0)quickstart.mdx"),
+ ["---", "title: Quick Start", "---", "", "Start here."].join("\n"),
+ );
+ await writeFile(
+ join(root, "src", "routes", "guide", "features", "(3)llms.mdx"),
+ ["---", "title: LLMs.txt", "---", "", "AI docs."].join("\n"),
+ );
+
+ const documents = await getLlmDocuments(root, {
+ ...config,
+ themeConfig: {},
+ });
+
+ expect(documents.map((document) => document.routePath)).toEqual([
+ "/guide/quickstart",
+ "/guide/features/llms",
+ ]);
+ expect(documents.map((document) => document.markdownPath)).toEqual([
+ "/guide/quickstart.md",
+ "/guide/features/llms.md",
+ ]);
+ });
+});
diff --git a/tests/config/llms-plugin.test.ts b/tests/config/llms-plugin.test.ts
new file mode 100644
index 00000000..0f529693
--- /dev/null
+++ b/tests/config/llms-plugin.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it, vi } from "vitest";
+
+import solidBaseLlmsPlugin from "../../src/config/vite-plugin/llms.ts";
+import { fixtureSiteRoot } from "../helpers/fixtures.ts";
+
+describe("solidBaseLlmsPlugin", () => {
+ it("returns no plugin when llms is disabled", () => {
+ expect(solidBaseLlmsPlugin({ llms: false } as any)).toEqual([]);
+ });
+
+ it("emits llms assets for the configured root", async () => {
+ const pluginOption = solidBaseLlmsPlugin({
+ title: "SolidBase Docs",
+ description: "Documentation for SolidBase",
+ llms: true,
+ themeConfig: {
+ sidebar: {
+ "/": [
+ {
+ title: "Guide",
+ items: [
+ { title: "Getting Started", link: "/guide/getting-started" },
+ ],
+ },
+ ],
+ },
+ },
+ markdown: {},
+ } as any);
+ const plugin = (
+ Array.isArray(pluginOption) ? pluginOption[0] : pluginOption
+ ) as any;
+ expect(plugin).toBeDefined();
+ if (!plugin) throw new Error("Expected LLMS plugin to be defined");
+
+ plugin.configResolved?.({ root: fixtureSiteRoot } as any);
+ const emitFile = vi.fn();
+
+ await plugin.generateBundle?.call({ emitFile } as any);
+
+ expect(emitFile).toHaveBeenCalledWith(
+ expect.objectContaining({ fileName: "llms.txt", type: "asset" }),
+ );
+ expect(emitFile).toHaveBeenCalledWith(
+ expect.objectContaining({ fileName: "index.md", type: "asset" }),
+ );
+ expect(emitFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileName: "guide/getting-started.md",
+ type: "asset",
+ }),
+ );
+ });
+});
diff --git a/tests/config/remark-core.test.ts b/tests/config/remark-core.test.ts
new file mode 100644
index 00000000..065c8b4a
--- /dev/null
+++ b/tests/config/remark-core.test.ts
@@ -0,0 +1,149 @@
+import remarkFrontmatter from "remark-frontmatter";
+import remarkMdx from "remark-mdx";
+import remarkParse from "remark-parse";
+import { unified } from "unified";
+import { VFile } from "vfile";
+import { describe, expect, it } from "vitest";
+
+import { remarkAddClass } from "../../src/config/remark-plugins/kbd.ts";
+import {
+ type FrontmatterRoot,
+ remarkMdxFrontmatter,
+} from "../../src/config/remark-plugins/mdx-frontmatter.ts";
+import { remarkSteps } from "../../src/config/remark-plugins/steps.ts";
+
+async function runMarkdown(markdown: string, ...plugins: Array) {
+ const processor = unified().use(remarkParse).use(remarkMdx);
+ for (const plugin of plugins) {
+ if (Array.isArray(plugin)) processor.use(plugin[0], plugin[1]);
+ else processor.use(plugin);
+ }
+ const file = new VFile({ value: markdown, path: "tests/fixtures/test.mdx" });
+ const tree = processor.parse(file);
+ return processor.run(tree, file);
+}
+
+describe("remarkSteps", () => {
+ it("groups consecutive numbered headings into a Steps container", async () => {
+ const tree: any = await runMarkdown(
+ [
+ "## 1. Install",
+ "",
+ "Install the package.",
+ "",
+ "## 2. Configure",
+ "",
+ "Add config.",
+ ].join("\n"),
+ remarkSteps,
+ );
+
+ const steps = tree.children[0];
+ expect(steps.type).toBe("mdxJsxFlowElement");
+ expect(steps.name).toBe("Steps");
+ expect(steps.children).toHaveLength(2);
+ expect(steps.children[0].name).toBe("Step");
+ expect(steps.children[0].children[0].children[0].value).toBe("Install");
+ expect(steps.children[1].children[1].children[0].value).toBe("Add config.");
+ });
+
+ it("stops grouping when numbering breaks or heading depth changes", async () => {
+ const tree: any = await runMarkdown(
+ [
+ "## 1. First",
+ "",
+ "## 3. Skipped",
+ "",
+ "### 4. Nested",
+ "",
+ "## Not a step",
+ ].join("\n"),
+ remarkSteps,
+ );
+
+ expect(tree.children[0].name).toBe("Steps");
+ expect(tree.children[0].children).toHaveLength(1);
+ expect(tree.children[1].name).toBe("Steps");
+ expect(tree.children[1].children).toHaveLength(1);
+ expect(tree.children[2].type).toBe("heading");
+ expect(tree.children[2].children[0].value).toBe("Not a step");
+ });
+});
+
+describe("remarkAddClass", () => {
+ it("adds sb-kbd class to ", async () => {
+ const tree: any = await runMarkdown(
+ "Press Enter",
+ remarkAddClass,
+ );
+ const kbd = tree.children[0].children[1];
+
+ expect(kbd.attributes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "class", value: "sb-kbd" }),
+ ]),
+ );
+ });
+
+ it("appends sb-kbd to an existing class on ", async () => {
+ const tree: any = await runMarkdown(
+ 'Press Enter',
+ remarkAddClass,
+ );
+ const kbd = tree.children[0].children[1];
+
+ expect(kbd.attributes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "class", value: "hotkey sb-kbd" }),
+ ]),
+ );
+ });
+});
+
+describe("remarkMdxFrontmatter", () => {
+ it("parses yaml frontmatter into exports and ast data", async () => {
+ const tree = (await runMarkdown(
+ ["---", "title: Hello", "count: 3", "---", "", "# Heading"].join("\n"),
+ [remarkFrontmatter, ["yaml", "toml"]],
+ remarkMdxFrontmatter,
+ )) as FrontmatterRoot;
+ const exportNode = tree.children.find(
+ (child: any) => child.type === "mdxjsEsm",
+ );
+
+ expect(tree.data?.frontmatter).toEqual({ title: "Hello", count: 3 });
+ expect(exportNode?.type).toBe("mdxjsEsm");
+ expect(JSON.stringify((exportNode as any).data.estree)).toContain(
+ "frontmatter",
+ );
+ });
+
+ it("supports toml and custom export names", async () => {
+ const tree = (await runMarkdown(
+ ["+++", 'title = "Hello"', "enabled = true", "+++", "", "# Heading"].join(
+ "\n",
+ ),
+ [remarkFrontmatter, ["yaml", "toml"]],
+ [remarkMdxFrontmatter, { name: "meta" }],
+ )) as FrontmatterRoot;
+ const exportNode = tree.children.find(
+ (child: any) => child.type === "mdxjsEsm",
+ );
+
+ expect(tree.data?.frontmatter).toEqual({ title: "Hello", enabled: true });
+ expect(exportNode?.type).toBe("mdxjsEsm");
+ expect(JSON.stringify((exportNode as any).data.estree)).toContain("meta");
+ });
+
+ it("defaults to an empty frontmatter object when none exists", async () => {
+ const tree = (await runMarkdown(
+ "# Heading",
+ remarkMdxFrontmatter,
+ )) as FrontmatterRoot;
+
+ expect(tree.data?.frontmatter).toEqual({});
+ expect(JSON.stringify((tree.children[0] as any).data.estree)).toContain(
+ "frontmatter",
+ );
+ });
+});
diff --git a/tests/config/remark-misc.test.ts b/tests/config/remark-misc.test.ts
new file mode 100644
index 00000000..168bd9aa
--- /dev/null
+++ b/tests/config/remark-misc.test.ts
@@ -0,0 +1,82 @@
+import remarkDirective from "remark-directive";
+import remarkParse from "remark-parse";
+import { unified } from "unified";
+import { describe, expect, it } from "vitest";
+
+import { remarkDirectiveContainers } from "../../src/config/remark-plugins/directives.ts";
+import { remarkGithubAlertsToDirectives } from "../../src/config/remark-plugins/gh-directives.ts";
+import { remarkIssueAutolink } from "../../src/config/remark-plugins/issue-autolink.ts";
+import { remarkRelativeImports } from "../../src/config/remark-plugins/relative-imports.ts";
+
+async function run(markdown: string, ...plugins: Array) {
+ const processor = unified().use(remarkParse);
+ for (const plugin of plugins) {
+ if (Array.isArray(plugin)) processor.use(plugin[0], plugin[1]);
+ else processor.use(plugin);
+ }
+ const tree = processor.parse(markdown);
+ return processor.run(tree);
+}
+
+describe("misc remark plugins", () => {
+ it("converts github alert blockquotes into directive containers", async () => {
+ const tree: any = await run(
+ ["> [!NOTE]", "> Heads up"].join("\n"),
+ remarkGithubAlertsToDirectives,
+ remarkDirectiveContainers,
+ );
+
+ expect(tree.children[0].type).toBe("mdxJsxFlowElement");
+ expect(tree.children[0].name).toBe("DirectiveContainer");
+ expect(tree.children[0].attributes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "type", value: "note" }),
+ ]),
+ );
+ });
+
+ it("rewrites relative image imports into MDX imports", async () => {
+ const tree: any = await run("", remarkRelativeImports);
+
+ expect(tree.children[0].children[0].url).toMatch(
+ /^\$\$SolidBase_RelativeImport/,
+ );
+ expect(tree.children[1].type).toBe("mdxjsEsm");
+ expect(JSON.stringify(tree.children[1].data.estree)).toContain(
+ "./logo.png",
+ );
+ });
+
+ it("autolinks issue references using the configured template", async () => {
+ const tree: any = await run("Fixes #123", [
+ remarkIssueAutolink,
+ "https://example.com/issues/:issue",
+ ]);
+
+ expect(tree.children[0].children).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: "link",
+ url: "https://example.com/issues/123",
+ }),
+ ]),
+ );
+ });
+
+ it("converts directive syntax into DirectiveContainer JSX", async () => {
+ const processor = unified()
+ .use(remarkParse)
+ .use(remarkDirective)
+ .use(remarkDirectiveContainers);
+ const markdown = [":::note[Custom title]", "Body", ":::"].join("\n");
+ const tree: any = await processor.run(processor.parse(markdown));
+
+ expect(tree.children[0].type).toBe("mdxJsxFlowElement");
+ expect(tree.children[0].attributes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "type", value: "note" }),
+ expect.objectContaining({ name: "title", value: "Custom title" }),
+ ]),
+ );
+ });
+});
diff --git a/tests/config/tabs.test.ts b/tests/config/tabs.test.ts
new file mode 100644
index 00000000..32c03f36
--- /dev/null
+++ b/tests/config/tabs.test.ts
@@ -0,0 +1,113 @@
+import remarkParse from "remark-parse";
+import { unified } from "unified";
+import { describe, expect, it } from "vitest";
+
+import { remarkCodeTabs } from "../../src/config/remark-plugins/code-tabs.ts";
+import { remarkPackageManagerTabs } from "../../src/config/remark-plugins/package-manager-tabs.ts";
+import { remarkTabGroup } from "../../src/config/remark-plugins/tab-group.ts";
+
+async function transformCodeTabs(markdown: string, withTsJsToggle = false) {
+ const processor = unified().use(remarkParse).use(remarkCodeTabs, {
+ withTsJsToggle,
+ });
+ const tree = processor.parse(markdown);
+ return processor.run(tree);
+}
+
+async function transformPackageTabs(markdown: string, options?: any) {
+ const processor = unified()
+ .use(remarkParse)
+ .use(remarkPackageManagerTabs, options);
+ const tree = processor.parse(markdown);
+ return processor.run(tree);
+}
+
+describe("tab-related remark plugins", () => {
+ it("groups adjacent tabbed code fences into a tab-group container", async () => {
+ const tree: any = await transformCodeTabs(
+ [
+ '```ts tab="install" title="npm"',
+ "npm install solidbase",
+ "```",
+ '```ts tab="install" title="pnpm"',
+ "pnpm add solidbase",
+ "```",
+ ].join("\n"),
+ true,
+ );
+
+ const group = tree.children[0];
+ expect(group.type).toBe("containerDirective");
+ expect(group.name).toBe("tab-group");
+ expect(group.attributes).toMatchObject({
+ codeGroup: "true",
+ title: "install",
+ withTsJsToggle: "true",
+ });
+ expect(group.children).toHaveLength(2);
+ expect(group.children[0].children[1].meta).toContain('frame="none"');
+ expect(group.children[1].children[0].children[0].value).toBe("pnpm");
+ });
+
+ it("creates package-manager tabs from package-* code fences", async () => {
+ const tree: any = await transformPackageTabs(
+ "```package-install\nsolidbase@latest\n```",
+ {
+ show: ["npm", "pnpm"],
+ default: "pnpm",
+ },
+ );
+
+ const group = tree.children[0];
+ expect(group.name).toBe("tab-group");
+ expect(group.attributes.title).toBe("package-manager");
+ expect(group.children[0].children[1].value).toContain(
+ "npm i solidbase@latest",
+ );
+ expect(group.children[1].children[1].value).toContain("pnpm add solidbase");
+ });
+
+ it("annotates tab groups with ordered tab names", async () => {
+ const tree: any = {
+ type: "root",
+ children: [
+ {
+ type: "containerDirective",
+ name: "tab-group",
+ attributes: {},
+ children: [
+ {
+ type: "containerDirective",
+ name: "tab",
+ attributes: {},
+ children: [
+ {
+ type: "paragraph",
+ data: { directiveLabel: true },
+ children: [{ type: "text", value: "Alpha" }],
+ },
+ ],
+ },
+ {
+ type: "containerDirective",
+ name: "tab",
+ attributes: {},
+ children: [
+ {
+ type: "paragraph",
+ data: { directiveLabel: true },
+ children: [{ type: "text", value: "Beta" }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ remarkTabGroup()(tree);
+
+ expect(tree.children[0].attributes.tabNames).toBe("Alpha\0Beta");
+ expect(tree.children[0].children).toHaveLength(2);
+ });
+});
diff --git a/tests/config/toc.test.ts b/tests/config/toc.test.ts
new file mode 100644
index 00000000..bcd44389
--- /dev/null
+++ b/tests/config/toc.test.ts
@@ -0,0 +1,51 @@
+import remarkMdx from "remark-mdx";
+import remarkParse from "remark-parse";
+import { unified } from "unified";
+import { describe, expect, it } from "vitest";
+
+import {
+ remarkTOC,
+ SolidBaseTOC,
+ type TOCOptions,
+} from "../../src/config/remark-plugins/toc.ts";
+
+async function transform(markdown: string, opts?: TOCOptions) {
+ const processor = unified()
+ .use(remarkParse)
+ .use(remarkMdx)
+ .use(remarkTOC, opts);
+ const tree = processor.parse(markdown);
+ return processor.run(tree);
+}
+
+describe("remarkTOC", () => {
+ it("replaces [[toc]] with a generated list and exports toc data", async () => {
+ const tree: any = await transform(
+ ["# Intro", "", "[[toc]]", "", "## Install", "", "### CLI"].join("\n"),
+ );
+
+ expect(tree.children[0].type).toBe("mdxjsEsm");
+ expect(JSON.stringify(tree.children[0].data.estree)).toContain(
+ SolidBaseTOC,
+ );
+ expect(JSON.stringify(tree.children[0].data.estree)).toContain(
+ '"value":"Install"',
+ );
+ expect(JSON.stringify(tree)).toContain('"data-toc":""');
+ expect(JSON.stringify(tree)).toContain('"type":"list"');
+ });
+
+ it("respects heading depth limits", async () => {
+ const tree: any = await transform(
+ ["# Intro", "", "[[toc]]", "", "## Install", "", "### CLI"].join("\n"),
+ { minDepth: 2, maxDepth: 2 },
+ );
+
+ expect(JSON.stringify(tree.children[0].data.estree)).toContain(
+ '"value":"Install"',
+ );
+ expect(JSON.stringify(tree.children[0].data.estree)).not.toContain(
+ '"value":"CLI"',
+ );
+ });
+});
diff --git a/tests/config/virtual.test.ts b/tests/config/virtual.test.ts
new file mode 100644
index 00000000..fa1016a2
--- /dev/null
+++ b/tests/config/virtual.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { transformMdxModule } from "../../src/config/vite-plugin/virtual.ts";
+import { routeFixturePath } from "../helpers/fixtures.ts";
+
+describe("transformMdxModule", () => {
+ const previousPwd = process.env.PWD;
+
+ afterEach(() => {
+ process.env.PWD = previousPwd;
+ });
+
+ it("embeds llmText using the same transformed markdown output", async () => {
+ const modulePath = routeFixturePath("index.mdx");
+
+ const code = await transformMdxModule(
+ "export default function Page() {}",
+ modulePath,
+ { markdown: {} },
+ );
+
+ expect(code).toContain('llmText: "# Home');
+ expect(code).toContain("Welcome to SolidBase.");
+ expect(code).toContain("```md\\n{frontmatter.product}\\n```");
+ });
+
+ it("supports nested vite ids and function edit links", async () => {
+ const modulePath = routeFixturePath("guide", "getting-started.mdx");
+
+ const code = await transformMdxModule(
+ "export default function Page() {}",
+ `${modulePath}?id=${encodeURIComponent(`${modulePath}?import`)}`,
+ {
+ markdown: {},
+ editPath: (file: string) => `https://example.com/edit/${file}`,
+ },
+ );
+
+ expect(code).toContain("Getting Started");
+ expect(code).toContain("https://example.com/edit/");
+ expect(code).toContain(
+ "/tests/fixtures/src/routes/guide/getting-started.mdx",
+ );
+ });
+
+ it("works for markdown files without frontmatter", async () => {
+ const markdownPath = routeFixturePath("plain.md");
+ const code = await transformMdxModule(
+ "export default function Page() {}",
+ markdownPath,
+ { markdown: {} },
+ );
+
+ expect(code).toContain('llmText: "Just plain markdown."');
+ });
+});
diff --git a/tests/fixtures/code/example.ts b/tests/fixtures/code/example.ts
new file mode 100644
index 00000000..7f2d47ae
--- /dev/null
+++ b/tests/fixtures/code/example.ts
@@ -0,0 +1,4 @@
+export function greet() {
+ console.log("hi");
+ console.log("bye");
+}
diff --git a/tests/fixtures/src/routes/excluded.mdx b/tests/fixtures/src/routes/excluded.mdx
new file mode 100644
index 00000000..e404db1a
--- /dev/null
+++ b/tests/fixtures/src/routes/excluded.mdx
@@ -0,0 +1,8 @@
+---
+title: Hidden Doc
+llms: false
+---
+
+# Hidden Doc
+
+This should not be emitted.
diff --git a/tests/fixtures/src/routes/guide/getting-started.mdx b/tests/fixtures/src/routes/guide/getting-started.mdx
new file mode 100644
index 00000000..60c8a843
--- /dev/null
+++ b/tests/fixtures/src/routes/guide/getting-started.mdx
@@ -0,0 +1,11 @@
+---
+title: Getting Started
+description: Learn the basics
+product: SolidBase
+llms:
+ exclude: false
+---
+
+# {frontmatter.title}
+
+Start with {frontmatter.product}.
diff --git a/tests/fixtures/src/routes/index.mdx b/tests/fixtures/src/routes/index.mdx
new file mode 100644
index 00000000..9513d854
--- /dev/null
+++ b/tests/fixtures/src/routes/index.mdx
@@ -0,0 +1,21 @@
+---
+title: Home
+description: Welcome home
+product: SolidBase
+tagline: Fast docs
+release: 3
+---
+
+# {frontmatter.title}
+
+Welcome to {frontmatter.product}.
+
+> {frontmatter.tagline}
+
+- v{frontmatter.release}
+
+{frontmatter["product"]}
+
+```md
+{frontmatter.product}
+```
diff --git a/tests/fixtures/src/routes/plain.md b/tests/fixtures/src/routes/plain.md
new file mode 100644
index 00000000..40c3e2f5
--- /dev/null
+++ b/tests/fixtures/src/routes/plain.md
@@ -0,0 +1 @@
+Just plain markdown.
diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts
new file mode 100644
index 00000000..15d3d82e
--- /dev/null
+++ b/tests/helpers/fixtures.ts
@@ -0,0 +1,10 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const testRoot = dirname(fileURLToPath(import.meta.url));
+
+export const fixtureSiteRoot = resolve(testRoot, "..", "fixtures");
+
+export function routeFixturePath(...parts: string[]) {
+ return resolve(fixtureSiteRoot, "src", "routes", ...parts);
+}
diff --git a/tests/mocks/virtual-solidbase-config.ts b/tests/mocks/virtual-solidbase-config.ts
new file mode 100644
index 00000000..60be9633
--- /dev/null
+++ b/tests/mocks/virtual-solidbase-config.ts
@@ -0,0 +1,3 @@
+const store = ((globalThis as any).__solidBaseConfig ??= {});
+
+export const solidBaseConfig = store;
diff --git a/tests/vite-mdx/integration.test.ts b/tests/vite-mdx/integration.test.ts
new file mode 100644
index 00000000..73d5badf
--- /dev/null
+++ b/tests/vite-mdx/integration.test.ts
@@ -0,0 +1,134 @@
+import remarkMdx from "remark-mdx";
+import remarkParse from "remark-parse";
+import { unified } from "unified";
+import { VFile } from "vfile";
+import { describe, expect, it } from "vitest";
+
+import viteMdx from "../../src/vite-mdx/index.ts";
+import { createTransformer } from "../../src/vite-mdx/transform.ts";
+import { ImportMap } from "../../src/vite-mdx/viteMdxTransclusion/ImportMap.ts";
+import { remarkTransclusion } from "../../src/vite-mdx/viteMdxTransclusion/remarkTransclusion.ts";
+
+function appendParagraph(text: string) {
+ return () => (tree: any) => {
+ tree.children.push({
+ type: "paragraph",
+ children: [{ type: "text", value: text }],
+ });
+ };
+}
+
+function createStubCompiler() {
+ const processor = unified().use(remarkParse).use(remarkMdx);
+
+ return {
+ parse(file: { contents: string; path: string }) {
+ return processor.parse(
+ new VFile({ path: file.path, value: file.contents }),
+ );
+ },
+ async run(tree: any) {
+ return tree;
+ },
+ };
+}
+
+describe("vite-mdx integration", () => {
+ const pagePath = "tests/fixtures/page.mdx";
+ const sharedPath = "tests/fixtures/shared.mdx";
+
+ it("createTransformer prepends imports and transpiles JSX output", async () => {
+ const transform = createTransformer(process.cwd(), {});
+
+ const code = await transform("# Hello");
+
+ expect(code).toContain("export default MDXContent; function MDXContent");
+ expect(code).toContain('"Hello"');
+ });
+
+ it("viteMdx merges global and local remark plugins during transform", async () => {
+ const [plugin] = viteMdx.withImports({})((filename) => ({
+ remarkPlugins: filename.endsWith("page.mdx")
+ ? [appendParagraph("local")]
+ : [],
+ })) as any[];
+
+ plugin.configResolved({
+ root: process.cwd(),
+ plugins: [],
+ } as any);
+
+ plugin.mdxOptions.remarkPlugins.push(appendParagraph("global"));
+
+ const result = await plugin.transform.call({}, "# Hello", pagePath, false);
+
+ expect(result.code).toContain('"global"');
+ expect(result.code).toContain('"local"');
+ });
+
+ it("remarkTransclusion inlines imported mdx, caches ASTs, and tracks imports", async () => {
+ const importMap = new ImportMap();
+ const astCache = new Map();
+ const compiler = createStubCompiler();
+ let readCount = 0;
+
+ const processor = unified().use(remarkParse).use(remarkMdx);
+ const transformTransclusion = (remarkTransclusion as any)({
+ astCache: astCache as any,
+ importMap,
+ resolve: async (id: string) =>
+ id === "./shared.mdx" ? sharedPath : undefined,
+ readFile: async () => {
+ readCount += 1;
+ return "## Shared\n\nFrom import.";
+ },
+ getCompiler: () => compiler as any,
+ })();
+
+ const firstFile = new VFile({
+ path: pagePath,
+ value: 'import "./shared.mdx"\n\n# Page',
+ });
+ const firstTree: any = processor.parse(firstFile);
+ await transformTransclusion(firstTree, firstFile);
+
+ expect(firstTree.children.map((node: any) => node.type)).toEqual([
+ "heading",
+ "paragraph",
+ "heading",
+ ]);
+ expect(firstTree.children[0].children[0].value).toBe("Shared");
+ expect(importMap.importers.get(sharedPath)).toEqual(new Set([pagePath]));
+ expect(readCount).toBe(1);
+
+ const secondFile = new VFile({
+ path: pagePath,
+ value: 'import "./shared.mdx"\n\n# Page',
+ });
+ const secondTree: any = processor.parse(secondFile);
+ await transformTransclusion(secondTree, secondFile);
+
+ expect(readCount).toBe(1);
+ expect(secondTree.children[0].children[0].value).toBe("Shared");
+ });
+
+ it("remarkTransclusion strips unresolved mdx imports", async () => {
+ const processor = unified().use(remarkParse).use(remarkMdx);
+ const compiler = createStubCompiler();
+ const transformTransclusion = (remarkTransclusion as any)({
+ resolve: async () => undefined,
+ readFile: async () => "",
+ getCompiler: () => compiler as any,
+ })();
+
+ const file = new VFile({
+ path: pagePath,
+ value: 'import "./missing.mdx"\n\n# Page',
+ });
+ const tree: any = processor.parse(file);
+ await transformTransclusion(tree, file);
+
+ expect(tree.children).toHaveLength(1);
+ expect(tree.children[0].children[0].value).toBe("Page");
+ });
+});
diff --git a/tsconfig.config.json b/tsconfig.config.json
deleted file mode 100644
index ea51d4b6..00000000
--- a/tsconfig.config.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/tsconfig.json",
- "extends": "./tsconfig.json",
- "compilerOptions": {
- "composite": true,
- "module": "Node16",
- "moduleResolution": "node16"
- },
- "include": ["src/config", "src/default-theme/index.ts", "src/vite-mdx"]
-}
diff --git a/tsconfig.json b/tsconfig.json
index e6041b58..93694fdf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,30 +1,27 @@
{
- "compilerOptions": {
- "target": "esnext",
- "module": "esnext",
- "newLine": "LF",
- "allowJs": false,
- "allowSyntheticDefaultImports": true,
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "noFallthroughCasesInSwitch": false,
- "isolatedModules": true,
- "jsx": "preserve",
- "jsxImportSource": "solid-js",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "strict": true,
- "declaration": true,
- "sourceMap": true,
- "lib": ["dom", "esnext"],
- "types": ["solid-js", "vite", "vinxi/types/client", "vite/client", "unplugin-icons/types/solid"],
- "rootDir": "./src",
- "outDir": "./dist"
- },
- "references": [
- { "path": "./tsconfig.config.json" }
- ],
- "include": ["src/**/*"],
- "exclude": ["node_modules", "docs"]
+ "compilerOptions": {
+ "target": "esnext",
+ "module": "Node16",
+ "moduleResolution": "node16",
+ "newLine": "LF",
+ "allowJs": false,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": false,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "declaration": true,
+ "sourceMap": true,
+ "lib": ["dom", "esnext"],
+ "types": ["solid-js", "vite/client", "unplugin-icons/types/solid"],
+ "rootDir": "./src",
+ "outDir": "./dist"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "docs"]
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000..830a688f
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,17 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "virtual:solidbase/config": resolve(
+ __dirname,
+ "tests/mocks/virtual-solidbase-config.ts",
+ ),
+ },
+ },
+ test: {
+ environment: "node",
+ include: ["tests/**/*.test.ts"],
+ },
+});