Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
gt pul
<div align="center">
<a href="https://github.com/kobaltedev/solidbase" target="_blank"><img width="400" src="https://raw.githubusercontent.com/kobaltedev/solidbase/refs/heads/main/.github/solidbase.png" alt="SolidBase"></a>
</div>
Expand Down
6 changes: 5 additions & 1 deletion docs/src/routes/guide/(2)config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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).
:::
Expand Down
14 changes: 14 additions & 0 deletions docs/src/routes/reference/frontmatter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
23 changes: 0 additions & 23 deletions docs/src/solidbase-theme/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,34 +18,13 @@ export default function (props: ComponentProps<typeof Layout>) {
}

function OpenGraph() {
const location = useLocation();

const solidBaseCtx = useSolidBaseContext();
const locale = useLocale();
const frontmatter = useDefaultThemeFrontmatter();

return (
<>
<Meta name="og:type" content="website" />
<Meta name="og:site_name" content={solidBaseCtx.config().title} />
<Meta name="og:title" content={solidBaseCtx.metaTitle()} />
<Meta
name="og:description"
content={
frontmatter()?.description ?? solidBaseCtx.config().description
}
/>
<Meta name="og:locale" content={locale.currentLocale().code} />
<Meta
name="og:url"
content={new URL(
location.pathname,
solidBaseCtx.config().siteUrl ??
import.meta.env.VITE_ORIGIN ??
"https://solidbase.netlify.app",
).toString()}
/>
<Meta name="twitter:card" content="summary_large_image" />
{/*<OGImage />*/}
</>
);
Expand Down
66 changes: 48 additions & 18 deletions src/client/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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 (
<SolidBaseContext.Provider value={{ config, metaTitle }}>
<Title>{metaTitle()}</Title>
{description() && <Meta name="description" content={description()} />}
<Show when={metadata()} keyed>
{(head) => (
<>
<Title>{head.title}</Title>
<Show when={head.description} keyed>
{(description) => (
<>
<Meta name="description" content={description} />
<Meta property="og:description" content={description} />
<Meta name="twitter:description" content={description} />
</>
)}
</Show>
<Meta property="og:title" content={head.title} />
<Meta property="og:type" content="website" />
<Show when={head.canonicalUrl} keyed>
{(url) => (
<>
<Link rel="canonical" href={url} />
<Meta property="og:url" content={url} />
</>
)}
</Show>
<Meta name="twitter:card" content="summary" />
<Meta name="twitter:title" content={head.title} />
</>
)}
</Show>
<Layout>{props.children}</Layout>
</SolidBaseContext.Provider>
);
Expand Down
49 changes: 49 additions & 0 deletions src/client/document-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<Location, "pathname">,
) {
return siteUrl ? new URL(location.pathname, siteUrl).toString() : undefined;
}

export function resolveHeadMetadata(
config: MetadataConfig,
pageData: MetadataPageData | undefined,
location: Pick<Location, "pathname">,
) {
if (!pageData) return;

return {
...resolveDocumentMetadata(config, pageData.frontmatter),
canonicalUrl: resolveCanonicalUrl(config.siteUrl, location),
};
}
10 changes: 10 additions & 0 deletions tests/client/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe("route config helper", () => {
pathname.mockReturnValue("/v1/fr");
setSolidBaseConfig({
title: "Docs",
description: "Documentation",
routes: {
path: "/{version}/{locale}",
version: {
Expand All @@ -119,6 +120,7 @@ describe("route config helper", () => {
{
version: "v1",
title: "Docs v1",
description: "Legacy documentation",
themeConfig: {
sidebar: { "/v1": [] },
},
Expand All @@ -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<typeof useRouteSolidBaseConfig<any>> | undefined;
Expand All @@ -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();
});
});
Expand Down
97 changes: 97 additions & 0 deletions tests/client/document-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
Loading