Skip to content

Commit 2bdeea4

Browse files
chaancefernandojbf
authored andcommitted
Add support for creating non-meta tags with v2_meta (remix-run#5746)
1 parent 8a6eb39 commit 2bdeea4

File tree

5 files changed

+157
-13
lines changed

5 files changed

+157
-13
lines changed

.changeset/meta-v2-enhancements.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@remix-run/react": minor
3+
"@remix-run/server-runtime": patch
4+
---
5+
6+
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

integration/meta-test.ts

+88-3
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,9 @@ test.describe("v2_meta", () => {
443443
`,
444444

445445
"app/routes/_index.jsx": js`
446-
export const meta = ({ data, matches }) => [
447-
...matches.map((match) => match.meta),
448-
];
446+
export const meta = ({ data, matches }) =>
447+
matches.flatMap((match) => match.meta);
448+
449449
export default function Index() {
450450
return <div>This is the index file</div>;
451451
}
@@ -464,6 +464,59 @@ test.describe("v2_meta", () => {
464464
}
465465
`,
466466

467+
"app/routes/authors.$authorId.jsx": js`
468+
import { json } from "@remix-run/node";
469+
470+
export async function loader({ params }) {
471+
return json({
472+
author: {
473+
id: params.authorId,
474+
name: "Sonny Day",
475+
address: {
476+
streetAddress: "123 Sunset Cliffs Blvd",
477+
city: "San Diego",
478+
state: "CA",
479+
zip: "92107",
480+
},
481+
emails: [
482+
"sonnyday@fancymail.com",
483+
"surfergal@veryprofessional.org",
484+
],
485+
},
486+
});
487+
}
488+
489+
export function meta({ data }) {
490+
let { author } = data;
491+
return [
492+
{ title: data.name + " Profile" },
493+
{
494+
tagName: "link",
495+
rel: "canonical",
496+
href: "https://website.com/authors/" + author.id,
497+
},
498+
{
499+
"script:ld+json": {
500+
"@context": "http://schema.org",
501+
"@type": "Person",
502+
"name": author.name,
503+
"address": {
504+
"@type": "PostalAddress",
505+
"streetAddress": author.address.streetAddress,
506+
"addressLocality": author.address.city,
507+
"addressRegion": author.address.state,
508+
"postalCode": author.address.zip,
509+
},
510+
"email": author.emails,
511+
},
512+
},
513+
];
514+
}
515+
export default function AuthorBio() {
516+
return <div>Bio here!</div>;
517+
}
518+
`,
519+
467520
"app/routes/music.jsx": js`
468521
export function meta({ data, matches }) {
469522
let rootModule = matches.find(match => match.route.id === "root");
@@ -531,4 +584,36 @@ test.describe("v2_meta", () => {
531584
await app.goto("/");
532585
expect(await app.getHtml('meta[property="og:image"]')).toBeTruthy();
533586
});
587+
588+
test("{ 'script:ld+json': {} } adds a <script type='application/ld+json' />", async ({
589+
page,
590+
}) => {
591+
let app = new PlaywrightFixture(appFixture, page);
592+
await app.goto("/authors/1");
593+
let scriptTag = await app.getHtml('script[type="application/ld+json"]');
594+
let scriptContents = scriptTag
595+
.replace('<script type="application/ld+json">', "")
596+
.replace("</script>", "")
597+
.trim();
598+
599+
expect(JSON.parse(scriptContents)).toEqual({
600+
"@context": "http://schema.org",
601+
"@type": "Person",
602+
name: "Sonny Day",
603+
address: {
604+
"@type": "PostalAddress",
605+
streetAddress: "123 Sunset Cliffs Blvd",
606+
addressLocality: "San Diego",
607+
addressRegion: "CA",
608+
postalCode: "92107",
609+
},
610+
email: ["sonnyday@fancymail.com", "surfergal@veryprofessional.org"],
611+
});
612+
});
613+
614+
test("{ tagName: 'link' } adds a <link />", async ({ page }) => {
615+
let app = new PlaywrightFixture(appFixture, page);
616+
await app.goto("/authors/1");
617+
expect(await app.getHtml('link[rel="canonical"]')).toBeTruthy();
618+
});
534619
});

packages/remix-react/components.tsx

+43-8
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ function V1Meta() {
602602
}
603603

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

608608
if (name === "title") {
@@ -719,18 +719,49 @@ function V2Meta() {
719719
return null;
720720
}
721721

722+
if ("tagName" in metaProps) {
723+
let tagName = metaProps.tagName;
724+
delete metaProps.tagName;
725+
if (!isValidMetaTag(tagName)) {
726+
console.warn(
727+
`A meta object uses an invalid tagName: ${tagName}. Expected either 'link' or 'meta'`
728+
);
729+
return null;
730+
}
731+
let Comp = tagName;
732+
return <Comp key={JSON.stringify(metaProps)} {...metaProps} />;
733+
}
734+
722735
if ("title" in metaProps) {
723736
return <title key="title">{String(metaProps.title)}</title>;
724737
}
725738

726-
if ("charSet" in metaProps || "charset" in metaProps) {
727-
// TODO: We normalize this for the user in v1, but should we continue
728-
// to do that? Seems like a nice convenience IMO.
739+
if ("charset" in metaProps) {
740+
metaProps.charSet ??= metaProps.charset;
741+
delete metaProps.charset;
742+
}
743+
744+
if ("charSet" in metaProps && metaProps.charSet != null) {
745+
return typeof metaProps.charSet === "string" ? (
746+
<meta key="charSet" charSet={metaProps.charSet} />
747+
) : null;
748+
}
749+
750+
if ("script:ld+json" in metaProps) {
751+
let json: string | null = null;
752+
try {
753+
json = JSON.stringify(metaProps["script:ld+json"]);
754+
} catch (err) {}
729755
return (
730-
<meta
731-
key="charset"
732-
charSet={metaProps.charSet || (metaProps as any).charset}
733-
/>
756+
json != null && (
757+
<script
758+
key="script:ld+json"
759+
type="application/ld+json"
760+
dangerouslySetInnerHTML={{
761+
__html: JSON.stringify(metaProps["script:ld+json"]),
762+
}}
763+
/>
764+
)
734765
);
735766
}
736767
return <meta key={JSON.stringify(metaProps)} {...metaProps} />;
@@ -739,6 +770,10 @@ function V2Meta() {
739770
);
740771
}
741772

773+
function isValidMetaTag(tagName: unknown): tagName is "meta" | "link" {
774+
return typeof tagName === "string" && /^(meta|link)$/.test(tagName);
775+
}
776+
742777
export function Meta() {
743778
let { future } = useRemixContext();
744779
return future?.v2_meta ? <V2Meta /> : <V1Meta />;

packages/remix-react/routeModules.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,16 @@ export type V2_HtmlMetaDescriptor =
124124
| { name: string; content: string }
125125
| { property: string; content: string }
126126
| { httpEquiv: string; content: string }
127-
| { [name: string]: string };
127+
| { "script:ld+json": LdJsonObject }
128+
| { tagName: "meta" | "link"; [name: string]: string }
129+
| { [name: string]: unknown };
130+
131+
type LdJsonObject = { [Key in string]: LdJsonValue } & {
132+
[Key in string]?: LdJsonValue | undefined;
133+
};
134+
type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[];
135+
type LdJsonPrimitive = string | number | boolean | null;
136+
type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray;
128137

129138
/**
130139
* A React component that is rendered for a route.

packages/remix-server-runtime/routeModules.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,16 @@ export type V2_HtmlMetaDescriptor =
221221
| { name: string; content: string }
222222
| { property: string; content: string }
223223
| { httpEquiv: string; content: string }
224-
| { [name: string]: string };
224+
| { "script:ld+json": LdJsonObject }
225+
| { tagName: "meta" | "link"; [name: string]: string }
226+
| { [name: string]: unknown };
227+
228+
type LdJsonObject = { [Key in string]: LdJsonValue } & {
229+
[Key in string]?: LdJsonValue | undefined;
230+
};
231+
type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[];
232+
type LdJsonPrimitive = string | number | boolean | null;
233+
type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray;
225234

226235
/**
227236
* A React component that is rendered for a route.

0 commit comments

Comments
 (0)