Skip to content
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

Add support for creating non-meta tags with v2_meta #5746

Merged
merged 8 commits into from
Mar 10, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/meta-v2-enhancements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@remix-run/react": minor
"@remix-run/server-runtime": patch
---

Add support for generating `<script type='application/ld+json' />` and meta-related `<link />` tags to document head via the route `meta` function when using the `v2_meta` future flag
91 changes: 88 additions & 3 deletions integration/meta-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,9 @@ test.describe("v2_meta", () => {
`,

"app/routes/_index.jsx": js`
export const meta = ({ data, matches }) => [
...matches.map((match) => match.meta),
];
export const meta = ({ data, matches }) =>
matches.flatMap((match) => match.meta);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While both technically work (React renders nested arrays of arrays just fine), the output of map doesn't match our return type API.


export default function Index() {
return <div>This is the index file</div>;
}
Expand All @@ -464,6 +464,59 @@ test.describe("v2_meta", () => {
}
`,

"app/routes/authors.$authorId.jsx": js`
import { json } from "@remix-run/node";

export async function loader({ params }) {
return json({
author: {
id: params.authorId,
name: "Sonny Day",
address: {
streetAddress: "123 Sunset Cliffs Blvd",
city: "San Diego",
state: "CA",
zip: "92107",
},
emails: [
"sonnyday@fancymail.com",
"surfergal@veryprofessional.org",
],
},
});
}

export function meta({ data }) {
let { author } = data;
return [
{ title: data.name + " Profile" },
{
tagName: "link",
rel: "canonical",
href: "https://website.com/authors/" + author.id,
},
{
"script:ld+json": {
"@context": "http://schema.org",
"@type": "Person",
"name": author.name,
"address": {
"@type": "PostalAddress",
"streetAddress": author.address.streetAddress,
"addressLocality": author.address.city,
"addressRegion": author.address.state,
"postalCode": author.address.zip,
},
"email": author.emails,
},
},
];
}
export default function AuthorBio() {
return <div>Bio here!</div>;
}
`,

"app/routes/music.jsx": js`
export function meta({ data, matches }) {
let rootModule = matches.find(match => match.route.id === "root");
Expand Down Expand Up @@ -531,4 +584,36 @@ test.describe("v2_meta", () => {
await app.goto("/");
expect(await app.getHtml('meta[property="og:image"]')).toBeTruthy();
});

test("{ 'script:ld+json': {} } adds a <script type='application/ld+json' />", async ({
page,
}) => {
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/authors/1");
let scriptTag = await app.getHtml('script[type="application/ld+json"]');
let scriptContents = scriptTag
.replace('<script type="application/ld+json">', "")
.replace("</script>", "")
.trim();

expect(JSON.parse(scriptContents)).toEqual({
"@context": "http://schema.org",
"@type": "Person",
name: "Sonny Day",
address: {
"@type": "PostalAddress",
streetAddress: "123 Sunset Cliffs Blvd",
addressLocality: "San Diego",
addressRegion: "CA",
postalCode: "92107",
},
email: ["sonnyday@fancymail.com", "surfergal@veryprofessional.org"],
});
});

test("{ tagName: 'link' } adds a <link />", async ({ page }) => {
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/authors/1");
expect(await app.getHtml('link[rel="canonical"]')).toBeTruthy();
});
});
51 changes: 43 additions & 8 deletions packages/remix-react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ function V1Meta() {
}

if (["charset", "charSet"].includes(name)) {
return <meta key="charset" charSet={value as string} />;
return <meta key="charSet" charSet={value as string} />;
}

if (name === "title") {
Expand Down Expand Up @@ -719,18 +719,49 @@ function V2Meta() {
return null;
}

if ("tagName" in metaProps) {
let tagName = metaProps.tagName;
delete metaProps.tagName;
if (!isValidMetaTag(tagName)) {
console.warn(
`A meta object uses an invalid tagName: ${tagName}. Expected either 'link' or 'meta'`
);
return null;
}
let Comp = tagName;
return <Comp key={JSON.stringify(metaProps)} {...metaProps} />;
}

if ("title" in metaProps) {
return <title key="title">{String(metaProps.title)}</title>;
}

if ("charSet" in metaProps || "charset" in metaProps) {
// TODO: We normalize this for the user in v1, but should we continue
// to do that? Seems like a nice convenience IMO.
if ("charset" in metaProps) {
metaProps.charSet ??= metaProps.charset;
delete metaProps.charset;
}

if ("charSet" in metaProps && metaProps.charSet != null) {
return typeof metaProps.charSet === "string" ? (
<meta key="charSet" charSet={metaProps.charSet} />
) : null;
}

if ("script:ld+json" in metaProps) {
let json: string | null = null;
try {
json = JSON.stringify(metaProps["script:ld+json"]);
} catch (err) {}
return (
<meta
key="charset"
charSet={metaProps.charSet || (metaProps as any).charset}
/>
json != null && (
<script
key="script:ld+json"
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(metaProps["script:ld+json"]),
}}
/>
)
);
}
return <meta key={JSON.stringify(metaProps)} {...metaProps} />;
Expand All @@ -739,6 +770,10 @@ function V2Meta() {
);
}

function isValidMetaTag(tagName: unknown): tagName is "meta" | "link" {
return typeof tagName === "string" && /^(meta|link)$/.test(tagName);
}

export function Meta() {
let { future } = useRemixContext();
return future?.v2_meta ? <V2Meta /> : <V1Meta />;
Expand Down
11 changes: 10 additions & 1 deletion packages/remix-react/routeModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,16 @@ export type V2_HtmlMetaDescriptor =
| { name: string; content: string }
| { property: string; content: string }
| { httpEquiv: string; content: string }
| { [name: string]: string };
| { "script:ld+json": LdJsonObject }
| { tagName: "meta" | "link"; [name: string]: string }
| { [name: string]: unknown };

type LdJsonObject = { [Key in string]: LdJsonValue } & {
[Key in string]?: LdJsonValue | undefined;
};
type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[];
type LdJsonPrimitive = string | number | boolean | null;
type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray;

/**
* A React component that is rendered for a route.
Expand Down
11 changes: 10 additions & 1 deletion packages/remix-server-runtime/routeModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,16 @@ export type V2_HtmlMetaDescriptor =
| { name: string; content: string }
| { property: string; content: string }
| { httpEquiv: string; content: string }
| { [name: string]: string };
| { "script:ld+json": LdJsonObject }
| { tagName: "meta" | "link"; [name: string]: string }
| { [name: string]: unknown };

type LdJsonObject = { [Key in string]: LdJsonValue } & {
[Key in string]?: LdJsonValue | undefined;
};
type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[];
type LdJsonPrimitive = string | number | boolean | null;
type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray;

/**
* A React component that is rendered for a route.
Expand Down