Skip to content

Commit 6e89173

Browse files
authored
feat: support viewport (#13)
* viewport * fix type error * add tests * add curvy-areas-cry.md * format
1 parent c24e955 commit 6e89173

10 files changed

Lines changed: 470 additions & 8 deletions

File tree

.changeset/curvy-areas-cry.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 viewport

packages/meta/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ You can generally use it the same way as Next.js’s [`generateMetadata`](https:
4747
- Features that work across routes are not supported
4848
- `title` only accepts a plain string
4949
- `metadataBase` is not available
50-
- `viewport` is not supported yet
5150

5251
## Reference
5352

@@ -93,3 +92,4 @@ An object containing `meta` and `links` properties, which can be used as the ret
9392
- `twitter`
9493
- `appLinks`
9594
- `icons`
95+
- `viewport`
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { normalizeMetadata } from "../normalize";
4+
import { generateViewport } from "./viewport";
5+
6+
describe("generateViewport", () => {
7+
test("returns empty array when viewport metadata is missing", () => {
8+
const metadata = normalizeMetadata({});
9+
10+
expect(generateViewport(metadata)).toEqual([]);
11+
});
12+
13+
test("emits viewport layout, theme-color entries, and color-scheme", () => {
14+
const metadata = normalizeMetadata({
15+
viewport: {
16+
width: "device-width",
17+
initialScale: 1,
18+
userScalable: false,
19+
themeColor: [
20+
{ color: "#000000" },
21+
{ color: "#ffffff", media: "(prefers-color-scheme: light)" },
22+
],
23+
colorScheme: "dark",
24+
},
25+
});
26+
27+
expect(generateViewport(metadata)).toEqual([
28+
{
29+
name: "viewport",
30+
content: "width=device-width, initial-scale=1, user-scalable=no",
31+
},
32+
{
33+
name: "theme-color",
34+
content: "#000000",
35+
},
36+
{
37+
name: "theme-color",
38+
content: "#ffffff",
39+
media: "(prefers-color-scheme: light)",
40+
},
41+
{
42+
name: "color-scheme",
43+
content: "dark",
44+
},
45+
]);
46+
});
47+
48+
test("omits viewport meta when layout resolves empty but keeps theme-color", () => {
49+
const metadata = normalizeMetadata({
50+
viewport: {
51+
initialScale: 0,
52+
themeColor: "#eeeeee",
53+
},
54+
});
55+
56+
expect(generateViewport(metadata)).toEqual([
57+
{
58+
name: "theme-color",
59+
content: "#eeeeee",
60+
},
61+
]);
62+
});
63+
64+
test("omits theme-color entries when themeColor is null", () => {
65+
const metadata = normalizeMetadata({
66+
viewport: {
67+
width: "device-width",
68+
themeColor: null,
69+
},
70+
});
71+
72+
expect(generateViewport(metadata)).toEqual([
73+
{
74+
name: "viewport",
75+
content: "width=device-width",
76+
},
77+
]);
78+
});
79+
80+
test("omits theme-color entries when themeColor is an empty array", () => {
81+
const metadata = normalizeMetadata({
82+
viewport: {
83+
width: "device-width",
84+
themeColor: [],
85+
},
86+
});
87+
88+
expect(generateViewport(metadata)).toEqual([
89+
{
90+
name: "viewport",
91+
content: "width=device-width",
92+
},
93+
]);
94+
});
95+
96+
test("omits color-scheme meta when colorScheme is null", () => {
97+
const metadata = normalizeMetadata({
98+
viewport: {
99+
width: "device-width",
100+
colorScheme: null,
101+
},
102+
});
103+
104+
expect(generateViewport(metadata)).toEqual([
105+
{
106+
name: "viewport",
107+
content: "width=device-width",
108+
},
109+
]);
110+
});
111+
112+
test("emits only viewport meta when layout is provided without theme/color properties", () => {
113+
const metadata = normalizeMetadata({
114+
viewport: {
115+
width: "device-width",
116+
height: 800,
117+
initialScale: 1,
118+
},
119+
});
120+
121+
expect(generateViewport(metadata)).toEqual([
122+
{
123+
name: "viewport",
124+
content: "width=device-width, height=800, initial-scale=1",
125+
},
126+
]);
127+
});
128+
129+
test("emits multiple theme-color entries without media queries", () => {
130+
const metadata = normalizeMetadata({
131+
viewport: {
132+
themeColor: [{ color: "#000" }, { color: "#fff" }],
133+
},
134+
});
135+
136+
expect(generateViewport(metadata)).toEqual([
137+
{ name: "theme-color", content: "#000" },
138+
{ name: "theme-color", content: "#fff" },
139+
]);
140+
});
141+
142+
test("emits color-scheme even without layout keys", () => {
143+
const metadata = normalizeMetadata({
144+
viewport: {
145+
colorScheme: "light",
146+
},
147+
});
148+
149+
expect(generateViewport(metadata)).toEqual([
150+
{
151+
name: "color-scheme",
152+
content: "light",
153+
},
154+
]);
155+
});
156+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { normalizeViewportLayout } from "../normalize/viewport";
2+
import type { NormalizedMetadata, OutputMeta } from "../types/io";
3+
import { createMetaTag, flattenMetaList } from "./utils";
4+
5+
export function generateViewport(metadata: NormalizedMetadata): OutputMeta {
6+
const { viewport } = metadata;
7+
8+
if (!viewport) return [];
9+
10+
const viewportContent = normalizeViewportLayout(viewport);
11+
return flattenMetaList([
12+
createMetaTag({
13+
name: "viewport",
14+
content: viewportContent ?? undefined,
15+
}),
16+
...(viewport.themeColor
17+
? viewport.themeColor.map((themeColor) =>
18+
createMetaTag({
19+
name: "theme-color",
20+
content: themeColor.color,
21+
media: themeColor.media,
22+
}),
23+
)
24+
: []),
25+
createMetaTag({
26+
name: "color-scheme",
27+
content: viewport.colorScheme ?? undefined,
28+
}),
29+
]);
30+
}

packages/meta/src/meta.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
generateTwitter,
1414
} from "./generate/opengraph";
1515
import { flattenMetaList } from "./generate/utils";
16+
import { generateViewport } from "./generate/viewport";
1617
import type { NormalizedMetadata, OutputMeta } from "./types/io";
1718

1819
export function meta(metadata: NormalizedMetadata): OutputMeta {
@@ -28,5 +29,6 @@ export function meta(metadata: NormalizedMetadata): OutputMeta {
2829
generateOpenGraph(metadata),
2930
generateTwitter(metadata),
3031
generateAppLinks(metadata),
32+
generateViewport(metadata),
3133
]);
3234
}

packages/meta/src/normalize/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
normalizeTwitter,
1616
} from "./opengraph";
1717
import { resolveAsArrayOrUndefined } from "./utils";
18+
import { normalizeViewport } from "./viewport";
1819

1920
// https://github.com/vercel/next.js/blob/d673568300ab9336ebe610516c5a3439ab7cb8f5/packages/next/src/lib/metadata/resolve-metadata.ts#L213
2021
export function normalizeMetadata(metadata: InputMetadata): NormalizedMetadata {
@@ -59,6 +60,7 @@ export function normalizeMetadata(metadata: InputMetadata): NormalizedMetadata {
5960
twitter: convertUrlsToStrings(normalizeTwitter(metadata.twitter)),
6061
appLinks: normalizeAppLink(metadata.appLinks),
6162
icons: convertUrlsToStrings(normalizeIcons(metadata.icons)),
63+
viewport: normalizeViewport(metadata.viewport ?? null),
6264
};
6365
}
6466

0 commit comments

Comments
 (0)