diff --git a/.changeset/meta-v2-enhancements.md b/.changeset/meta-v2-enhancements.md
new file mode 100644
index 00000000000..08bc9d5e875
--- /dev/null
+++ b/.changeset/meta-v2-enhancements.md
@@ -0,0 +1,6 @@
+---
+"@remix-run/react": minor
+"@remix-run/server-runtime": patch
+---
+
+Add support for generating `` and meta-related `` tags to document head via the route `meta` function when using the `v2_meta` future flag
diff --git a/integration/meta-test.ts b/integration/meta-test.ts
index b490e1861d2..a3a2b4eb2bf 100644
--- a/integration/meta-test.ts
+++ b/integration/meta-test.ts
@@ -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);
+
export default function Index() {
return
This is the index file
;
}
@@ -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 Bio here!
;
+ }
+ `,
+
"app/routes/music.jsx": js`
export function meta({ data, matches }) {
let rootModule = matches.find(match => match.route.id === "root");
@@ -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 ", 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('", "")
+ .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 ", async ({ page }) => {
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/authors/1");
+ expect(await app.getHtml('link[rel="canonical"]')).toBeTruthy();
+ });
});
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index 9c484975f08..52fe9d257e1 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -602,7 +602,7 @@ function V1Meta() {
}
if (["charset", "charSet"].includes(name)) {
- return ;
+ return ;
}
if (name === "title") {
@@ -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 ;
+ }
+
if ("title" in metaProps) {
return {String(metaProps.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" ? (
+
+ ) : null;
+ }
+
+ if ("script:ld+json" in metaProps) {
+ let json: string | null = null;
+ try {
+ json = JSON.stringify(metaProps["script:ld+json"]);
+ } catch (err) {}
return (
-
+ json != null && (
+
+ )
);
}
return ;
@@ -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 ? : ;
diff --git a/packages/remix-react/routeModules.ts b/packages/remix-react/routeModules.ts
index 147d799bdff..ab4c974c401 100644
--- a/packages/remix-react/routeModules.ts
+++ b/packages/remix-react/routeModules.ts
@@ -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.
diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts
index 25a7bb19820..5c5e825ddbc 100644
--- a/packages/remix-server-runtime/routeModules.ts
+++ b/packages/remix-server-runtime/routeModules.ts
@@ -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.