diff --git a/dev/vite.config.mts b/dev/vite.config.mts index c202805..e269e2b 100644 --- a/dev/vite.config.mts +++ b/dev/vite.config.mts @@ -112,6 +112,12 @@ export default defineConfig({ }, { version: "v1", + route: { + version: { + v1: { label: "v1 (legacy)" }, + }, + }, + title: "SolidBase v1 Demo", themeConfig: { sidebar: { diff --git a/docs/src/routes/guide/(2)config.mdx b/docs/src/routes/guide/(2)config.mdx index 55a7349..cc41c96 100644 --- a/docs/src/routes/guide/(2)config.mdx +++ b/docs/src/routes/guide/(2)config.mdx @@ -170,12 +170,17 @@ overrides: [ { project: "solidbase", version: "v1", + route: { + version: { + v1: { label: "v1 (legacy)" }, + }, + }, }, ], // .. ``` -Route override selectors use route axis names. Override config is applied over the base config, and `themeConfig` is shallow-merged with the base `themeConfig`. +Route override selectors use route axis names. Override config is applied over the base config, and `themeConfig` is shallow-merged with the base `themeConfig`. Use the `route` key to patch route axis value metadata for a matching route — for example, renaming a version's `label` only when browsing that version's routes. Route value patches cannot change `path`, `href`, or `lang`; those are fixed by the `routes` config. #### Miscellaneous options 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 ba8e401..a684c85 100644 --- a/docs/src/routes/reference/default-theme/components/version-selector.mdx +++ b/docs/src/routes/reference/default-theme/components/version-selector.mdx @@ -21,6 +21,7 @@ Version switcher for sites that configure `routes.version`. - omitted when fewer than two version options are available - uses Kobalte `Popover` +- applies `route` override patches from the resolved config to version labels - 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 af228d3..464ae17 100644 --- a/docs/src/routes/reference/runtime-api.mdx +++ b/docs/src/routes/reference/runtime-api.mdx @@ -151,10 +151,16 @@ function useSolidBaseRoutes(): { axis: string, selection?: Partial, ): SolidBaseRouteOption[]; + routeOverride: Accessor; }; type SolidBaseRouteSelection = Record; +type SolidBaseRouteValueOverride = Record< + string, + Record +>; + type SolidBaseRouteOption = { name: string; axis: string; @@ -166,7 +172,7 @@ type SolidBaseRouteOption = { }; ``` -`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`. +`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`. `routeOverride()` returns the merged `route` override patches for the current selection, applied to the `meta` of options. ### `useSolidBaseRoute` diff --git a/src/client/config.ts b/src/client/config.ts index 0b9e1a3..5774cde 100644 --- a/src/client/config.ts +++ b/src/client/config.ts @@ -2,7 +2,10 @@ 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 { + resolveSolidBaseRouteConfig, + resolveSolidBaseRouteValueOverrides, +} from "../config/route-config.js"; import { useLocale } from "./locale.js"; import { useSolidBaseRoute } from "./routes.js"; @@ -18,10 +21,16 @@ export function useRouteSolidBaseConfig(): Accessor< currentRoute(), ); const localeConfig = currentLocale().config.themeConfig ?? {}; + const routeOverride = resolveSolidBaseRouteValueOverrides( + solidBaseConfig.routes, + solidBaseConfig.overrides ?? [], + currentRoute(), + ); return { ...routeConfig, themeConfig: { ...routeConfig.themeConfig, ...localeConfig }, + routeOverride, }; }); } diff --git a/src/client/locale.ts b/src/client/locale.ts index dbc85e1..c2aa3dd 100644 --- a/src/client/locale.ts +++ b/src/client/locale.ts @@ -11,6 +11,7 @@ import { getSolidBaseRouteOptions, getSolidBaseRoutePathWithRest, getSolidBaseRouteSelectionForPath, + resolveSolidBaseRouteValueOverrides, type SolidBaseRouteOption, } from "../config/route-config.js"; import { useSolidBaseRoutes } from "./routes.js"; @@ -113,10 +114,22 @@ function getRouteLocaleForPath(path: string) { 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); + const option = getSolidBaseRouteOptions( + solidBaseConfig.routes, + LOCALE_AXIS, + { + ...selection, + [LOCALE_AXIS]: value, + }, + resolveSolidBaseRouteValueOverrides( + solidBaseConfig.routes, + solidBaseConfig.overrides ?? [], + { + ...selection, + [LOCALE_AXIS]: value, + }, + ), + ).find((option) => option.name === value); return option ? routeOptionToLocale(option) : undefined; } diff --git a/src/client/routes.ts b/src/client/routes.ts index bffada1..c200ec1 100644 --- a/src/client/routes.ts +++ b/src/client/routes.ts @@ -8,6 +8,7 @@ import { getSolidBaseRouteOptions, getSolidBaseRouteSelectionForPath, normalizeSolidBaseRouteSelection, + resolveSolidBaseRouteValueOverrides, type SolidBaseRouteOption, type SolidBaseRouteSelection, } from "../config/route-config.js"; @@ -24,6 +25,13 @@ const [SolidBaseRoutesContextProvider, useSolidBaseRoutesContext] = normalizeSolidBaseRouteSelection(solidBaseConfig.routes) ?? {}, ); + const routeOverride = createMemo(() => + resolveSolidBaseRouteValueOverrides( + solidBaseConfig.routes, + solidBaseConfig.overrides ?? [], + current(), + ), + ); return { routes: solidBaseConfig.routes, @@ -38,7 +46,9 @@ const [SolidBaseRoutesContextProvider, useSolidBaseRoutesContext] = solidBaseConfig.routes, axis, selection ?? current(), + routeOverride(), ), + routeOverride, }; }); diff --git a/src/config/index.ts b/src/config/index.ts index 48635f5..89a42d1 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -7,11 +7,19 @@ 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 { SolidBaseRoutesConfig } from "./route-config.js"; +import type { + SolidBaseRoutesConfig, + SolidBaseRouteValueOverride, + SolidBaseRouteValueOverrideConfig, +} from "./route-config.js"; import { validateSolidBaseRoutesConfig as validateRoutes } from "./route-config.js"; import solidBaseVitePlugin from "./vite-plugin/index.js"; -export type { SolidBaseRouteOption } from "./route-config.js"; +export type { + SolidBaseRouteOption, + SolidBaseRouteValueOverride, + SolidBaseRouteValueOverrideConfig, +} from "./route-config.js"; export { getSolidBaseRouteFallbackOptions } from "./route-config.js"; const SOLID_BASE_OVERRIDE_CONFIG_KEYS = [ @@ -26,6 +34,7 @@ const SOLID_BASE_OVERRIDE_CONFIG_KEYS = [ "issueAutolink", "lang", "locales", + "route", "themeConfig", "editPath", "lastUpdated", @@ -73,7 +82,9 @@ export type SolidBaseResolvedConfig = Omit< SolidBaseConfig, ResolvedConfigKeys > & - Required, ResolvedConfigKeys>>; + Required, ResolvedConfigKeys>> & { + routeOverride?: SolidBaseRouteValueOverride; + }; export type LocaleConfig = { label: string; @@ -84,8 +95,12 @@ export type LocaleConfig = { export type SolidBaseRouteOverride = Partial< Omit, "routes" | "overrides"> -> & - Record; +> & { + route?: Record< + string, + Record + >; +} & Record; export type SitemapConfig = { hostname?: string; diff --git a/src/config/route-config.ts b/src/config/route-config.ts index 60d48de..72cd719 100644 --- a/src/config/route-config.ts +++ b/src/config/route-config.ts @@ -6,8 +6,6 @@ export type SolidBaseRouteValueConfig = { path?: string; href?: string; label?: string; - title?: string; - status?: string; lang?: string; } & Record; @@ -41,6 +39,17 @@ export type SolidBaseRoutePathMatch = { restPath: `/${string}`; }; +export type SolidBaseRouteValueOverrideConfig = { + label?: string; + title?: string; + status?: string; +} & Record; + +export type SolidBaseRouteValueOverride = Record< + string, + Record +>; + type RouteConfigValue = Record; type RouteTemplateSegment = @@ -48,6 +57,8 @@ type RouteTemplateSegment = | { type: "axis"; name: string }; const ROUTES_RESERVED_KEYS = new Set(["path", "include"]); +const ROUTE_OVERRIDE_KEY = "route"; +const ROUTE_OVERRIDE_RESERVED_KEYS = new Set(["path", "href", "lang"]); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -284,6 +295,65 @@ export function validateSolidBaseRoutesConfig( continue; } + if (key === ROUTE_OVERRIDE_KEY) { + assertSolidBaseRouteConfig( + isRecord(value), + "`overrides.route` must be an object of route axis value overrides.", + ); + + for (const [axisName, values] of Object.entries(value)) { + const axis = axisMap.get(axisName); + assertSolidBaseRouteConfig( + axis, + "`overrides.route." + + axisName + + "` references unknown route axis `" + + axisName + + "`.", + ); + assertSolidBaseRouteConfig( + isRecord(values), + "`overrides.route." + + axisName + + "` must be an object of `" + + axisName + + "` value overrides.", + ); + + for (const valueName of Object.keys(values)) { + assertSolidBaseRouteConfig( + Object.hasOwn(axis.values, valueName), + "`overrides.route." + + axisName + + "` references unknown `" + + axisName + + "` value `" + + valueName + + "`.", + ); + } + + for (const [valueName, patch] of Object.entries(values)) { + if (!isRecord(patch)) continue; + + for (const key of Object.keys(patch)) { + assertSolidBaseRouteConfig( + !ROUTE_OVERRIDE_RESERVED_KEYS.has(key), + "`overrides.route." + + axisName + + "." + + valueName + + "." + + key + + "` cannot be overridden.", + ); + } + } + } + + continue; + } + assertSolidBaseRouteConfig( configKeys.has(key), `\`overrides\` contains unknown config key or route axis \`${key}\`.`, @@ -489,6 +559,7 @@ export function getSolidBaseRouteOptions( routes: SolidBaseRoutesConfig | undefined, axisName: string, current: Partial = {}, + routeOverride: SolidBaseRouteValueOverride = {}, ): SolidBaseRouteOption[] { if (!routes) return []; @@ -497,15 +568,21 @@ export function getSolidBaseRouteOptions( const normalized = normalizeSolidBaseRouteSelection(routes, current) ?? {}; const options: SolidBaseRouteOption[] = []; + const axisOverride = routeOverride[axisName]; for (const [valueName, value] of Object.entries(axis.values)) { + const meta = { + ...value, + ...axisOverride?.[valueName], + }; + if (value.href) { options.push({ name: valueName, axis: axisName, href: value.href, isExternal: true, - meta: value, + meta, }); continue; } @@ -523,7 +600,7 @@ export function getSolidBaseRouteOptions( path: buildSolidBaseRoutePath(routes, selection), isExternal: false, selection, - meta: value, + meta, }); } @@ -534,6 +611,7 @@ export function getSolidBaseRouteFallbackOptions( routes: SolidBaseRoutesConfig | undefined, axisName: string, current: Partial = {}, + routeOverride: SolidBaseRouteValueOverride = {}, ): SolidBaseRouteOption[] { if (!routes) return []; @@ -544,15 +622,21 @@ export function getSolidBaseRouteFallbackOptions( const axisIndex = axisNames.indexOf(axisName); const lockedAxes = axisIndex > 0 ? axisNames.slice(0, axisIndex) : []; const options: SolidBaseRouteOption[] = []; + const axisOverride = routeOverride[axisName]; for (const [valueName, value] of Object.entries(axis.values)) { + const meta = { + ...value, + ...axisOverride?.[valueName], + }; + if (value.href) { options.push({ name: valueName, axis: axisName, href: value.href, isExternal: true, - meta: value, + meta, }); continue; } @@ -573,16 +657,32 @@ export function getSolidBaseRouteFallbackOptions( path: buildSolidBaseRoutePath(routes, selection), isExternal: false, selection, - meta: value, + meta, }); } return options; } +function toRouteValuePatch(value: unknown): SolidBaseRouteValueOverrideConfig { + if (typeof value === "string") return { label: value }; + if (isRecord(value)) { + const patch: SolidBaseRouteValueOverrideConfig = {}; + + for (const [key, item] of Object.entries(value)) { + if (ROUTE_OVERRIDE_RESERVED_KEYS.has(key)) continue; + patch[key] = item; + } + + return patch; + } + return {}; +} + function splitRouteOverride(override: RouteConfigValue, axisNames: string[]) { const selectors: SolidBaseRouteRule = {}; const config: RouteConfigValue = {}; + const routeOverrides: SolidBaseRouteValueOverride = {}; for (const [key, value] of Object.entries(override)) { if (axisNames.includes(key)) { @@ -592,10 +692,25 @@ function splitRouteOverride(override: RouteConfigValue, axisNames: string[]) { continue; } - if (key !== "routes" && key !== "overrides") config[key] = value; + if (key === "routes" || key === "overrides") continue; + + if (key === ROUTE_OVERRIDE_KEY) { + if (isRecord(value)) { + for (const [axisName, values] of Object.entries(value)) { + if (!isRecord(values)) continue; + const axisOverrides = (routeOverrides[axisName] ??= {}); + for (const [valueName, patch] of Object.entries(values)) { + axisOverrides[valueName] = toRouteValuePatch(patch); + } + } + } + continue; + } + + config[key] = value; } - return { selectors, config }; + return { selectors, config, routeOverrides }; } function mergeRouteConfig( @@ -619,6 +734,41 @@ function mergeRouteConfig( return result as T; } +export function resolveSolidBaseRouteValueOverrides( + routes: SolidBaseRoutesConfig | undefined, + overrides: RouteConfigValue[], + selection: Partial, +): SolidBaseRouteValueOverride { + const result: SolidBaseRouteValueOverride = {}; + if (!routes) return result; + + const normalized = normalizeSolidBaseRouteSelection(routes, selection); + if (!normalized) return result; + + const axisNames = getSolidBaseRouteAxisNames(routes); + + for (const override of overrides) { + const { selectors, routeOverrides } = splitRouteOverride( + override, + axisNames, + ); + + if (!matchesSolidBaseRouteRule(normalized, selectors)) continue; + + for (const [axisName, values] of Object.entries(routeOverrides)) { + const axisResult = (result[axisName] ??= {}); + for (const [valueName, patch] of Object.entries(values)) { + axisResult[valueName] = { + ...axisResult[valueName], + ...patch, + }; + } + } + } + + return result; +} + export function resolveSolidBaseRouteConfig( baseConfig: T & { overrides?: RouteConfigValue[]; diff --git a/src/default-theme/components/ProjectSelector.tsx b/src/default-theme/components/ProjectSelector.tsx index 6329b2c..82bc892 100644 --- a/src/default-theme/components/ProjectSelector.tsx +++ b/src/default-theme/components/ProjectSelector.tsx @@ -1,4 +1,3 @@ -import { solidBaseConfig } from "virtual:solidbase/config"; import { Popover } from "@kobalte/core/popover"; import { createMemo, createSignal, For, Show } from "solid-js"; @@ -8,6 +7,7 @@ import { getSolidBaseRouteFallbackOptions, type SolidBaseRouteOption, } from "../../config/route-config.js"; +import { useRouteConfig } from "../utils.js"; import styles from "./ProjectSelector.module.css"; const PROJECT_AXIS = "project"; @@ -16,11 +16,13 @@ export default function ProjectSelector() { const [open, setOpen] = createSignal(false); const current = useSolidBaseRoute(); + const config = useRouteConfig(); const options = createMemo(() => getSolidBaseRouteFallbackOptions( - solidBaseConfig.routes, + config().routes, PROJECT_AXIS, current(), + config().routeOverride, ), ); const currentOption = createMemo(() => diff --git a/src/default-theme/components/VersionSelector.tsx b/src/default-theme/components/VersionSelector.tsx index f4a70ff..3d65dac 100644 --- a/src/default-theme/components/VersionSelector.tsx +++ b/src/default-theme/components/VersionSelector.tsx @@ -18,7 +18,12 @@ export default function VersionSelector() { const current = useSolidBaseRoute(); const options = createMemo(() => - getSolidBaseRouteFallbackOptions(config().routes, VERSION_AXIS, current()), + getSolidBaseRouteFallbackOptions( + config().routes, + VERSION_AXIS, + current(), + config().routeOverride, + ), ); const currentOption = createMemo(() => options().find((option) => option.name === current()[VERSION_AXIS]), diff --git a/tests/config/index.test.ts b/tests/config/index.test.ts index b792d1a..6b2292a 100644 --- a/tests/config/index.test.ts +++ b/tests/config/index.test.ts @@ -141,7 +141,11 @@ describe("createSolidBase", () => { solidBase.plugin({ routes: validRoutes, overrides: [ - { version: "v1", title: "v1" }, + { + version: "v1", + route: { version: { v1: { label: "v1 (legacy)" } } }, + title: "v1", + }, { locale: ["en", "fr"], themeConfig: { nav: [] } }, ], }), diff --git a/tests/config/route-config.test.ts b/tests/config/route-config.test.ts index 6467a0e..397d2db 100644 --- a/tests/config/route-config.test.ts +++ b/tests/config/route-config.test.ts @@ -8,7 +8,9 @@ import { getSolidBaseRouteSelectionForPath, isSolidBaseRouteIncluded, resolveSolidBaseRouteConfig, + resolveSolidBaseRouteValueOverrides, type SolidBaseRoutesConfig, + validateSolidBaseRoutesConfig, } from "../../src/config/route-config.ts"; const routes = { @@ -227,6 +229,11 @@ describe("route config helpers", () => { project: "solid", version: "v1", locale: "fr", + route: { + version: { + v1: { label: "v1 (legacy)" }, + }, + }, title: "Solid v1 FR", }, ], @@ -244,4 +251,221 @@ describe("route config helpers", () => { }); expect("overrides" in config).toBe(false); }); + + it("resolves matching route value overrides in order", () => { + const overrides = [ + { + project: "solid", + route: { + version: { v1: { label: "v1", status: "Archived" } }, + }, + }, + { + locale: "fr", + route: { + version: { + v1: "v1 (legacy)", + latest: "Dernière", + }, + }, + }, + ]; + + expect( + resolveSolidBaseRouteValueOverrides(routes, overrides, { + project: "solid", + version: "v1", + locale: "fr", + }), + ).toEqual({ + version: { + v1: { label: "v1 (legacy)", status: "Archived" }, + latest: { label: "Dernière" }, + }, + }); + + expect( + resolveSolidBaseRouteValueOverrides(routes, overrides, { + project: "router", + version: "v1", + locale: "fr", + }), + ).toEqual({ + version: { + v1: { label: "v1 (legacy)" }, + latest: { label: "Dernière" }, + }, + }); + + expect( + resolveSolidBaseRouteValueOverrides(routes, overrides, { + project: "solid", + version: "v1", + locale: "en", + }), + ).toEqual({ + version: { + v1: { label: "v1", status: "Archived" }, + }, + }); + }); + + it("applies route value overrides to option metadata", () => { + const routeOverride = resolveSolidBaseRouteValueOverrides( + routes, + [ + { + version: "v1", + route: { + version: { + v1: { label: "v1 (legacy)" }, + v0: { label: "v0 (legacy)" }, + }, + project: { router: { label: "Router v1" } }, + }, + }, + ], + { project: "solid", version: "v1", locale: "fr" }, + ); + + expect( + getSolidBaseRouteFallbackOptions( + routes, + "version", + { + project: "solid", + version: "v1", + locale: "fr", + }, + routeOverride, + ), + ).toMatchObject([ + { name: "latest", meta: { label: "Latest" } }, + { name: "v1", meta: { label: "v1 (legacy)", status: "Legacy" } }, + { + name: "v0", + href: "https://v0.solidjs.com", + meta: { label: "v0 (legacy)" }, + }, + ]); + + expect( + getSolidBaseRouteFallbackOptions( + routes, + "project", + { + project: "solid", + version: "v1", + locale: "fr", + }, + routeOverride, + ), + ).toMatchObject([ + { name: "solid", meta: { label: "Solid" } }, + { name: "router", meta: { label: "Router v1" } }, + { name: "start", meta: { label: "SolidStart" } }, + ]); + + expect( + getSolidBaseRouteOptions( + routes, + "locale", + { + project: "solid", + version: "v1", + locale: "fr", + }, + routeOverride, + ).map((option) => option.meta.label), + ).toEqual(["English", "Français", "Español"]); + }); + + it("validates route override selectors and value overrides", () => { + expect(() => + validateSolidBaseRoutesConfig( + routes, + [{ version: "v1", route: { project: { nope: { label: "x" } } } }], + [], + ), + ).toThrow("unknown `project` value `nope`"); + + expect(() => + validateSolidBaseRoutesConfig( + routes, + [{ version: "v1", route: { nope: { v1: { label: "x" } } } }], + [], + ), + ).toThrow("unknown route axis `nope`"); + + expect(() => + validateSolidBaseRoutesConfig( + routes, + [{ version: "v1", route: "nope" }], + [], + ), + ).toThrow("`overrides.route` must be an object"); + + expect(() => + validateSolidBaseRoutesConfig( + routes, + [{ version: "v1", route: { version: { v1: "v1 (legacy)" } } }], + [], + ), + ).not.toThrow(); + }); + + it("rejects route value overrides for path, href and lang", () => { + for (const key of ["path", "href", "lang"]) { + expect(() => + validateSolidBaseRoutesConfig( + routes, + [ + { + version: "v1", + route: { + version: { v1: { label: "x", [key]: "nope" } }, + }, + }, + ], + [], + ), + ).toThrow(`overrides.route.version.v1.${key}\` cannot be overridden`); + } + + expect(() => + validateSolidBaseRoutesConfig( + routes, + [ + { + version: "v1", + route: { version: { v1: { label: "ok", status: "Legacy" } } }, + }, + ], + [], + ), + ).not.toThrow(); + }); + + it("strips path, href and lang from resolved route value overrides", () => { + expect( + resolveSolidBaseRouteValueOverrides( + routes, + [ + { + version: "v1", + route: { + version: { + v1: { label: "v1 (legacy)", path: "x", href: "y", lang: "z" }, + }, + }, + }, + ], + { project: "solid", version: "v1", locale: "fr" }, + ), + ).toEqual({ + version: { + v1: { label: "v1 (legacy)" }, + }, + }); + }); });