From 531877ad63a9ac3872286fa5f637440b98273ce2 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 12:42:46 -0700 Subject: [PATCH 01/19] add route variant resolver and client APIs --- src/client/Root.tsx | 17 +- src/client/config.ts | 11 +- src/client/index.tsx | 6 + src/client/routes.ts | 66 ++++++ src/config/index.ts | 9 + src/config/route-config.ts | 337 ++++++++++++++++++++++++++++++ tests/client/config.test.ts | 89 +++++++- tests/client/routes.test.ts | 119 +++++++++++ tests/config/route-config.test.ts | 191 +++++++++++++++++ 9 files changed, 834 insertions(+), 11 deletions(-) create mode 100644 src/client/routes.ts create mode 100644 src/config/route-config.ts create mode 100644 tests/client/routes.test.ts create mode 100644 tests/config/route-config.test.ts diff --git a/src/client/Root.tsx b/src/client/Root.tsx index 4d742c56..e8c51980 100644 --- a/src/client/Root.tsx +++ b/src/client/Root.tsx @@ -28,13 +28,15 @@ export function SolidBaseRoot( const base = () => ( - - - - {props.children} - - - + + + + + {props.children} + + + + ); @@ -50,6 +52,7 @@ export function SolidBaseRoot( import { LocaleContextProvider } from "./locale.js"; import { CurrentPageDataProvider, useCurrentPageData } from "./page-data.js"; +import { SolidBaseRoutesContextProvider } from "./routes.js"; export function Inner(props: ParentProps) { const config = useRouteSolidBaseConfig(); diff --git a/src/client/config.ts b/src/client/config.ts index 9c86d068..0b9e1a35 100644 --- a/src/client/config.ts +++ b/src/client/config.ts @@ -2,19 +2,26 @@ import { solidBaseConfig } from "virtual:solidbase/config"; import { type Accessor, createMemo } from "solid-js"; import type { SolidBaseResolvedConfig } from "../config/index.js"; +import { resolveSolidBaseRouteConfig } from "../config/route-config.js"; import { useLocale } from "./locale.js"; +import { useSolidBaseRoute } from "./routes.js"; export function useRouteSolidBaseConfig(): Accessor< SolidBaseResolvedConfig > { const { currentLocale } = useLocale(); + const currentRoute = useSolidBaseRoute(); return createMemo(() => { + const routeConfig = resolveSolidBaseRouteConfig( + solidBaseConfig, + currentRoute(), + ); const localeConfig = currentLocale().config.themeConfig ?? {}; return { - ...solidBaseConfig, - themeConfig: { ...solidBaseConfig.themeConfig, ...localeConfig }, + ...routeConfig, + themeConfig: { ...routeConfig.themeConfig, ...localeConfig }, }; }); } diff --git a/src/client/index.tsx b/src/client/index.tsx index 09174d0e..2e3e985e 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -25,6 +25,12 @@ export { } from "./page-markdown.js"; export { usePreferredLanguage } from "./preferred-language.js"; export { SolidBaseRoot } from "./Root.jsx"; +export { + SolidBaseRoutesContextProvider, + useSolidBaseRoute, + useSolidBaseRouteOptions, + useSolidBaseRoutes, +} from "./routes.js"; export type * from "./sidebar.js"; export { SidebarProvider, usePrevNext, useSidebar } from "./sidebar.js"; export { diff --git a/src/client/routes.ts b/src/client/routes.ts new file mode 100644 index 00000000..bffada12 --- /dev/null +++ b/src/client/routes.ts @@ -0,0 +1,66 @@ +import { solidBaseConfig } from "virtual:solidbase/config"; +import { createContextProvider } from "@solid-primitives/context"; +import { useLocation } from "@solidjs/router"; +import { createMemo } from "solid-js"; + +import { + buildSolidBaseRoutePath, + getSolidBaseRouteOptions, + getSolidBaseRouteSelectionForPath, + normalizeSolidBaseRouteSelection, + type SolidBaseRouteOption, + type SolidBaseRouteSelection, +} from "../config/route-config.js"; + +const [SolidBaseRoutesContextProvider, useSolidBaseRoutesContext] = + createContextProvider(() => { + const location = useLocation(); + const current = createMemo( + () => + getSolidBaseRouteSelectionForPath( + solidBaseConfig.routes, + location.pathname, + ) ?? + normalizeSolidBaseRouteSelection(solidBaseConfig.routes) ?? + {}, + ); + + return { + routes: solidBaseConfig.routes, + current, + path: (selection: Partial) => + buildSolidBaseRoutePath(solidBaseConfig.routes, { + ...current(), + ...selection, + }), + options: (axis: string, selection?: Partial) => + getSolidBaseRouteOptions( + solidBaseConfig.routes, + axis, + selection ?? current(), + ), + }; + }); + +export { SolidBaseRoutesContextProvider }; + +export function useSolidBaseRoutes() { + return ( + useSolidBaseRoutesContext() ?? + (() => { + throw new Error( + "useSolidBaseRoutes must be called underneath a SolidBaseRoutesContextProvider", + ); + })() + ); +} + +export function useSolidBaseRoute() { + return useSolidBaseRoutes().current; +} + +export function useSolidBaseRouteOptions(axis: string) { + const routes = useSolidBaseRoutes(); + + return createMemo(() => routes.options(axis)); +} diff --git a/src/config/index.ts b/src/config/index.ts index 01d38794..b283b26b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -7,6 +7,10 @@ import type { PluginOption } from "vite"; import defaultTheme from "../default-theme/index.js"; import { type MdxOptions, solidBaseMdx } from "./mdx.js"; import type { IssueAutoLinkConfig } from "./remark-plugins/issue-autolink.js"; +import type { + SolidBaseRouteRule, + SolidBaseRoutesConfig, +} from "./route-config.js"; import solidBaseVitePlugin from "./vite-plugin/index.js"; export interface SolidBaseConfig { @@ -21,6 +25,8 @@ export interface SolidBaseConfig { issueAutolink?: IssueAutoLinkConfig | false; lang?: string; locales?: Record>; + routes?: SolidBaseRoutesConfig; + overrides?: Array>; themeConfig?: ThemeConfig; editPath?: string | ((path: string) => string); lastUpdated?: Intl.DateTimeFormatOptions | false; @@ -55,6 +61,9 @@ export type LocaleConfig = { themeConfig?: ThemeConfig; }; +export type SolidBaseRouteOverride = SolidBaseRouteRule & + Partial, "routes" | "overrides">>; + export type SitemapConfig = { hostname?: string; maxUrlsPerSitemap?: number; diff --git a/src/config/route-config.ts b/src/config/route-config.ts new file mode 100644 index 00000000..ef8b0c81 --- /dev/null +++ b/src/config/route-config.ts @@ -0,0 +1,337 @@ +export type SolidBaseRouteSelection = Record; + +export type SolidBaseRouteRule = Record; + +export type SolidBaseRouteValueConfig = { + path?: string; + href?: string; + label?: string; + title?: string; + status?: string; + lang?: string; +} & Record; + +export type SolidBaseRouteAxisConfig = { + default: string; + values: Record; +}; + +export type SolidBaseRoutesConfig = { + path: `/${string}`; + include?: SolidBaseRouteRule[]; + [key: string]: + | `/${string}` + | SolidBaseRouteRule[] + | SolidBaseRouteAxisConfig + | undefined; +}; + +export type SolidBaseRouteOption = { + name: string; + axis: string; + path?: string; + href?: string; + isExternal: boolean; + selection?: SolidBaseRouteSelection; + meta: SolidBaseRouteValueConfig; +}; + +type RouteConfigValue = Record; + +type RouteTemplateSegment = + | { type: "static"; value: string } + | { type: "axis"; name: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isSolidBaseRouteAxisConfig( + value: unknown, +): value is SolidBaseRouteAxisConfig { + return ( + isRecord(value) && + typeof value.default === "string" && + isRecord(value.values) + ); +} + +export function getSolidBaseRouteAxes( + routes: SolidBaseRoutesConfig | undefined, +) { + if (!routes) return []; + + return Object.entries(routes).filter( + (entry): entry is [string, SolidBaseRouteAxisConfig] => + isSolidBaseRouteAxisConfig(entry[1]), + ); +} + +export function getSolidBaseRouteAxisNames( + routes: SolidBaseRoutesConfig | undefined, +) { + return getSolidBaseRouteAxes(routes).map(([name]) => name); +} + +function trimSlashes(value: string) { + return value.replace(/^\/+|\/+$/g, ""); +} + +function normalizeRoutePath(path: string): `/${string}` { + const normalized = `/${path + .split("/") + .map((segment) => trimSlashes(segment)) + .filter(Boolean) + .join("/")}`; + + return normalized as `/${string}`; +} + +function getRouteTemplateSegments(path: string): RouteTemplateSegment[] { + return path + .split("/") + .map((segment) => trimSlashes(segment)) + .filter(Boolean) + .map((segment) => { + const match = segment.match(/^\{([^}]+)\}$/); + if (match) return { type: "axis", name: match[1]! }; + return { type: "static", value: segment }; + }); +} + +export function normalizeSolidBaseRouteSelection( + routes: SolidBaseRoutesConfig | undefined, + selection: Partial = {}, +) { + const normalized: SolidBaseRouteSelection = {}; + + for (const [axisName, axisConfig] of getSolidBaseRouteAxes(routes)) { + const valueName = selection[axisName] ?? axisConfig.default; + const value = axisConfig.values[valueName]; + + if (!value || value.href) return undefined; + + normalized[axisName] = valueName; + } + + return normalized; +} + +export function matchesSolidBaseRouteRule( + selection: SolidBaseRouteSelection, + rule: SolidBaseRouteRule, +) { + for (const [axisName, allowed] of Object.entries(rule)) { + const current = selection[axisName]; + + if (Array.isArray(allowed)) { + if (!current || !allowed.includes(current)) return false; + continue; + } + + if (current !== allowed) return false; + } + + return true; +} + +export function isSolidBaseRouteIncluded( + routes: SolidBaseRoutesConfig | undefined, + selection: Partial, +) { + const normalized = normalizeSolidBaseRouteSelection(routes, selection); + if (!normalized) return false; + if (!routes?.include) return true; + + return routes.include.some((rule) => + matchesSolidBaseRouteRule(normalized, rule), + ); +} + +export function buildSolidBaseRoutePath( + routes: SolidBaseRoutesConfig | undefined, + selection: Partial = {}, +) { + const normalized = normalizeSolidBaseRouteSelection(routes, selection); + if (!routes || !normalized || !isSolidBaseRouteIncluded(routes, normalized)) { + return undefined; + } + + const axes = new Map(getSolidBaseRouteAxes(routes)); + const pathSegments = getRouteTemplateSegments(routes.path).map((segment) => { + if (segment.type === "static") return segment.value; + + const axis = axes.get(segment.name); + const valueName = normalized[segment.name]; + if (!axis || !valueName) return ""; + + const value = axis.values[valueName]; + return trimSlashes(value?.path ?? valueName); + }); + + return normalizeRoutePath(pathSegments.join("/")); +} + +export function getSolidBaseRouteSelections( + routes: SolidBaseRoutesConfig | undefined, +) { + const axes = getSolidBaseRouteAxes(routes); + if (axes.length === 0) return []; + + const selections = axes.reduce( + (acc, [axisName, axisConfig]) => { + const next: SolidBaseRouteSelection[] = []; + + for (const selection of acc) { + for (const [valueName, value] of Object.entries(axisConfig.values)) { + if (value.href) continue; + next.push({ ...selection, [axisName]: valueName }); + } + } + + return next; + }, + [{}], + ); + + return selections.filter((selection) => + isSolidBaseRouteIncluded(routes, selection), + ); +} + +export function getSolidBaseRouteSelectionForPath( + routes: SolidBaseRoutesConfig | undefined, + path: string, +) { + const normalizedPath = normalizeRoutePath(path); + const selections = getSolidBaseRouteSelections(routes).sort((a, b) => { + const aPath = buildSolidBaseRoutePath(routes, a) ?? "/"; + const bPath = buildSolidBaseRoutePath(routes, b) ?? "/"; + return bPath.length - aPath.length; + }); + + return selections.find( + (selection) => buildSolidBaseRoutePath(routes, selection) === normalizedPath, + ); +} + +export function getSolidBaseRouteOptions( + routes: SolidBaseRoutesConfig | undefined, + axisName: string, + current: Partial = {}, +): SolidBaseRouteOption[] { + if (!routes) return []; + + const axis = getSolidBaseRouteAxes(routes).find( + ([name]) => name === axisName, + ); + if (!axis) return []; + + const [name, config] = axis; + const normalized = normalizeSolidBaseRouteSelection(routes, current) ?? {}; + const options: SolidBaseRouteOption[] = []; + + for (const [valueName, value] of Object.entries(config.values)) { + if (value.href) { + options.push({ + name: valueName, + axis: name, + href: value.href, + isExternal: true, + meta: value, + }); + continue; + } + + const selection = { + ...normalized, + [axisName]: valueName, + }; + + if (!isSolidBaseRouteIncluded(routes, selection)) continue; + + options.push({ + name: valueName, + axis: name, + path: buildSolidBaseRoutePath(routes, selection), + isExternal: false, + selection, + meta: value, + }); + } + + return options; +} + +function splitRouteOverride(override: RouteConfigValue, axisNames: string[]) { + const selectors: SolidBaseRouteRule = {}; + const config: RouteConfigValue = {}; + + for (const [key, value] of Object.entries(override)) { + if (axisNames.includes(key)) { + if (typeof value === "string" || Array.isArray(value)) { + selectors[key] = value as string | string[]; + } + continue; + } + + if (key !== "routes" && key !== "overrides") config[key] = value; + } + + return { selectors, config }; +} + +function mergeRouteConfig( + base: T, + override: RouteConfigValue, +): T { + const result: RouteConfigValue = { ...base }; + + for (const [key, value] of Object.entries(override)) { + if (key === "themeConfig" && isRecord(result[key]) && isRecord(value)) { + result[key] = { + ...result[key], + ...value, + }; + continue; + } + + result[key] = value; + } + + return result as T; +} + +export function resolveSolidBaseRouteConfig( + baseConfig: T & { + overrides?: RouteConfigValue[]; + routes?: SolidBaseRoutesConfig; + }, + selection: Partial, +): T { + const { overrides: _overrides, ...base } = baseConfig; + const routes = baseConfig.routes; + if (!routes) return base as T; + + const normalized = normalizeSolidBaseRouteSelection(routes, selection); + + if (!normalized || !isSolidBaseRouteIncluded(routes, normalized)) { + return base as T; + } + + const axisNames = getSolidBaseRouteAxisNames(routes); + let config = base as T; + + for (const override of baseConfig.overrides ?? []) { + const { selectors, config: overrideConfig } = splitRouteOverride( + override, + axisNames, + ); + + if (!matchesSolidBaseRouteRule(normalized, selectors)) continue; + + config = mergeRouteConfig(config, overrideConfig); + } + + return config; +} diff --git a/tests/client/config.test.ts b/tests/client/config.test.ts index eda59027..db710140 100644 --- a/tests/client/config.test.ts +++ b/tests/client/config.test.ts @@ -2,6 +2,7 @@ import { createRoot } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; const NpmIcon = () => null; +const pathname = vi.fn<() => string>(() => "/"); function setSolidBaseConfig(value: Record) { const store = ((globalThis as any).__solidBaseConfig ??= {}) as Record< @@ -12,6 +13,14 @@ function setSolidBaseConfig(value: Record) { Object.assign(store, value); } +vi.mock("@solidjs/router", () => ({ + useLocation: () => ({ + get pathname() { + return pathname(); + }, + }), +})); + vi.mock("../../src/client/locale.ts", () => ({ useLocale: () => ({ currentLocale: () => ({ @@ -26,6 +35,8 @@ vi.mock("../../src/client/locale.ts", () => ({ describe("route config helper", () => { afterEach(() => { + pathname.mockReset(); + pathname.mockReturnValue("/"); setSolidBaseConfig({}); vi.resetModules(); }); @@ -48,11 +59,21 @@ describe("route config helper", () => { const { useRouteSolidBaseConfig } = await import( "../../src/client/config.ts" ); + const { SolidBaseRoutesContextProvider } = await import( + "../../src/client/routes.ts" + ); createRoot((dispose) => { - const config = useRouteSolidBaseConfig(); + let config: ReturnType> | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + config = useRouteSolidBaseConfig(); + return null; + }, + } as any); - expect(config()).toMatchObject({ + expect(config?.()).toMatchObject({ title: "Docs", themeConfig: { actions: { @@ -68,4 +89,68 @@ describe("route config helper", () => { dispose(); }); }); + + it("merges route overrides before locale theme config", async () => { + pathname.mockReturnValue("/v1/fr"); + setSolidBaseConfig({ + title: "Docs", + routes: { + path: "/{version}/{locale}", + version: { + default: "latest", + values: { + latest: { path: "", label: "Latest" }, + v1: { path: "v1", label: "v1" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English" }, + fr: { path: "fr", label: "Français" }, + }, + }, + }, + themeConfig: { + nav: { title: "Default" }, + sidebar: { "/": [] }, + }, + overrides: [ + { + version: "v1", + title: "Docs v1", + themeConfig: { + sidebar: { "/v1": [] }, + }, + }, + ], + }); + + const { useRouteSolidBaseConfig } = await import( + "../../src/client/config.ts" + ); + const { SolidBaseRoutesContextProvider } = await import( + "../../src/client/routes.ts" + ); + + createRoot((dispose) => { + let config: ReturnType> | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + config = useRouteSolidBaseConfig(); + return null; + }, + } as any); + + expect(config?.()).toMatchObject({ + title: "Docs v1", + themeConfig: { + nav: { title: "Localized" }, + sidebar: { "/v1": [] }, + }, + }); + dispose(); + }); + }); }); diff --git a/tests/client/routes.test.ts b/tests/client/routes.test.ts new file mode 100644 index 00000000..3d2e6310 --- /dev/null +++ b/tests/client/routes.test.ts @@ -0,0 +1,119 @@ +import { createRoot } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const pathname = vi.fn<() => string>(() => "/router/fr"); + +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(); + }, + }), +})); + +const routes = { + path: "/{project}/{version}/{locale}", + project: { + default: "solid", + values: { + solid: { path: "", label: "Solid" }, + router: { path: "router", label: "Solid Router" }, + }, + }, + version: { + default: "latest", + values: { + latest: { path: "", label: "Latest" }, + v1: { path: "v1", label: "v1" }, + v0: { href: "https://v0.solidjs.com", label: "v0" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English" }, + fr: { path: "fr", label: "Français" }, + es: { path: "es", label: "Español" }, + }, + }, + include: [ + { project: ["solid", "router"], version: "latest", locale: ["en", "fr"] }, + { project: "solid", version: "v1", locale: ["en", "fr", "es"] }, + ], +}; + +describe("solidbase route client helpers", () => { + afterEach(() => { + pathname.mockReset(); + pathname.mockReturnValue("/router/fr"); + setSolidBaseConfig({}); + vi.resetModules(); + }); + + it("resolves the current route selection from the current pathname", async () => { + setSolidBaseConfig({ routes }); + + const { SolidBaseRoutesContextProvider, useSolidBaseRoute } = await import( + "../../src/client/routes.ts" + ); + + createRoot((dispose) => { + let current: ReturnType | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + current = useSolidBaseRoute(); + return null; + }, + } as any); + + expect(current?.()).toEqual({ + project: "router", + version: "latest", + locale: "fr", + }); + dispose(); + }); + }); + + it("returns filtered options and external route links", async () => { + setSolidBaseConfig({ routes }); + + const { SolidBaseRoutesContextProvider, useSolidBaseRoutes } = await import( + "../../src/client/routes.ts" + ); + + createRoot((dispose) => { + let helpers: ReturnType | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + helpers = useSolidBaseRoutes(); + return null; + }, + } as any); + + expect(helpers?.options("locale").map((option) => option.name)).toEqual([ + "en", + "fr", + ]); + expect(helpers?.options("version")).toMatchObject([ + { name: "latest", path: "/router/fr", isExternal: false }, + { name: "v0", href: "https://v0.solidjs.com", isExternal: true }, + ]); + expect(helpers?.path({ project: "solid", version: "v1" })).toBe( + "/v1/fr", + ); + dispose(); + }); + }); +}); diff --git a/tests/config/route-config.test.ts b/tests/config/route-config.test.ts new file mode 100644 index 00000000..69f86c93 --- /dev/null +++ b/tests/config/route-config.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { + buildSolidBaseRoutePath, + getSolidBaseRouteSelectionForPath, + getSolidBaseRouteOptions, + isSolidBaseRouteIncluded, + resolveSolidBaseRouteConfig, + type SolidBaseRoutesConfig, +} from "../../src/config/route-config.ts"; + +const routes = { + path: "/{project}/{version}/{locale}", + project: { + default: "solid", + values: { + solid: { path: "", label: "Solid" }, + router: { path: "router", label: "Solid Router" }, + start: { path: "start", label: "SolidStart" }, + }, + }, + version: { + default: "latest", + values: { + latest: { path: "", label: "Latest" }, + v1: { path: "v1", label: "v1", status: "Legacy" }, + v0: { href: "https://v0.solidjs.com", label: "v0" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en-US" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + es: { path: "es", label: "Español", lang: "es-ES" }, + }, + }, + include: [ + { + project: ["solid", "router", "start"], + version: "latest", + locale: ["en", "fr"], + }, + { project: "solid", version: ["latest", "v1"], locale: ["en", "fr", "es"] }, + ], +} satisfies SolidBaseRoutesConfig; + +describe("route config helpers", () => { + it("builds paths from default and selected route values", () => { + expect(buildSolidBaseRoutePath(routes)).toBe("/"); + expect(buildSolidBaseRoutePath(routes, { locale: "fr" })).toBe("/fr"); + expect(buildSolidBaseRoutePath(routes, { version: "v1" })).toBe("/v1"); + expect( + buildSolidBaseRoutePath(routes, { + project: "router", + locale: "fr", + }), + ).toBe("/router/fr"); + expect(getSolidBaseRouteSelectionForPath(routes, "/router/fr")).toEqual({ + project: "router", + version: "latest", + locale: "fr", + }); + }); + + it("filters internal route combinations through include rules", () => { + expect( + isSolidBaseRouteIncluded(routes, { + project: "router", + version: "latest", + locale: "fr", + }), + ).toBe(true); + expect( + isSolidBaseRouteIncluded(routes, { + project: "router", + version: "latest", + locale: "es", + }), + ).toBe(false); + expect( + isSolidBaseRouteIncluded(routes, { + project: "router", + version: "v1", + locale: "en", + }), + ).toBe(false); + expect( + isSolidBaseRouteIncluded(routes, { + project: "solid", + version: "v1", + locale: "es", + }), + ).toBe(true); + }); + + it("returns valid route options plus external links", () => { + expect( + getSolidBaseRouteOptions(routes, "locale", { + project: "router", + version: "latest", + locale: "fr", + }).map((option) => option.name), + ).toEqual(["en", "fr"]); + + expect( + getSolidBaseRouteOptions(routes, "locale", { + project: "solid", + version: "v1", + locale: "fr", + }).map((option) => option.name), + ).toEqual(["en", "fr", "es"]); + + expect( + getSolidBaseRouteOptions(routes, "version", { + project: "router", + version: "latest", + locale: "fr", + }), + ).toMatchObject([ + { + name: "latest", + path: "/router/fr", + isExternal: false, + }, + { + name: "v0", + href: "https://v0.solidjs.com", + isExternal: true, + }, + ]); + }); + + it("treats omitted include as all internal combinations", () => { + const openRoutes = { + ...routes, + include: undefined, + } satisfies SolidBaseRoutesConfig; + + expect( + buildSolidBaseRoutePath(openRoutes, { + project: "router", + version: "v1", + locale: "es", + }), + ).toBe("/router/v1/es"); + }); + + it("applies matching overrides in order with a shallow theme config merge", () => { + const config = resolveSolidBaseRouteConfig( + { + title: "Docs", + routes, + themeConfig: { + nav: ["base"], + sidebar: { "/": [] }, + socialLinks: { github: "base" }, + }, + overrides: [ + { + project: "solid", + title: "Solid", + themeConfig: { nav: ["solid"] }, + }, + { + locale: "fr", + title: "Docs FR", + themeConfig: { sidebar: { "/fr": [] } }, + }, + { + project: "solid", + version: "v1", + locale: "fr", + title: "Solid v1 FR", + }, + ], + }, + { project: "solid", version: "v1", locale: "fr" }, + ); + + expect(config).toMatchObject({ + title: "Solid v1 FR", + themeConfig: { + nav: ["solid"], + sidebar: { "/fr": [] }, + socialLinks: { github: "base" }, + }, + }); + expect("overrides" in config).toBe(false); + }); +}); From fd11f786402326f645cb83f36daef589876f3153 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 13:01:53 -0700 Subject: [PATCH 02/19] add SolidBase route variants --- src/client/locale.ts | 97 +++++++++++++-- src/config/index.ts | 38 +++++- src/config/route-config.ts | 234 +++++++++++++++++++++++++++++------- tests/client/locale.test.ts | 80 +++++++++++- tests/config/index.test.ts | 131 ++++++++++++++++++++ 5 files changed, 520 insertions(+), 60 deletions(-) diff --git a/src/client/locale.ts b/src/client/locale.ts index 9db149fe..cc42be4e 100644 --- a/src/client/locale.ts +++ b/src/client/locale.ts @@ -5,17 +5,62 @@ import { createMemo, startTransition } from "solid-js"; import { getRequestEvent, isServer } from "solid-js/web"; import type { LocaleConfig } from "../config/index.js"; +import { + buildSolidBaseRoutePath, + getSolidBaseRouteOptions, + getSolidBaseRouteSelectionForPath, + type SolidBaseRouteOption, +} from "../config/route-config.js"; +import { useSolidBaseRoutes } from "./routes.js"; export const DEFAULT_LANG_CODE = "en-US"; export const DEFAULT_LANG_LABEL = "English"; +const LOCALE_AXIS = "locale"; export interface ResolvedLocale { code: string; isRoot?: boolean; config: LocaleConfig; + option?: SolidBaseRouteOption; } -const locales = (() => { +function getRouteLocaleAxis() { + const axis = solidBaseConfig.routes?.[LOCALE_AXIS]; + if ( + axis && + typeof axis === "object" && + "default" in axis && + "values" in axis + ) { + return axis; + } + + return undefined; +} + +function routeOptionToLocale(option: SolidBaseRouteOption): ResolvedLocale { + return { + code: option.meta.lang + ? String(option.meta.lang) + : option.name === getRouteLocaleAxis()?.default + ? solidBaseConfig.lang ?? DEFAULT_LANG_CODE + : option.name, + isRoot: option.name === getRouteLocaleAxis()?.default, + option, + config: { + label: + typeof option.meta.label === "string" + ? option.meta.label + : option.name === getRouteLocaleAxis()?.default + ? DEFAULT_LANG_LABEL + : option.name, + lang: typeof option.meta.lang === "string" ? option.meta.lang : undefined, + link: option.path, + }, + }; +} + +const legacyLocales = (() => { let rootHandled = false; const array: Array> = Object.entries( @@ -46,32 +91,66 @@ const locales = (() => { return array; })(); -function getLocaleForPath(path: string) { - for (const locale of locales) { +function getLegacyLocaleForPath(path: string) { + for (const locale of legacyLocales) { if (locale.isRoot) continue; if (path.startsWith(locale.config.link ?? `/${locale.code}`)) return locale; } - return locales.find((l) => l.isRoot)!; + return legacyLocales.find((l) => l.isRoot)!; +} + +function getRouteLocaleForPath(path: string) { + const selection = getSolidBaseRouteSelectionForPath(solidBaseConfig.routes, path); + const value = selection?.[LOCALE_AXIS]; + if (!value) return undefined; + + const option = getSolidBaseRouteOptions(solidBaseConfig.routes, LOCALE_AXIS, { + ...selection, + [LOCALE_AXIS]: value, + }).find((option) => option.name === value); + + return option ? routeOptionToLocale(option) : undefined; +} + +function getLocaleForPath(path: string) { + return getRouteLocaleForPath(path) ?? getLegacyLocaleForPath(path); } const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { const location = useLocation(); const navigate = useNavigate(); + const routes = useSolidBaseRoutes(); const currentLocale = createMemo(() => getLocaleForPath(location.pathname)); + const locales = createMemo(() => { + if (!solidBaseConfig.routes) return legacyLocales; + + return routes.options(LOCALE_AXIS).map(routeOptionToLocale); + }); const match = useMatch(() => `${getLocaleLink(currentLocale())}*rest`); return { - locales, + get locales() { + return locales(); + }, currentLocale, setLocale: (locale: ResolvedLocale) => { - const searchValue = getLocaleLink(locale); + const routePath = + locale.option && + buildSolidBaseRoutePath(solidBaseConfig.routes, { + ...routes.current(), + [LOCALE_AXIS]: locale.option.name, + }); + + const searchValue = routePath ?? getLocaleLink(locale); startTransition(() => - navigate(`${searchValue}${match()?.params.rest ?? ""}`), + navigate( + routePath ? searchValue : `${searchValue}${match()?.params.rest ?? ""}`, + ), ).then(() => { document.documentElement.lang = locale.code; }); @@ -111,7 +190,9 @@ export function useLocale() { } export const getLocaleLink = (locale: ResolvedLocale): `/${string}` => - locale.config?.link ?? (`/${locale.isRoot ? "" : `${locale.code}/`}` as any); + (locale.option?.path ?? + locale.config?.link ?? + `/${locale.isRoot ? "" : `${locale.code}/`}`) as any; export function getLocale(_path?: string) { let path = _path; diff --git a/src/config/index.ts b/src/config/index.ts index b283b26b..fdc2ea1b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -7,12 +7,30 @@ import type { PluginOption } from "vite"; import defaultTheme from "../default-theme/index.js"; import { type MdxOptions, solidBaseMdx } from "./mdx.js"; import type { IssueAutoLinkConfig } from "./remark-plugins/issue-autolink.js"; -import type { - SolidBaseRouteRule, - SolidBaseRoutesConfig, -} from "./route-config.js"; +import type { SolidBaseRoutesConfig } from "./route-config.js"; +import { validateSolidBaseRoutesConfig as validateRoutes } from "./route-config.js"; import solidBaseVitePlugin from "./vite-plugin/index.js"; +const SOLID_BASE_OVERRIDE_CONFIG_KEYS = [ + "title", + "titleTemplate", + "description", + "siteUrl", + "llms", + "sitemap", + "robots", + "logo", + "issueAutolink", + "lang", + "locales", + "themeConfig", + "editPath", + "lastUpdated", + "markdown", + "icons", + "autoImport", +]; + export interface SolidBaseConfig { title?: string; titleTemplate?: string; @@ -61,8 +79,10 @@ export type LocaleConfig = { themeConfig?: ThemeConfig; }; -export type SolidBaseRouteOverride = SolidBaseRouteRule & - Partial, "routes" | "overrides">>; +export type SolidBaseRouteOverride = Partial< + Omit, "routes" | "overrides"> +> & + Record; export type SitemapConfig = { hostname?: string; @@ -108,6 +128,12 @@ export function createSolidBase( ...solidBaseConfig, }; + validateRoutes( + sbConfig.routes, + sbConfig.overrides, + SOLID_BASE_OVERRIDE_CONFIG_KEYS, + ); + { let t: ThemeDefinition | undefined = theme; while (t !== undefined) { diff --git a/src/config/route-config.ts b/src/config/route-config.ts index ef8b0c81..3c3ef2f6 100644 --- a/src/config/route-config.ts +++ b/src/config/route-config.ts @@ -42,6 +42,8 @@ type RouteTemplateSegment = | { type: "static"; value: string } | { type: "axis"; name: string }; +const ROUTES_RESERVED_KEYS = new Set(["path", "include"]); + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -73,6 +75,17 @@ export function getSolidBaseRouteAxisNames( return getSolidBaseRouteAxes(routes).map(([name]) => name); } +function getSolidBaseRouteAxisMap(routes: SolidBaseRoutesConfig | undefined) { + return new Map(getSolidBaseRouteAxes(routes)); +} + +function assertSolidBaseRouteConfig( + condition: unknown, + message: string, +): asserts condition { + if (!condition) throw new Error(`[SolidBase]: ${message}`); +} + function trimSlashes(value: string) { return value.replace(/^\/+|\/+$/g, ""); } @@ -99,6 +112,151 @@ function getRouteTemplateSegments(path: string): RouteTemplateSegment[] { }); } +function getRouteTemplateAxisNames(path: string) { + return getRouteTemplateSegments(path) + .filter((segment): segment is { type: "axis"; name: string } => { + return segment.type === "axis"; + }) + .map((segment) => segment.name); +} + +function toRuleValues(value: string | string[]) { + return Array.isArray(value) ? value : [value]; +} + +function validateRouteRule( + rule: SolidBaseRouteRule, + axisMap: Map, + source: string, +) { + for (const [axisName, value] of Object.entries(rule)) { + const axis = axisMap.get(axisName); + assertSolidBaseRouteConfig( + axis, + "`" + source + "` references unknown route axis `" + axisName + "`.", + ); + + for (const valueName of toRuleValues(value)) { + assertSolidBaseRouteConfig( + Object.hasOwn(axis.values, valueName), + "`" + + source + + "` references unknown `" + + axisName + + "` value `" + + valueName + + "`.", + ); + } + } +} + +function getInternalRouteValueEntries(axis: SolidBaseRouteAxisConfig) { + return Object.entries(axis.values).filter(([, value]) => !value.href); +} + +function matchRoutePathSegment( + axis: SolidBaseRouteAxisConfig, + pathSegment: string | undefined, +) { + const entries = getInternalRouteValueEntries(axis); + const defaultEntry = entries.find(([valueName]) => valueName === axis.default); + const explicitMatch = + pathSegment === undefined + ? undefined + : entries.find(([, value]) => { + const valuePath = trimSlashes(value.path ?? ""); + return valuePath !== "" && pathSegment === valuePath; + }); + + if (explicitMatch) return explicitMatch; + if (!defaultEntry) return undefined; + + const defaultPath = trimSlashes(defaultEntry[1].path ?? defaultEntry[0]); + return defaultPath === "" ? defaultEntry : undefined; +} + +function getRouteValuePath(valueName: string, value: SolidBaseRouteValueConfig) { + return trimSlashes(value.path ?? valueName); +} + +export function validateSolidBaseRoutesConfig( + routes: SolidBaseRoutesConfig | undefined, + overrides: RouteConfigValue[] = [], + overrideConfigKeys: Iterable = [], +) { + if (!routes) return; + + const axisMap = getSolidBaseRouteAxisMap(routes); + const configKeys = new Set(overrideConfigKeys); + const placeholders = getRouteTemplateAxisNames(routes.path); + + assertSolidBaseRouteConfig( + placeholders.length > 0, + "`routes.path` must include at least one route axis placeholder.", + ); + + for (const key of Object.keys(routes)) { + if (ROUTES_RESERVED_KEYS.has(key)) continue; + assertSolidBaseRouteConfig( + axisMap.has(key), + "`routes." + key + "` must include `default` and `values`.", + ); + } + + for (const axisName of placeholders) { + assertSolidBaseRouteConfig( + axisMap.has(axisName), + "`routes.path` references unknown route axis `" + axisName + "`.", + ); + } + + for (const [axisName, axis] of axisMap) { + assertSolidBaseRouteConfig( + placeholders.includes(axisName), + "`routes." + + axisName + + "` must be included in `routes.path` as `{" + + axisName + + "}`.", + ); + assertSolidBaseRouteConfig( + Object.hasOwn(axis.values, axis.default), + "`routes." + + axisName + + ".default` must reference a key in `routes." + + axisName + + ".values`.", + ); + } + + for (const rule of routes.include ?? []) { + validateRouteRule(rule, axisMap, "routes.include"); + } + + for (const override of overrides) { + const selectors: SolidBaseRouteRule = {}; + + for (const [key, value] of Object.entries(override)) { + if (axisMap.has(key)) { + assertSolidBaseRouteConfig( + typeof value === "string" || Array.isArray(value), + "`overrides` selector `" + key + "` must be a string or string array.", + ); + selectors[key] = value; + continue; + } + + assertSolidBaseRouteConfig( + configKeys.has(key), + "`overrides` contains unknown config key or route axis `" + key + "`.", + ); + } + + validateRouteRule(selectors, axisMap, "overrides"); + } +} + export function normalizeSolidBaseRouteSelection( routes: SolidBaseRoutesConfig | undefined, selection: Partial = {}, @@ -157,7 +315,7 @@ export function buildSolidBaseRoutePath( return undefined; } - const axes = new Map(getSolidBaseRouteAxes(routes)); + const axes = getSolidBaseRouteAxisMap(routes); const pathSegments = getRouteTemplateSegments(routes.path).map((segment) => { if (segment.type === "static") return segment.value; @@ -172,47 +330,40 @@ export function buildSolidBaseRoutePath( return normalizeRoutePath(pathSegments.join("/")); } -export function getSolidBaseRouteSelections( +export function getSolidBaseRouteSelectionForPath( routes: SolidBaseRoutesConfig | undefined, + path: string, ) { - const axes = getSolidBaseRouteAxes(routes); - if (axes.length === 0) return []; - - const selections = axes.reduce( - (acc, [axisName, axisConfig]) => { - const next: SolidBaseRouteSelection[] = []; - - for (const selection of acc) { - for (const [valueName, value] of Object.entries(axisConfig.values)) { - if (value.href) continue; - next.push({ ...selection, [axisName]: valueName }); - } - } + if (!routes) return undefined; - return next; - }, - [{}], - ); + const axes = getSolidBaseRouteAxisMap(routes); + const pathSegments = trimSlashes(path).split("/").filter(Boolean); + const selection: SolidBaseRouteSelection = {}; + let pathIndex = 0; - return selections.filter((selection) => - isSolidBaseRouteIncluded(routes, selection), - ); -} + for (const segment of getRouteTemplateSegments(routes.path)) { + if (segment.type === "static") { + if (pathSegments[pathIndex] !== segment.value) return undefined; + pathIndex++; + continue; + } -export function getSolidBaseRouteSelectionForPath( - routes: SolidBaseRoutesConfig | undefined, - path: string, -) { - const normalizedPath = normalizeRoutePath(path); - const selections = getSolidBaseRouteSelections(routes).sort((a, b) => { - const aPath = buildSolidBaseRoutePath(routes, a) ?? "/"; - const bPath = buildSolidBaseRoutePath(routes, b) ?? "/"; - return bPath.length - aPath.length; - }); + const axis = axes.get(segment.name); + if (!axis) return undefined; - return selections.find( - (selection) => buildSolidBaseRoutePath(routes, selection) === normalizedPath, - ); + const matched = matchRoutePathSegment(axis, pathSegments[pathIndex]); + + if (!matched) return undefined; + + const [valueName, value] = matched; + selection[segment.name] = valueName; + + if (getRouteValuePath(valueName, value) !== "") pathIndex++; + } + + if (pathIndex !== pathSegments.length) return undefined; + + return isSolidBaseRouteIncluded(routes, selection) ? selection : undefined; } export function getSolidBaseRouteOptions( @@ -222,20 +373,17 @@ export function getSolidBaseRouteOptions( ): SolidBaseRouteOption[] { if (!routes) return []; - const axis = getSolidBaseRouteAxes(routes).find( - ([name]) => name === axisName, - ); + const axis = getSolidBaseRouteAxisMap(routes).get(axisName); if (!axis) return []; - const [name, config] = axis; const normalized = normalizeSolidBaseRouteSelection(routes, current) ?? {}; const options: SolidBaseRouteOption[] = []; - for (const [valueName, value] of Object.entries(config.values)) { + for (const [valueName, value] of Object.entries(axis.values)) { if (value.href) { options.push({ name: valueName, - axis: name, + axis: axisName, href: value.href, isExternal: true, meta: value, @@ -252,7 +400,7 @@ export function getSolidBaseRouteOptions( options.push({ name: valueName, - axis: name, + axis: axisName, path: buildSolidBaseRoutePath(routes, selection), isExternal: false, selection, diff --git a/tests/client/locale.test.ts b/tests/client/locale.test.ts index b1bae52e..3c5e9c73 100644 --- a/tests/client/locale.test.ts +++ b/tests/client/locale.test.ts @@ -64,14 +64,21 @@ describe("locale client helpers", () => { const { LocaleContextProvider, getLocaleLink, useLocale } = await import( "../../src/client/locale.ts" ); + const { SolidBaseRoutesContextProvider } = await import( + "../../src/client/routes.ts" + ); createRoot((dispose) => { let value: ReturnType | undefined; - LocaleContextProvider({ + SolidBaseRoutesContextProvider({ get children() { - value = useLocale(); - return null; + return LocaleContextProvider({ + get children() { + value = useLocale(); + return null; + }, + } as any); }, } as any); @@ -107,4 +114,71 @@ describe("locale client helpers", () => { expect(getLocale("/docs/setup").code).toBe("en-US"); expect(getLocaleLink(getLocale("/de/docs/setup"))).toBe("/de/docs/"); }); + + it("uses routes.locale options and navigation when routes are configured", async () => { + document.documentElement.lang = ""; + setSolidBaseConfig({ + lang: "en-US", + routes: { + path: "/{project}/{locale}", + project: { + default: "solid", + values: { + solid: { path: "", label: "Solid" }, + router: { path: "router", label: "Router" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en-US" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + es: { path: "es", label: "Español", lang: "es-ES" }, + }, + }, + include: [ + { project: ["solid", "router"], locale: ["en", "fr"] }, + { project: "solid", locale: "es" }, + ], + }, + }); + pathname.mockReturnValue("/router/fr"); + + const { LocaleContextProvider, getLocaleLink, useLocale } = await import( + "../../src/client/locale.ts" + ); + const { SolidBaseRoutesContextProvider } = await import( + "../../src/client/routes.ts" + ); + + createRoot((dispose) => { + let value: ReturnType | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + return LocaleContextProvider({ + get children() { + value = useLocale(); + return null; + }, + } as any); + }, + } as any); + + expect(value?.currentLocale().code).toBe("fr-FR"); + expect(value?.locales.map((locale) => locale.config.label)).toEqual([ + "English", + "Français", + ]); + expect(getLocaleLink(value!.locales[0]!)).toBe("/router"); + expect(getLocaleLink(value!.locales[1]!)).toBe("/router/fr"); + + void value?.setLocale(value!.locales[0]!); + dispose(); + }); + + await Promise.resolve(); + expect(navigate).toHaveBeenCalledWith("/router"); + expect(document.documentElement.lang).toBe("en-US"); + }); }); diff --git a/tests/config/index.test.ts b/tests/config/index.test.ts index 6ca62d0a..3757c0b6 100644 --- a/tests/config/index.test.ts +++ b/tests/config/index.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { SolidBaseRoutesConfig } from "../../src/config/route-config.ts"; const solidBaseMdx = vi.fn(); const solidBaseVitePlugin = vi.fn(); @@ -17,6 +18,25 @@ vi.mock("../../src/default-theme/index.js", () => ({ }, })); +const validRoutes = { + path: "/{version}/{locale}", + version: { + default: "latest", + values: { + latest: { path: "", label: "Latest" }, + v1: { path: "v1", label: "v1" }, + v0: { href: "https://v0.solidjs.com", label: "v0" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English" }, + fr: { path: "fr", label: "Français" }, + }, + }, +} satisfies SolidBaseRoutesConfig; + describe("createSolidBase", () => { it("applies defaults and returns mdx, core, and theme plugins", async () => { solidBaseMdx.mockReset(); @@ -103,4 +123,115 @@ describe("createSolidBase", () => { "child-plugin", ]); }); + + it("accepts valid route config and route overrides", 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", + } as any; + + const solidBase = createSolidBase(theme); + + expect(() => + solidBase.plugin({ + routes: validRoutes, + overrides: [ + { version: "v1", title: "v1" }, + { locale: ["en", "fr"], themeConfig: { nav: [] } }, + ], + }), + ).not.toThrow(); + }); + + it("rejects route paths that reference unknown axes", async () => { + const { createSolidBase } = await import("../../src/config/index.ts"); + const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + + expect(() => + solidBase.plugin({ + routes: { + ...validRoutes, + path: "/{version}/{project}", + }, + }), + ).toThrow("unknown route axis `project`"); + }); + + it("rejects route axes that are missing from the path", async () => { + const { createSolidBase } = await import("../../src/config/index.ts"); + const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + + expect(() => + solidBase.plugin({ + routes: { + ...validRoutes, + path: "/{version}", + }, + }), + ).toThrow("`routes.locale` must be included"); + }); + + it("rejects route defaults that are not route values", async () => { + const { createSolidBase } = await import("../../src/config/index.ts"); + const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + + expect(() => + solidBase.plugin({ + routes: { + ...validRoutes, + version: { + ...validRoutes.version, + default: "missing", + }, + }, + }), + ).toThrow("default` must reference a key"); + }); + + it("rejects include rules with unknown axes or values", async () => { + const { createSolidBase } = await import("../../src/config/index.ts"); + const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + + expect(() => + solidBase.plugin({ + routes: { + ...validRoutes, + include: [{ project: "solid" }], + }, + }), + ).toThrow("include` references unknown route axis `project`"); + + expect(() => + solidBase.plugin({ + routes: { + ...validRoutes, + include: [{ version: "missing" }], + }, + }), + ).toThrow("include` references unknown `version` value `missing`"); + }); + + it("rejects overrides with unknown route selectors or values", async () => { + const { createSolidBase } = await import("../../src/config/index.ts"); + const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + + expect(() => + solidBase.plugin({ + routes: validRoutes, + overrides: [{ project: "solid" }], + }), + ).toThrow("unknown config key or route axis `project`"); + + expect(() => + solidBase.plugin({ + routes: validRoutes, + overrides: [{ version: "missing" }], + }), + ).toThrow("overrides` references unknown `version` value `missing`"); + }); }); From 768e35ae442471b50f5120670f4cf5622ef8a97a Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 13:30:50 -0700 Subject: [PATCH 03/19] support route locales in generated outputs --- dev/src/routes/es/index.mdx | 11 +++ dev/src/routes/fr/index.mdx | 13 +++ dev/src/routes/index.mdx | 20 +++++ dev/src/routes/router/fr/about.mdx | 11 +++ dev/src/routes/router/fr/index.mdx | 11 +++ dev/src/routes/router/index.mdx | 11 +++ dev/src/routes/v1/fr/index.mdx | 11 +++ dev/src/routes/v1/index.mdx | 11 +++ dev/vite.config.mts | 54 +++++++++++++ src/client/locale.ts | 59 +++++++++++++- src/config/llms-index.ts | 7 +- src/config/route-config.ts | 30 ++++++- src/config/routes-index.ts | 123 ++++++++++++++++++++++++++++- src/config/sitemap-index.ts | 99 +++-------------------- tests/client/locale.test.ts | 8 +- tests/config/llms-index.test.ts | 75 ++++++++++++++++++ tests/config/routes-index.test.ts | 35 ++++++++ tests/config/sitemap-index.test.ts | 107 +++++++++++++++++++++++++ 18 files changed, 598 insertions(+), 98 deletions(-) create mode 100644 dev/src/routes/es/index.mdx create mode 100644 dev/src/routes/fr/index.mdx create mode 100644 dev/src/routes/router/fr/about.mdx create mode 100644 dev/src/routes/router/fr/index.mdx create mode 100644 dev/src/routes/router/index.mdx create mode 100644 dev/src/routes/v1/fr/index.mdx create mode 100644 dev/src/routes/v1/index.mdx diff --git a/dev/src/routes/es/index.mdx b/dev/src/routes/es/index.mdx new file mode 100644 index 00000000..ff81255b --- /dev/null +++ b/dev/src/routes/es/index.mdx @@ -0,0 +1,11 @@ +--- +title: SolidBase en español +--- + +# SolidBase en español + +Spanish content is valid for the default project on the latest version. + +[English](/) + +[French](/fr) diff --git a/dev/src/routes/fr/index.mdx b/dev/src/routes/fr/index.mdx new file mode 100644 index 00000000..38cff41a --- /dev/null +++ b/dev/src/routes/fr/index.mdx @@ -0,0 +1,13 @@ +--- +title: SolidBase en français +--- + +# SolidBase en français + +French content for the default project and latest version. + +[English](/) + +[Legacy French](/v1/fr) + +[Router French](/router/fr) diff --git a/dev/src/routes/index.mdx b/dev/src/routes/index.mdx index a2bd2728..3f9fc783 100644 --- a/dev/src/routes/index.mdx +++ b/dev/src/routes/index.mdx @@ -66,6 +66,26 @@ www.example.com [Link internal /about](/about) +## Route config demo + +[Latest English](/) + +[Latest French](/fr) + +[Latest Spanish](/es) + +[Legacy English](/v1) + +[Legacy French](/v1/fr) + +[Router English](/router) + +[Router French](/router/fr) + +[Router French About](/router/fr/about) + +[External v0](https://solidbase.dev) + #20 also #4 but no \\#20 A note[^1] diff --git a/dev/src/routes/router/fr/about.mdx b/dev/src/routes/router/fr/about.mdx new file mode 100644 index 00000000..eb9dc21a --- /dev/null +++ b/dev/src/routes/router/fr/about.mdx @@ -0,0 +1,11 @@ +--- +title: Router Demo about en français +--- + +# Router Demo about en français + +French router content with an extra page route after the route config prefix. + +[Router French](/router/fr) + +[Router English](/router) diff --git a/dev/src/routes/router/fr/index.mdx b/dev/src/routes/router/fr/index.mdx new file mode 100644 index 00000000..d163e6b0 --- /dev/null +++ b/dev/src/routes/router/fr/index.mdx @@ -0,0 +1,11 @@ +--- +title: Router Demo en français +--- + +# Router Demo en français + +Router project content for the latest French route. + +[SolidBase French](/fr) + +[Router English](/router) diff --git a/dev/src/routes/router/index.mdx b/dev/src/routes/router/index.mdx new file mode 100644 index 00000000..09649b26 --- /dev/null +++ b/dev/src/routes/router/index.mdx @@ -0,0 +1,11 @@ +--- +title: Router Demo +--- + +# Router Demo + +Router project content for the latest English route. + +[SolidBase English](/) + +[Router French](/router/fr) diff --git a/dev/src/routes/v1/fr/index.mdx b/dev/src/routes/v1/fr/index.mdx new file mode 100644 index 00000000..1680f48c --- /dev/null +++ b/dev/src/routes/v1/fr/index.mdx @@ -0,0 +1,11 @@ +--- +title: SolidBase v1 en français +--- + +# SolidBase v1 en français + +Legacy French content for the default project. + +[Latest French](/fr) + +[Legacy English](/v1) diff --git a/dev/src/routes/v1/index.mdx b/dev/src/routes/v1/index.mdx new file mode 100644 index 00000000..90d82b34 --- /dev/null +++ b/dev/src/routes/v1/index.mdx @@ -0,0 +1,11 @@ +--- +title: SolidBase v1 +--- + +# SolidBase v1 + +Legacy English content for the default project. + +[Latest English](/) + +[Legacy French](/v1/fr) diff --git a/dev/vite.config.mts b/dev/vite.config.mts index 3cf574ed..8b2e06f6 100644 --- a/dev/vite.config.mts +++ b/dev/vite.config.mts @@ -22,6 +22,60 @@ export default defineConfig({ description: "Development playground for the latest SolidBase features", llms: true, lang: "en", + routes: { + path: "/{project}/{version}/{locale}", + project: { + default: "solidbase", + values: { + solidbase: { path: "", label: "SolidBase" }, + router: { path: "router", label: "Router Demo" }, + }, + }, + version: { + default: "latest", + values: { + latest: { path: "", label: "Latest" }, + v1: { path: "v1", label: "v1", status: "Legacy" }, + v0: { href: "https://solidbase.dev", label: "External v0" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en-US" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + es: { path: "es", label: "Español", lang: "es-ES" }, + }, + }, + include: [ + { + project: ["solidbase", "router"], + version: "latest", + locale: ["en", "fr"], + }, + { + project: "solidbase", + version: "latest", + locale: "es", + }, + { + project: "solidbase", + version: "v1", + locale: ["en", "fr"], + }, + ], + }, + overrides: [ + { project: "router", title: "Router Demo" }, + { version: "v1", title: "SolidBase v1 Demo" }, + { locale: "fr", titleTemplate: ":title - Demo SolidBase" }, + { + project: "solidbase", + version: "v1", + locale: "fr", + title: "SolidBase v1 en français", + }, + ], themeConfig: { sidebar: { "/": createFilesystemSidebar("./src/routes", { diff --git a/src/client/locale.ts b/src/client/locale.ts index cc42be4e..79fbdb7c 100644 --- a/src/client/locale.ts +++ b/src/client/locale.ts @@ -7,7 +7,9 @@ import { getRequestEvent, isServer } from "solid-js/web"; import type { LocaleConfig } from "../config/index.js"; import { buildSolidBaseRoutePath, + getSolidBaseRouteMatchForPath, getSolidBaseRouteOptions, + getSolidBaseRoutePathWithRest, getSolidBaseRouteSelectionForPath, type SolidBaseRouteOption, } from "../config/route-config.js"; @@ -118,12 +120,27 @@ function getLocaleForPath(path: string) { return getRouteLocaleForPath(path) ?? getLegacyLocaleForPath(path); } +function normalizeClientPath(path: string): `/${string}` { + return (path.startsWith("/") ? path : `/${path}`) as `/${string}`; +} + +function isExternalPath(path: string) { + return path.includes("://") || path.startsWith("//"); +} + +function isPathWithinPrefix(path: string, prefix: string) { + return path === prefix || path.startsWith(`${prefix}/`); +} + const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { const location = useLocation(); const navigate = useNavigate(); const routes = useSolidBaseRoutes(); const currentLocale = createMemo(() => getLocaleForPath(location.pathname)); + const currentRouteMatch = createMemo(() => + getSolidBaseRouteMatchForPath(solidBaseConfig.routes, location.pathname), + ); const locales = createMemo(() => { if (!solidBaseConfig.routes) return legacyLocales; @@ -140,10 +157,14 @@ const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { setLocale: (locale: ResolvedLocale) => { const routePath = locale.option && - buildSolidBaseRoutePath(solidBaseConfig.routes, { - ...routes.current(), - [LOCALE_AXIS]: locale.option.name, - }); + getSolidBaseRoutePathWithRest( + solidBaseConfig.routes, + { + ...routes.current(), + [LOCALE_AXIS]: locale.option.name, + }, + currentRouteMatch()?.restPath ?? "/", + ); const searchValue = routePath ?? getLocaleLink(locale); @@ -156,6 +177,33 @@ const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { }); }, applyPathPrefix: (_path: string): `/${string}` => { + if (solidBaseConfig.routes && !isExternalPath(_path)) { + const path = normalizeClientPath(_path); + const pathMatch = getSolidBaseRouteMatchForPath( + solidBaseConfig.routes, + path, + ); + const pathPrefix = + pathMatch && + buildSolidBaseRoutePath(solidBaseConfig.routes, pathMatch.selection); + + if ( + pathPrefix && + pathPrefix !== "/" && + isPathWithinPrefix(path, pathPrefix) + ) { + return path; + } + + const routePath = getSolidBaseRoutePathWithRest( + solidBaseConfig.routes, + routes.current(), + path, + ); + + if (routePath) return routePath; + } + let path = _path; const link = getLocaleLink(currentLocale()); @@ -168,6 +216,9 @@ const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { return `${link}${path}` as `/${string}`; }, routePath: () => { + const routePath = currentRouteMatch()?.restPath; + if (routePath) return routePath; + const rest = match()?.params.rest; if (!rest) return "/"; diff --git a/src/config/llms-index.ts b/src/config/llms-index.ts index 17693034..468ea85a 100644 --- a/src/config/llms-index.ts +++ b/src/config/llms-index.ts @@ -1,7 +1,11 @@ import { toDocumentMarkdown } from "./document-markdown.js"; import type { SolidBaseResolvedConfig } from "./index.js"; import { viteAliasCodeImports } from "./remark-plugins/import-code-file.js"; -import { getRoutesIndex, isDefaultLocaleRoute } from "./routes-index.js"; +import { + getRoutesIndex, + isDefaultLocaleRoute, + isRouteIncludedByConfig, +} from "./routes-index.js"; import type { SidebarConfig } from "./sidebar.js"; type LlmFrontmatter = { @@ -106,6 +110,7 @@ export async function getLlmDocuments( const frontmatter = route.frontmatter as LlmFrontmatter; if (isExcluded(frontmatter)) return null; + if (!isRouteIncludedByConfig(route.routePath, config)) return null; const source = (await ( diff --git a/src/config/route-config.ts b/src/config/route-config.ts index 3c3ef2f6..d45a720b 100644 --- a/src/config/route-config.ts +++ b/src/config/route-config.ts @@ -36,6 +36,11 @@ export type SolidBaseRouteOption = { meta: SolidBaseRouteValueConfig; }; +export type SolidBaseRoutePathMatch = { + selection: SolidBaseRouteSelection; + restPath: `/${string}`; +}; + type RouteConfigValue = Record; type RouteTemplateSegment = @@ -333,6 +338,13 @@ export function buildSolidBaseRoutePath( export function getSolidBaseRouteSelectionForPath( routes: SolidBaseRoutesConfig | undefined, path: string, +) { + return getSolidBaseRouteMatchForPath(routes, path)?.selection; +} + +export function getSolidBaseRouteMatchForPath( + routes: SolidBaseRoutesConfig | undefined, + path: string, ) { if (!routes) return undefined; @@ -361,9 +373,23 @@ export function getSolidBaseRouteSelectionForPath( if (getRouteValuePath(valueName, value) !== "") pathIndex++; } - if (pathIndex !== pathSegments.length) return undefined; + if (!isSolidBaseRouteIncluded(routes, selection)) return undefined; + + return { + selection, + restPath: normalizeRoutePath(pathSegments.slice(pathIndex).join("/")), + } satisfies SolidBaseRoutePathMatch; +} + +export function getSolidBaseRoutePathWithRest( + routes: SolidBaseRoutesConfig | undefined, + selection: Partial, + restPath: string, +) { + const routePath = buildSolidBaseRoutePath(routes, selection); + if (!routePath) return undefined; - return isSolidBaseRouteIncluded(routes, selection) ? selection : undefined; + return normalizeRoutePath(`${routePath}/${restPath}`); } export function getSolidBaseRouteOptions( diff --git a/src/config/routes-index.ts b/src/config/routes-index.ts index 1d256cb9..9538b21f 100644 --- a/src/config/routes-index.ts +++ b/src/config/routes-index.ts @@ -4,8 +4,16 @@ import { extname, join, relative } from "node:path"; import matter from "gray-matter"; import type { SolidBaseResolvedConfig } from "./index.js"; +import { + buildSolidBaseRoutePath, + getSolidBaseRouteMatchForPath, + getSolidBaseRoutePathWithRest, + isSolidBaseRouteAxisConfig, + type SolidBaseRouteAxisConfig, +} from "./route-config.js"; const MARKDOWN_EXTENSIONS = new Set([".md", ".mdx"]); +const LOCALE_AXIS = "locale"; export type RouteIndexEntry = { filePath: string; @@ -72,18 +80,131 @@ function normalizeLocalePrefix(prefix: string) { return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; } +export type RouteLocaleInfo = { + locale: string; + hreflang: string; + groupPath: string; + isDefaultLocale: boolean; +}; + +function getRouteLocaleAxis( + config: SolidBaseResolvedConfig, +): SolidBaseRouteAxisConfig | undefined { + const axis = config.routes?.[LOCALE_AXIS]; + return isSolidBaseRouteAxisConfig(axis) ? axis : undefined; +} + +function getRouteLocaleHreflang( + locale: string, + axis: SolidBaseRouteAxisConfig, + config: SolidBaseResolvedConfig, +) { + const value = axis.values[locale]; + if (typeof value?.lang === "string") return value.lang; + if (locale === axis.default) return config.lang; + return locale; +} + +function getRouteLocaleInfo( + routePath: string, + config: SolidBaseResolvedConfig, +): RouteLocaleInfo | undefined { + const axis = getRouteLocaleAxis(config); + const match = getSolidBaseRouteMatchForPath(config.routes, routePath); + const locale = match?.selection[LOCALE_AXIS]; + + if (!axis || !match || !locale) return undefined; + + const defaultSelection = { + ...match.selection, + [LOCALE_AXIS]: axis.default, + }; + const defaultPath = + getSolidBaseRoutePathWithRest( + config.routes, + defaultSelection, + match.restPath, + ) ?? + buildSolidBaseRoutePath(config.routes, defaultSelection) ?? + routePath; + + return { + locale, + hreflang: getRouteLocaleHreflang(locale, axis, config), + groupPath: defaultPath, + isDefaultLocale: locale === axis.default, + }; +} + export function getNonRootLocalePrefixes(config: SolidBaseResolvedConfig) { return Object.entries(config.locales ?? {}) .filter(([locale]) => locale !== "root") .map(([locale, localeConfig]) => normalizeLocalePrefix(localeConfig.link ?? `/${locale}/`), - ); + ); +} + +function getLegacyLocaleInfo( + routePath: string, + config: SolidBaseResolvedConfig, +): RouteLocaleInfo { + for (const [locale, localeConfig] of Object.entries(config.locales ?? {})) { + if (locale === "root") continue; + + const prefix = normalizeLocalePrefix(localeConfig.link ?? `/${locale}/`); + + if (routePath === prefix) { + return { + locale, + hreflang: localeConfig.lang ?? locale, + groupPath: "/", + isDefaultLocale: false, + }; + } + + if (routePath.startsWith(`${prefix}/`)) { + return { + locale, + hreflang: localeConfig.lang ?? locale, + groupPath: routePath.slice(prefix.length), + isDefaultLocale: false, + }; + } + } + + return { + locale: "root", + hreflang: config.lang, + groupPath: routePath, + isDefaultLocale: true, + }; +} + +export function getRouteLocaleMetadata( + routePath: string, + config: SolidBaseResolvedConfig, +) { + return ( + getRouteLocaleInfo(routePath, config) ?? getLegacyLocaleInfo(routePath, config) + ); +} + +export function isRouteIncludedByConfig( + routePath: string, + config: SolidBaseResolvedConfig, +) { + if (!config.routes) return true; + return Boolean(getSolidBaseRouteMatchForPath(config.routes, routePath)); } export function isDefaultLocaleRoute( routePath: string, config: SolidBaseResolvedConfig, ) { + const routeLocale = getRouteLocaleInfo(routePath, config); + if (config.routes) return routeLocale?.isDefaultLocale ?? false; + if (routeLocale) return routeLocale.isDefaultLocale; + const localePrefixes = getNonRootLocalePrefixes(config); return !localePrefixes.some( diff --git a/src/config/sitemap-index.ts b/src/config/sitemap-index.ts index 5c05fad2..a9b47799 100644 --- a/src/config/sitemap-index.ts +++ b/src/config/sitemap-index.ts @@ -3,7 +3,12 @@ import { normalizeSiteUrl, type SolidBaseResolvedConfig, } from "./index.js"; -import { getRoutesIndex, type RouteIndexEntry } from "./routes-index.js"; +import { + getRouteLocaleMetadata, + getRoutesIndex, + isRouteIncludedByConfig, + type RouteIndexEntry, +} from "./routes-index.js"; type SitemapFrontmatter = { sitemap?: boolean | { exclude?: boolean }; @@ -20,81 +25,6 @@ export type SitemapEntry = { alternates: SitemapAlternate[]; }; -type LocaleRouteInfo = { - locale: string; - hreflang: string; - groupPath: string; - isDefaultLocale: boolean; -}; - -type LocaleDefinition = { - locale: string; - prefix: string; - hreflang: string; -}; - -function normalizeLocalePrefix(prefix: string) { - if (prefix === "/") return "/"; - return prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; -} - -function getLocaleDefinitions(config: SolidBaseResolvedConfig) { - const locales = config.locales ?? {}; - const definitions: LocaleDefinition[] = [ - { - locale: "root", - prefix: "/", - hreflang: config.lang, - }, - ]; - - for (const [locale, localeConfig] of Object.entries(locales)) { - if (locale === "root") continue; - - definitions.push({ - locale, - prefix: normalizeLocalePrefix(localeConfig.link ?? `/${locale}/`), - hreflang: localeConfig.lang ?? locale, - }); - } - - return definitions.sort((a, b) => b.prefix.length - a.prefix.length); -} - -function getLocaleRouteInfo( - routePath: string, - localeDefinitions: LocaleDefinition[], - defaultHreflang: string, -): LocaleRouteInfo { - for (const definition of localeDefinitions) { - if (definition.locale === "root") continue; - if (routePath === definition.prefix) { - return { - locale: definition.locale, - hreflang: definition.hreflang, - groupPath: "/", - isDefaultLocale: false, - }; - } - - if (routePath.startsWith(`${definition.prefix}/`)) { - return { - locale: definition.locale, - hreflang: definition.hreflang, - groupPath: routePath.slice(definition.prefix.length), - isDefaultLocale: false, - }; - } - } - - return { - locale: "root", - hreflang: defaultHreflang, - groupPath: routePath, - isDefaultLocale: true, - }; -} - function isSitemapExcluded(frontmatter: SitemapFrontmatter) { if (frontmatter.sitemap === false) return true; if (frontmatter.sitemap && typeof frontmatter.sitemap === "object") { @@ -113,9 +43,10 @@ export function buildSitemapEntries( config: SolidBaseResolvedConfig, routes: RouteIndexEntry[], ): SitemapEntry[] { - const localeDefinitions = getLocaleDefinitions(config); const includedRoutes = routes.filter( - (route) => !isSitemapExcluded(route.frontmatter as SitemapFrontmatter), + (route) => + !isSitemapExcluded(route.frontmatter as SitemapFrontmatter) && + isRouteIncludedByConfig(route.routePath, config), ); const groups = new Map< @@ -130,11 +61,7 @@ export function buildSitemapEntries( >(); for (const route of includedRoutes) { - const localeInfo = getLocaleRouteInfo( - route.routePath, - localeDefinitions, - config.lang, - ); + const localeInfo = getRouteLocaleMetadata(route.routePath, config); const entry = { routePath: route.routePath, url: toAbsoluteUrl(hostname, route.routePath), @@ -153,11 +80,7 @@ export function buildSitemapEntries( return includedRoutes .map((route) => { - const localeInfo = getLocaleRouteInfo( - route.routePath, - localeDefinitions, - config.lang, - ); + const localeInfo = getRouteLocaleMetadata(route.routePath, config); const variants = groups.get(localeInfo.groupPath) ?? []; return { diff --git a/tests/client/locale.test.ts b/tests/client/locale.test.ts index 3c5e9c73..bb387350 100644 --- a/tests/client/locale.test.ts +++ b/tests/client/locale.test.ts @@ -142,7 +142,7 @@ describe("locale client helpers", () => { ], }, }); - pathname.mockReturnValue("/router/fr"); + pathname.mockReturnValue("/router/fr/about"); const { LocaleContextProvider, getLocaleLink, useLocale } = await import( "../../src/client/locale.ts" @@ -166,19 +166,23 @@ describe("locale client helpers", () => { } as any); expect(value?.currentLocale().code).toBe("fr-FR"); + expect(value?.routePath()).toBe("/about"); expect(value?.locales.map((locale) => locale.config.label)).toEqual([ "English", "Français", ]); expect(getLocaleLink(value!.locales[0]!)).toBe("/router"); expect(getLocaleLink(value!.locales[1]!)).toBe("/router/fr"); + expect(value?.applyPathPrefix("about")).toBe("/router/fr/about"); + expect(value?.applyPathPrefix("/about")).toBe("/router/fr/about"); + expect(value?.applyPathPrefix("/router/fr")).toBe("/router/fr"); void value?.setLocale(value!.locales[0]!); dispose(); }); await Promise.resolve(); - expect(navigate).toHaveBeenCalledWith("/router"); + expect(navigate).toHaveBeenCalledWith("/router/about"); expect(document.documentElement.lang).toBe("en-US"); }); }); diff --git a/tests/config/llms-index.test.ts b/tests/config/llms-index.test.ts index aaf57a52..c4e57365 100644 --- a/tests/config/llms-index.test.ts +++ b/tests/config/llms-index.test.ts @@ -134,6 +134,81 @@ describe("getLlmDocuments", () => { expect(index).not.toContain("/fr/guide/getting-started.md"); }); + it("defaults llms.txt to routes.locale default documents", () => { + const index = buildLlmsIndex( + undefined, + { + ...config, + themeConfig: {}, + routes: { + path: "/{version}/{locale}", + version: { + default: "latest", + values: { + latest: { path: "" }, + v1: { path: "v1" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", lang: "en-US" }, + fr: { path: "fr", lang: "fr-FR" }, + es: { path: "es", lang: "es-ES" }, + }, + }, + include: [ + { version: "latest", locale: ["en", "fr"] }, + { version: "v1", locale: ["en", "es"] }, + ], + }, + }, + [ + { + title: "Home", + description: "Latest English", + routePath: "/", + markdownPath: "/index.md", + content: "Home", + }, + { + title: "Accueil", + description: "Latest French", + routePath: "/fr", + markdownPath: "/fr.md", + content: "Accueil", + }, + { + title: "v1", + description: "v1 English", + routePath: "/v1", + markdownPath: "/v1.md", + content: "v1", + }, + { + title: "v1 ES", + description: "v1 Spanish", + routePath: "/v1/es", + markdownPath: "/v1/es.md", + content: "v1 ES", + }, + { + title: "v1 FR", + description: "Invalid by include", + routePath: "/v1/fr", + markdownPath: "/v1/fr.md", + content: "v1 FR", + }, + ], + ); + + expect(index).toContain("- [Home](/index.md): Latest English"); + expect(index).toContain("- [v1](/v1.md): v1 English"); + expect(index).not.toContain("/fr.md"); + expect(index).not.toContain("/v1/es.md"); + expect(index).not.toContain("/v1/fr.md"); + }); + it("renders nav sections and nested sidebar groups as headings", () => { const index = buildLlmsIndex( undefined, diff --git a/tests/config/routes-index.test.ts b/tests/config/routes-index.test.ts index fadae2e1..acfd926e 100644 --- a/tests/config/routes-index.test.ts +++ b/tests/config/routes-index.test.ts @@ -114,4 +114,39 @@ describe("isDefaultLocaleRoute", () => { expect(isDefaultLocaleRoute("/documentation", config)).toBe(false); expect(isDefaultLocaleRoute("/documentation/api", config)).toBe(false); }); + + it("uses routes.locale when route config is present", () => { + const config = { + lang: "en-US", + routes: { + path: "/{project}/{version}/{locale}", + project: { + default: "solid", + values: { + solid: { path: "" }, + router: { path: "router" }, + }, + }, + version: { + default: "latest", + values: { + latest: { path: "" }, + v1: { path: "v1" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", lang: "en-US" }, + fr: { path: "fr", lang: "fr-FR" }, + }, + }, + }, + } as any; + + expect(isDefaultLocaleRoute("/router/guide", config)).toBe(true); + expect(isDefaultLocaleRoute("/router/fr/guide", config)).toBe(false); + expect(isDefaultLocaleRoute("/v1/api", config)).toBe(true); + expect(isDefaultLocaleRoute("/v1/fr/api", config)).toBe(false); + }); }); diff --git a/tests/config/sitemap-index.test.ts b/tests/config/sitemap-index.test.ts index 3732469d..4abb5570 100644 --- a/tests/config/sitemap-index.test.ts +++ b/tests/config/sitemap-index.test.ts @@ -206,4 +206,111 @@ describe("buildSitemapEntries", () => { }, ]); }); + + it("uses routes.locale for alternates and include filtering", () => { + const entries = buildSitemapEntries( + "https://example.com", + { + lang: "en-US", + routes: { + path: "/{version}/{locale}", + version: { + default: "latest", + values: { + latest: { path: "" }, + v1: { path: "v1" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", lang: "en-US" }, + fr: { path: "fr", lang: "fr-FR" }, + es: { path: "es", lang: "es-ES" }, + }, + }, + include: [ + { version: "latest", locale: ["en", "fr"] }, + { version: "v1", locale: ["en", "es"] }, + ], + }, + } as any, + [ + { + routePath: "/", + markdownPath: "/index.md", + filePath: "/tmp/src/routes/index.mdx", + source: "", + frontmatter: { title: "Home" }, + }, + { + routePath: "/fr", + markdownPath: "/fr.md", + filePath: "/tmp/src/routes/fr/index.mdx", + source: "", + frontmatter: { title: "Accueil" }, + }, + { + routePath: "/v1", + markdownPath: "/v1.md", + filePath: "/tmp/src/routes/v1/index.mdx", + source: "", + frontmatter: { title: "v1" }, + }, + { + routePath: "/v1/es", + markdownPath: "/v1/es.md", + filePath: "/tmp/src/routes/v1/es/index.mdx", + source: "", + frontmatter: { title: "v1 ES" }, + }, + { + routePath: "/v1/fr", + markdownPath: "/v1/fr.md", + filePath: "/tmp/src/routes/v1/fr/index.mdx", + source: "", + frontmatter: { title: "v1 FR" }, + }, + ], + ); + + expect(entries).toEqual([ + { + routePath: "/", + url: "https://example.com/", + alternates: [ + { hreflang: "en-US", href: "https://example.com/" }, + { hreflang: "fr-FR", href: "https://example.com/fr" }, + { hreflang: "x-default", href: "https://example.com/" }, + ], + }, + { + routePath: "/fr", + url: "https://example.com/fr", + alternates: [ + { hreflang: "en-US", href: "https://example.com/" }, + { hreflang: "fr-FR", href: "https://example.com/fr" }, + { hreflang: "x-default", href: "https://example.com/" }, + ], + }, + { + routePath: "/v1", + url: "https://example.com/v1", + alternates: [ + { hreflang: "en-US", href: "https://example.com/v1" }, + { hreflang: "es-ES", href: "https://example.com/v1/es" }, + { hreflang: "x-default", href: "https://example.com/v1" }, + ], + }, + { + routePath: "/v1/es", + url: "https://example.com/v1/es", + alternates: [ + { hreflang: "en-US", href: "https://example.com/v1" }, + { hreflang: "es-ES", href: "https://example.com/v1/es" }, + { hreflang: "x-default", href: "https://example.com/v1" }, + ], + }, + ]); + }); }); From 7fb2bad56ad06536547c1743ae447a54b49277da Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 13:47:40 -0700 Subject: [PATCH 04/19] Add default route selectors and scoped dev sidebars --- dev/vite.config.mts | 83 +++++++++++-- src/default-theme/components/Header.tsx | 4 +- .../components/RouteSelector.tsx | 111 ++++++++++++++++++ src/default-theme/default-components.ts | 2 + 4 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 src/default-theme/components/RouteSelector.tsx diff --git a/dev/vite.config.mts b/dev/vite.config.mts index 8b2e06f6..18709be3 100644 --- a/dev/vite.config.mts +++ b/dev/vite.config.mts @@ -4,7 +4,10 @@ import { defineConfig } from "vite"; import Inspect from "vite-plugin-inspect"; import { createSolidBase, defineTheme } from "../src/config"; -import { createFilesystemSidebar } from "../src/config/sidebar"; +import { + createFilesystemSidebar, + type SidebarItemWithMeta, +} from "../src/config/sidebar"; import defaultTheme from "../src/default-theme"; const theme = defineTheme({ @@ -14,6 +17,21 @@ const theme = defineTheme({ const solidBase = createSolidBase(theme); +function getSidebarFileName(item: SidebarItemWithMeta) { + const segments = item.filePath.split(/[\\/]/); + return segments[segments.length - 1]; +} + +function createDevSidebar(route: string, hiddenFolders: string[] = []) { + return createFilesystemSidebar(route, { + filter: (item) => { + if (hiddenFolders.includes(getSidebarFileName(item) ?? "")) return false; + if ("items" in item) return true; + return /\.(md|mdx)$/.test(item.filePath); + }, + }); +} + export default defineConfig({ plugins: [ Inspect(), @@ -66,9 +84,59 @@ export default defineConfig({ ], }, overrides: [ - { project: "router", title: "Router Demo" }, - { version: "v1", title: "SolidBase v1 Demo" }, - { locale: "fr", titleTemplate: ":title - Demo SolidBase" }, + { + locale: "fr", + titleTemplate: ":title - Demo SolidBase", + themeConfig: { + sidebar: { + "/": createDevSidebar("./src/routes/fr"), + }, + }, + }, + { + locale: "es", + themeConfig: { + sidebar: { + "/": createDevSidebar("./src/routes/es"), + }, + }, + }, + { + project: "router", + title: "Router Demo", + themeConfig: { + sidebar: { + "/": createDevSidebar("./src/routes/router", ["fr"]), + }, + }, + }, + { + version: "v1", + title: "SolidBase v1 Demo", + themeConfig: { + sidebar: { + "/": createDevSidebar("./src/routes/v1", ["fr"]), + }, + }, + }, + { + project: "router", + locale: "fr", + themeConfig: { + sidebar: { + "/": createDevSidebar("./src/routes/router/fr"), + }, + }, + }, + { + version: "v1", + locale: "fr", + themeConfig: { + sidebar: { + "/": createDevSidebar("./src/routes/v1/fr"), + }, + }, + }, { project: "solidbase", version: "v1", @@ -78,12 +146,7 @@ export default defineConfig({ ], themeConfig: { sidebar: { - "/": createFilesystemSidebar("./src/routes", { - filter: (item) => { - if ("items" in item) return true; - return /\.(md|mdx)$/.test(item.filePath); - }, - }), + "/": createDevSidebar("./src/routes", ["es", "fr", "router", "v1"]), }, }, }), diff --git a/src/default-theme/components/Header.tsx b/src/default-theme/components/Header.tsx index 50babcb4..514d7cad 100644 --- a/src/default-theme/components/Header.tsx +++ b/src/default-theme/components/Header.tsx @@ -23,7 +23,7 @@ export default function Header() { const [tocRef, setTocRef] = createSignal(); const [navRef, setNavRef] = createSignal(); - const { ThemeSelector, LocaleSelector, TableOfContents } = + const { ThemeSelector, LocaleSelector, RouteSelector, TableOfContents } = useDefaultThemeComponents(); const { @@ -98,6 +98,7 @@ export default function Header() { )}
+
@@ -127,6 +128,7 @@ export default function Header() { )} + diff --git a/src/default-theme/components/RouteSelector.tsx b/src/default-theme/components/RouteSelector.tsx new file mode 100644 index 00000000..81361198 --- /dev/null +++ b/src/default-theme/components/RouteSelector.tsx @@ -0,0 +1,111 @@ +import { solidBaseConfig } from "virtual:solidbase/config"; +import { Select } from "@kobalte/core/select"; +import { useLocation, useNavigate } from "@solidjs/router"; +import { createMemo, For, Show } from "solid-js"; + +import { + getSolidBaseRouteMatchForPath, + getSolidBaseRoutePathWithRest, + isSolidBaseRouteAxisConfig, + type SolidBaseRouteOption, +} from "../../config/route-config.js"; +import { + useSolidBaseRoute, + useSolidBaseRouteOptions, +} from "../../client/index.jsx"; +import styles from "./ThemeSelector.module.css"; + +const LOCALE_AXIS = "locale"; + +export default function RouteSelector() { + const axes = createMemo(() => { + const routes = solidBaseConfig.routes; + if (!routes) return []; + + return Object.entries(routes) + .filter(([name, value]) => { + return name !== LOCALE_AXIS && isSolidBaseRouteAxisConfig(value); + }) + .map(([name]) => name); + }); + + return {(axis) => }; +} + +function RouteAxisSelector(props: { axis: string }) { + const location = useLocation(); + const navigate = useNavigate(); + const current = useSolidBaseRoute(); + const options = useSolidBaseRouteOptions(props.axis); + const currentOption = createMemo(() => + options().find((option) => option.name === current()[props.axis]), + ); + + const getOptionLabel = (option: SolidBaseRouteOption) => { + return typeof option.meta.label === "string" ? option.meta.label : option.name; + }; + + const getOptionPath = (option: SolidBaseRouteOption) => { + if (!option.selection) return undefined; + + return getSolidBaseRoutePathWithRest( + solidBaseConfig.routes, + option.selection, + getSolidBaseRouteMatchForPath( + solidBaseConfig.routes, + location.pathname, + )?.restPath ?? "/", + ); + }; + + const onChange = (option: SolidBaseRouteOption | null) => { + if (!option) return; + if (option.href) { + globalThis.location.href = option.href; + return; + } + + const path = getOptionPath(option); + if (path) navigate(path); + }; + + return ( + 1 && currentOption()}> + {(value) => ( + + class={styles.root} + value={value()} + options={options()} + optionValue="name" + optionTextValue={getOptionLabel} + allowDuplicateSelectionEvents + onChange={onChange} + gutter={8} + sameWidth={false} + placement="bottom" + itemComponent={(props) => ( + + + {getOptionLabel(props.item.rawValue)} + + + )} + > + + > + {(state) => getOptionLabel(state.selectedOption())} + + + + + + + + + )} + + ); +} diff --git a/src/default-theme/default-components.ts b/src/default-theme/default-components.ts index 2d520012..cfd3e49d 100644 --- a/src/default-theme/default-components.ts +++ b/src/default-theme/default-components.ts @@ -7,6 +7,7 @@ import Hero from "./components/Hero.jsx"; import LastUpdated from "./components/LastUpdated.jsx"; import Link from "./components/Link.jsx"; import LocaleSelector from "./components/LocaleSelector.jsx"; +import RouteSelector from "./components/RouteSelector.jsx"; import TableOfContents from "./components/TableOfContents.jsx"; import ThemeSelector from "./components/ThemeSelector.jsx"; @@ -18,6 +19,7 @@ export const defaultThemeComponents = { LastUpdated, Link, LocaleSelector, + RouteSelector, TableOfContents, ThemeSelector, Hero, From 9c3141a3915deb350a65c455fb4d424ca1120f21 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 13:59:22 -0700 Subject: [PATCH 05/19] Add fallback route selector options --- src/config/route-config.ts | 133 ++++++++++++++++++ .../components/RouteSelector.tsx | 38 ++--- tests/config/route-config.test.ts | 56 ++++++++ 3 files changed, 202 insertions(+), 25 deletions(-) diff --git a/src/config/route-config.ts b/src/config/route-config.ts index d45a720b..44621bac 100644 --- a/src/config/route-config.ts +++ b/src/config/route-config.ts @@ -160,6 +160,29 @@ function getInternalRouteValueEntries(axis: SolidBaseRouteAxisConfig) { return Object.entries(axis.values).filter(([, value]) => !value.href); } +function getSolidBaseRouteSelections(routes: SolidBaseRoutesConfig | undefined) { + const axes = getSolidBaseRouteAxes(routes); + const selections: SolidBaseRouteSelection[] = []; + + const visit = (index: number, selection: SolidBaseRouteSelection) => { + const entry = axes[index]; + if (!entry) { + if (isSolidBaseRouteIncluded(routes, selection)) { + selections.push(selection); + } + return; + } + + const [axisName, axis] = entry; + for (const [valueName] of getInternalRouteValueEntries(axis)) { + visit(index + 1, { ...selection, [axisName]: valueName }); + } + }; + + visit(0, {}); + return selections; +} + function matchRoutePathSegment( axis: SolidBaseRouteAxisConfig, pathSegment: string | undefined, @@ -392,6 +415,66 @@ export function getSolidBaseRoutePathWithRest( return normalizeRoutePath(`${routePath}/${restPath}`); } +export function getSolidBaseRouteFallbackSelection( + routes: SolidBaseRoutesConfig | undefined, + current: Partial = {}, + next: Partial = {}, + options: { lockedAxes?: string[] } = {}, +) { + if (!routes) return undefined; + + const normalizedCurrent = normalizeSolidBaseRouteSelection(routes, current) ?? {}; + const axisMap = getSolidBaseRouteAxisMap(routes); + const lockedSelection: Partial = {}; + + for (const axisName of options.lockedAxes ?? []) { + const valueName = normalizedCurrent[axisName]; + if (valueName) lockedSelection[axisName] = valueName; + } + + const requiredSelection = { + ...lockedSelection, + ...next, + }; + const required = normalizeSolidBaseRouteSelection(routes, { + ...normalizedCurrent, + ...requiredSelection, + }); + if (!required) return undefined; + + let fallback: SolidBaseRouteSelection | undefined; + let fallbackScore = -1; + + for (const selection of getSolidBaseRouteSelections(routes)) { + if ( + Object.entries(requiredSelection).some(([axisName]) => { + return selection[axisName] !== required[axisName]; + }) + ) { + continue; + } + + let score = 0; + for (const [axisName, axis] of axisMap) { + if (selection[axisName] === normalizedCurrent[axisName]) { + score += 100; + continue; + } + + if (selection[axisName] === axis.default) { + score += 10; + } + } + + if (score > fallbackScore) { + fallback = selection; + fallbackScore = score; + } + } + + return fallback; +} + export function getSolidBaseRouteOptions( routes: SolidBaseRoutesConfig | undefined, axisName: string, @@ -437,6 +520,56 @@ export function getSolidBaseRouteOptions( return options; } +export function getSolidBaseRouteFallbackOptions( + routes: SolidBaseRoutesConfig | undefined, + axisName: string, + current: Partial = {}, +): SolidBaseRouteOption[] { + if (!routes) return []; + + const axis = getSolidBaseRouteAxisMap(routes).get(axisName); + if (!axis) return []; + + const axisNames = getRouteTemplateAxisNames(routes.path); + const axisIndex = axisNames.indexOf(axisName); + const lockedAxes = axisIndex > 0 ? axisNames.slice(0, axisIndex) : []; + const options: SolidBaseRouteOption[] = []; + + for (const [valueName, value] of Object.entries(axis.values)) { + if (value.href) { + options.push({ + name: valueName, + axis: axisName, + href: value.href, + isExternal: true, + meta: value, + }); + continue; + } + + const selection = getSolidBaseRouteFallbackSelection( + routes, + current, + { + [axisName]: valueName, + }, + { lockedAxes }, + ); + if (!selection) continue; + + options.push({ + name: valueName, + axis: axisName, + path: buildSolidBaseRoutePath(routes, selection), + isExternal: false, + selection, + meta: value, + }); + } + + return options; +} + function splitRouteOverride(override: RouteConfigValue, axisNames: string[]) { const selectors: SolidBaseRouteRule = {}; const config: RouteConfigValue = {}; diff --git a/src/default-theme/components/RouteSelector.tsx b/src/default-theme/components/RouteSelector.tsx index 81361198..99f35040 100644 --- a/src/default-theme/components/RouteSelector.tsx +++ b/src/default-theme/components/RouteSelector.tsx @@ -1,18 +1,14 @@ import { solidBaseConfig } from "virtual:solidbase/config"; import { Select } from "@kobalte/core/select"; -import { useLocation, useNavigate } from "@solidjs/router"; +import { useNavigate } from "@solidjs/router"; import { createMemo, For, Show } from "solid-js"; import { - getSolidBaseRouteMatchForPath, - getSolidBaseRoutePathWithRest, + getSolidBaseRouteFallbackOptions, isSolidBaseRouteAxisConfig, type SolidBaseRouteOption, } from "../../config/route-config.js"; -import { - useSolidBaseRoute, - useSolidBaseRouteOptions, -} from "../../client/index.jsx"; +import { useSolidBaseRoute } from "../../client/index.jsx"; import styles from "./ThemeSelector.module.css"; const LOCALE_AXIS = "locale"; @@ -33,10 +29,15 @@ export default function RouteSelector() { } function RouteAxisSelector(props: { axis: string }) { - const location = useLocation(); const navigate = useNavigate(); const current = useSolidBaseRoute(); - const options = useSolidBaseRouteOptions(props.axis); + const options = createMemo(() => + getSolidBaseRouteFallbackOptions( + solidBaseConfig.routes, + props.axis, + current(), + ), + ); const currentOption = createMemo(() => options().find((option) => option.name === current()[props.axis]), ); @@ -45,28 +46,16 @@ function RouteAxisSelector(props: { axis: string }) { return typeof option.meta.label === "string" ? option.meta.label : option.name; }; - const getOptionPath = (option: SolidBaseRouteOption) => { - if (!option.selection) return undefined; - - return getSolidBaseRoutePathWithRest( - solidBaseConfig.routes, - option.selection, - getSolidBaseRouteMatchForPath( - solidBaseConfig.routes, - location.pathname, - )?.restPath ?? "/", - ); - }; - const onChange = (option: SolidBaseRouteOption | null) => { if (!option) return; + if (option.name === current()[props.axis]) return; + if (option.href) { globalThis.location.href = option.href; return; } - const path = getOptionPath(option); - if (path) navigate(path); + if (option.path) navigate(option.path); }; return ( @@ -78,7 +67,6 @@ function RouteAxisSelector(props: { axis: string }) { options={options()} optionValue="name" optionTextValue={getOptionLabel} - allowDuplicateSelectionEvents onChange={onChange} gutter={8} sameWidth={false} diff --git a/tests/config/route-config.test.ts b/tests/config/route-config.test.ts index 69f86c93..1754ccf9 100644 --- a/tests/config/route-config.test.ts +++ b/tests/config/route-config.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { buildSolidBaseRoutePath, + getSolidBaseRouteFallbackOptions, + getSolidBaseRouteFallbackSelection, getSolidBaseRouteSelectionForPath, getSolidBaseRouteOptions, isSolidBaseRouteIncluded, @@ -131,6 +133,60 @@ describe("route config helpers", () => { ]); }); + it("falls back invalid route axes when resolving cross-axis options", () => { + expect( + getSolidBaseRouteFallbackSelection( + routes, + { project: "solid", version: "v1", locale: "fr" }, + { project: "router" }, + ), + ).toEqual({ + project: "router", + version: "latest", + locale: "fr", + }); + expect( + getSolidBaseRouteFallbackSelection( + routes, + { project: "solid", version: "v1", locale: "es" }, + { project: "router" }, + ), + ).toEqual({ + project: "router", + version: "latest", + locale: "en", + }); + expect( + getSolidBaseRouteFallbackSelection( + routes, + { project: "router", version: "latest", locale: "fr" }, + { version: "v1" }, + { lockedAxes: ["project"] }, + ), + ).toBeUndefined(); + expect( + getSolidBaseRouteFallbackOptions(routes, "project", { + project: "solid", + version: "v1", + locale: "fr", + }), + ).toMatchObject([ + { name: "solid", path: "/v1/fr" }, + { name: "router", path: "/router/fr" }, + { name: "start", path: "/start/fr" }, + ]); + expect( + getSolidBaseRouteFallbackOptions(routes, "version", { + project: "router", + version: "latest", + locale: "fr", + }), + ).toMatchObject([ + { name: "latest", path: "/router/fr" }, + { name: "v0", href: "https://v0.solidjs.com" }, + ]); + }); + it("treats omitted include as all internal combinations", () => { const openRoutes = { ...routes, From c916fe06968b2c5d525474118b27376d079eb130 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 14:48:08 -0700 Subject: [PATCH 06/19] fix dropdowns --- src/client/locale.ts | 24 +++-- src/default-theme/Layout.tsx | 4 +- .../components/Header.module.css | 11 +++ src/default-theme/components/Header.tsx | 25 +++--- .../components/LocaleSelector.tsx | 10 +-- .../components/ProjectSelector.module.css | 87 +++++++++++++++++++ .../components/ProjectSelector.tsx | 86 ++++++++++++++++++ .../components/VersionSelector.module.css | 67 ++++++++++++++ ...{RouteSelector.tsx => VersionSelector.tsx} | 41 +++------ src/default-theme/default-components.ts | 6 +- tests/client/locale.test.ts | 83 +++++++++++++++++- tests/client/routes.test.ts | 47 +++++++++- 12 files changed, 434 insertions(+), 57 deletions(-) create mode 100644 src/default-theme/components/ProjectSelector.module.css create mode 100644 src/default-theme/components/ProjectSelector.tsx create mode 100644 src/default-theme/components/VersionSelector.module.css rename src/default-theme/components/{RouteSelector.tsx => VersionSelector.tsx} (66%) diff --git a/src/client/locale.ts b/src/client/locale.ts index 79fbdb7c..82e6acfd 100644 --- a/src/client/locale.ts +++ b/src/client/locale.ts @@ -144,7 +144,10 @@ const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { const locales = createMemo(() => { if (!solidBaseConfig.routes) return legacyLocales; - return routes.options(LOCALE_AXIS).map(routeOptionToLocale); + const match = currentRouteMatch(); + if (!match) return []; + + return routes.options(LOCALE_AXIS, match.selection).map(routeOptionToLocale); }); const match = useMatch(() => `${getLocaleLink(currentLocale())}*rest`); @@ -155,9 +158,8 @@ const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { }, currentLocale, setLocale: (locale: ResolvedLocale) => { - const routePath = - locale.option && - getSolidBaseRoutePathWithRest( + if (locale.option) { + const routePath = getSolidBaseRoutePathWithRest( solidBaseConfig.routes, { ...routes.current(), @@ -166,12 +168,18 @@ const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { currentRouteMatch()?.restPath ?? "/", ); - const searchValue = routePath ?? getLocaleLink(locale); + if (!routePath) return; + + startTransition(() => navigate(routePath)).then(() => { + document.documentElement.lang = locale.code; + }); + return; + } + + const searchValue = getLocaleLink(locale); startTransition(() => - navigate( - routePath ? searchValue : `${searchValue}${match()?.params.rest ?? ""}`, - ), + navigate(`${searchValue}${match()?.params.rest ?? ""}`), ).then(() => { document.documentElement.lang = locale.code; }); diff --git a/src/default-theme/Layout.tsx b/src/default-theme/Layout.tsx index d2192636..28c47149 100644 --- a/src/default-theme/Layout.tsx +++ b/src/default-theme/Layout.tsx @@ -59,7 +59,7 @@ export default (props: ParentProps) => { }; function Layout(props: ParentProps) { - const { Header, Article, Link } = useDefaultThemeComponents(); + const { Header, Article, Link, ProjectSelector } = useDefaultThemeComponents(); const { sidebarOpen, setSidebarOpen, frontmatter } = useDefaultThemeState(); const config = useRouteConfig(); @@ -137,6 +137,7 @@ function Layout(props: ParentProps) { fallback={ @@ -157,6 +158,7 @@ function Layout(props: ParentProps) { + diff --git a/src/default-theme/components/Header.module.css b/src/default-theme/components/Header.module.css index 1590928e..97a7c916 100644 --- a/src/default-theme/components/Header.module.css +++ b/src/default-theme/components/Header.module.css @@ -48,6 +48,17 @@ font-size: 1.25em; } +.logo-cluster { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.version-selector { + display: flex; + align-items: center; +} + .mobile-menu, .mobile-nav-menu { appearance: none; diff --git a/src/default-theme/components/Header.tsx b/src/default-theme/components/Header.tsx index 514d7cad..11c55db8 100644 --- a/src/default-theme/components/Header.tsx +++ b/src/default-theme/components/Header.tsx @@ -23,7 +23,7 @@ export default function Header() { const [tocRef, setTocRef] = createSignal(); const [navRef, setNavRef] = createSignal(); - const { ThemeSelector, LocaleSelector, RouteSelector, TableOfContents } = + const { ThemeSelector, LocaleSelector, VersionSelector, TableOfContents } = useDefaultThemeComponents(); const { @@ -50,14 +50,19 @@ export default function Header() { return (
- - {config().title}}> - {config().title} - - +
-
@@ -128,7 +132,6 @@ export default function Header() { )} -
diff --git a/src/default-theme/components/LocaleSelector.tsx b/src/default-theme/components/LocaleSelector.tsx index b69fd245..458d5a45 100644 --- a/src/default-theme/components/LocaleSelector.tsx +++ b/src/default-theme/components/LocaleSelector.tsx @@ -5,20 +5,20 @@ import { type ResolvedLocale, useLocale } from "../../client/index.jsx"; import styles from "./ThemeSelector.module.css"; export default function LocaleSelector() { - const { locales, currentLocale, setLocale } = useLocale(); + const locale = useLocale(); return ( - 1}> + 1}> {(_) => { return ( > class={styles.root} - value={currentLocale()} - options={locales} + value={locale.currentLocale()} + options={locale.locales} optionValue="code" optionTextValue={(v) => v.config.label} allowDuplicateSelectionEvents - onChange={(option) => option && setLocale(option)} + onChange={(option) => option && locale.setLocale(option)} gutter={8} sameWidth={false} placement="bottom" diff --git a/src/default-theme/components/ProjectSelector.module.css b/src/default-theme/components/ProjectSelector.module.css new file mode 100644 index 00000000..f622c477 --- /dev/null +++ b/src/default-theme/components/ProjectSelector.module.css @@ -0,0 +1,87 @@ +.root { + width: 100%; + margin-bottom: 0.75rem; +} + +.item { + padding: 0.3rem 0.5rem; + list-style-type: none; + border-radius: var(--sb-border-radius); + cursor: var(--sb-button-cursor); + + &:hover, + &:focus { + outline: none; + background: color-mix(in hsl, var(--sb-text-color) 7.5%, transparent); + } + + &[data-selected] { + color: var(--sb-active-link-color); + } +} + +.trigger { + appearance: none; + display: inline-flex; + align-items: center; + gap: 0.35rem; + width: 100%; + justify-content: flex-start; + padding: 0.45rem 0.5rem; + background: transparent; + border: 1px solid + color-mix(in hsl, var(--sb-decoration-color) 28%, transparent); + outline: none; + border-radius: calc(var(--sb-border-radius) * 1.25); + color: var(--sb-heading-color); + cursor: var(--sb-button-cursor); + transition-property: background-color, opacity, color; + transition-timing-function: var(--sb-transition-timing); + transition-duration: 0.15s; + + &:hover, + &:focus, + &[data-expanded] { + background: color-mix(in hsl, var(--sb-text-color) 7%, transparent); + } +} + +.label { + min-width: 0; + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.95rem; + font-weight: 500; +} + +.icon { + width: 1rem; + height: 1rem; + margin-left: auto; + flex: 0 0 auto; + color: color-mix(in hsl, var(--sb-text-color) 55%, transparent); +} + +.content { + z-index: 51; + width: var(--kb-popper-anchor-width); + color: var(--sb-text-color); + background: color-mix( + in hsl, + var(--sb-background-color) 95%, + var(--sb-tint-color-opposite) + ); + border: 1px solid + color-mix(in hsl, var(--sb-decoration-color) 20%, transparent); + border-radius: var(--sb-border-radius); +} + +.list { + width: 100%; + padding: 0.25rem; + display: flex; + flex-direction: column; +} diff --git a/src/default-theme/components/ProjectSelector.tsx b/src/default-theme/components/ProjectSelector.tsx new file mode 100644 index 00000000..f16ff4d1 --- /dev/null +++ b/src/default-theme/components/ProjectSelector.tsx @@ -0,0 +1,86 @@ +import { solidBaseConfig } from "virtual:solidbase/config"; +import { Select } from "@kobalte/core/select"; +import { useNavigate } from "@solidjs/router"; +import { createMemo, Show } from "solid-js"; + +import IconExpandUpDownLine from "~icons/ri/expand-up-down-line"; +import { + getSolidBaseRouteFallbackOptions, + type SolidBaseRouteOption, +} from "../../config/route-config.js"; +import { useSolidBaseRoute } from "../../client/index.jsx"; +import styles from "./ProjectSelector.module.css"; + +const PROJECT_AXIS = "project"; + +export default function ProjectSelector() { + const navigate = useNavigate(); + const current = useSolidBaseRoute(); + const options = createMemo(() => + getSolidBaseRouteFallbackOptions( + solidBaseConfig.routes, + PROJECT_AXIS, + current(), + ), + ); + const currentOption = createMemo(() => + options().find((option) => option.name === current()[PROJECT_AXIS]), + ); + + const getOptionLabel = (option: SolidBaseRouteOption) => { + return typeof option.meta.label === "string" ? option.meta.label : option.name; + }; + + const onChange = (option: SolidBaseRouteOption | null) => { + if (!option) return; + if (option.name === current()[PROJECT_AXIS]) return; + + if (option.href) { + globalThis.location.href = option.href; + return; + } + + if (option.path) navigate(option.path); + }; + + return ( + 1 && currentOption()}> + {(value) => ( + + class={styles.root} + value={value()} + options={options()} + optionValue="name" + optionTextValue={getOptionLabel} + onChange={onChange} + gutter={4} + sameWidth + placement="bottom-start" + itemComponent={(props) => ( + + + {getOptionLabel(props.item.rawValue)} + + + )} + > + + > + {(state) => ( + + {getOptionLabel(state.selectedOption())} + + )} + + + + + + + + + + )} + + ); +} diff --git a/src/default-theme/components/VersionSelector.module.css b/src/default-theme/components/VersionSelector.module.css new file mode 100644 index 00000000..d8cde80c --- /dev/null +++ b/src/default-theme/components/VersionSelector.module.css @@ -0,0 +1,67 @@ +.root { + font-size: 0.85rem; +} + +.item { + padding: 0.3rem 0.5rem; + list-style-type: none; + border-radius: var(--sb-border-radius); + cursor: var(--sb-button-cursor); + + &:hover, + &:focus { + outline: none; + background: color-mix(in hsl, var(--sb-text-color) 7.5%, transparent); + } + + &[data-selected] { + color: var(--sb-active-link-color); + } +} + +.trigger { + appearance: none; + display: inline-flex; + align-items: center; + padding: 0.2rem 0.45rem; + background: color-mix(in hsl, var(--sb-text-color) 10%, transparent); + border: none; + outline: none; + border-radius: calc(var(--sb-border-radius) * 0.75); + color: color-mix(in hsl, var(--sb-text-color) 82%, transparent); + line-height: 1; + cursor: var(--sb-button-cursor); + transition-property: background-color, opacity, color; + transition-timing-function: var(--sb-transition-timing); + transition-duration: 0.15s; + + &:hover, + &:focus, + &[data-expanded] { + background: color-mix(in hsl, var(--sb-text-color) 15%, transparent); + color: var(--sb-heading-color); + } +} + +.label { + min-width: 0; +} + +.content { + z-index: 51; + color: var(--sb-text-color); + background: color-mix( + in hsl, + var(--sb-background-color) 95%, + var(--sb-tint-color-opposite) + ); + border-radius: var(--sb-border-radius); + min-width: max-content; +} + +.list { + min-width: max-content; + padding: 0.25rem; + display: flex; + flex-direction: column; +} diff --git a/src/default-theme/components/RouteSelector.tsx b/src/default-theme/components/VersionSelector.tsx similarity index 66% rename from src/default-theme/components/RouteSelector.tsx rename to src/default-theme/components/VersionSelector.tsx index 99f35040..83464d34 100644 --- a/src/default-theme/components/RouteSelector.tsx +++ b/src/default-theme/components/VersionSelector.tsx @@ -1,45 +1,29 @@ import { solidBaseConfig } from "virtual:solidbase/config"; import { Select } from "@kobalte/core/select"; import { useNavigate } from "@solidjs/router"; -import { createMemo, For, Show } from "solid-js"; +import { createMemo, Show } from "solid-js"; import { getSolidBaseRouteFallbackOptions, - isSolidBaseRouteAxisConfig, type SolidBaseRouteOption, } from "../../config/route-config.js"; import { useSolidBaseRoute } from "../../client/index.jsx"; -import styles from "./ThemeSelector.module.css"; +import styles from "./VersionSelector.module.css"; -const LOCALE_AXIS = "locale"; +const VERSION_AXIS = "version"; -export default function RouteSelector() { - const axes = createMemo(() => { - const routes = solidBaseConfig.routes; - if (!routes) return []; - - return Object.entries(routes) - .filter(([name, value]) => { - return name !== LOCALE_AXIS && isSolidBaseRouteAxisConfig(value); - }) - .map(([name]) => name); - }); - - return {(axis) => }; -} - -function RouteAxisSelector(props: { axis: string }) { +export default function VersionSelector() { const navigate = useNavigate(); const current = useSolidBaseRoute(); const options = createMemo(() => getSolidBaseRouteFallbackOptions( solidBaseConfig.routes, - props.axis, + VERSION_AXIS, current(), ), ); const currentOption = createMemo(() => - options().find((option) => option.name === current()[props.axis]), + options().find((option) => option.name === current()[VERSION_AXIS]), ); const getOptionLabel = (option: SolidBaseRouteOption) => { @@ -48,7 +32,7 @@ function RouteAxisSelector(props: { axis: string }) { const onChange = (option: SolidBaseRouteOption | null) => { if (!option) return; - if (option.name === current()[props.axis]) return; + if (option.name === current()[VERSION_AXIS]) return; if (option.href) { globalThis.location.href = option.href; @@ -79,12 +63,13 @@ function RouteAxisSelector(props: { axis: string }) { )} > - + > - {(state) => getOptionLabel(state.selectedOption())} + {(state) => ( + + {getOptionLabel(state.selectedOption())} + + )} diff --git a/src/default-theme/default-components.ts b/src/default-theme/default-components.ts index cfd3e49d..c5783a67 100644 --- a/src/default-theme/default-components.ts +++ b/src/default-theme/default-components.ts @@ -7,9 +7,10 @@ import Hero from "./components/Hero.jsx"; import LastUpdated from "./components/LastUpdated.jsx"; import Link from "./components/Link.jsx"; import LocaleSelector from "./components/LocaleSelector.jsx"; -import RouteSelector from "./components/RouteSelector.jsx"; +import ProjectSelector from "./components/ProjectSelector.jsx"; import TableOfContents from "./components/TableOfContents.jsx"; import ThemeSelector from "./components/ThemeSelector.jsx"; +import VersionSelector from "./components/VersionSelector.jsx"; export const defaultThemeComponents = { Article, @@ -19,9 +20,10 @@ export const defaultThemeComponents = { LastUpdated, Link, LocaleSelector, - RouteSelector, + ProjectSelector, TableOfContents, ThemeSelector, + VersionSelector, Hero, Features, }; diff --git a/tests/client/locale.test.ts b/tests/client/locale.test.ts index bb387350..654a1d75 100644 --- a/tests/client/locale.test.ts +++ b/tests/client/locale.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { createRoot } from "solid-js"; +import { createRoot, createSignal } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; const pathname = vi.fn<() => string>(() => "/fr/guide/install"); @@ -185,4 +185,85 @@ describe("locale client helpers", () => { expect(navigate).toHaveBeenCalledWith("/router/about"); expect(document.documentElement.lang).toBe("en-US"); }); + + it("returns route-backed locale options for the current route selection", async () => { + setSolidBaseConfig({ + lang: "en-US", + routes: { + path: "/{project}/{version}/{locale}", + project: { + default: "solid", + values: { + solid: { path: "", label: "Solid" }, + router: { path: "router", label: "Router" }, + }, + }, + version: { + default: "latest", + values: { + latest: { path: "", label: "Latest" }, + v1: { path: "v1", label: "v1" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en-US" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + es: { path: "es", label: "Español", lang: "es-ES" }, + }, + }, + include: [ + { + project: ["solid", "router"], + version: "latest", + locale: ["en", "fr"], + }, + { project: "solid", version: "latest", locale: "es" }, + { project: "solid", version: "v1", locale: ["en", "fr"] }, + ], + }, + }); + const { LocaleContextProvider, useLocale } = await import( + "../../src/client/locale.ts" + ); + const { SolidBaseRoutesContextProvider } = await import( + "../../src/client/routes.ts" + ); + + createRoot((dispose) => { + const [currentPathname, setCurrentPathname] = createSignal("/"); + pathname.mockImplementation(currentPathname); + let value: ReturnType | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + return LocaleContextProvider({ + get children() { + value = useLocale(); + return null; + }, + } as any); + }, + } as any); + + const rootLocales = value?.locales ?? []; + expect(rootLocales.map((locale) => locale.config.label)).toEqual([ + "English", + "Français", + "Español", + ]); + + setCurrentPathname("/router"); + const routerLocales = value?.locales ?? []; + expect(routerLocales.map((locale) => locale.config.label)).toEqual([ + "English", + "Français", + ]); + expect(routerLocales.some((locale) => locale.option?.name === "es")).toBe( + false, + ); + dispose(); + }); + }); }); diff --git a/tests/client/routes.test.ts b/tests/client/routes.test.ts index 3d2e6310..29e15f42 100644 --- a/tests/client/routes.test.ts +++ b/tests/client/routes.test.ts @@ -1,4 +1,4 @@ -import { createRoot } from "solid-js"; +import { createRoot, createSignal } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; const pathname = vi.fn<() => string>(() => "/router/fr"); @@ -47,6 +47,7 @@ const routes = { }, include: [ { project: ["solid", "router"], version: "latest", locale: ["en", "fr"] }, + { project: "solid", version: "latest", locale: "es" }, { project: "solid", version: "v1", locale: ["en", "fr", "es"] }, ], }; @@ -116,4 +117,48 @@ describe("solidbase route client helpers", () => { dispose(); }); }); + + it("returns locale options for the current route selection", async () => { + setSolidBaseConfig({ routes }); + + const { SolidBaseRoutesContextProvider, useSolidBaseRoutes } = await import( + "../../src/client/routes.ts" + ); + + createRoot((dispose) => { + const [currentPathname, setCurrentPathname] = createSignal("/"); + pathname.mockImplementation(currentPathname); + let helpers: ReturnType | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + helpers = useSolidBaseRoutes(); + return null; + }, + } as any); + + expect(helpers?.current()).toEqual({ + project: "solid", + version: "latest", + locale: "en", + }); + expect(helpers?.options("locale").map((option) => option.name)).toEqual([ + "en", + "fr", + "es", + ]); + + setCurrentPathname("/router"); + expect(helpers?.current()).toEqual({ + project: "router", + version: "latest", + locale: "en", + }); + expect(helpers?.options("locale").map((option) => option.name)).toEqual([ + "en", + "fr", + ]); + dispose(); + }); + }); }); From 78e9c277fb25ddfafc818d6ee9ff4e27d1240c92 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 14:48:19 -0700 Subject: [PATCH 07/19] fix broken tests --- tests/client/locale.test.ts | 23 +++++++++++++++++++---- tests/client/routes.test.ts | 19 +++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/client/locale.test.ts b/tests/client/locale.test.ts index 654a1d75..b6bb594e 100644 --- a/tests/client/locale.test.ts +++ b/tests/client/locale.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { createRoot, createSignal } from "solid-js"; +import { createRoot } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; const pathname = vi.fn<() => string>(() => "/fr/guide/install"); @@ -231,9 +231,8 @@ describe("locale client helpers", () => { "../../src/client/routes.ts" ); + pathname.mockReturnValue("/"); createRoot((dispose) => { - const [currentPathname, setCurrentPathname] = createSignal("/"); - pathname.mockImplementation(currentPathname); let value: ReturnType | undefined; SolidBaseRoutesContextProvider({ @@ -253,8 +252,24 @@ describe("locale client helpers", () => { "Français", "Español", ]); + dispose(); + }); + + pathname.mockReturnValue("/router"); + createRoot((dispose) => { + let value: ReturnType | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + return LocaleContextProvider({ + get children() { + value = useLocale(); + return null; + }, + } as any); + }, + } as any); - setCurrentPathname("/router"); const routerLocales = value?.locales ?? []; expect(routerLocales.map((locale) => locale.config.label)).toEqual([ "English", diff --git a/tests/client/routes.test.ts b/tests/client/routes.test.ts index 29e15f42..e385d924 100644 --- a/tests/client/routes.test.ts +++ b/tests/client/routes.test.ts @@ -1,4 +1,4 @@ -import { createRoot, createSignal } from "solid-js"; +import { createRoot } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; const pathname = vi.fn<() => string>(() => "/router/fr"); @@ -125,9 +125,8 @@ describe("solidbase route client helpers", () => { "../../src/client/routes.ts" ); + pathname.mockReturnValue("/"); createRoot((dispose) => { - const [currentPathname, setCurrentPathname] = createSignal("/"); - pathname.mockImplementation(currentPathname); let helpers: ReturnType | undefined; SolidBaseRoutesContextProvider({ @@ -147,8 +146,20 @@ describe("solidbase route client helpers", () => { "fr", "es", ]); + dispose(); + }); + + pathname.mockReturnValue("/router"); + createRoot((dispose) => { + let helpers: ReturnType | undefined; + + SolidBaseRoutesContextProvider({ + get children() { + helpers = useSolidBaseRoutes(); + return null; + }, + } as any); - setCurrentPathname("/router"); expect(helpers?.current()).toEqual({ project: "router", version: "latest", From 716cb251879ad9ac0bcc5ac26ffa47e4aaa062ca Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 14:57:36 -0700 Subject: [PATCH 08/19] docs --- docs/src/routes/guide/(2)config.mdx | 90 ++++++++++++++++++- docs/src/routes/guide/features/(2)i18n.mdx | 27 ++++-- .../default-theme/components/header.mdx | 3 +- .../components/project-selector.mdx | 27 ++++++ .../default-theme/components/sidebar.mdx | 2 + .../components/version-selector.mdx | 27 ++++++ .../routes/reference/default-theme/index.mdx | 2 + docs/src/routes/reference/index.mdx | 2 + docs/src/routes/reference/runtime-api.mdx | 51 +++++++++++ 9 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 docs/src/routes/reference/default-theme/components/project-selector.mdx create mode 100644 docs/src/routes/reference/default-theme/components/version-selector.mdx diff --git a/docs/src/routes/guide/(2)config.mdx b/docs/src/routes/guide/(2)config.mdx index c402b490..e29344af 100644 --- a/docs/src/routes/guide/(2)config.mdx +++ b/docs/src/routes/guide/(2)config.mdx @@ -55,6 +55,8 @@ interface SolidBaseConfig { robots?: boolean | RobotsConfig; lang?: string; locales?: Record>; + routes?: SolidBaseRoutesConfig; + overrides?: Array>; themeConfig?: ThemeConfig; editPath?: string | ((path: string) => string); lastUpdated?: Intl.DateTimeFormatOptions | false; @@ -85,9 +87,95 @@ There are several options for setting site-wide metadata and behavior. These opt Set `siteUrl` to the canonical public URL for your site. SolidBase uses it as the shared base for generated sitemap URLs, `robots.txt`, and default Open Graph URL metadata. :::note -For multilingual support, you can use the `locales` option to define different configurations for each locale. More details can be found in the [Internationalization guide](/guide/features/i18n). +For multilingual support, use the `routes.locale` option. More details can be found in the [Internationalization guide](/guide/features/i18n). ::: +### Routes + +Use `routes` when a site has route dimensions such as project, version, or locale. + +```ts title="app.config.ts" +// .. +routes: { + path: "/{project}/{version}/{locale}", + project: { + default: "solidbase", + values: { + solidbase: { path: "", label: "SolidBase" }, + router: { path: "router", label: "Router" }, + }, + }, + version: { + default: "latest", + values: { + latest: { path: "", label: "Latest" }, + v1: { path: "v1", label: "v1" }, + v0: { href: "https://v0.example.com", label: "v0" }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en-US" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + }, + }, + include: [ + { + project: ["solidbase", "router"], + version: "latest", + locale: ["en", "fr"], + }, + { + project: "solidbase", + version: "v1", + locale: "en", + }, + ], +}, +// .. +``` + +`routes.path` defines the URL prefix template. Each `{placeholder}` must match a route axis in the same object. + +Each route axis has: + +- `default`: the default value name. +- `values`: the allowed values for that axis. +- `values.*.path`: the URL segment for an internal value. Use an empty string for the default root segment. +- `values.*.href`: an external link for selector options that should navigate away from the current site. +- `values.*.label`, `title`, `status`, `lang`, and other metadata: client-facing metadata available to custom themes through the route APIs. + +`include` is an allowlist for valid internal route combinations. If omitted, all internal combinations are valid. External values with `href` are not included in generated route combinations. + +Use `overrides` to change SolidBase config for matching route selections: + +```ts title="app.config.ts" +// .. +overrides: [ + { + project: "router", + title: "Router Docs", + }, + { + locale: "fr", + titleTemplate: ":title - Documentation française", + }, + { + project: "solidbase", + version: "v1", + markdown: { + expressiveCode: { + languageSwitcher: false, + }, + }, + }, +], +// .. +``` + +Route override selectors use route axis names. Override config is applied over the base config, and `themeConfig` is shallow-merged with the base `themeConfig`. + #### Miscellaneous options In addition to setting the site title and description, you can also configure other options. This includes: diff --git a/docs/src/routes/guide/features/(2)i18n.mdx b/docs/src/routes/guide/features/(2)i18n.mdx index c7f3e136..5e4d7c73 100644 --- a/docs/src/routes/guide/features/(2)i18n.mdx +++ b/docs/src/routes/guide/features/(2)i18n.mdx @@ -16,15 +16,24 @@ export default defineConfig({ plugins: [ solidBase.plugin({ ... - lang: "en", // default lang without route prefix - locales: { - fr: { - label: "Français", + routes: { + path: "/{locale}", + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en-US" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + }, + }, + }, + overrides: [ + { + locale: "fr", themeConfig: { ... }, }, - }, + ], ... }), ], @@ -48,3 +57,11 @@ src ## Theme Configuration Similar to the theme configuration for the default locale, you can provide a `themeConfig` for each locale to customize the theme for that specific language. To learn more about how to adjust the theme configuration, refer to the [Theme Configuration section within the config page](/guide/config#theme-configuration). + +## Locale API + +The default theme and custom themes can keep using `useLocale()` for locale-specific UI. + +When `routes.locale` is configured, `useLocale().locales` is derived from the current route selection. For example, if `/router` only includes English and French, the locale selector only receives those two locales even if another project also includes Spanish. + +Use the generic route APIs when custom UI needs to read non-locale route axes such as project or version. diff --git a/docs/src/routes/reference/default-theme/components/header.mdx b/docs/src/routes/reference/default-theme/components/header.mdx index 1565b03f..201a8f38 100644 --- a/docs/src/routes/reference/default-theme/components/header.mdx +++ b/docs/src/routes/reference/default-theme/components/header.mdx @@ -10,7 +10,7 @@ badges: # {frontmatter.title} -Top navigation, locale controls, theme controls, and mobile navigation affordances. +Top navigation, version controls, locale controls, theme controls, and mobile navigation affordances. ## Uses @@ -23,6 +23,7 @@ Top navigation, locale controls, theme controls, and mobile navigation affordanc - shows desktop nav links when `themeConfig.nav` is set - uses a mobile nav dialog on small screens +- includes [`VersionSelector`](/reference/default-theme/components/version-selector) next to the logo when multiple versions are available - includes [`LocaleSelector`](/reference/default-theme/components/locale-selector) and [`ThemeSelector`](/reference/default-theme/components/theme-selector) - shows mobile sidebar and table-of-contents toggles only when content exists - marks nav links active using `activeMatch` or the item `link` diff --git a/docs/src/routes/reference/default-theme/components/project-selector.mdx b/docs/src/routes/reference/default-theme/components/project-selector.mdx new file mode 100644 index 00000000..dc9f6df8 --- /dev/null +++ b/docs/src/routes/reference/default-theme/components/project-selector.mdx @@ -0,0 +1,27 @@ +--- +title: Project Selector +badges: + - icon: npm + label: since 0.0.9 + - icon: source + label: Source + url: https://github.com/kobaltedev/solidbase/blob/main/src/default-theme/components/ProjectSelector.tsx +--- + +# {frontmatter.title} + +Project switcher for sites that configure `routes.project`. + +## Data Source + +- `useSolidBaseRoute()` +- `getSolidBaseRouteFallbackOptions(routes, "project", currentSelection)` + +## Behavior + +- omitted when fewer than two project options are available +- uses Kobalte `Select` +- displays each project option's `meta.label` when provided, otherwise the option name +- navigates to the selected project's route root +- keeps project options visible across version gaps by falling back to a valid route selection for the target project +- supports external project values through `href` diff --git a/docs/src/routes/reference/default-theme/components/sidebar.mdx b/docs/src/routes/reference/default-theme/components/sidebar.mdx index b3fabb6e..cd5ae459 100644 --- a/docs/src/routes/reference/default-theme/components/sidebar.mdx +++ b/docs/src/routes/reference/default-theme/components/sidebar.mdx @@ -95,3 +95,5 @@ export default defineConfig({ }); ``` + +When `routes.project` is configured with multiple available project options, the default theme renders [`ProjectSelector`](/reference/default-theme/components/project-selector) above the sidebar links. The selector navigates to the selected project's route root and only appears when more than one project option is available. diff --git a/docs/src/routes/reference/default-theme/components/version-selector.mdx b/docs/src/routes/reference/default-theme/components/version-selector.mdx new file mode 100644 index 00000000..b64a2bed --- /dev/null +++ b/docs/src/routes/reference/default-theme/components/version-selector.mdx @@ -0,0 +1,27 @@ +--- +title: Version Selector +badges: + - icon: npm + label: since 0.0.9 + - icon: source + label: Source + url: https://github.com/kobaltedev/solidbase/blob/main/src/default-theme/components/VersionSelector.tsx +--- + +# {frontmatter.title} + +Version switcher for sites that configure `routes.version`. + +## Data Source + +- `useSolidBaseRoute()` +- `getSolidBaseRouteFallbackOptions(routes, "version", currentSelection)` + +## Behavior + +- omitted when fewer than two version options are available +- uses Kobalte `Select` +- displays each version option's `meta.label` when provided, otherwise the option name +- navigates to the selected version's route root +- scopes available versions to the current project +- supports external version values through `href` diff --git a/docs/src/routes/reference/default-theme/index.mdx b/docs/src/routes/reference/default-theme/index.mdx index 8bb9016c..5a39ed84 100644 --- a/docs/src/routes/reference/default-theme/index.mdx +++ b/docs/src/routes/reference/default-theme/index.mdx @@ -49,7 +49,9 @@ The default theme includes the following components that can be used in your mar - [`LastUpdated`](/reference/default-theme/components/last-updated) - [`Link`](/reference/default-theme/components/link) - [`LocaleSelector`](/reference/default-theme/components/locale-selector) +- [`ProjectSelector`](/reference/default-theme/components/project-selector) - [`TableOfContents`](/reference/default-theme/components/toc) - [`ThemeSelector`](/reference/default-theme/components/theme-selector) +- [`VersionSelector`](/reference/default-theme/components/version-selector) - [`Hero`](/reference/default-theme/components/hero) - [`Features`](/reference/default-theme/components/features) diff --git a/docs/src/routes/reference/index.mdx b/docs/src/routes/reference/index.mdx index 177672b4..732ba035 100644 --- a/docs/src/routes/reference/index.mdx +++ b/docs/src/routes/reference/index.mdx @@ -35,6 +35,8 @@ Reference pages for SolidBase APIs, frontmatter, and the default theme. - [Last Updated](/reference/default-theme/components/last-updated) - [Link](/reference/default-theme/components/link) - [Locale Selector](/reference/default-theme/components/locale-selector) +- [Project Selector](/reference/default-theme/components/project-selector) - [Sidebar](/reference/default-theme/components/sidebar) - [Table of Contents](/reference/default-theme/components/toc) - [Theme Selector](/reference/default-theme/components/theme-selector) +- [Version Selector](/reference/default-theme/components/version-selector) diff --git a/docs/src/routes/reference/runtime-api.mdx b/docs/src/routes/reference/runtime-api.mdx index 7b61f2c3..cb283524 100644 --- a/docs/src/routes/reference/runtime-api.mdx +++ b/docs/src/routes/reference/runtime-api.mdx @@ -113,6 +113,7 @@ interface ResolvedLocale { code: string; isRoot?: boolean; config: LocaleConfig; + option?: SolidBaseRouteOption; } ``` @@ -135,6 +136,56 @@ function getLocaleLink(locale: ResolvedLocale): `/${string}`; Returns the root path for the provided locale. +## Routes + +### `useSolidBaseRoutes` + +Returns route config helpers for the current `routes` selection. + +```ts +function useSolidBaseRoutes(): { + routes: SolidBaseRoutesConfig | undefined; + current: Accessor; + path(selection: Partial): `/${string}` | undefined; + options( + axis: string, + selection?: Partial, + ): SolidBaseRouteOption[]; +}; + +type SolidBaseRouteSelection = Record; + +type SolidBaseRouteOption = { + name: string; + axis: string; + path?: string; + href?: string; + isExternal: boolean; + selection?: SolidBaseRouteSelection; + meta: SolidBaseRouteValueConfig; +}; +``` + +`current()` returns the active route axis values for the current pathname. `path(...)` returns an internal route prefix for the provided partial selection merged over the current selection. `options(...)` returns valid options for an axis and filters internal values through `routes.include`. + +### `useSolidBaseRoute` + +Returns the current route selection accessor. + +```ts +function useSolidBaseRoute(): Accessor; +``` + +### `useSolidBaseRouteOptions` + +Returns route options for one axis using the current route selection. + +```ts +function useSolidBaseRouteOptions( + axis: string, +): Accessor; +``` + ## Preferred Language ### `usePreferredLanguage` From de629cab46812bf24055c4ddbf58c7a1e53664ad Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 17:21:54 -0700 Subject: [PATCH 09/19] updates --- docs/vite.config.mts | 18 +++++++++++---- src/client/locale.ts | 15 ++++++++---- src/config/route-config.ts | 20 ++++++++++++---- src/config/routes-index.ts | 5 ++-- src/default-theme/Layout.tsx | 3 ++- .../components/ProjectSelector.tsx | 6 +++-- .../components/VersionSelector.module.css | 23 ++++++++++++++++--- .../components/VersionSelector.tsx | 17 ++++++++++---- tests/client/routes.test.ts | 4 +--- tests/config/index.test.ts | 20 ++++++++++++---- tests/config/route-config.test.ts | 2 +- 11 files changed, 98 insertions(+), 35 deletions(-) diff --git a/docs/vite.config.mts b/docs/vite.config.mts index d465f46a..a06b99f5 100644 --- a/docs/vite.config.mts +++ b/docs/vite.config.mts @@ -34,9 +34,19 @@ export default defineConfig({ languageSwitcher: false, }, }, - locales: { - fr: { - label: "Français", + routes: { + path: "/{locale}", + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + }, + }, + }, + overrides: [ + { + locale: "fr", themeConfig: { nav: [ { @@ -81,7 +91,7 @@ export default defineConfig({ }, }, }, - }, + ], editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", themeConfig: { badges: { diff --git a/src/client/locale.ts b/src/client/locale.ts index 82e6acfd..dbc85e19 100644 --- a/src/client/locale.ts +++ b/src/client/locale.ts @@ -40,12 +40,14 @@ function getRouteLocaleAxis() { return undefined; } -function routeOptionToLocale(option: SolidBaseRouteOption): ResolvedLocale { +function routeOptionToLocale( + option: SolidBaseRouteOption, +): ResolvedLocale { return { code: option.meta.lang ? String(option.meta.lang) : option.name === getRouteLocaleAxis()?.default - ? solidBaseConfig.lang ?? DEFAULT_LANG_CODE + ? (solidBaseConfig.lang ?? DEFAULT_LANG_CODE) : option.name, isRoot: option.name === getRouteLocaleAxis()?.default, option, @@ -104,7 +106,10 @@ function getLegacyLocaleForPath(path: string) { } function getRouteLocaleForPath(path: string) { - const selection = getSolidBaseRouteSelectionForPath(solidBaseConfig.routes, path); + const selection = getSolidBaseRouteSelectionForPath( + solidBaseConfig.routes, + path, + ); const value = selection?.[LOCALE_AXIS]; if (!value) return undefined; @@ -147,7 +152,9 @@ const [LocaleContextProvider, useLocaleContext] = createContextProvider(() => { const match = currentRouteMatch(); if (!match) return []; - return routes.options(LOCALE_AXIS, match.selection).map(routeOptionToLocale); + return routes + .options(LOCALE_AXIS, match.selection) + .map(routeOptionToLocale); }); const match = useMatch(() => `${getLocaleLink(currentLocale())}*rest`); diff --git a/src/config/route-config.ts b/src/config/route-config.ts index 44621bac..e04d0311 100644 --- a/src/config/route-config.ts +++ b/src/config/route-config.ts @@ -160,7 +160,9 @@ function getInternalRouteValueEntries(axis: SolidBaseRouteAxisConfig) { return Object.entries(axis.values).filter(([, value]) => !value.href); } -function getSolidBaseRouteSelections(routes: SolidBaseRoutesConfig | undefined) { +function getSolidBaseRouteSelections( + routes: SolidBaseRoutesConfig | undefined, +) { const axes = getSolidBaseRouteAxes(routes); const selections: SolidBaseRouteSelection[] = []; @@ -188,7 +190,9 @@ function matchRoutePathSegment( pathSegment: string | undefined, ) { const entries = getInternalRouteValueEntries(axis); - const defaultEntry = entries.find(([valueName]) => valueName === axis.default); + const defaultEntry = entries.find( + ([valueName]) => valueName === axis.default, + ); const explicitMatch = pathSegment === undefined ? undefined @@ -204,7 +208,10 @@ function matchRoutePathSegment( return defaultPath === "" ? defaultEntry : undefined; } -function getRouteValuePath(valueName: string, value: SolidBaseRouteValueConfig) { +function getRouteValuePath( + valueName: string, + value: SolidBaseRouteValueConfig, +) { return trimSlashes(value.path ?? valueName); } @@ -269,7 +276,9 @@ export function validateSolidBaseRoutesConfig( if (axisMap.has(key)) { assertSolidBaseRouteConfig( typeof value === "string" || Array.isArray(value), - "`overrides` selector `" + key + "` must be a string or string array.", + "`overrides` selector `" + + key + + "` must be a string or string array.", ); selectors[key] = value; continue; @@ -423,7 +432,8 @@ export function getSolidBaseRouteFallbackSelection( ) { if (!routes) return undefined; - const normalizedCurrent = normalizeSolidBaseRouteSelection(routes, current) ?? {}; + const normalizedCurrent = + normalizeSolidBaseRouteSelection(routes, current) ?? {}; const axisMap = getSolidBaseRouteAxisMap(routes); const lockedSelection: Partial = {}; diff --git a/src/config/routes-index.ts b/src/config/routes-index.ts index 9538b21f..9fe23c42 100644 --- a/src/config/routes-index.ts +++ b/src/config/routes-index.ts @@ -141,7 +141,7 @@ export function getNonRootLocalePrefixes(config: SolidBaseResolvedConfig) { .filter(([locale]) => locale !== "root") .map(([locale, localeConfig]) => normalizeLocalePrefix(localeConfig.link ?? `/${locale}/`), - ); + ); } function getLegacyLocaleInfo( @@ -185,7 +185,8 @@ export function getRouteLocaleMetadata( config: SolidBaseResolvedConfig, ) { return ( - getRouteLocaleInfo(routePath, config) ?? getLegacyLocaleInfo(routePath, config) + getRouteLocaleInfo(routePath, config) ?? + getLegacyLocaleInfo(routePath, config) ); } diff --git a/src/default-theme/Layout.tsx b/src/default-theme/Layout.tsx index 28c47149..9660bae2 100644 --- a/src/default-theme/Layout.tsx +++ b/src/default-theme/Layout.tsx @@ -59,7 +59,8 @@ export default (props: ParentProps) => { }; function Layout(props: ParentProps) { - const { Header, Article, Link, ProjectSelector } = useDefaultThemeComponents(); + const { Header, Article, Link, ProjectSelector } = + useDefaultThemeComponents(); const { sidebarOpen, setSidebarOpen, frontmatter } = useDefaultThemeState(); const config = useRouteConfig(); diff --git a/src/default-theme/components/ProjectSelector.tsx b/src/default-theme/components/ProjectSelector.tsx index f16ff4d1..e64af1d8 100644 --- a/src/default-theme/components/ProjectSelector.tsx +++ b/src/default-theme/components/ProjectSelector.tsx @@ -4,11 +4,11 @@ import { useNavigate } from "@solidjs/router"; import { createMemo, Show } from "solid-js"; import IconExpandUpDownLine from "~icons/ri/expand-up-down-line"; +import { useSolidBaseRoute } from "../../client/index.jsx"; import { getSolidBaseRouteFallbackOptions, type SolidBaseRouteOption, } from "../../config/route-config.js"; -import { useSolidBaseRoute } from "../../client/index.jsx"; import styles from "./ProjectSelector.module.css"; const PROJECT_AXIS = "project"; @@ -28,7 +28,9 @@ export default function ProjectSelector() { ); const getOptionLabel = (option: SolidBaseRouteOption) => { - return typeof option.meta.label === "string" ? option.meta.label : option.name; + return typeof option.meta.label === "string" + ? option.meta.label + : option.name; }; const onChange = (option: SolidBaseRouteOption | null) => { diff --git a/src/default-theme/components/VersionSelector.module.css b/src/default-theme/components/VersionSelector.module.css index d8cde80c..0a011fc2 100644 --- a/src/default-theme/components/VersionSelector.module.css +++ b/src/default-theme/components/VersionSelector.module.css @@ -21,9 +21,9 @@ .trigger { appearance: none; - display: inline-flex; + display: flex; align-items: center; - padding: 0.2rem 0.45rem; + padding: 1px; background: color-mix(in hsl, var(--sb-text-color) 10%, transparent); border: none; outline: none; @@ -43,8 +43,18 @@ } } -.label { +.labelSegment { min-width: 0; + padding: 0.1rem 0.25rem; + line-height: 1; +} + +.iconSegment { + display: flex; + padding-right: 2px; + align-items: center; + justify-content: center; + align-self: stretch; } .content { @@ -65,3 +75,10 @@ display: flex; flex-direction: column; } + +.icon { + width: 0.75rem; + height: 0.75rem; + flex: 0 0 auto; + color: color-mix(in hsl, var(--sb-text-color) 55%, transparent); +} diff --git a/src/default-theme/components/VersionSelector.tsx b/src/default-theme/components/VersionSelector.tsx index 83464d34..96ea56bf 100644 --- a/src/default-theme/components/VersionSelector.tsx +++ b/src/default-theme/components/VersionSelector.tsx @@ -2,12 +2,12 @@ import { solidBaseConfig } from "virtual:solidbase/config"; import { Select } from "@kobalte/core/select"; import { useNavigate } from "@solidjs/router"; import { createMemo, Show } from "solid-js"; - +import { useSolidBaseRoute } from "../../client/index.jsx"; +import IconExpandUpDownLine from "~icons/ri/expand-up-down-line"; import { getSolidBaseRouteFallbackOptions, type SolidBaseRouteOption, } from "../../config/route-config.js"; -import { useSolidBaseRoute } from "../../client/index.jsx"; import styles from "./VersionSelector.module.css"; const VERSION_AXIS = "version"; @@ -27,7 +27,9 @@ export default function VersionSelector() { ); const getOptionLabel = (option: SolidBaseRouteOption) => { - return typeof option.meta.label === "string" ? option.meta.label : option.name; + return typeof option.meta.label === "string" + ? option.meta.label + : option.name; }; const onChange = (option: SolidBaseRouteOption | null) => { @@ -66,11 +68,16 @@ export default function VersionSelector() { > {(state) => ( - +
{getOptionLabel(state.selectedOption())} - +
)} + 1}> +
+ +
+
diff --git a/tests/client/routes.test.ts b/tests/client/routes.test.ts index e385d924..b8e55bf4 100644 --- a/tests/client/routes.test.ts +++ b/tests/client/routes.test.ts @@ -111,9 +111,7 @@ describe("solidbase route client helpers", () => { { name: "latest", path: "/router/fr", isExternal: false }, { name: "v0", href: "https://v0.solidjs.com", isExternal: true }, ]); - expect(helpers?.path({ project: "solid", version: "v1" })).toBe( - "/v1/fr", - ); + expect(helpers?.path({ project: "solid", version: "v1" })).toBe("/v1/fr"); dispose(); }); }); diff --git a/tests/config/index.test.ts b/tests/config/index.test.ts index 3757c0b6..b792d1ae 100644 --- a/tests/config/index.test.ts +++ b/tests/config/index.test.ts @@ -150,7 +150,9 @@ describe("createSolidBase", () => { it("rejects route paths that reference unknown axes", async () => { const { createSolidBase } = await import("../../src/config/index.ts"); - const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + const solidBase = createSolidBase({ + componentsPath: "/themes/custom", + } as any); expect(() => solidBase.plugin({ @@ -164,7 +166,9 @@ describe("createSolidBase", () => { it("rejects route axes that are missing from the path", async () => { const { createSolidBase } = await import("../../src/config/index.ts"); - const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + const solidBase = createSolidBase({ + componentsPath: "/themes/custom", + } as any); expect(() => solidBase.plugin({ @@ -178,7 +182,9 @@ describe("createSolidBase", () => { it("rejects route defaults that are not route values", async () => { const { createSolidBase } = await import("../../src/config/index.ts"); - const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + const solidBase = createSolidBase({ + componentsPath: "/themes/custom", + } as any); expect(() => solidBase.plugin({ @@ -195,7 +201,9 @@ describe("createSolidBase", () => { it("rejects include rules with unknown axes or values", async () => { const { createSolidBase } = await import("../../src/config/index.ts"); - const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + const solidBase = createSolidBase({ + componentsPath: "/themes/custom", + } as any); expect(() => solidBase.plugin({ @@ -218,7 +226,9 @@ describe("createSolidBase", () => { it("rejects overrides with unknown route selectors or values", async () => { const { createSolidBase } = await import("../../src/config/index.ts"); - const solidBase = createSolidBase({ componentsPath: "/themes/custom" } as any); + const solidBase = createSolidBase({ + componentsPath: "/themes/custom", + } as any); expect(() => solidBase.plugin({ diff --git a/tests/config/route-config.test.ts b/tests/config/route-config.test.ts index 1754ccf9..6467a0e1 100644 --- a/tests/config/route-config.test.ts +++ b/tests/config/route-config.test.ts @@ -4,8 +4,8 @@ import { buildSolidBaseRoutePath, getSolidBaseRouteFallbackOptions, getSolidBaseRouteFallbackSelection, - getSolidBaseRouteSelectionForPath, getSolidBaseRouteOptions, + getSolidBaseRouteSelectionForPath, isSolidBaseRouteIncluded, resolveSolidBaseRouteConfig, type SolidBaseRoutesConfig, From f9fd0a211e083e89d53bb849932d2b884d21fb46 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 17:24:04 -0700 Subject: [PATCH 10/19] fix jeremy's obsession with things being 0.05rems off --- src/default-theme/components/VersionSelector.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/default-theme/components/VersionSelector.module.css b/src/default-theme/components/VersionSelector.module.css index 0a011fc2..c37c68f9 100644 --- a/src/default-theme/components/VersionSelector.module.css +++ b/src/default-theme/components/VersionSelector.module.css @@ -45,7 +45,7 @@ .labelSegment { min-width: 0; - padding: 0.1rem 0.25rem; + padding: 0.1rem 0.2rem 0.15rem 0.25rem; line-height: 1; } From 5b10fe7c3db8adb4d88049c25b4e596994935702 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Tue, 21 Apr 2026 17:27:44 -0700 Subject: [PATCH 11/19] he was unhappy again --- src/default-theme/components/VersionSelector.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/default-theme/components/VersionSelector.module.css b/src/default-theme/components/VersionSelector.module.css index c37c68f9..b7f47598 100644 --- a/src/default-theme/components/VersionSelector.module.css +++ b/src/default-theme/components/VersionSelector.module.css @@ -45,7 +45,7 @@ .labelSegment { min-width: 0; - padding: 0.1rem 0.2rem 0.15rem 0.25rem; + padding: 0.1rem 0.15rem 0.15rem 0.25rem; line-height: 1; } From d271f1ddbb11b29a950bef26e8468941bcf92d88 Mon Sep 17 00:00:00 2001 From: jer3m01 Date: Wed, 22 Apr 2026 19:38:23 +0200 Subject: [PATCH 12/19] fix --- package.json | 1 + src/config/route-config.ts | 8 ++++---- src/default-theme/components/VersionSelector.tsx | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c3435749..6d2f945b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "author": "jer3m01 ", "contributors": [ "Brendan Allan ", + "Sarah Gerrard ", "dev-rb <43100342+dev-rb@users.noreply.github.com>" ], "license": "MIT", diff --git a/src/config/route-config.ts b/src/config/route-config.ts index e04d0311..60d48de2 100644 --- a/src/config/route-config.ts +++ b/src/config/route-config.ts @@ -138,7 +138,7 @@ function validateRouteRule( const axis = axisMap.get(axisName); assertSolidBaseRouteConfig( axis, - "`" + source + "` references unknown route axis `" + axisName + "`.", + `\`${source}\` references unknown route axis \`${axisName}\`.`, ); for (const valueName of toRuleValues(value)) { @@ -235,14 +235,14 @@ export function validateSolidBaseRoutesConfig( if (ROUTES_RESERVED_KEYS.has(key)) continue; assertSolidBaseRouteConfig( axisMap.has(key), - "`routes." + key + "` must include `default` and `values`.", + `\`routes.${key}\` must include \`default\` and \`values\`.`, ); } for (const axisName of placeholders) { assertSolidBaseRouteConfig( axisMap.has(axisName), - "`routes.path` references unknown route axis `" + axisName + "`.", + `\`routes.path\` references unknown route axis \`${axisName}\`.`, ); } @@ -286,7 +286,7 @@ export function validateSolidBaseRoutesConfig( assertSolidBaseRouteConfig( configKeys.has(key), - "`overrides` contains unknown config key or route axis `" + key + "`.", + `\`overrides\` contains unknown config key or route axis \`${key}\`.`, ); } diff --git a/src/default-theme/components/VersionSelector.tsx b/src/default-theme/components/VersionSelector.tsx index 96ea56bf..6259382b 100644 --- a/src/default-theme/components/VersionSelector.tsx +++ b/src/default-theme/components/VersionSelector.tsx @@ -2,8 +2,8 @@ import { solidBaseConfig } from "virtual:solidbase/config"; import { Select } from "@kobalte/core/select"; import { useNavigate } from "@solidjs/router"; import { createMemo, Show } from "solid-js"; -import { useSolidBaseRoute } from "../../client/index.jsx"; import IconExpandUpDownLine from "~icons/ri/expand-up-down-line"; +import { useSolidBaseRoute } from "../../client/index.jsx"; import { getSolidBaseRouteFallbackOptions, type SolidBaseRouteOption, From fd1c2dfde3faebe44d0bbb405d774260bf2f3d6b Mon Sep 17 00:00:00 2001 From: jer3m01 Date: Wed, 22 Apr 2026 20:22:18 +0200 Subject: [PATCH 13/19] fix --- docs/package.json | 6 +++--- pnpm-lock.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/package.json b/docs/package.json index c5b89611..20b961da 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,8 +3,8 @@ "type": "module", "private": "true", "scripts": { - "dev": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" VITE_SOLIDBASE_DEV=true vite dev", - "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" vite build" + "dev": "cross-env VITE_SOLIDBASE_DEV=true vite dev", + "build": "cross-env vite build" }, "dependencies": { "@kobalte/solidbase": "workspace:*", @@ -13,7 +13,7 @@ "@solidjs/start": "https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2080", "nitro": "3.0.260311-beta", "solid-js": "^1.9.9", - "vite": "^8.0.0" + "vite": "8.0.0" }, "engines": { "node": ">=22.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08c767a7..6d3cdc0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,7 +284,7 @@ importers: specifier: ^1.9.9 version: 1.9.11 vite: - specifier: ^8.0.0 + specifier: 8.0.0 version: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.2) devDependencies: '@iconify-json/ri': From 072171895b433f38d48024c4bddf8c5ce11923ee Mon Sep 17 00:00:00 2001 From: jer3m01 Date: Wed, 22 Apr 2026 20:25:48 +0200 Subject: [PATCH 14/19] fix --- docs/src/solidbase-theme/Layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/solidbase-theme/Layout.tsx b/docs/src/solidbase-theme/Layout.tsx index dfa9b1c9..b8e151fa 100644 --- a/docs/src/solidbase-theme/Layout.tsx +++ b/docs/src/solidbase-theme/Layout.tsx @@ -12,7 +12,7 @@ import { useLocation } from "@solidjs/router"; import { type ComponentProps, Show } from "solid-js"; import { Dynamic } from "solid-js/web"; -import { OGImage } from "./og-image"; +// import { OGImage } from "./og-image"; // re enable after start 2 viite 8 release export default function (props: ComponentProps) { const frontmatter = useDefaultThemeFrontmatter(); @@ -76,7 +76,7 @@ function OpenGraph() { ).toString()} /> - + {/**/} ); } From 8ec12b2ddde8e5e5ae3e49e6b3f4e69c24098041 Mon Sep 17 00:00:00 2001 From: jer3m01 Date: Wed, 22 Apr 2026 23:38:17 +0200 Subject: [PATCH 15/19] small changes --- docs/src/routes/guide/(2)config.mdx | 11 +- .../components/project-selector.mdx | 4 +- .../components/version-selector.mdx | 4 +- docs/src/routes/reference/runtime-api.mdx | 2 +- docs/vite.config.mts | 231 +++++++++--------- package.json | 2 +- src/default-theme/components/Hero.tsx | 2 +- .../components/ProjectSelector.module.css | 16 +- .../components/ProjectSelector.tsx | 83 +++---- .../components/VersionSelector.module.css | 46 ++-- .../components/VersionSelector.tsx | 96 ++++---- src/default-theme/frontmatter.ts | 2 +- src/default-theme/mdx-components.tsx | 3 +- 13 files changed, 239 insertions(+), 263 deletions(-) diff --git a/docs/src/routes/guide/(2)config.mdx b/docs/src/routes/guide/(2)config.mdx index e29344af..55a73498 100644 --- a/docs/src/routes/guide/(2)config.mdx +++ b/docs/src/routes/guide/(2)config.mdx @@ -156,6 +156,12 @@ overrides: [ { project: "router", title: "Router Docs", + themeConfig: { + socialLinks: { + github: "https://github.com/solidjs/solid-router", + discord: "https://discord.com/invite/solidjs", + }, + } }, { locale: "fr", @@ -164,11 +170,6 @@ overrides: [ { project: "solidbase", version: "v1", - markdown: { - expressiveCode: { - languageSwitcher: false, - }, - }, }, ], // .. diff --git a/docs/src/routes/reference/default-theme/components/project-selector.mdx b/docs/src/routes/reference/default-theme/components/project-selector.mdx index dc9f6df8..5df43dcc 100644 --- a/docs/src/routes/reference/default-theme/components/project-selector.mdx +++ b/docs/src/routes/reference/default-theme/components/project-selector.mdx @@ -2,7 +2,7 @@ title: Project Selector badges: - icon: npm - label: since 0.0.9 + label: since 0.4.4 - icon: source label: Source url: https://github.com/kobaltedev/solidbase/blob/main/src/default-theme/components/ProjectSelector.tsx @@ -20,7 +20,7 @@ Project switcher for sites that configure `routes.project`. ## Behavior - omitted when fewer than two project options are available -- uses Kobalte `Select` +- uses Kobalte `Popover` - displays each project option's `meta.label` when provided, otherwise the option name - navigates to the selected project's route root - keeps project options visible across version gaps by falling back to a valid route selection for the target project diff --git a/docs/src/routes/reference/default-theme/components/version-selector.mdx b/docs/src/routes/reference/default-theme/components/version-selector.mdx index b64a2bed..38b4655e 100644 --- a/docs/src/routes/reference/default-theme/components/version-selector.mdx +++ b/docs/src/routes/reference/default-theme/components/version-selector.mdx @@ -2,7 +2,7 @@ title: Version Selector badges: - icon: npm - label: since 0.0.9 + label: since 0.4.4 - icon: source label: Source url: https://github.com/kobaltedev/solidbase/blob/main/src/default-theme/components/VersionSelector.tsx @@ -20,7 +20,7 @@ Version switcher for sites that configure `routes.version`. ## Behavior - omitted when fewer than two version options are available -- uses Kobalte `Select` +- uses Kobalte `Popover` - displays each version option's `meta.label` when provided, otherwise the option name - navigates to the selected version's route root - scopes available versions to the current project diff --git a/docs/src/routes/reference/runtime-api.mdx b/docs/src/routes/reference/runtime-api.mdx index cb283524..af228d33 100644 --- a/docs/src/routes/reference/runtime-api.mdx +++ b/docs/src/routes/reference/runtime-api.mdx @@ -2,7 +2,7 @@ title: Runtime API badges: - icon: npm - label: since 0.0.9 + label: updated 0.4.4 - icon: source label: Source url: https://github.com/kobaltedev/solidbase/tree/main/src/client diff --git a/docs/vite.config.mts b/docs/vite.config.mts index a06b99f5..e60fa5af 100644 --- a/docs/vite.config.mts +++ b/docs/vite.config.mts @@ -7,123 +7,130 @@ import arraybuffer from "vite-plugin-arraybuffer"; import { createSolidBase, defineTheme } from "../src/config"; import { createFilesystemSidebar } from "../src/config/sidebar"; import defaultTheme from "../src/default-theme"; +import { version as SBVersion } from "../package.json"; const theme = defineTheme({ - componentsPath: import.meta.resolve("./src/solidbase-theme"), - extends: defaultTheme, + componentsPath: import.meta.resolve("./src/solidbase-theme"), + extends: defaultTheme, }); const solidBase = createSolidBase(theme); export default defineConfig({ - plugins: [ - OGPlugin(), - arraybuffer(), - solidBase.plugin({ - title: "SolidBase", - description: - "Fully featured, fully customisable static site generation for SolidStart", - siteUrl: "https://solidbase.dev", - llms: true, - sitemap: true, - robots: true, - issueAutolink: "https://github.com/kobaltedev/solidbase/issues/:issue", - lang: "en", - markdown: { - expressiveCode: { - languageSwitcher: false, - }, - }, - routes: { - path: "/{locale}", - locale: { - default: "en", - values: { - en: { path: "", label: "English", lang: "en" }, - fr: { path: "fr", label: "Français", lang: "fr-FR" }, - }, - }, - }, - overrides: [ - { - locale: "fr", - themeConfig: { - nav: [ - { - text: "Guide", - link: "/guide", - }, - { - text: "Référence", - link: "/reference", - }, - ], - sidebar: { - "/guide": [ - { - title: "Aperçu", - collapsed: false, - items: [ - { - title: "Qu'est-ce que SolidBase ?", - link: "/", - }, - ], - }, - { - title: "Fonctionnalités", - collapsed: false, - items: [ - { - title: "Extensions Markdown", - link: "/markdown", - }, - ], - }, - ], - "/reference": [ - { - title: "Référence", - collapsed: false, - items: [], - }, - ], - }, - }, - }, - ], - editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", - themeConfig: { - badges: { - icons: { - npm: ``, - source: ``, - }, - }, - socialLinks: { - github: "https://github.com/kobaltedev/solidbase", - discord: "https://discord.com/invite/solidjs", - }, - nav: [ - { - text: "Guide", - link: "/guide", - }, - { - text: "Reference", - link: "/reference", - }, - ], - sidebar: { - "/guide": createFilesystemSidebar("./src/routes/guide"), - "/reference": createFilesystemSidebar("./src/routes/reference"), - }, - }, - }), - solidStart(solidBase.startConfig()), - nitro({ - preset: "netlify", - prerender: { crawlLinks: true }, - }), - ], + plugins: [ + OGPlugin(), + arraybuffer(), + solidBase.plugin({ + title: "SolidBase", + description: + "Fully featured, fully customisable static site generation for SolidStart", + siteUrl: "https://solidbase.dev", + llms: true, + sitemap: true, + robots: true, + issueAutolink: "https://github.com/kobaltedev/solidbase/issues/:issue", + lang: "en", + markdown: { + expressiveCode: { + languageSwitcher: false, + }, + }, + routes: { + path: "/{version}/{locale}", + version: { + default: "latest", + values: { + latest: { path: "", label: `v${SBVersion}` }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + }, + }, + }, + overrides: [ + { + locale: "fr", + themeConfig: { + nav: [ + { + text: "Guide", + link: "/guide", + }, + { + text: "Référence", + link: "/reference", + }, + ], + sidebar: { + "/guide": [ + { + title: "Aperçu", + collapsed: false, + items: [ + { + title: "Qu'est-ce que SolidBase ?", + link: "/", + }, + ], + }, + { + title: "Fonctionnalités", + collapsed: false, + items: [ + { + title: "Extensions Markdown", + link: "/markdown", + }, + ], + }, + ], + "/reference": [ + { + title: "Référence", + collapsed: false, + items: [], + }, + ], + }, + }, + }, + ], + editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", + themeConfig: { + badges: { + icons: { + npm: ``, + source: ``, + }, + }, + socialLinks: { + github: "https://github.com/kobaltedev/solidbase", + discord: "https://discord.com/invite/solidjs", + }, + nav: [ + { + text: "Guide", + link: "/guide", + }, + { + text: "Reference", + link: "/reference", + }, + ], + sidebar: { + "/guide": createFilesystemSidebar("./src/routes/guide"), + "/reference": createFilesystemSidebar("./src/routes/reference"), + }, + }, + }), + solidStart(solidBase.startConfig()), + nitro({ + preset: "netlify", + prerender: { crawlLinks: true }, + }), + ], }); diff --git a/package.json b/package.json index 6d2f945b..e0e2a71c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kobalte/solidbase", - "version": "0.0.0-dev", + "version": "0.4.4", "description": "Fully featured, fully customisable static site generation for SolidStart", "type": "module", "sideEffects": true, diff --git a/src/default-theme/components/Hero.tsx b/src/default-theme/components/Hero.tsx index 6af0be50..10d34146 100644 --- a/src/default-theme/components/Hero.tsx +++ b/src/default-theme/components/Hero.tsx @@ -18,7 +18,7 @@ export default function Hero(props: { data: HeroConfig }) { {(t) =>

{t()}

}
- + {(actions) => (
diff --git a/src/default-theme/components/ProjectSelector.module.css b/src/default-theme/components/ProjectSelector.module.css index f622c477..a1914ccf 100644 --- a/src/default-theme/components/ProjectSelector.module.css +++ b/src/default-theme/components/ProjectSelector.module.css @@ -1,13 +1,9 @@ -.root { - width: 100%; - margin-bottom: 0.75rem; -} - .item { padding: 0.3rem 0.5rem; list-style-type: none; border-radius: var(--sb-border-radius); - cursor: var(--sb-button-cursor); + color: var(--sb-text-color); + text-decoration: none; &:hover, &:focus { @@ -15,7 +11,7 @@ background: color-mix(in hsl, var(--sb-text-color) 7.5%, transparent); } - &[data-selected] { + &[aria-current] { color: var(--sb-active-link-color); } } @@ -38,6 +34,7 @@ transition-property: background-color, opacity, color; transition-timing-function: var(--sb-transition-timing); transition-duration: 0.15s; + margin-bottom: 0.75rem; &:hover, &:focus, @@ -68,7 +65,6 @@ .content { z-index: 51; width: var(--kb-popper-anchor-width); - color: var(--sb-text-color); background: color-mix( in hsl, var(--sb-background-color) 95%, @@ -77,10 +73,6 @@ border: 1px solid color-mix(in hsl, var(--sb-decoration-color) 20%, transparent); border-radius: var(--sb-border-radius); -} - -.list { - width: 100%; padding: 0.25rem; display: flex; flex-direction: column; diff --git a/src/default-theme/components/ProjectSelector.tsx b/src/default-theme/components/ProjectSelector.tsx index e64af1d8..2affc87d 100644 --- a/src/default-theme/components/ProjectSelector.tsx +++ b/src/default-theme/components/ProjectSelector.tsx @@ -1,7 +1,6 @@ import { solidBaseConfig } from "virtual:solidbase/config"; -import { Select } from "@kobalte/core/select"; -import { useNavigate } from "@solidjs/router"; -import { createMemo, Show } from "solid-js"; +import { Popover } from "@kobalte/core/popover"; +import { createMemo, createSignal, For, Show } from "solid-js"; import IconExpandUpDownLine from "~icons/ri/expand-up-down-line"; import { useSolidBaseRoute } from "../../client/index.jsx"; @@ -14,7 +13,8 @@ import styles from "./ProjectSelector.module.css"; const PROJECT_AXIS = "project"; export default function ProjectSelector() { - const navigate = useNavigate(); + const [open, setOpen] = createSignal(false); + const current = useSolidBaseRoute(); const options = createMemo(() => getSolidBaseRouteFallbackOptions( @@ -33,55 +33,46 @@ export default function ProjectSelector() { : option.name; }; - const onChange = (option: SolidBaseRouteOption | null) => { - if (!option) return; - if (option.name === current()[PROJECT_AXIS]) return; - - if (option.href) { - globalThis.location.href = option.href; - return; - } - - if (option.path) navigate(option.path); - }; - return ( 1 && currentOption()}> - {(value) => ( - - class={styles.root} - value={value()} - options={options()} - optionValue="name" - optionTextValue={getOptionLabel} - onChange={onChange} + {(current) => ( + ( - - - {getOptionLabel(props.item.rawValue)} - - - )} > - - > - {(state) => ( - - {getOptionLabel(state.selectedOption())} - - )} - + + + {getOptionLabel(current())} + - - - - - - - + + + + + {(option) => { + const outbound = () => !!option.href; + + return ( + e.currentTarget.focus()} + onClick={() => setOpen(false)} + > + {getOptionLabel(option)} + + ); + }} + + + + )} ); diff --git a/src/default-theme/components/VersionSelector.module.css b/src/default-theme/components/VersionSelector.module.css index b7f47598..7b48d4cd 100644 --- a/src/default-theme/components/VersionSelector.module.css +++ b/src/default-theme/components/VersionSelector.module.css @@ -6,7 +6,8 @@ padding: 0.3rem 0.5rem; list-style-type: none; border-radius: var(--sb-border-radius); - cursor: var(--sb-button-cursor); + color: var(--sb-text-color); + text-decoration: none; &:hover, &:focus { @@ -14,7 +15,7 @@ background: color-mix(in hsl, var(--sb-text-color) 7.5%, transparent); } - &[data-selected] { + &[aria-current] { color: var(--sb-active-link-color); } } @@ -24,42 +25,41 @@ display: flex; align-items: center; padding: 1px; - background: color-mix(in hsl, var(--sb-text-color) 10%, transparent); + background: color-mix(in hsl, var(--sb-text-color) 8%, transparent); border: none; outline: none; border-radius: calc(var(--sb-border-radius) * 0.75); - color: color-mix(in hsl, var(--sb-text-color) 82%, transparent); + color: var(--sb-text-color); line-height: 1; - cursor: var(--sb-button-cursor); transition-property: background-color, opacity, color; transition-timing-function: var(--sb-transition-timing); transition-duration: 0.15s; - &:hover, - &:focus, - &[data-expanded] { - background: color-mix(in hsl, var(--sb-text-color) 15%, transparent); - color: var(--sb-heading-color); + &:not(:disabled) { + cursor: var(--sb-button-cursor); + + &:hover, + &:focus, + &[data-expanded] { + background: color-mix(in hsl, var(--sb-text-color) 15%, transparent); + color: var(--sb-heading-color); + } + } + + &:disabled { + cursor: text; + user-select: text; } } -.labelSegment { +.label { min-width: 0; padding: 0.1rem 0.15rem 0.15rem 0.25rem; line-height: 1; } -.iconSegment { - display: flex; - padding-right: 2px; - align-items: center; - justify-content: center; - align-self: stretch; -} - .content { z-index: 51; - color: var(--sb-text-color); background: color-mix( in hsl, var(--sb-background-color) 95%, @@ -67,10 +67,6 @@ ); border-radius: var(--sb-border-radius); min-width: max-content; -} - -.list { - min-width: max-content; padding: 0.25rem; display: flex; flex-direction: column; @@ -79,6 +75,6 @@ .icon { width: 0.75rem; height: 0.75rem; - flex: 0 0 auto; + padding-right: 2px; color: color-mix(in hsl, var(--sb-text-color) 55%, transparent); } diff --git a/src/default-theme/components/VersionSelector.tsx b/src/default-theme/components/VersionSelector.tsx index 6259382b..2e31bb5e 100644 --- a/src/default-theme/components/VersionSelector.tsx +++ b/src/default-theme/components/VersionSelector.tsx @@ -1,7 +1,5 @@ import { solidBaseConfig } from "virtual:solidbase/config"; -import { Select } from "@kobalte/core/select"; -import { useNavigate } from "@solidjs/router"; -import { createMemo, Show } from "solid-js"; +import { createMemo, createSignal, For, Show } from "solid-js"; import IconExpandUpDownLine from "~icons/ri/expand-up-down-line"; import { useSolidBaseRoute } from "../../client/index.jsx"; import { @@ -9,11 +7,13 @@ import { type SolidBaseRouteOption, } from "../../config/route-config.js"; import styles from "./VersionSelector.module.css"; +import { Popover } from "@kobalte/core/popover"; const VERSION_AXIS = "version"; export default function VersionSelector() { - const navigate = useNavigate(); + const [open, setOpen] = createSignal(false); + const current = useSolidBaseRoute(); const options = createMemo(() => getSolidBaseRouteFallbackOptions( @@ -32,59 +32,49 @@ export default function VersionSelector() { : option.name; }; - const onChange = (option: SolidBaseRouteOption | null) => { - if (!option) return; - if (option.name === current()[VERSION_AXIS]) return; - - if (option.href) { - globalThis.location.href = option.href; - return; - } - - if (option.path) navigate(option.path); - }; - return ( - 1 && currentOption()}> - {(value) => ( - - class={styles.root} - value={value()} - options={options()} - optionValue="name" - optionTextValue={getOptionLabel} - onChange={onChange} - gutter={8} - sameWidth={false} - placement="bottom" - itemComponent={(props) => ( - - - {getOptionLabel(props.item.rawValue)} - - - )} + + {(current) => ( + - - > - {(state) => ( -
- {getOptionLabel(state.selectedOption())} -
- )} - + + + {getOptionLabel(current())} + + 1}> -
- -
+
-
- - - - - - + + + + + {(option) => { + const outbound = () => !!option.href; + + return ( + e.currentTarget.focus()} + onClick={() => setOpen(false)} + > + {getOptionLabel(option)} + + ); + }} + + + +
)}
); diff --git a/src/default-theme/frontmatter.ts b/src/default-theme/frontmatter.ts index 0cd5c495..778ab1a9 100644 --- a/src/default-theme/frontmatter.ts +++ b/src/default-theme/frontmatter.ts @@ -66,7 +66,7 @@ export interface HeroConfig { src: string; alt?: string; }; - badges?: Array; + actions?: Array; } export interface FeaturesConfig { diff --git a/src/default-theme/mdx-components.tsx b/src/default-theme/mdx-components.tsx index 5b4525e7..d1c1ae6b 100644 --- a/src/default-theme/mdx-components.tsx +++ b/src/default-theme/mdx-components.tsx @@ -4,7 +4,6 @@ import { makePersisted, messageSync, } from "@solid-primitives/storage"; -import { A } from "@solidjs/router"; import { type Accessor, type ComponentProps, @@ -65,7 +64,7 @@ export function a(props: ComponentProps<"a"> & { "data-auto-heading"?: "" }) { const autoHeading = () => props["data-auto-heading"] === ""; return ( - Date: Wed, 22 Apr 2026 23:38:37 +0200 Subject: [PATCH 16/19] style: format --- docs/vite.config.mts | 239 +++++++++--------- .../components/ProjectSelector.tsx | 4 +- .../components/VersionSelector.tsx | 12 +- 3 files changed, 127 insertions(+), 128 deletions(-) diff --git a/docs/vite.config.mts b/docs/vite.config.mts index e60fa5af..c5b341f6 100644 --- a/docs/vite.config.mts +++ b/docs/vite.config.mts @@ -3,134 +3,133 @@ import { solidStart } from "@solidjs/start/config"; import { nitro } from "nitro/vite"; import { defineConfig } from "vite"; import arraybuffer from "vite-plugin-arraybuffer"; - +import { version as SBVersion } from "../package.json"; import { createSolidBase, defineTheme } from "../src/config"; import { createFilesystemSidebar } from "../src/config/sidebar"; import defaultTheme from "../src/default-theme"; -import { version as SBVersion } from "../package.json"; const theme = defineTheme({ - componentsPath: import.meta.resolve("./src/solidbase-theme"), - extends: defaultTheme, + componentsPath: import.meta.resolve("./src/solidbase-theme"), + extends: defaultTheme, }); const solidBase = createSolidBase(theme); export default defineConfig({ - plugins: [ - OGPlugin(), - arraybuffer(), - solidBase.plugin({ - title: "SolidBase", - description: - "Fully featured, fully customisable static site generation for SolidStart", - siteUrl: "https://solidbase.dev", - llms: true, - sitemap: true, - robots: true, - issueAutolink: "https://github.com/kobaltedev/solidbase/issues/:issue", - lang: "en", - markdown: { - expressiveCode: { - languageSwitcher: false, - }, - }, - routes: { - path: "/{version}/{locale}", - version: { - default: "latest", - values: { - latest: { path: "", label: `v${SBVersion}` }, - }, - }, - locale: { - default: "en", - values: { - en: { path: "", label: "English", lang: "en" }, - fr: { path: "fr", label: "Français", lang: "fr-FR" }, - }, - }, - }, - overrides: [ - { - locale: "fr", - themeConfig: { - nav: [ - { - text: "Guide", - link: "/guide", - }, - { - text: "Référence", - link: "/reference", - }, - ], - sidebar: { - "/guide": [ - { - title: "Aperçu", - collapsed: false, - items: [ - { - title: "Qu'est-ce que SolidBase ?", - link: "/", - }, - ], - }, - { - title: "Fonctionnalités", - collapsed: false, - items: [ - { - title: "Extensions Markdown", - link: "/markdown", - }, - ], - }, - ], - "/reference": [ - { - title: "Référence", - collapsed: false, - items: [], - }, - ], - }, - }, - }, - ], - editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", - themeConfig: { - badges: { - icons: { - npm: ``, - source: ``, - }, - }, - socialLinks: { - github: "https://github.com/kobaltedev/solidbase", - discord: "https://discord.com/invite/solidjs", - }, - nav: [ - { - text: "Guide", - link: "/guide", - }, - { - text: "Reference", - link: "/reference", - }, - ], - sidebar: { - "/guide": createFilesystemSidebar("./src/routes/guide"), - "/reference": createFilesystemSidebar("./src/routes/reference"), - }, - }, - }), - solidStart(solidBase.startConfig()), - nitro({ - preset: "netlify", - prerender: { crawlLinks: true }, - }), - ], + plugins: [ + OGPlugin(), + arraybuffer(), + solidBase.plugin({ + title: "SolidBase", + description: + "Fully featured, fully customisable static site generation for SolidStart", + siteUrl: "https://solidbase.dev", + llms: true, + sitemap: true, + robots: true, + issueAutolink: "https://github.com/kobaltedev/solidbase/issues/:issue", + lang: "en", + markdown: { + expressiveCode: { + languageSwitcher: false, + }, + }, + routes: { + path: "/{version}/{locale}", + version: { + default: "latest", + values: { + latest: { path: "", label: `v${SBVersion}` }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + }, + }, + }, + overrides: [ + { + locale: "fr", + themeConfig: { + nav: [ + { + text: "Guide", + link: "/guide", + }, + { + text: "Référence", + link: "/reference", + }, + ], + sidebar: { + "/guide": [ + { + title: "Aperçu", + collapsed: false, + items: [ + { + title: "Qu'est-ce que SolidBase ?", + link: "/", + }, + ], + }, + { + title: "Fonctionnalités", + collapsed: false, + items: [ + { + title: "Extensions Markdown", + link: "/markdown", + }, + ], + }, + ], + "/reference": [ + { + title: "Référence", + collapsed: false, + items: [], + }, + ], + }, + }, + }, + ], + editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", + themeConfig: { + badges: { + icons: { + npm: ``, + source: ``, + }, + }, + socialLinks: { + github: "https://github.com/kobaltedev/solidbase", + discord: "https://discord.com/invite/solidjs", + }, + nav: [ + { + text: "Guide", + link: "/guide", + }, + { + text: "Reference", + link: "/reference", + }, + ], + sidebar: { + "/guide": createFilesystemSidebar("./src/routes/guide"), + "/reference": createFilesystemSidebar("./src/routes/reference"), + }, + }, + }), + solidStart(solidBase.startConfig()), + nitro({ + preset: "netlify", + prerender: { crawlLinks: true }, + }), + ], }); diff --git a/src/default-theme/components/ProjectSelector.tsx b/src/default-theme/components/ProjectSelector.tsx index 2affc87d..6329b2ce 100644 --- a/src/default-theme/components/ProjectSelector.tsx +++ b/src/default-theme/components/ProjectSelector.tsx @@ -44,9 +44,7 @@ export default function ProjectSelector() { placement="bottom-start" > - - {getOptionLabel(current())} - + {getOptionLabel(current())} diff --git a/src/default-theme/components/VersionSelector.tsx b/src/default-theme/components/VersionSelector.tsx index 2e31bb5e..06c31340 100644 --- a/src/default-theme/components/VersionSelector.tsx +++ b/src/default-theme/components/VersionSelector.tsx @@ -1,4 +1,5 @@ import { solidBaseConfig } from "virtual:solidbase/config"; +import { Popover } from "@kobalte/core/popover"; import { createMemo, createSignal, For, Show } from "solid-js"; import IconExpandUpDownLine from "~icons/ri/expand-up-down-line"; import { useSolidBaseRoute } from "../../client/index.jsx"; @@ -7,7 +8,6 @@ import { type SolidBaseRouteOption, } from "../../config/route-config.js"; import styles from "./VersionSelector.module.css"; -import { Popover } from "@kobalte/core/popover"; const VERSION_AXIS = "version"; @@ -42,10 +42,12 @@ export default function VersionSelector() { sameWidth placement="bottom-start" > - - - {getOptionLabel(current())} - + + {getOptionLabel(current())} 1}> From 84cf3619e75e4f729f1f6ff821d03c3f23fae326 Mon Sep 17 00:00:00 2001 From: jer3m01 Date: Wed, 22 Apr 2026 23:44:26 +0200 Subject: [PATCH 17/19] fix fr --- .../routes/fr/guide/{about.mdx => index.mdx} | 0 docs/src/routes/fr/guide/markdown.mdx | 7 + docs/vite.config.mts | 236 +++++++++--------- 3 files changed, 125 insertions(+), 118 deletions(-) rename docs/src/routes/fr/guide/{about.mdx => index.mdx} (100%) create mode 100644 docs/src/routes/fr/guide/markdown.mdx diff --git a/docs/src/routes/fr/guide/about.mdx b/docs/src/routes/fr/guide/index.mdx similarity index 100% rename from docs/src/routes/fr/guide/about.mdx rename to docs/src/routes/fr/guide/index.mdx diff --git a/docs/src/routes/fr/guide/markdown.mdx b/docs/src/routes/fr/guide/markdown.mdx new file mode 100644 index 00000000..e12cd1aa --- /dev/null +++ b/docs/src/routes/fr/guide/markdown.mdx @@ -0,0 +1,7 @@ +--- +title: Markdown +--- + +# Markdown + +SolidBase inclut plein de fonctionnalités pour votre markdown. diff --git a/docs/vite.config.mts b/docs/vite.config.mts index c5b341f6..e7ae4c8a 100644 --- a/docs/vite.config.mts +++ b/docs/vite.config.mts @@ -9,127 +9,127 @@ import { createFilesystemSidebar } from "../src/config/sidebar"; import defaultTheme from "../src/default-theme"; const theme = defineTheme({ - componentsPath: import.meta.resolve("./src/solidbase-theme"), - extends: defaultTheme, + componentsPath: import.meta.resolve("./src/solidbase-theme"), + extends: defaultTheme, }); const solidBase = createSolidBase(theme); export default defineConfig({ - plugins: [ - OGPlugin(), - arraybuffer(), - solidBase.plugin({ - title: "SolidBase", - description: - "Fully featured, fully customisable static site generation for SolidStart", - siteUrl: "https://solidbase.dev", - llms: true, - sitemap: true, - robots: true, - issueAutolink: "https://github.com/kobaltedev/solidbase/issues/:issue", - lang: "en", - markdown: { - expressiveCode: { - languageSwitcher: false, - }, - }, - routes: { - path: "/{version}/{locale}", - version: { - default: "latest", - values: { - latest: { path: "", label: `v${SBVersion}` }, - }, - }, - locale: { - default: "en", - values: { - en: { path: "", label: "English", lang: "en" }, - fr: { path: "fr", label: "Français", lang: "fr-FR" }, - }, - }, - }, - overrides: [ - { - locale: "fr", - themeConfig: { - nav: [ - { - text: "Guide", - link: "/guide", - }, - { - text: "Référence", - link: "/reference", - }, - ], - sidebar: { - "/guide": [ - { - title: "Aperçu", - collapsed: false, - items: [ - { - title: "Qu'est-ce que SolidBase ?", - link: "/", - }, - ], - }, - { - title: "Fonctionnalités", - collapsed: false, - items: [ - { - title: "Extensions Markdown", - link: "/markdown", - }, - ], - }, - ], - "/reference": [ - { - title: "Référence", - collapsed: false, - items: [], - }, - ], - }, - }, - }, - ], - editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", - themeConfig: { - badges: { - icons: { - npm: ``, - source: ``, - }, - }, - socialLinks: { - github: "https://github.com/kobaltedev/solidbase", - discord: "https://discord.com/invite/solidjs", - }, - nav: [ - { - text: "Guide", - link: "/guide", - }, - { - text: "Reference", - link: "/reference", - }, - ], - sidebar: { - "/guide": createFilesystemSidebar("./src/routes/guide"), - "/reference": createFilesystemSidebar("./src/routes/reference"), - }, - }, - }), - solidStart(solidBase.startConfig()), - nitro({ - preset: "netlify", - prerender: { crawlLinks: true }, - }), - ], + plugins: [ + OGPlugin(), + arraybuffer(), + solidBase.plugin({ + title: "SolidBase", + description: + "Fully featured, fully customisable static site generation for SolidStart", + siteUrl: "https://solidbase.dev", + llms: true, + sitemap: true, + robots: true, + issueAutolink: "https://github.com/kobaltedev/solidbase/issues/:issue", + lang: "en", + markdown: { + expressiveCode: { + languageSwitcher: false, + }, + }, + routes: { + path: "/{version}/{locale}", + version: { + default: "latest", + values: { + latest: { path: "", label: `v${SBVersion}` }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + }, + }, + }, + overrides: [ + { + locale: "fr", + themeConfig: { + nav: [ + { + text: "Guide", + link: "/guide", + }, + { + text: "Référence", + link: "/reference", + }, + ], + sidebar: { + "/guide": [ + { + title: "Aperçu", + collapsed: false, + items: [ + { + title: "Qu'est-ce que SolidBase ?", + link: "/", + }, + ], + }, + { + title: "Fonctionnalités", + collapsed: false, + items: [ + { + title: "Extensions Markdown", + link: "/markdown", + }, + ], + }, + ], + "/reference": [ + { + title: "Référence", + collapsed: false, + items: [], + }, + ], + }, + }, + }, + ], + editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", + themeConfig: { + badges: { + icons: { + npm: ``, + source: ``, + }, + }, + socialLinks: { + github: "https://github.com/kobaltedev/solidbase", + discord: "https://discord.com/invite/solidjs", + }, + nav: [ + { + text: "Guide", + link: "/guide", + }, + { + text: "Reference", + link: "/reference", + }, + ], + sidebar: { + "/guide": createFilesystemSidebar("./src/routes/guide"), + "/reference": createFilesystemSidebar("./src/routes/reference"), + }, + }, + }), + solidStart(solidBase.startConfig()), + nitro({ + preset: "netlify", + prerender: { crawlLinks: true }, + }), + ], }); From d7f325f68603786ffe27f7f0729c06918e6611ef Mon Sep 17 00:00:00 2001 From: jer3m01 Date: Wed, 22 Apr 2026 23:45:59 +0200 Subject: [PATCH 18/19] style: format --- docs/vite.config.mts | 236 +++++++++++++++++++++---------------------- 1 file changed, 118 insertions(+), 118 deletions(-) diff --git a/docs/vite.config.mts b/docs/vite.config.mts index e7ae4c8a..c5b341f6 100644 --- a/docs/vite.config.mts +++ b/docs/vite.config.mts @@ -9,127 +9,127 @@ import { createFilesystemSidebar } from "../src/config/sidebar"; import defaultTheme from "../src/default-theme"; const theme = defineTheme({ - componentsPath: import.meta.resolve("./src/solidbase-theme"), - extends: defaultTheme, + componentsPath: import.meta.resolve("./src/solidbase-theme"), + extends: defaultTheme, }); const solidBase = createSolidBase(theme); export default defineConfig({ - plugins: [ - OGPlugin(), - arraybuffer(), - solidBase.plugin({ - title: "SolidBase", - description: - "Fully featured, fully customisable static site generation for SolidStart", - siteUrl: "https://solidbase.dev", - llms: true, - sitemap: true, - robots: true, - issueAutolink: "https://github.com/kobaltedev/solidbase/issues/:issue", - lang: "en", - markdown: { - expressiveCode: { - languageSwitcher: false, - }, - }, - routes: { - path: "/{version}/{locale}", - version: { - default: "latest", - values: { - latest: { path: "", label: `v${SBVersion}` }, - }, - }, - locale: { - default: "en", - values: { - en: { path: "", label: "English", lang: "en" }, - fr: { path: "fr", label: "Français", lang: "fr-FR" }, - }, - }, - }, - overrides: [ - { - locale: "fr", - themeConfig: { - nav: [ - { - text: "Guide", - link: "/guide", - }, - { - text: "Référence", - link: "/reference", - }, - ], - sidebar: { - "/guide": [ - { - title: "Aperçu", - collapsed: false, - items: [ - { - title: "Qu'est-ce que SolidBase ?", - link: "/", - }, - ], - }, - { - title: "Fonctionnalités", - collapsed: false, - items: [ - { - title: "Extensions Markdown", - link: "/markdown", - }, - ], - }, - ], - "/reference": [ - { - title: "Référence", - collapsed: false, - items: [], - }, - ], - }, - }, - }, - ], - editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", - themeConfig: { - badges: { - icons: { - npm: ``, - source: ``, - }, - }, - socialLinks: { - github: "https://github.com/kobaltedev/solidbase", - discord: "https://discord.com/invite/solidjs", - }, - nav: [ - { - text: "Guide", - link: "/guide", - }, - { - text: "Reference", - link: "/reference", - }, - ], - sidebar: { - "/guide": createFilesystemSidebar("./src/routes/guide"), - "/reference": createFilesystemSidebar("./src/routes/reference"), - }, - }, - }), - solidStart(solidBase.startConfig()), - nitro({ - preset: "netlify", - prerender: { crawlLinks: true }, - }), - ], + plugins: [ + OGPlugin(), + arraybuffer(), + solidBase.plugin({ + title: "SolidBase", + description: + "Fully featured, fully customisable static site generation for SolidStart", + siteUrl: "https://solidbase.dev", + llms: true, + sitemap: true, + robots: true, + issueAutolink: "https://github.com/kobaltedev/solidbase/issues/:issue", + lang: "en", + markdown: { + expressiveCode: { + languageSwitcher: false, + }, + }, + routes: { + path: "/{version}/{locale}", + version: { + default: "latest", + values: { + latest: { path: "", label: `v${SBVersion}` }, + }, + }, + locale: { + default: "en", + values: { + en: { path: "", label: "English", lang: "en" }, + fr: { path: "fr", label: "Français", lang: "fr-FR" }, + }, + }, + }, + overrides: [ + { + locale: "fr", + themeConfig: { + nav: [ + { + text: "Guide", + link: "/guide", + }, + { + text: "Référence", + link: "/reference", + }, + ], + sidebar: { + "/guide": [ + { + title: "Aperçu", + collapsed: false, + items: [ + { + title: "Qu'est-ce que SolidBase ?", + link: "/", + }, + ], + }, + { + title: "Fonctionnalités", + collapsed: false, + items: [ + { + title: "Extensions Markdown", + link: "/markdown", + }, + ], + }, + ], + "/reference": [ + { + title: "Référence", + collapsed: false, + items: [], + }, + ], + }, + }, + }, + ], + editPath: "https://github.com/kobaltedev/solidbase/edit/main/docs/:path", + themeConfig: { + badges: { + icons: { + npm: ``, + source: ``, + }, + }, + socialLinks: { + github: "https://github.com/kobaltedev/solidbase", + discord: "https://discord.com/invite/solidjs", + }, + nav: [ + { + text: "Guide", + link: "/guide", + }, + { + text: "Reference", + link: "/reference", + }, + ], + sidebar: { + "/guide": createFilesystemSidebar("./src/routes/guide"), + "/reference": createFilesystemSidebar("./src/routes/reference"), + }, + }, + }), + solidStart(solidBase.startConfig()), + nitro({ + preset: "netlify", + prerender: { crawlLinks: true }, + }), + ], }); From 875d9fc03bdaf72b13790de3c95bd17148031139 Mon Sep 17 00:00:00 2001 From: jer3m01 Date: Wed, 22 Apr 2026 23:55:01 +0200 Subject: [PATCH 19/19] correct next ver --- .../reference/default-theme/components/locale-selector.mdx | 2 +- .../reference/default-theme/components/project-selector.mdx | 2 +- docs/src/routes/reference/default-theme/components/sidebar.mdx | 3 +-- .../reference/default-theme/components/version-selector.mdx | 2 +- package.json | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/src/routes/reference/default-theme/components/locale-selector.mdx b/docs/src/routes/reference/default-theme/components/locale-selector.mdx index f7409df3..22833f9b 100644 --- a/docs/src/routes/reference/default-theme/components/locale-selector.mdx +++ b/docs/src/routes/reference/default-theme/components/locale-selector.mdx @@ -2,7 +2,7 @@ title: Locale Selector badges: - icon: npm - label: since 0.0.9 + label: updated 0.5.0 - icon: source label: Source url: https://github.com/kobaltedev/solidbase/blob/main/src/default-theme/components/LocaleSelector.tsx diff --git a/docs/src/routes/reference/default-theme/components/project-selector.mdx b/docs/src/routes/reference/default-theme/components/project-selector.mdx index 5df43dcc..c0eec611 100644 --- a/docs/src/routes/reference/default-theme/components/project-selector.mdx +++ b/docs/src/routes/reference/default-theme/components/project-selector.mdx @@ -2,7 +2,7 @@ title: Project Selector badges: - icon: npm - label: since 0.4.4 + label: since 0.5.0 - icon: source label: Source url: https://github.com/kobaltedev/solidbase/blob/main/src/default-theme/components/ProjectSelector.tsx diff --git a/docs/src/routes/reference/default-theme/components/sidebar.mdx b/docs/src/routes/reference/default-theme/components/sidebar.mdx index cd5ae459..d49d055f 100644 --- a/docs/src/routes/reference/default-theme/components/sidebar.mdx +++ b/docs/src/routes/reference/default-theme/components/sidebar.mdx @@ -12,8 +12,7 @@ badges: The sidebar for the default theme is configured in the app config: -```ts - +```ts title="app.config.ts" import { defineConfig } from "@solidjs/start/config"; import { createSolidBase } from "@kobalte/solidbase"; diff --git a/docs/src/routes/reference/default-theme/components/version-selector.mdx b/docs/src/routes/reference/default-theme/components/version-selector.mdx index 38b4655e..ba8e4010 100644 --- a/docs/src/routes/reference/default-theme/components/version-selector.mdx +++ b/docs/src/routes/reference/default-theme/components/version-selector.mdx @@ -2,7 +2,7 @@ title: Version Selector badges: - icon: npm - label: since 0.4.4 + label: since 0.5.0 - icon: source label: Source url: https://github.com/kobaltedev/solidbase/blob/main/src/default-theme/components/VersionSelector.tsx diff --git a/package.json b/package.json index e0e2a71c..03024d15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kobalte/solidbase", - "version": "0.4.4", + "version": "0.5.0", "description": "Fully featured, fully customisable static site generation for SolidStart", "type": "module", "sideEffects": true,