Skip to content

Commit ce55b48

Browse files
authored
feat: add baseUrl option for resolving relative URLs in metadata (#31)
* wip * wip * wip * alternates * appLinks * opengraph * readme * refactor * Add curly-trainers-begin.md * playground * remove unnecessary
1 parent c998328 commit ce55b48

File tree

17 files changed

+1900
-154
lines changed

17 files changed

+1900
-154
lines changed

.changeset/curly-trainers-begin.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: add baseUrl option for resolving relative URLs in metadata

packages/meta/README.md

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const Route = createFileRoute("/")({
4242
})
4343
```
4444

45-
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`.
45+
You can use it almost the same way as Next.js's [`generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) function.
4646

4747
### Title Template
4848

@@ -79,6 +79,69 @@ generateMetadata({ title: { absolute: "Home" } })
7979

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

82+
### Base URL
83+
84+
Similar to Next.js's `metadataBase`, you can use the `baseUrl` option to resolve relative URLs to absolute URLs for metadata fields like `openGraph`, `twitter`, and `alternates`:
85+
86+
```ts
87+
import { createMetadataGenerator } from "tanstack-meta";
88+
89+
const generateMetadata = createMetadataGenerator({
90+
baseUrl: "https://example.com"
91+
});
92+
93+
// Relative URLs are resolved to absolute URLs
94+
generateMetadata({
95+
openGraph: {
96+
images: "/og.png"
97+
},
98+
alternates: {
99+
canonical: "/about"
100+
}
101+
})
102+
// Output:
103+
// <meta property="og:image" content="https://example.com/og.png" />
104+
// <link rel="canonical" href="https://example.com/about" />
105+
```
106+
107+
You can also pass a `URL` object:
108+
109+
```ts
110+
const generateMetadata = createMetadataGenerator({
111+
baseUrl: new URL("https://example.com")
112+
});
113+
```
114+
115+
Absolute URLs are preserved unchanged:
116+
117+
```ts
118+
generateMetadata({
119+
openGraph: {
120+
images: "https://cdn.example.com/og.png"
121+
}
122+
})
123+
// Output: <meta property="og:image" content="https://cdn.example.com/og.png" />
124+
```
125+
126+
You can combine `baseUrl` with `titleTemplate`:
127+
128+
```ts
129+
const generateMetadata = createMetadataGenerator({
130+
titleTemplate: { default: "My Site", template: "%s | My Site" },
131+
baseUrl: "https://example.com"
132+
});
133+
134+
generateMetadata({
135+
title: "About",
136+
openGraph: {
137+
images: "/og.png"
138+
}
139+
})
140+
// Output:
141+
// <title>About | My Site</title>
142+
// <meta property="og:image" content="https://example.com/og.png" />
143+
```
144+
82145
## Reference
83146

84147
### `generateMetadata`
@@ -95,7 +158,7 @@ An object containing `meta` and `links` properties, which can be used as the ret
95158

96159
### `createMetadataGenerator`
97160

98-
Creates a customized metadata generator with options like title templates.
161+
Creates a customized metadata generator with options like title templates and base URL resolution.
99162

100163
#### Parameters
101164

@@ -104,6 +167,10 @@ An options object with the following properties:
104167
- `titleTemplate` (optional): An object containing:
105168
- `default`: The default title used when no title is provided
106169
- `template`: A template string where `%s` is replaced with the page title
170+
- `baseUrl` (optional): A string or `URL` object used to resolve relative URLs to absolute URLs. Applies to:
171+
- `openGraph` (images, audio, videos, url)
172+
- `twitter` (images, players, app URLs)
173+
- `alternates` (canonical, languages, media, types)
107174

108175
#### Return Value
109176

packages/meta/src/index.test.ts

Lines changed: 24 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -26,134 +26,43 @@ describe("generateMetadata", () => {
2626
});
2727

2828
describe("createMetadataGenerator", () => {
29-
describe("without options", () => {
30-
test("passes through title unchanged", () => {
31-
const generateMetadata = createMetadataGenerator();
32-
const result = generateMetadata({ title: "My Page" });
33-
34-
expect(result.meta).toContainEqual({ title: "My Page" });
35-
});
36-
37-
test("handles null title", () => {
38-
const generateMetadata = createMetadataGenerator();
39-
const result = generateMetadata({ title: null });
40-
41-
expect(result.meta).not.toContainEqual(
42-
expect.objectContaining({ title: expect.any(String) }),
43-
);
44-
});
45-
46-
test("handles undefined title", () => {
47-
const generateMetadata = createMetadataGenerator();
48-
const result = generateMetadata({});
49-
50-
expect(result.meta).not.toContainEqual(
51-
expect.objectContaining({ title: expect.any(String) }),
52-
);
53-
});
54-
});
55-
56-
describe("with titleTemplate", () => {
29+
test("preserves other metadata when applying title template", () => {
5730
const generateMetadata = createMetadataGenerator({
58-
titleTemplate: { default: "My Site", template: "%s | My Site" },
31+
titleTemplate: { default: "Site", template: "%s - Site" },
5932
});
6033

61-
test("applies template to string title", () => {
62-
const result = generateMetadata({ title: "About" });
63-
64-
expect(result.meta).toContainEqual({ title: "About | My Site" });
65-
});
66-
67-
test("uses default when title is null", () => {
68-
const result = generateMetadata({ title: null });
69-
70-
expect(result.meta).toContainEqual({ title: "My Site" });
71-
});
72-
73-
test("uses default when title is undefined", () => {
74-
const result = generateMetadata({});
75-
76-
expect(result.meta).toContainEqual({ title: "My Site" });
77-
});
78-
79-
test("ignores template when title is absolute", () => {
80-
const result = generateMetadata({ title: { absolute: "Home" } });
81-
82-
expect(result.meta).toContainEqual({ title: "Home" });
34+
const result = generateMetadata({
35+
title: "Blog",
36+
description: "My blog description",
37+
keywords: ["blog", "posts"],
8338
});
8439

85-
test("handles absolute title with special characters", () => {
86-
const result = generateMetadata({
87-
title: { absolute: "Welcome | Special Page" },
88-
});
89-
90-
expect(result.meta).toContainEqual({ title: "Welcome | Special Page" });
40+
expect(result.meta).toContainEqual({ title: "Blog - Site" });
41+
expect(result.meta).toContainEqual({
42+
name: "description",
43+
content: "My blog description",
9144
});
92-
});
93-
94-
describe("with other metadata fields", () => {
95-
test("preserves other metadata when applying title template", () => {
96-
const generateMetadata = createMetadataGenerator({
97-
titleTemplate: { default: "Site", template: "%s - Site" },
98-
});
99-
100-
const result = generateMetadata({
101-
title: "Blog",
102-
description: "My blog description",
103-
keywords: ["blog", "posts"],
104-
});
105-
106-
expect(result.meta).toContainEqual({ title: "Blog - Site" });
107-
expect(result.meta).toContainEqual({
108-
name: "description",
109-
content: "My blog description",
110-
});
111-
expect(result.meta).toContainEqual({
112-
name: "keywords",
113-
content: "blog,posts",
114-
});
45+
expect(result.meta).toContainEqual({
46+
name: "keywords",
47+
content: "blog,posts",
11548
});
11649
});
11750

118-
describe("template variations", () => {
119-
test("supports prefix template", () => {
120-
const generateMetadata = createMetadataGenerator({
121-
titleTemplate: { default: "Home", template: "Acme | %s" },
122-
});
123-
124-
const result = generateMetadata({ title: "Products" });
125-
126-
expect(result.meta).toContainEqual({ title: "Acme | Products" });
127-
});
128-
129-
test("supports template without separator", () => {
130-
const generateMetadata = createMetadataGenerator({
131-
titleTemplate: { default: "Welcome", template: "%s" },
132-
});
133-
134-
const result = generateMetadata({ title: "Hello" });
135-
136-
expect(result.meta).toContainEqual({ title: "Hello" });
51+
test("combines titleTemplate and baseUrl options", () => {
52+
const generateMetadata = createMetadataGenerator({
53+
titleTemplate: { default: "Site", template: "%s | Site" },
54+
baseUrl: "https://example.com",
13755
});
13856

139-
test("handles empty string title with template", () => {
140-
const generateMetadata = createMetadataGenerator({
141-
titleTemplate: { default: "Default", template: "%s | Site" },
142-
});
143-
144-
const result = generateMetadata({ title: "" });
145-
146-
expect(result.meta).toContainEqual({ title: " | Site" });
57+
const result = generateMetadata({
58+
title: "About",
59+
openGraph: { images: "/og.png" },
14760
});
14861

149-
test("replaces all %s placeholders", () => {
150-
const generateMetadata = createMetadataGenerator({
151-
titleTemplate: { default: "Site", template: "%s | %s | Site" },
152-
});
153-
154-
const result = generateMetadata({ title: "Docs" });
155-
156-
expect(result.meta).toContainEqual({ title: "Docs | Docs | Site" });
62+
expect(result.meta).toContainEqual({ title: "About | Site" });
63+
expect(result.meta).toContainEqual({
64+
property: "og:image",
65+
content: "https://example.com/og.png",
15766
});
15867
});
15968
});

packages/meta/src/index.ts

Lines changed: 23 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { links } from "./links";
22
import { meta } from "./meta";
33
import { normalizeMetadata } from "./normalize";
4-
import type { InputMetadata, OutputLinks, OutputMeta } from "./types/io";
4+
import { resolveAlternates } from "./resolve/alternates";
5+
import { resolveOpenGraph } from "./resolve/opengraph";
6+
import { resolveTitle } from "./resolve/title";
7+
import { resolveTwitter } from "./resolve/twitter";
8+
import type {
9+
GeneratorInputMetadata,
10+
InputMetadata,
11+
OutputLinks,
12+
OutputMeta,
13+
} from "./types/io";
514

615
type OutputMetadata = {
716
meta: OutputMeta;
@@ -17,47 +26,25 @@ export function generateMetadata(metadata: InputMetadata): OutputMetadata {
1726
};
1827
}
1928

20-
type GeneratorInputMetadata = Omit<InputMetadata, "title"> & {
21-
title?: string | { absolute: string } | null;
22-
};
23-
24-
function resolveTitle(
25-
metadata: GeneratorInputMetadata,
26-
options: { titleTemplate?: { default: string; template: string } },
27-
) {
28-
let title: string | null | undefined;
29-
30-
if (
31-
metadata.title &&
32-
typeof metadata.title === "object" &&
33-
"absolute" in metadata.title
34-
) {
35-
title = metadata.title.absolute;
36-
} else {
37-
const { titleTemplate } = options;
38-
if (!titleTemplate) {
39-
title = metadata.title;
40-
} else {
41-
if (typeof metadata.title === "string") {
42-
title = titleTemplate.template.split("%s").join(metadata.title);
43-
} else {
44-
title = titleTemplate.default;
45-
}
46-
}
47-
}
48-
49-
return title;
50-
}
51-
5229
export function createMetadataGenerator(
53-
options: { titleTemplate?: { default: string; template: string } } = {},
54-
) {
30+
options: {
31+
titleTemplate?: { default: string; template: string };
32+
baseUrl?: string | URL | null;
33+
} = {},
34+
): (metadata: GeneratorInputMetadata) => OutputMetadata {
5535
return (metadata: GeneratorInputMetadata) => {
56-
const title = resolveTitle(metadata, options);
36+
const title = resolveTitle(metadata, options.titleTemplate);
37+
38+
const openGraph = resolveOpenGraph(metadata, options.baseUrl);
39+
const twitter = resolveTwitter(metadata, options.baseUrl);
40+
const alternates = resolveAlternates(metadata, options.baseUrl);
5741

5842
return generateMetadata({
5943
...metadata,
6044
title,
45+
openGraph,
46+
twitter,
47+
alternates,
6148
});
6249
};
6350
}

0 commit comments

Comments
 (0)