diff --git a/README.md b/README.md index ed5cbe7..515fbd5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -gt pul
SolidBase
diff --git a/docs/src/routes/guide/(2)config.mdx b/docs/src/routes/guide/(2)config.mdx index db697e2..43b0a6c 100644 --- a/docs/src/routes/guide/(2)config.mdx +++ b/docs/src/routes/guide/(2)config.mdx @@ -76,7 +76,7 @@ There are several options for setting site-wide metadata and behavior. These opt // .. { title: "My Documentation Site", - titleTemplate: "%s - MySite", + titleTemplate: ":title - MySite", description: "A comprehensive guide to MySite.", siteUrl: "https://docs.example.com", logo: "/logo.png", @@ -86,6 +86,10 @@ 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. +SolidBase resolves `title`, `titleTemplate`, and `description` from the active route config. Page frontmatter overrides those values. Client-side navigation updates the document title, description, Open Graph metadata, and Twitter metadata for the new route. + +When `siteUrl` is set, SolidBase also emits a canonical link and `og:url`. The URL uses the route pathname without its query string or hash. Without `siteUrl`, these URL tags are omitted. + :::note For multilingual support, use the `routes.locale` option. More details can be found in the [Internationalization guide](/guide/features/i18n). ::: diff --git a/docs/src/routes/reference/frontmatter.mdx b/docs/src/routes/reference/frontmatter.mdx index 7dc9dcd..d529991 100644 --- a/docs/src/routes/reference/frontmatter.mdx +++ b/docs/src/routes/reference/frontmatter.mdx @@ -38,6 +38,20 @@ The title is formatted from the config: } ``` +Set `titleTemplate` in page frontmatter to override the active route config for one page. Use `:title` where the page title must appear. + +## Description + +Type: `string` + +Overrides the active route description for the document description, Open Graph description, and Twitter description. + +```md +--- +description: Install and configure SolidBase. +--- +``` + ## Layout Type: `"home" | undefined` diff --git a/docs/src/solidbase-theme/Layout.tsx b/docs/src/solidbase-theme/Layout.tsx index 081b840..7bc791f 100644 --- a/docs/src/solidbase-theme/Layout.tsx +++ b/docs/src/solidbase-theme/Layout.tsx @@ -1,9 +1,7 @@ import { useLocale, useSolidBaseContext } from "@kobalte/solidbase/client"; import { DefaultThemeComponentsProvider } from "@kobalte/solidbase/default-theme/context.jsx"; -import { useDefaultThemeFrontmatter } from "@kobalte/solidbase/default-theme/frontmatter.js"; import Layout from "@kobalte/solidbase/default-theme/Layout.jsx"; import { Meta } from "@solidjs/meta"; -import { useLocation } from "@solidjs/router"; import type { ComponentProps } from "solid-js"; // import { OGImage } from "./og-image"; // re enable after start 2 vite 8 release @@ -20,34 +18,13 @@ export default function (props: ComponentProps) { } function OpenGraph() { - const location = useLocation(); - const solidBaseCtx = useSolidBaseContext(); const locale = useLocale(); - const frontmatter = useDefaultThemeFrontmatter(); return ( <> - - - - - {/**/} ); diff --git a/src/client/Root.tsx b/src/client/Root.tsx index 831ceab..c4b03fc 100644 --- a/src/client/Root.tsx +++ b/src/client/Root.tsx @@ -5,10 +5,21 @@ import { Layout, mdxComponents } from "virtual:solidbase/components"; // as different modules, resulting in this file getting its own MDXContext (id `file://.../mdx.js), // and the MDX files sharing another (id `@kobalte/solidbase/mdx`). import { MDXProvider } from "virtual:solidbase/mdx"; -import { Meta, MetaProvider, Title } from "@solidjs/meta"; -import { createMemo, onMount, type ParentProps, Suspense } from "solid-js"; +import { Link, Meta, MetaProvider, Title } from "@solidjs/meta"; +import { useLocation } from "@solidjs/router"; +import { + createMemo, + onMount, + type ParentProps, + Show, + Suspense, +} from "solid-js"; import { useRouteSolidBaseConfig } from "./config.js"; import { SolidBaseContext } from "./context.jsx"; +import { + resolveDocumentMetadata, + resolveHeadMetadata, +} from "./document-metadata.js"; export function SolidBaseRoot( props: ParentProps & { @@ -56,26 +67,45 @@ import { SolidBaseRoutesContextProvider } from "./routes.js"; export function Inner(props: ParentProps) { const config = useRouteSolidBaseConfig(); + const location = useLocation(); const pageData = useCurrentPageData(); - - const metaTitle = createMemo(() => { - const titleTemplate = - pageData()?.frontmatter.titleTemplate ?? config().titleTemplate; - - const title = pageData()?.frontmatter?.title ?? config().title; - - if (titleTemplate?.includes(":title")) - return titleTemplate.replace(":title", title); - return `${title} - ${titleTemplate ?? config().title}`; - }); - - const description = () => - pageData()?.frontmatter?.description ?? config().description; + // @solidjs/meta cannot retract tags from an incomplete SSR pass. + const metadata = createMemo(() => + resolveHeadMetadata(config(), pageData(), location), + ); + const metaTitle = () => + metadata()?.title ?? resolveDocumentMetadata(config()).title; return ( - {metaTitle()} - {description() && } + + {(head) => ( + <> + {head.title} + + {(description) => ( + <> + + + + + )} + + + + + {(url) => ( + <> + + + + )} + + + + + )} + {props.children} ); diff --git a/src/client/document-metadata.ts b/src/client/document-metadata.ts new file mode 100644 index 0000000..635e3f0 --- /dev/null +++ b/src/client/document-metadata.ts @@ -0,0 +1,49 @@ +import type { Location } from "@solidjs/router"; + +import type { BaseFrontmatter } from "./page-data.js"; + +type MetadataConfig = { + description?: string; + siteUrl?: string; + title: string; + titleTemplate?: string; +}; + +type MetadataPageData = { + frontmatter: BaseFrontmatter; +}; + +export function resolveDocumentMetadata( + config: MetadataConfig, + frontmatter?: BaseFrontmatter, +) { + const titleTemplate = frontmatter?.titleTemplate ?? config.titleTemplate; + const title = frontmatter?.title ?? config.title; + + return { + description: frontmatter?.description ?? config.description, + title: titleTemplate?.includes(":title") + ? titleTemplate.replace(":title", title) + : `${title} - ${titleTemplate ?? config.title}`, + }; +} + +export function resolveCanonicalUrl( + siteUrl: string | undefined, + location: Pick, +) { + return siteUrl ? new URL(location.pathname, siteUrl).toString() : undefined; +} + +export function resolveHeadMetadata( + config: MetadataConfig, + pageData: MetadataPageData | undefined, + location: Pick, +) { + if (!pageData) return; + + return { + ...resolveDocumentMetadata(config, pageData.frontmatter), + canonicalUrl: resolveCanonicalUrl(config.siteUrl, location), + }; +} diff --git a/tests/client/config.test.ts b/tests/client/config.test.ts index db71014..55b6217 100644 --- a/tests/client/config.test.ts +++ b/tests/client/config.test.ts @@ -94,6 +94,7 @@ describe("route config helper", () => { pathname.mockReturnValue("/v1/fr"); setSolidBaseConfig({ title: "Docs", + description: "Documentation", routes: { path: "/{version}/{locale}", version: { @@ -119,6 +120,7 @@ describe("route config helper", () => { { version: "v1", title: "Docs v1", + description: "Legacy documentation", themeConfig: { sidebar: { "/v1": [] }, }, @@ -132,6 +134,9 @@ describe("route config helper", () => { const { SolidBaseRoutesContextProvider } = await import( "../../src/client/routes.ts" ); + const { resolveDocumentMetadata } = await import( + "../../src/client/document-metadata.ts" + ); createRoot((dispose) => { let config: ReturnType> | undefined; @@ -145,11 +150,16 @@ describe("route config helper", () => { expect(config?.()).toMatchObject({ title: "Docs v1", + description: "Legacy documentation", themeConfig: { nav: { title: "Localized" }, sidebar: { "/v1": [] }, }, }); + expect(resolveDocumentMetadata(config!(), { title: "Install" })).toEqual({ + description: "Legacy documentation", + title: "Install - Docs v1", + }); dispose(); }); }); diff --git a/tests/client/document-metadata.test.ts b/tests/client/document-metadata.test.ts new file mode 100644 index 0000000..ced261b --- /dev/null +++ b/tests/client/document-metadata.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveCanonicalUrl, + resolveDocumentMetadata, + resolveHeadMetadata, +} from "../../src/client/document-metadata.ts"; + +describe("SolidBase document metadata", () => { + it("resolves a normal route from page and site metadata", () => { + expect( + resolveDocumentMetadata( + { + description: "SolidBase documentation", + siteUrl: "https://solidbase.dev", + title: "SolidBase", + }, + { title: "Getting Started" }, + ), + ).toEqual({ + description: "SolidBase documentation", + title: "Getting Started - SolidBase", + }); + expect( + resolveCanonicalUrl("https://solidbase.dev", { + pathname: "/guide/getting-started", + }), + ).toBe("https://solidbase.dev/guide/getting-started"); + }); + + it("uses only pathname and normalizes URL slashes", () => { + const location = { + hash: "#installation", + pathname: "/fr/guide/getting-started/", + search: "?tab=api", + }; + + expect(resolveCanonicalUrl("https://solidbase.dev///", location)).toBe( + "https://solidbase.dev/fr/guide/getting-started/", + ); + }); + + it("uses frontmatter title, template, and description overrides", () => { + expect( + resolveDocumentMetadata( + { + description: "Config description", + title: "SolidBase", + titleTemplate: ":title | Docs", + }, + { + description: "Page description", + title: "Installation", + titleTemplate: ":title | Custom", + }, + ), + ).toEqual({ + description: "Page description", + title: "Installation | Custom", + }); + }); + + it("returns no description when none resolves", () => { + expect(resolveDocumentMetadata({ title: "SolidBase" })).toEqual({ + description: undefined, + title: "SolidBase - SolidBase", + }); + }); + + it("returns no canonical URL without siteUrl", () => { + expect( + resolveCanonicalUrl(undefined, { pathname: "/reference" }), + ).toBeUndefined(); + }); + + it("waits for page data before resolving the head metadata set", () => { + const config = { + description: "SolidBase documentation", + siteUrl: "https://solidbase.dev", + title: "SolidBase", + }; + const location = { pathname: "/guide/getting-started" }; + + expect(resolveHeadMetadata(config, undefined, location)).toBeUndefined(); + expect( + resolveHeadMetadata( + config, + { frontmatter: { title: "Getting Started" } }, + location, + ), + ).toEqual({ + canonicalUrl: "https://solidbase.dev/guide/getting-started", + description: "SolidBase documentation", + title: "Getting Started - SolidBase", + }); + }); +});