From e469bf45946d31c64ed147c947460b95b85b72e7 Mon Sep 17 00:00:00 2001 From: Iha Shin Date: Thu, 4 Jun 2026 22:11:27 +0900 Subject: [PATCH] Setup OpenGraph/Twitter Card to the website --- package-lock.json | 241 ++++++++++++++++++++++++++++++++++++ package.json | 1 + scripts/build-website.js | 233 ++++++++++++++++++++++++++++++++-- website/templates/page.html | 1 + 4 files changed, 469 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index e69676e..62a2a1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "@graphql/gaps", "devDependencies": { "@mlarah/spec-md": "^3.1.0", + "@resvg/resvg-js": "^2.6.2", "ajv": "^8.17.1", "cspell": "5.9.1", "handlebars": "^4.7.9", @@ -369,6 +370,246 @@ "node": ">=16" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", diff --git a/package.json b/package.json index a2621eb..84d4728 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@mlarah/spec-md": "^3.1.0", + "@resvg/resvg-js": "^2.6.2", "ajv": "^8.17.1", "cspell": "5.9.1", "handlebars": "^4.7.9", diff --git a/scripts/build-website.js b/scripts/build-website.js index af56b78..fd158dc 100755 --- a/scripts/build-website.js +++ b/scripts/build-website.js @@ -24,6 +24,7 @@ import Handlebars from "handlebars"; import merge from "lodash.merge"; import memoize from "lodash.memoize"; import pLimit from "p-limit"; +import { Resvg } from "@resvg/resvg-js"; import { parse as parseYaml } from "yaml"; const require = createRequire(import.meta.url); @@ -35,11 +36,24 @@ const logoAssetPath = join(websiteDir, "assets", "graphql-logo-wordmark.svg"); const siteCssPath = join(websiteDir, "site.css"); const templatesDir = join(websiteDir, "templates"); +const siteName = "GraphQL Auxiliary Proposals"; +const siteUrl = "https://gaps.graphql.org/"; +const siteDescription = + "Community specifications and auxiliary proposals outside the core GraphQL specification."; +const openGraphImage = { + dir: "assets/opengraph", + width: 1200, + height: 630, + type: "image/png", +}; + async function findGapDirs(parent) { return (await readdir(parent, { withFileTypes: true })) .filter((entry) => entry.isDirectory() && entry.name.startsWith("GAP-")) .map((entry) => entry.name) - .sort((a, b) => parseInt(a.split("-")[1], 10) - parseInt(b.split("-")[1], 10)) + .sort( + (a, b) => parseInt(a.split("-")[1], 10) - parseInt(b.split("-")[1], 10), + ) .map((name) => join(parent, name)); } @@ -91,6 +105,168 @@ function titleCase(value) { .join(" "); } +function renderHeadMetadata({ title, description, path, type, imagePath }) { + const canonicalUrl = new URL(path, siteUrl).href; + const imageUrl = new URL(imagePath, siteUrl).href; + const escaped = { + title: Handlebars.escapeExpression(title.replace(/\s+/g, " ")), + description: Handlebars.escapeExpression(description.replace(/\s+/g, " ")), + type: Handlebars.escapeExpression(type), + siteName: Handlebars.escapeExpression(siteName), + canonicalUrl: Handlebars.escapeExpression(canonicalUrl), + imageUrl: Handlebars.escapeExpression(imageUrl), + imageType: Handlebars.escapeExpression(openGraphImage.type), + }; + + return [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + '', + ``, + ``, + ``, + ].join("\n"); +} + +function wrapText(value, maxLineLength, maxLines) { + const words = value.replace(/\s+/g, " ").split(" "); + const lines = []; + let line = ""; + + for (const word of words) { + const nextLine = line ? `${line} ${word}` : word; + if (nextLine.length <= maxLineLength) { + line = nextLine; + continue; + } + + if (line) { + lines.push(line); + line = word; + } else { + lines.push(word); + } + + if (lines.length === maxLines) { + break; + } + } + + if (line && lines.length < maxLines) { + lines.push(line); + } + + const consumedLength = lines.join(" ").length; + const normalized = value.replace(/\s+/g, " "); + if (consumedLength < normalized.length && lines.length > 0) { + const lastLine = lines[lines.length - 1]; + lines[lines.length - 1] = lastLine.endsWith("…") + ? lastLine + : lastLine.length >= maxLineLength + ? lastLine + .replace(/\s+$/g, " ") + .slice(0, maxLineLength - 1) + .trimEnd() + "…" + : `${lastLine}…`; + } + + return lines; +} + +function renderTextLines(lines, { x, y, lineHeight, className }) { + return lines + .map( + (line, index) => + `${Handlebars.escapeExpression(line)}`, + ) + .join("\n"); +} + +let graphQLLogoWordmarkPromise; + +async function writeOpenGraphImage(outDir, image) { + const imagePath = `${openGraphImage.dir}/${image.name}.png`; + const outputPath = join(outDir, imagePath); + const titleLines = wrapText(image.title, 32, 2); + const titleFontSize = 60; + const titleLineHeight = titleFontSize + 10; + const descriptionY = 350 + (titleLines.length - 1) * titleLineHeight; + const descriptionLines = wrapText(image.description, 70, 3); + + graphQLLogoWordmarkPromise ??= readFile( + join(websiteDir, "assets", "graphql-logo-wordmark.svg"), + "utf8", + ).then((source) => + source + .replace(/ + + + + ${logoWordmark} + ${siteName} + ${renderTextLines(titleLines, { + x: 92, + y: 284, + lineHeight: titleLineHeight, + className: "title", + })} + ${renderTextLines(descriptionLines, { + x: 96, + y: descriptionY, + lineHeight: 43, + className: "description", + })} +`; + const resvg = new Resvg(svg, { + fitTo: { + mode: "width", + value: openGraphImage.width, + }, + font: { + loadSystemFonts: true, + defaultFontFamily: "Arial", + }, + }); + const png = resvg.render().asPng(); + + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, png); + + return imagePath; +} const readTemplate = memoize(async (name) => { const templatePath = join(templatesDir, name); @@ -183,6 +359,11 @@ async function renderGapVersionRows(gap) { async function renderPage({ pageTitle, + pageDescription, + pagePath, + openGraphTitle, + openGraphType = "website", + openGraphImagePath, assetPrefix = "", header, mainClass = "wrap", @@ -195,6 +376,13 @@ async function renderPage({ return pageTemplate({ pageTitle, + headMetadataHtml: renderHeadMetadata({ + title: openGraphTitle, + description: pageDescription, + path: pagePath, + type: openGraphType, + imagePath: openGraphImagePath, + }), assetPrefix, headerHtml: headerTemplate({ assetPrefix, @@ -223,6 +411,11 @@ async function renderGapOverview(gap) { return renderPage({ pageTitle: `${gap.name}: ${gap.title} | GraphQL Auxiliary Proposals`, + pageDescription: gap.summary, + pagePath: gap.href, + openGraphTitle: `${gap.name}: ${gap.title}`, + openGraphType: "article", + openGraphImagePath: gap.openGraphImagePath, assetPrefix: "../", header: { eyebrow: "GAPs Directory", @@ -241,16 +434,20 @@ async function renderGapOverview(gap) { }); } -async function renderIndex(manifest) { +async function renderIndex(manifest, openGraphImagePath) { const indexTemplate = await readTemplate("index.html"); const gapRows = await Promise.all(manifest.gaps.map(renderGapRow)); return renderPage({ - pageTitle: "GraphQL Auxiliary Proposals", + pageTitle: siteName, + pageDescription: siteDescription, + pagePath: "", + openGraphTitle: siteName, + openGraphImagePath, header: { - eyebrow: "GraphQL Auxiliary Proposals", + eyebrow: siteName, title: "GAPs Directory", - lede: "Community specifications and auxiliary proposals outside the core GraphQL specification.", + lede: siteDescription, }, mainHtml: indexTemplate({ gapRowsHtml: gapRows.join("\n"), @@ -269,6 +466,12 @@ async function buildGap(gapDir, outDir) { ? JSON.parse(await readFile(specMetadataPath, "utf8")) : {}; + const openGraphImagePath = await writeOpenGraphImage(outDir, { + name: gapName, + title: `${gapName}: ${gapMetadata.title}`, + description: gapMetadata.summary, + }); + const documents = await discoverDocuments(gapDir, gapName); const builtDocuments = await Promise.all( documents.map(async (document) => { @@ -284,6 +487,13 @@ async function buildGap(gapDir, outDir) { Discussion: gapMetadata.discussion, }, }); + metadata.head = renderHeadMetadata({ + title: `${gapName}: ${gapMetadata.title} - ${document.label}`, + description: gapMetadata.summary, + path: document.href, + type: "article", + imagePath: openGraphImagePath, + }); const documentOutputPath = join(documentOutDir, "index.html"); await writeFile( @@ -316,6 +526,7 @@ async function buildGap(gapDir, outDir) { summary: gapMetadata.summary, href: `${gapName}/`, documents: builtDocuments, + openGraphImagePath, }; await writeFile( @@ -363,14 +574,22 @@ async function main() { const manifest = { source: relative(rootDir, gapsParentDir) || ".", - gaps, + gaps: gaps.map(({ openGraphImagePath: _, ...gap }) => gap), }; + const indexOpenGraphImagePath = await writeOpenGraphImage(outDir, { + name: "index", + title: "GAPs Directory", + description: siteDescription, + }); await writeFile( join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, ); - await writeFile(join(outDir, "index.html"), await renderIndex(manifest)); + await writeFile( + join(outDir, "index.html"), + await renderIndex(manifest, indexOpenGraphImagePath), + ); console.log(`Built site in ${relative(rootDir, outDir)}`); } diff --git a/website/templates/page.html b/website/templates/page.html index 00a995e..89692c9 100644 --- a/website/templates/page.html +++ b/website/templates/page.html @@ -4,6 +4,7 @@ {{pageTitle}} + {{{headMetadataHtml}}}