Add sharing meta tags to outfit pages
Meta tags are a bit tricky in apps built with `create-react-app`! While some bots like Google are able to render the full page when crawling, not all bots are. Most will just see the empty-ish index.html that would normally load up the application. But we want outfit sharing to work! And be cool! And use our new outfit thumbnails! In this change, we add a new server-side rendering API route to handle `/outfits/:id`. It's very weak server-side rendering: it just loads index.html, and makes a few small tweaks inside the `<head>` tag. But it should be enough for sharing to work in clients that support the basics of Open Graph, which I think most major providers respect! (I know Twitter has their own tags, but it also respects the basics of OG, so let's see whether there's anything we end up _wanting_ to tweak or not!)
This commit is contained in:
parent
fddeeecbdb
commit
8f83ac412c
4 changed files with 144 additions and 1 deletions
141
api/outfitPageSSR.js
Normal file
141
api/outfitPageSSR.js
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
/**
|
||||||
|
* /api/outfitPageSSR also serves the initial request for /outfits/:id, to
|
||||||
|
* add title and meta tags. This primarily for sharing, like on Discord or
|
||||||
|
* Twitter or Facebook!
|
||||||
|
*
|
||||||
|
* The route is configured in vercel.json, at the project root.
|
||||||
|
*
|
||||||
|
* TODO: We could add the basic outfit page layout and image preview, to use
|
||||||
|
* SSR to decrease time-to-first-content for the end-user, too…
|
||||||
|
*/
|
||||||
|
const beeline = require("honeycomb-beeline")({
|
||||||
|
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
|
||||||
|
dataset:
|
||||||
|
process.env["NODE_ENV"] === "production"
|
||||||
|
? "Dress to Impress (2020)"
|
||||||
|
: "Dress to Impress (2020, dev)",
|
||||||
|
serviceName: "impress-2020-gql-server",
|
||||||
|
});
|
||||||
|
|
||||||
|
import escapeHtml from "escape-html";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
import connectToDb from "../src/server/db";
|
||||||
|
import { normalizeRow } from "../src/server/util";
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
// Load index.html as our initial page content. If this fails, it probably
|
||||||
|
// means something is misconfigured in a big way; we don't have a great way
|
||||||
|
// to recover, and we'll just show an error message.
|
||||||
|
let initialHtml;
|
||||||
|
try {
|
||||||
|
initialHtml = await loadIndexPageHtml();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading index.html:", e);
|
||||||
|
return reject(res, "Sorry, there was an error loading this outfit page!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the given outfit by ID. If this fails, it's possible that it's just a
|
||||||
|
// problem with the SSR, and the client will be able to handle it better
|
||||||
|
// anyway, so just show the standard index.html and let the app load
|
||||||
|
// normally, as if there was no error. (We'll just log it.)
|
||||||
|
let outfit;
|
||||||
|
try {
|
||||||
|
outfit = await loadOutfitData(req.query.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading outfit data:", e);
|
||||||
|
return sendHtml(res, initialHtml, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similarly, if the outfit isn't found, we just show index.html - but with a
|
||||||
|
// 404 and a gentler log message.
|
||||||
|
if (outfit == null) {
|
||||||
|
console.info(`Outfit not found: ${req.query.id}`);
|
||||||
|
return sendHtml(res, initialHtml, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Okay, now let's rewrite the HTML to include some outfit data!
|
||||||
|
//
|
||||||
|
// WARNING!!!
|
||||||
|
// Be sure to always use `escapeHtml` when inserting user data!!
|
||||||
|
// WARNING!!!
|
||||||
|
//
|
||||||
|
let html = initialHtml;
|
||||||
|
|
||||||
|
// Add the outfit name to the title.
|
||||||
|
html = html.replace(
|
||||||
|
/<title>(.*)<\/title>/,
|
||||||
|
`<title>${escapeHtml(outfit.name)} | Dress to Impress</title>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add sharing meta tags just before the </head> tag.
|
||||||
|
const updatedAtTimestamp = Math.floor(
|
||||||
|
new Date(outfit.updatedAt).getTime() / 1000
|
||||||
|
);
|
||||||
|
const outfitUrl = `https://impress-2020.openneo.net/outfits/${encodeURIComponent(
|
||||||
|
outfit.id
|
||||||
|
)}`;
|
||||||
|
const imageUrl = `https://impress-2020.openneo.net/api/outfitImage?size=300&id=${encodeURIComponent(
|
||||||
|
outfit.id
|
||||||
|
)}&updatedAt=${updatedAtTimestamp}`;
|
||||||
|
const metaTags = `
|
||||||
|
<meta property="og:title" content="${escapeHtml(outfit.name)}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:image" content="${escapeHtml(imageUrl)}">
|
||||||
|
<meta property="og:url" content="${escapeHtml(outfitUrl)}">
|
||||||
|
<meta property="og:site_name" content="Dress to Impress">
|
||||||
|
<meta property="og:description" content="A custom Neopets outfit, designed on Dress to Impress!">
|
||||||
|
`;
|
||||||
|
html = html.replace(/<\/head>/, `${metaTags}</head>`);
|
||||||
|
|
||||||
|
console.info(`Successfully SSR'd outfit ${outfit.id}`);
|
||||||
|
|
||||||
|
return sendHtml(res, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOutfitData(id) {
|
||||||
|
const db = await connectToDb();
|
||||||
|
const [rows] = await db.query(`SELECT * FROM outfits WHERE id = ?;`, [id]);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeRow(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedIndexPageHtml = null;
|
||||||
|
async function loadIndexPageHtml() {
|
||||||
|
if (cachedIndexPageHtml == null) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
const htmlFromDevServer = await fetch(
|
||||||
|
"http://localhost:3000/"
|
||||||
|
).then((res) => res.text());
|
||||||
|
cachedIndexPageHtml = htmlFromDevServer;
|
||||||
|
} else {
|
||||||
|
const htmlFromFile = await fs.readFile("../build/index.html", "utf8");
|
||||||
|
cachedIndexPageHtml = htmlFromFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedIndexPageHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(res, message, status = 400) {
|
||||||
|
res.setHeader("Content-Type", "text/plain");
|
||||||
|
return res.status(status).send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHtml(res, html, status = 200) {
|
||||||
|
res.setHeader("Content-Type", "text/html");
|
||||||
|
return res.status(status).send(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWithBeeline(req, res) {
|
||||||
|
beeline.withTrace(
|
||||||
|
{ name: "api/outfitPageSSR", operation_name: "api/outfitPageSSR" },
|
||||||
|
() => handle(req, res)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default handleWithBeeline;
|
|
@ -29,6 +29,7 @@
|
||||||
"canvas": "^2.6.1",
|
"canvas": "^2.6.1",
|
||||||
"dataloader": "^2.0.0",
|
"dataloader": "^2.0.0",
|
||||||
"dompurify": "^2.2.0",
|
"dompurify": "^2.2.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
"framer-motion": "^4.1.11",
|
"framer-motion": "^4.1.11",
|
||||||
"graphql": "^15.5.0",
|
"graphql": "^15.5.0",
|
||||||
"honeycomb-beeline": "^2.2.0",
|
"honeycomb-beeline": "^2.2.0",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"routes": [
|
"routes": [
|
||||||
|
{ "src": "/outfits/(?<id>[^/]*)", "dest": "/api/outfitPageSSR.js?id=$id" },
|
||||||
{
|
{
|
||||||
"handle": "filesystem"
|
"handle": "filesystem"
|
||||||
},
|
},
|
||||||
|
|
|
@ -9542,7 +9542,7 @@ escape-goat@^2.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
|
resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
|
||||||
integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
|
integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
|
||||||
|
|
||||||
escape-html@~1.0.3:
|
escape-html@^1.0.3, escape-html@~1.0.3:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||||
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
|
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
|
||||||
|
|
Loading…
Reference in a new issue