-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(remix-react): Add array-syntax for meta
export
#2598
Changes from all commits
e2fb59b
b1e9a25
9ca21f6
c1ad9f8
eae5a20
8eda69e
90eb09a
d383bd3
7d62a61
cf71b52
8a81d93
983fb9d
dc605b3
8913d13
dfc3bca
6d5eb0d
87594af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
import React from "react"; | ||
/* eslint-disable @typescript-eslint/no-unused-vars */ | ||
import { renderToString } from "react-dom/server"; | ||
import "@testing-library/jest-dom/extend-expect"; | ||
|
||
import { processMeta } from "../components"; | ||
import type { HtmlMetaDescriptor, MetaFunction } from "../routeModules"; | ||
|
||
describe("meta", () => { | ||
it(`renders proper <meta> tags`, () => { | ||
function meta({ data }): HtmlMetaDescriptor { | ||
return { | ||
charset: "utf-8", | ||
description: data.description, | ||
"og:image": "https://picsum.photos/200/200", | ||
"og:type": data.contentType, // undefined | ||
title: data.title, | ||
}; | ||
} | ||
function meta2({ data }): HtmlMetaDescriptor[] { | ||
return [ | ||
{ name: "description", content: "override description" }, | ||
{ property: "og:image", content: "https://remix.run/logo.png" }, | ||
{ property: "og:type", content: "image/png" }, | ||
{ | ||
key: "http-equiv:refresh", | ||
httpEquiv: "refresh", | ||
content: "5;url=https://google.com", | ||
}, | ||
{ key: "title", content: "Updated title" }, | ||
{ key: "charset", content: "utf-16" }, | ||
{ name: "viewport", content: "width=device-width, initial-scale=1" }, | ||
]; | ||
} | ||
|
||
let map = getMeta( | ||
{ | ||
title: "test title", | ||
description: "test description", | ||
}, | ||
[meta, meta2] | ||
); | ||
|
||
// let rendered = renderMeta(map); | ||
// let html = renderToString(rendered); | ||
// console.log(html); | ||
|
||
// title should override the title from the first meta function | ||
expect(map.get("title")!.content).toBe("Updated title"); | ||
// viewport should be added | ||
expect(map.get("viewport")!.content).toBe( | ||
"width=device-width, initial-scale=1" | ||
); | ||
}); | ||
}); | ||
|
||
type MetaMap = Map<string, HtmlMetaDescriptor>; | ||
|
||
function getMeta(data: any, metaFunctions: MetaFunction[]) { | ||
let meta: MetaMap = new Map(); | ||
metaFunctions.forEach((metaFunction) => { | ||
let routeMeta = metaFunction({ | ||
data, | ||
parentsData: {}, | ||
params: {}, | ||
// @ts-expect-error | ||
location: null, | ||
}); | ||
if (routeMeta) { | ||
processMeta(meta, routeMeta); | ||
} | ||
}); | ||
|
||
return meta; | ||
} | ||
|
||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ | ||
function renderMeta(meta: MetaMap) { | ||
return ( | ||
<> | ||
{[...meta.entries()].map(([key, value]) => { | ||
if (key === "title" && typeof value.content === "string") { | ||
return <title key={key}>{value.content}</title>; | ||
} | ||
if (key === "charset" && typeof value.content === "string") { | ||
return <meta key={key} charSet={value.content} />; | ||
} | ||
return <meta key={key} {...value} />; | ||
})} | ||
</> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -688,6 +688,8 @@ function PrefetchPageLinksImpl({ | |
); | ||
} | ||
|
||
type MetaMap = Map<string, HtmlMetaDescriptor>; | ||
|
||
/** | ||
* Renders the `<title>` and `<meta>` tags for the current routes. | ||
* | ||
|
@@ -697,7 +699,7 @@ export function Meta() { | |
let { matches, routeData, routeModules } = useRemixEntryContext(); | ||
let location = useLocation(); | ||
|
||
let meta: HtmlMetaDescriptor = {}; | ||
let meta: MetaMap = new Map(); | ||
let parentsData: { [routeId: string]: AppData } = {}; | ||
|
||
for (let match of matches) { | ||
|
@@ -712,52 +714,103 @@ export function Meta() { | |
typeof routeModule.meta === "function" | ||
? routeModule.meta({ data, parentsData, params, location }) | ||
: routeModule.meta; | ||
Object.assign(meta, routeMeta); | ||
if (routeMeta) { | ||
processMeta(meta, routeMeta); | ||
} | ||
} | ||
|
||
parentsData[routeId] = data; | ||
} | ||
|
||
return ( | ||
<> | ||
{Object.entries(meta).map(([name, value]) => { | ||
if (!value) { | ||
return null; | ||
{Array.from(meta.entries()).map(([key, descriptor]) => { | ||
let { name, property, content } = descriptor; | ||
if (key === "title" && typeof content === "string") { | ||
return <title key={key}>{content}</title>; | ||
} | ||
|
||
if (["charset", "charSet"].includes(name)) { | ||
return <meta key="charset" charSet={value as string} />; | ||
if (key === "charset" && typeof content === "string") { | ||
return <meta key={key} charSet={content} />; | ||
} | ||
|
||
if (name === "title") { | ||
return <title key="title">{String(value)}</title>; | ||
if (property !== undefined) { | ||
return <meta key={key} property={property} content={content} />; | ||
} | ||
|
||
// Open Graph tags use the `property` attribute, while other meta tags | ||
// use `name`. See https://ogp.me/ | ||
let isOpenGraphTag = name.startsWith("og:"); | ||
return [value].flat().map((content) => { | ||
if (isOpenGraphTag) { | ||
return ( | ||
<meta | ||
property={name} | ||
content={content as string} | ||
key={name + content} | ||
/> | ||
); | ||
} | ||
|
||
if (typeof content === "string") { | ||
return <meta name={name} content={content} key={name + content} />; | ||
} | ||
|
||
return <meta key={name + JSON.stringify(content)} {...content} />; | ||
}); | ||
if (name !== undefined) { | ||
return <meta key={key} name={name} content={content} />; | ||
} | ||
return <meta key={key} {...descriptor} />; | ||
})} | ||
</> | ||
); | ||
} | ||
|
||
/* | ||
* This function processes a meta descriptor and adds it to the meta array. | ||
* This converts the original object syntax to new array syntax. | ||
*/ | ||
export function processMeta( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. change export type HtmlMetaDescriptor = {
key?: string;
name?: string;
property?: string;
title?: string;
content?: string;
} & Record<string, string | string[] | null | undefined>; and try this for export function processMeta(
meta: MetaMap,
routeMeta: HtmlMetaDescriptor | HtmlMetaDescriptor[]
) {
let items: HtmlMetaDescriptor[] = Array.isArray(routeMeta)
? routeMeta
: Object.entries(routeMeta)
.map(([key, value]) => {
if (!value) return [];
let propertyName = key.startsWith("og:") ? "property" : "name";
return key === "title"
? { key: "title", content: assertString(value) }
: ["charset", "charSet"].includes(key)
? { key: "charset", content: assertString(value) }
: Array.isArray(value)
? value.map((content) => ({
key: `${key}.${content}`,
[propertyName]: key,
content,
}))
: { key, [propertyName]: key, content: value };
})
.flat();
items.forEach((item) => {
let [key, value] = computeKey(item);
// only set if key doesn't exist (i.e. let the furthest route win)
if (!meta.has(key)) {
meta.set(key, value);
}
});
} other needed helpers: function assertString(value: string | string[]): string {
if (typeof value !== "string") {
throw new Error("Expected string, got an array of strings");
}
return value;
}
function generateKey(item: HtmlMetaDescriptor) {
console.warn("No key provided to meta", item);
return JSON.stringify(item); // generate a reasonable key
}
function computeKey(item: HtmlMetaDescriptor): [string, HtmlMetaDescriptor] {
let {
name,
property,
title,
key = name ?? property ?? title ?? generateKey(item),
...rest
} = item;
return [key, { name, property, title, ...rest }];
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've made these changes, but a couple of comments:
With these changes, my local tests work. Still having issues with the integration test. |
||
meta: MetaMap, | ||
routeMeta: HtmlMetaDescriptor | HtmlMetaDescriptor[] | ||
) { | ||
// normalize routeMeta to array format | ||
let items: HtmlMetaDescriptor[] = Array.isArray(routeMeta) | ||
? routeMeta.map((item) => | ||
item.title | ||
? { key: "title", content: item.title } | ||
: { key: generateKey(item), ...item } | ||
) | ||
: Object.entries(routeMeta) | ||
.map(([key, value]) => { | ||
if (!value) return []; | ||
|
||
let propertyName = key.startsWith("og:") ? "property" : "name"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will need to be updated. See #4445 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, I'll review this. Basically the key should be either |
||
return key === "title" | ||
? { key: "title", content: assertString(value) } | ||
: ["charset", "charSet"].includes(key) | ||
? { key: "charset", content: assertString(value) } | ||
: Array.isArray(value) | ||
? value.map((content) => ({ | ||
key: `${key}.${content}`, | ||
[propertyName]: key, | ||
content, | ||
})) | ||
: typeof value === "object" | ||
? { | ||
key: `${key}.${(value as Record<string, string>)?.content}`, | ||
...(value as Record<string, string>), | ||
} | ||
: { | ||
key: propertyName === "name" ? key : `${key}.${value}`, | ||
[propertyName]: key, | ||
content: assertString(value), | ||
}; | ||
}) | ||
.flat(); | ||
|
||
// add items to the meta map by key (only add if content is defined) | ||
items.forEach(({ key, ...rest }) => { | ||
if (key && rest && rest.content) { | ||
meta.set(key, rest); | ||
} | ||
}); | ||
} | ||
|
||
function assertString(value: string | string[]): string { | ||
if (typeof value !== "string") { | ||
throw new Error("Expected string, got an array of strings"); | ||
} | ||
return value; | ||
} | ||
|
||
function generateKey(item: HtmlMetaDescriptor) { | ||
if (item.key) return item.key; | ||
if (item.title) return "title"; | ||
if (item.charset || item.charSet) return "charset"; | ||
if (item.name) return item.name; | ||
if (item.property) return `${item.property}.${item.content}`; | ||
return JSON.stringify(item); // generate a reasonable key | ||
} | ||
|
||
/** | ||
* Tracks whether Remix has finished hydrating or not, so scripts can be skipped | ||
* during client-side updates. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And here's the rendering part. The keys have been pre-computed in
processMeta