Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/smart-spoons-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tanstack-meta": minor
---

feat: add createMetadataGenerator with titleTemplate support
72 changes: 65 additions & 7 deletions packages/meta/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,24 +42,73 @@ export const Route = createFileRoute("/")({
})
```

You can generally use it the same way as Next.jss [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) function, but keep the following points in mind:
You can use it almost the same way as Next.js's [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) function, but note that currently there is no equivalent option for `metadataBase`.

- Features that work across routes are not supported
- `title` only accepts a plain string
- `metadataBase` is not available
### Title Template

If you want to use a title template like Next.js's `title.template`, use `createMetadataGenerator` to create a customized metadata generator:

```ts
import { createMetadataGenerator } from "tanstack-meta";

// Create a generator with title template
const generateMetadata = createMetadataGenerator({
titleTemplate: {
default: "Default Title", // Used when title is not provided
template: "%s | My Site" // %s is replaced with the page title
}
});

// In your routes:
generateMetadata({ title: "About" })
// Output: <title>About | My Site</title>

generateMetadata({ title: null })
// Output: <title>Default Title</title>

generateMetadata({})
// Output: <title>Default Title</title>
```

To opt out of the title template on a specific page, use `title.absolute`:

```ts
generateMetadata({ title: { absolute: "Home" } })
// Output: <title>Home</title> (template is ignored)
```

`%s` placeholders are all replaced. For example, `template: "%s | %s | My Site"` with `title: "Docs"` renders `<title>Docs | Docs | My Site</title>`.

## Reference

`tanstack-meta` provides a function called `generateMetadata` that generates the document metadata compatible with TanStack Router/Start's `head` function.
### `generateMetadata`

### Parameters
Generates the document metadata compatible with TanStack Router/Start's `head` function.

#### Parameters

An object containing the document metadata to be set.

### Return Value
#### Return Value

An object containing `meta` and `links` properties, which can be used as the return value of the `head` function.

### `createMetadataGenerator`

Creates a customized metadata generator with options like title templates.

#### Parameters

An options object with the following properties:

- `titleTemplate` (optional): An object containing:
- `default`: The default title used when no title is provided
- `template`: A template string where `%s` is replaced with the page title

#### Return Value

A function that accepts metadata (with extended `title` support) and returns the same structure as `generateMetadata`.

### Supported Metadata Fields

- `charSet`
Expand All @@ -82,6 +131,15 @@ An object containing `meta` and `links` properties, which can be used as the ret
<!-- Output -->
<title>My Blog</title>
```
- When using `createMetadataGenerator` with a title template, you can also use `{ absolute: string }` to bypass the template:
- ```tsx
// Input (with createMetadataGenerator)
{ title: { absolute: "Special Page" } }
```
- ```html
<!-- Output -->
<title>Special Page</title>
```
- `description`
- The document description.
- ```tsx
Expand Down
159 changes: 159 additions & 0 deletions packages/meta/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { describe, expect, test } from "bun:test";

import { createMetadataGenerator, generateMetadata } from "./index";

describe("generateMetadata", () => {
test("returns meta and links for basic metadata", () => {
const result = generateMetadata({
title: "My Page",
description: "A description",
});

expect(result.meta).toContainEqual({ title: "My Page" });
expect(result.meta).toContainEqual({
name: "description",
content: "A description",
});
expect(result.links).toEqual([]);
});

test("returns empty arrays for empty metadata", () => {
const result = generateMetadata({});

expect(result.meta).toEqual([]);
expect(result.links).toEqual([]);
});
});

describe("createMetadataGenerator", () => {
describe("without options", () => {
test("passes through title unchanged", () => {
const generateMetadata = createMetadataGenerator();
const result = generateMetadata({ title: "My Page" });

expect(result.meta).toContainEqual({ title: "My Page" });
});

test("handles null title", () => {
const generateMetadata = createMetadataGenerator();
const result = generateMetadata({ title: null });

expect(result.meta).not.toContainEqual(
expect.objectContaining({ title: expect.any(String) }),
);
});

test("handles undefined title", () => {
const generateMetadata = createMetadataGenerator();
const result = generateMetadata({});

expect(result.meta).not.toContainEqual(
expect.objectContaining({ title: expect.any(String) }),
);
});
});

describe("with titleTemplate", () => {
const generateMetadata = createMetadataGenerator({
titleTemplate: { default: "My Site", template: "%s | My Site" },
});

test("applies template to string title", () => {
const result = generateMetadata({ title: "About" });

expect(result.meta).toContainEqual({ title: "About | My Site" });
});

test("uses default when title is null", () => {
const result = generateMetadata({ title: null });

expect(result.meta).toContainEqual({ title: "My Site" });
});

test("uses default when title is undefined", () => {
const result = generateMetadata({});

expect(result.meta).toContainEqual({ title: "My Site" });
});

test("ignores template when title is absolute", () => {
const result = generateMetadata({ title: { absolute: "Home" } });

expect(result.meta).toContainEqual({ title: "Home" });
});

test("handles absolute title with special characters", () => {
const result = generateMetadata({
title: { absolute: "Welcome | Special Page" },
});

expect(result.meta).toContainEqual({ title: "Welcome | Special Page" });
});
});

describe("with other metadata fields", () => {
test("preserves other metadata when applying title template", () => {
const generateMetadata = createMetadataGenerator({
titleTemplate: { default: "Site", template: "%s - Site" },
});

const result = generateMetadata({
title: "Blog",
description: "My blog description",
keywords: ["blog", "posts"],
});

expect(result.meta).toContainEqual({ title: "Blog - Site" });
expect(result.meta).toContainEqual({
name: "description",
content: "My blog description",
});
expect(result.meta).toContainEqual({
name: "keywords",
content: "blog,posts",
});
});
});

describe("template variations", () => {
test("supports prefix template", () => {
const generateMetadata = createMetadataGenerator({
titleTemplate: { default: "Home", template: "Acme | %s" },
});

const result = generateMetadata({ title: "Products" });

expect(result.meta).toContainEqual({ title: "Acme | Products" });
});

test("supports template without separator", () => {
const generateMetadata = createMetadataGenerator({
titleTemplate: { default: "Welcome", template: "%s" },
});

const result = generateMetadata({ title: "Hello" });

expect(result.meta).toContainEqual({ title: "Hello" });
});

test("handles empty string title with template", () => {
const generateMetadata = createMetadataGenerator({
titleTemplate: { default: "Default", template: "%s | Site" },
});

const result = generateMetadata({ title: "" });

expect(result.meta).toContainEqual({ title: " | Site" });
});

test("replaces all %s placeholders", () => {
const generateMetadata = createMetadataGenerator({
titleTemplate: { default: "Site", template: "%s | %s | Site" },
});

const result = generateMetadata({ title: "Docs" });

expect(result.meta).toContainEqual({ title: "Docs | Docs | Site" });
});
});
});
48 changes: 47 additions & 1 deletion packages/meta/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,56 @@ type OutputMetadata = {

export function generateMetadata(metadata: InputMetadata): OutputMetadata {
const normalizedMetadata = normalizeMetadata(metadata);

return {
meta: meta(normalizedMetadata),
links: links(normalizedMetadata),
};
}

export type { InputMetadata, OutputMetadata };
type GeneratorInputMetadata = Omit<InputMetadata, "title"> & {
title?: string | { absolute: string } | null;
};

function resolveTitle(
metadata: GeneratorInputMetadata,
options: { titleTemplate?: { default: string; template: string } },
) {
let title: string | null | undefined;

if (
metadata.title &&
typeof metadata.title === "object" &&
"absolute" in metadata.title
) {
title = metadata.title.absolute;
} else {
const { titleTemplate } = options;
if (!titleTemplate) {
title = metadata.title;
} else {
if (typeof metadata.title === "string") {
title = titleTemplate.template.split("%s").join(metadata.title);
} else {
title = titleTemplate.default;
}
}
}

return title;
}

export function createMetadataGenerator(
options: { titleTemplate?: { default: string; template: string } } = {},
) {
return (metadata: GeneratorInputMetadata) => {
const title = resolveTitle(metadata, options);

return generateMetadata({
...metadata,
title,
});
};
}

export type { InputMetadata, OutputMetadata, GeneratorInputMetadata };
8 changes: 8 additions & 0 deletions playground/src/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createMetadataGenerator } from "tanstack-meta";

export const generateMetadata = createMetadataGenerator({
titleTemplate: {
default: "TanStack | High Quality Open-Source Software for Web Developers",
template: "%s | High Quality Open-Source Software for Web Developers",
},
});
4 changes: 2 additions & 2 deletions playground/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { generateMetadata } from "tanstack-meta";

import Header from "../components/Header";
import { generateMetadata } from "../meta";

import appCss from "../styles.css?url";

Expand All @@ -17,11 +17,11 @@ export const Route = createRootRoute({

const { meta, links } = generateMetadata({
charSet: "utf-8",
title: "TanStack",
viewport: {
width: "device-width",
initialScale: 1,
},
title,
description,
openGraph: {
title,
Expand Down
Loading