Skip to content

Commit b0a9208

Browse files
authored
feat: support callback function in titleTemplate.template option (#36)
* feat: support callback function in titleTemplate.template option * Add purple-kings-build.md
1 parent deb1382 commit b0a9208

File tree

5 files changed

+140
-26
lines changed

5 files changed

+140
-26
lines changed

.changeset/purple-kings-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tanstack-meta": minor
3+
---
4+
5+
feat: support callback function in titleTemplate.template option

packages/meta/README.md

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const Route = createFileRoute("/")({
3939

4040
return { meta, links };
4141
},
42-
})
42+
});
4343
```
4444

4545
You can use it almost the same way as Next.js's [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) function.
@@ -55,25 +55,50 @@ import { createMetadataGenerator } from "tanstack-meta";
5555
const generateMetadata = createMetadataGenerator({
5656
titleTemplate: {
5757
default: "Default Title", // Used when title is not provided
58-
template: "%s | My Site" // %s is replaced with the page title
59-
}
58+
template: "%s | My Site", // %s is replaced with the page title
59+
},
6060
});
6161

6262
// In your routes:
63-
generateMetadata({ title: "About" })
63+
generateMetadata({ title: "About" });
6464
// Output: <title>About | My Site</title>
6565

66-
generateMetadata({ title: null })
66+
generateMetadata({ title: null });
6767
// Output: <title>Default Title</title>
6868

69-
generateMetadata({})
69+
generateMetadata({});
7070
// Output: <title>Default Title</title>
7171
```
7272

73+
You can also use a function for more complex title transformations:
74+
75+
```ts
76+
const generateMetadata = createMetadataGenerator({
77+
titleTemplate: {
78+
default: "My Site",
79+
template: (title) => `${title.toUpperCase()} — My Site`,
80+
},
81+
});
82+
83+
generateMetadata({ title: "about" });
84+
// Output: <title>ABOUT — My Site</title>
85+
86+
// Conditional logic example
87+
const generateMetadata = createMetadataGenerator({
88+
titleTemplate: {
89+
default: "My Site",
90+
template: (title) =>
91+
title.length > 50
92+
? `${title.slice(0, 47)}... | My Site`
93+
: `${title} | My Site`,
94+
},
95+
});
96+
```
97+
7398
To opt out of the title template on a specific page, use `title.absolute`:
7499

75100
```ts
76-
generateMetadata({ title: { absolute: "Home" } })
101+
generateMetadata({ title: { absolute: "Home" } });
77102
// Output: <title>Home</title> (template is ignored)
78103
```
79104

@@ -87,18 +112,18 @@ Similar to Next.js's `metadataBase`, you can use the `baseUrl` option to resolve
87112
import { createMetadataGenerator } from "tanstack-meta";
88113

89114
const generateMetadata = createMetadataGenerator({
90-
baseUrl: "https://example.com"
115+
baseUrl: "https://example.com",
91116
});
92117

93118
// Relative URLs are resolved to absolute URLs
94119
generateMetadata({
95120
openGraph: {
96-
images: "/og.png"
121+
images: "/og.png",
97122
},
98123
alternates: {
99-
canonical: "/about"
100-
}
101-
})
124+
canonical: "/about",
125+
},
126+
});
102127
// Output:
103128
// <meta property="og:image" content="https://example.com/og.png" />
104129
// <link rel="canonical" href="https://example.com/about" />
@@ -108,7 +133,7 @@ You can also pass a `URL` object:
108133

109134
```ts
110135
const generateMetadata = createMetadataGenerator({
111-
baseUrl: new URL("https://example.com")
136+
baseUrl: new URL("https://example.com"),
112137
});
113138
```
114139

@@ -117,9 +142,9 @@ Absolute URLs are preserved unchanged:
117142
```ts
118143
generateMetadata({
119144
openGraph: {
120-
images: "https://cdn.example.com/og.png"
121-
}
122-
})
145+
images: "https://cdn.example.com/og.png",
146+
},
147+
});
123148
// Output: <meta property="og:image" content="https://cdn.example.com/og.png" />
124149
```
125150

@@ -128,15 +153,15 @@ You can combine `baseUrl` with `titleTemplate`:
128153
```ts
129154
const generateMetadata = createMetadataGenerator({
130155
titleTemplate: { default: "My Site", template: "%s | My Site" },
131-
baseUrl: "https://example.com"
156+
baseUrl: "https://example.com",
132157
});
133158

134159
generateMetadata({
135160
title: "About",
136161
openGraph: {
137-
images: "/og.png"
138-
}
139-
})
162+
images: "/og.png",
163+
},
164+
});
140165
// Output:
141166
// <title>About | My Site</title>
142167
// <meta property="og:image" content="https://example.com/og.png" />
@@ -166,7 +191,7 @@ An options object with the following properties:
166191

167192
- `titleTemplate` (optional): An object containing:
168193
- `default`: The default title used when no title is provided
169-
- `template`: A template string where `%s` is replaced with the page title
194+
- `template`: A template string where `%s` is replaced with the page title, or a function that receives the page title and returns the formatted title
170195
- `baseUrl` (optional): A string or `URL` object used to resolve relative URLs to absolute URLs. Applies to:
171196
- `openGraph` (images, audio, videos, url)
172197
- `twitter` (images, players, app URLs)

packages/meta/src/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { meta } from "./meta";
33
import { normalizeMetadata } from "./normalize";
44
import { resolveAlternates } from "./resolve/alternates";
55
import { resolveOpenGraph } from "./resolve/opengraph";
6-
import { resolveTitle } from "./resolve/title";
6+
import { resolveTitle, type TitleTemplate } from "./resolve/title";
77
import { resolveTwitter } from "./resolve/twitter";
88
import type {
99
GeneratorInputMetadata,
@@ -28,7 +28,7 @@ export function generateMetadata(metadata: InputMetadata): OutputMetadata {
2828

2929
export function createMetadataGenerator(
3030
options: {
31-
titleTemplate?: { default: string; template: string };
31+
titleTemplate?: TitleTemplate;
3232
baseUrl?: string | URL | null;
3333
} = {},
3434
): (metadata: GeneratorInputMetadata) => OutputMetadata {
@@ -49,4 +49,9 @@ export function createMetadataGenerator(
4949
};
5050
}
5151

52-
export type { InputMetadata, OutputMetadata, GeneratorInputMetadata };
52+
export type {
53+
InputMetadata,
54+
OutputMetadata,
55+
GeneratorInputMetadata,
56+
TitleTemplate,
57+
};

packages/meta/src/resolve/title.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,74 @@ describe("resolveTitle", () => {
100100
expect(result).toBe("Docs | Docs | Site");
101101
});
102102
});
103+
104+
describe("with template function", () => {
105+
test("applies template function to string title", () => {
106+
const result = resolveTitle(
107+
{ title: "About" },
108+
{ default: "My Site", template: (title) => `${title} | My Site` },
109+
);
110+
111+
expect(result).toBe("About | My Site");
112+
});
113+
114+
test("allows complex transformations", () => {
115+
const result = resolveTitle(
116+
{ title: "hello world" },
117+
{
118+
default: "My Site",
119+
template: (title) => `${title.toUpperCase()} — My Site`,
120+
},
121+
);
122+
123+
expect(result).toBe("HELLO WORLD — My Site");
124+
});
125+
126+
test("uses default when title is null with template function", () => {
127+
const result = resolveTitle(
128+
{ title: null },
129+
{ default: "My Site", template: (title) => `${title} | My Site` },
130+
);
131+
132+
expect(result).toBe("My Site");
133+
});
134+
135+
test("uses default when title is undefined with template function", () => {
136+
const result = resolveTitle(
137+
{},
138+
{ default: "My Site", template: (title) => `${title} | My Site` },
139+
);
140+
141+
expect(result).toBe("My Site");
142+
});
143+
144+
test("ignores template function when title is absolute", () => {
145+
const result = resolveTitle(
146+
{ title: { absolute: "Home" } },
147+
{ default: "My Site", template: (title) => `${title} | My Site` },
148+
);
149+
150+
expect(result).toBe("Home");
151+
});
152+
153+
test("supports conditional logic in template function", () => {
154+
const result = resolveTitle(
155+
{
156+
title:
157+
"This is a very long title that exceeds fifty characters in length",
158+
},
159+
{
160+
default: "My Site",
161+
template: (title) =>
162+
title.length > 50
163+
? `${title.slice(0, 47)}... | My Site`
164+
: `${title} | My Site`,
165+
},
166+
);
167+
168+
expect(result).toBe(
169+
"This is a very long title that exceeds fifty ch... | My Site",
170+
);
171+
});
172+
});
103173
});

packages/meta/src/resolve/title.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import type { GeneratorInputMetadata, InputMetadata } from "../types/io";
22

3+
export type TitleTemplate = {
4+
default: string;
5+
template: string | ((title: string) => string);
6+
};
7+
38
export function resolveTitle(
49
metadata: GeneratorInputMetadata,
5-
titleTemplate?: { default: string; template: string },
10+
titleTemplate?: TitleTemplate,
611
): InputMetadata["title"] {
712
let title: string | null | undefined;
813

@@ -17,7 +22,11 @@ export function resolveTitle(
1722
title = metadata.title;
1823
} else {
1924
if (typeof metadata.title === "string") {
20-
title = titleTemplate.template.split("%s").join(metadata.title);
25+
if (typeof titleTemplate.template === "function") {
26+
title = titleTemplate.template(metadata.title);
27+
} else {
28+
title = titleTemplate.template.split("%s").join(metadata.title);
29+
}
2130
} else {
2231
title = titleTemplate.default;
2332
}

0 commit comments

Comments
 (0)