From 551fe43730fe30e3537cb5a9aed836f847e9ee88 Mon Sep 17 00:00:00 2001 From: akkki007 Date: Thu, 10 Sep 2026 07:04:55 +0530 Subject: [PATCH 1/4] feat: add Open Graph and Twitter Card social preview tags Links to badges.layer5.io did not unfurl with a preview card because the site emitted no og:* or twitter:* metadata. The title/description tags that index.js and discussion-leaderboard.js did render were placed in the component body rather than in , so they never reached the document head under React 18. Add a shared Seo component that renders title, description, canonical, Open Graph and Twitter Card tags, and wire it into each page through Gatsby's Head API. The card image is the existing Layer5 Recognition Program banner already in the repository. Supporting changes: - gatsby-config.js: add siteMetadata.social.twitter for twitter:site. - gatsby-node.js: declare title and description on SiteSiteMetadata. The type is explicitly created, which disables inference, so those fields were otherwise not queryable. Signed-off-by: akkki007 --- gatsby-config.js | 3 + gatsby-node.js | 2 + src/pages/404.js | 3 +- src/pages/discussion-leaderboard.js | 10 ++-- src/pages/index.js | 8 +-- src/sitecomponents/SEO/index.js | 88 +++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 src/sitecomponents/SEO/index.js diff --git a/gatsby-config.js b/gatsby-config.js index 89f6f96..8a3c1de 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -7,6 +7,9 @@ module.exports = { title: `Layer5 Recognition Program`, description: `Showcasing Your Achievements as a User and a Contributor`, siteUrl: `https://badges.layer5.io`, + social: { + twitter: `@layer5`, + }, }, plugins: [ `gatsby-plugin-styled-components`, diff --git a/gatsby-node.js b/gatsby-node.js index 84800ec..57b0667 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -94,6 +94,8 @@ exports.createSchemaCustomization = ({ actions }) => { // blog posts are stored inside "content/blog" instead of returning an error createTypes(` type SiteSiteMetadata { + title: String + description: String author: Author siteUrl: String social: Social diff --git a/src/pages/404.js b/src/pages/404.js index d0e63f3..60b169c 100644 --- a/src/pages/404.js +++ b/src/pages/404.js @@ -1,4 +1,5 @@ import * as React from "react" +import Seo from "../sitecomponents/SEO" const NotFoundPage = ({ location }) => { return ( @@ -9,6 +10,6 @@ const NotFoundPage = ({ location }) => { ) } -export const Head = () => 404: Not Found +export const Head = () => export default NotFoundPage \ No newline at end of file diff --git a/src/pages/discussion-leaderboard.js b/src/pages/discussion-leaderboard.js index 3942796..13e427d 100644 --- a/src/pages/discussion-leaderboard.js +++ b/src/pages/discussion-leaderboard.js @@ -12,6 +12,7 @@ import { } from '../sitecomponents/index.style'; import Navigation from '../sitecomponents/Navigation'; import Header from '../sitecomponents/Leaderboard/Header'; +import Seo from '../sitecomponents/SEO'; const LeaderBoard = () => { const [theme, toggleTheme] = useDarkMode(); @@ -38,11 +39,6 @@ const LeaderBoard = () => { theme === 'light' ? layer5LeaderboardLightMode : layer5LeaderboardDarkMode; return ( <> - Layer5 LeaderBoard - { }; export default LeaderBoard; + +export const Head = () => ( + +); diff --git a/src/pages/index.js b/src/pages/index.js index 22a6248..8685f30 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -16,6 +16,7 @@ import recognitionLogo from '../assets/images/recognition-program.png'; import recognitionBanner from '../assets/images/recognition-banner.png'; import '../fonts.css'; import GithubLogo from './githubLogo'; +import Seo from '../sitecomponents/SEO'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import layer5Logo from '../assets/images/layer5/layer5-badges.png'; import layer5LogoLight from '../assets/images/layer5/layer5-badges-white.png'; @@ -218,11 +219,6 @@ const App = () => { // }; return ( <> - Layer5 Recognition Program - @@ -380,3 +376,5 @@ const App = () => { }; export default App; + +export const Head = () => ; diff --git a/src/sitecomponents/SEO/index.js b/src/sitecomponents/SEO/index.js new file mode 100644 index 0000000..b631c22 --- /dev/null +++ b/src/sitecomponents/SEO/index.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { useStaticQuery, graphql } from 'gatsby'; +import defaultSocialImage from '../../assets/images/recognition-banner.png'; + +// Intrinsic dimensions of `defaultSocialImage`. Update alongside the image so +// that crawlers can lay the card out before the image itself is fetched. +const DEFAULT_SOCIAL_IMAGE_WIDTH = 3629; +const DEFAULT_SOCIAL_IMAGE_HEIGHT = 1599; + +/** + * Renders the document's SEO metadata, including Open Graph and Twitter Card + * tags so that links to the site unfurl with a rich preview on social media, + * Slack, Discord and other community channels. + * + * Intended to be used from a page's `Head` export: + * + * export const Head = () => ; + */ +const Seo = ({ title, description, image, pathname, children }) => { + const { site } = useStaticQuery(graphql` + query SeoMetadata { + site { + siteMetadata { + title + description + siteUrl + social { + twitter + } + } + } + } + `); + + const metadata = site.siteMetadata; + const siteUrl = metadata.siteUrl.replace(/\/$/, ''); + // Gatsby serves pages with a trailing slash, so keep the canonical and + // `og:url` values in step with the URL that is actually shared. + const path = (pathname || '/').replace(/\/?$/, '/'); + + const seo = { + title: title || metadata.title, + description: description || metadata.description, + url: `${siteUrl}${path}`, + image: `${siteUrl}${image || defaultSocialImage}`, + twitter: metadata.social?.twitter, + }; + + return ( + <> + {seo.title} + + + + + + + + + + + {!image && ( + + )} + {!image && ( + + )} + + + + + + + {seo.twitter && } + {seo.twitter && } + + {children} + + ); +}; + +export default Seo; From c645a46897cd2b1622092543558cfa948c02c1f7 Mon Sep 17 00:00:00 2001 From: akkki007 Date: Thu, 10 Sep 2026 07:13:42 +0530 Subject: [PATCH 2/4] fix: apply pathPrefix to canonical and og:url The site ships path-prefixed builds: build-preview-site.yml sets PATH_PREFIX and runs `npm run build -- --prefix-paths`. Page routes are served under that prefix, but the canonical and og:url values were built from the bare pathname, so they omitted it. Run the page path through Gatsby's withPrefix. Imported assets are left alone: webpack's publicPath already carries the prefix, so og:image was correct and passing it through withPrefix would apply the prefix twice. Verified both ways. With PATH_PREFIX=/test --prefix-paths, og:url and canonical are https://badges.layer5.io/test/... matching og:image; a plain build is unchanged at https://badges.layer5.io/... Signed-off-by: akkki007 --- src/sitecomponents/SEO/index.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/sitecomponents/SEO/index.js b/src/sitecomponents/SEO/index.js index b631c22..5fcbf47 100644 --- a/src/sitecomponents/SEO/index.js +++ b/src/sitecomponents/SEO/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useStaticQuery, graphql } from 'gatsby'; +import { useStaticQuery, graphql, withPrefix } from 'gatsby'; import defaultSocialImage from '../../assets/images/recognition-banner.png'; // Intrinsic dimensions of `defaultSocialImage`. Update alongside the image so @@ -41,7 +41,10 @@ const Seo = ({ title, description, image, pathname, children }) => { const seo = { title: title || metadata.title, description: description || metadata.description, - url: `${siteUrl}${path}`, + // Page routes need `withPrefix` for path-prefixed builds. Imported assets + // do not: webpack's publicPath already carries the prefix, so passing + // `defaultSocialImage` through `withPrefix` would apply it twice. + url: `${siteUrl}${withPrefix(path)}`, image: `${siteUrl}${image || defaultSocialImage}`, twitter: metadata.social?.twitter, }; From 74b0579c5d574145bff9a15222d98a150ccd4115 Mon Sep 17 00:00:00 2001 From: akkki007 Date: Thu, 10 Sep 2026 16:00:52 +0530 Subject: [PATCH 3/4] fix: keep canonical and og:url on the production route Per review: PATH_PREFIX is only ever set by build-preview-site.yml, and a PR preview is an ephemeral deployment. Pointing a preview's canonical at its own preview path is not wanted, so the page route stays unprefixed and resolves to the production URL. Assets still need the prefix, from the two sources that supply it: - an imported asset is prefixed by webpack's publicPath, so defaultSocialImage must not go through withPrefix as well - a caller-supplied path out of static/ is not, so the `image` prop does Verified with PATH_PREFIX=/test --prefix-paths. canonical and og:url are https://badges.layer5.io/... with no prefix; og:image is .../test/static/recognition-banner-.png for the default and .../test/assets/... for an `image` override, each prefixed exactly once. A plain root build is unchanged. Signed-off-by: akkki007 --- src/sitecomponents/SEO/index.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/sitecomponents/SEO/index.js b/src/sitecomponents/SEO/index.js index 5fcbf47..f94c029 100644 --- a/src/sitecomponents/SEO/index.js +++ b/src/sitecomponents/SEO/index.js @@ -35,17 +35,22 @@ const Seo = ({ title, description, image, pathname, children }) => { const metadata = site.siteMetadata; const siteUrl = metadata.siteUrl.replace(/\/$/, ''); // Gatsby serves pages with a trailing slash, so keep the canonical and - // `og:url` values in step with the URL that is actually shared. + // `og:url` values in step with the production URL that is actually shared. const path = (pathname || '/').replace(/\/?$/, '/'); const seo = { title: title || metadata.title, description: description || metadata.description, - // Page routes need `withPrefix` for path-prefixed builds. Imported assets - // do not: webpack's publicPath already carries the prefix, so passing - // `defaultSocialImage` through `withPrefix` would apply it twice. - url: `${siteUrl}${withPrefix(path)}`, - image: `${siteUrl}${image || defaultSocialImage}`, + // Deliberately unprefixed. `pathPrefix` is only ever set by the PR preview + // workflow, which is an ephemeral deployment; production is served from the + // root of siteUrl. The canonical and og:url of a preview should therefore + // point at the production route, not at the preview path. + url: `${siteUrl}${path}`, + // Assets do need the prefix. An imported asset gets it from webpack's + // publicPath, so `defaultSocialImage` is already prefixed and must not be + // passed through `withPrefix` again; a caller-supplied path out of + // `static/` does not, so it goes through `withPrefix`. + image: `${siteUrl}${image ? withPrefix(image) : defaultSocialImage}`, twitter: metadata.social?.twitter, }; From 321719b48e3099f0d6c0dc7c995d93a9eaa46d16 Mon Sep 17 00:00:00 2001 From: akkki007 Date: Mon, 14 Sep 2026 23:37:11 +0530 Subject: [PATCH 4/4] fix: guard og:image qualification and drop the 404 canonical Both per review. withPrefix returns an absolute URL unchanged, and webpack inlines a small enough asset as a data: URI, so prepending siteUrl unconditionally could emit "https://badges.layer5.iohttps://...". Qualify only a root-relative path and pass anything else through as-is. The 404 is served for every unknown path, so a canonical of /404/ claims a URL the visitor did not request. Seo now omits canonical and og:url when no pathname is given, and 404.js gives none. Verified against a throwaway probe page for the image cases, in both build modes: - image="https://cdn.example.com/card.png" -> emitted unchanged - image="/assets/badges/..." -> prefixed exactly once - default imported banner -> prefixed exactly once - 404 -> no canonical, no og:url Signed-off-by: akkki007 --- src/pages/404.js | 2 +- src/sitecomponents/SEO/index.js | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/pages/404.js b/src/pages/404.js index 60b169c..9a18cee 100644 --- a/src/pages/404.js +++ b/src/pages/404.js @@ -10,6 +10,6 @@ const NotFoundPage = ({ location }) => { ) } -export const Head = () => +export const Head = () => export default NotFoundPage \ No newline at end of file diff --git a/src/sitecomponents/SEO/index.js b/src/sitecomponents/SEO/index.js index f94c029..b4ad8c0 100644 --- a/src/sitecomponents/SEO/index.js +++ b/src/sitecomponents/SEO/index.js @@ -36,7 +36,16 @@ const Seo = ({ title, description, image, pathname, children }) => { const siteUrl = metadata.siteUrl.replace(/\/$/, ''); // Gatsby serves pages with a trailing slash, so keep the canonical and // `og:url` values in step with the production URL that is actually shared. - const path = (pathname || '/').replace(/\/?$/, '/'); + // A page with no URL of its own omits `pathname` and gets neither tag: the + // 404 is served for every unknown path, so claiming `/404/` as its canonical + // would be a claim about a URL the visitor did not request. + const path = pathname ? pathname.replace(/\/?$/, '/') : null; + + // Assets do need the prefix. An imported asset gets it from webpack's + // publicPath, so `defaultSocialImage` is already prefixed and must not be + // passed through `withPrefix` again; a caller-supplied path out of + // `static/` does not, so it goes through `withPrefix`. + const imageSrc = image ? withPrefix(image) : defaultSocialImage; const seo = { title: title || metadata.title, @@ -45,12 +54,11 @@ const Seo = ({ title, description, image, pathname, children }) => { // workflow, which is an ephemeral deployment; production is served from the // root of siteUrl. The canonical and og:url of a preview should therefore // point at the production route, not at the preview path. - url: `${siteUrl}${path}`, - // Assets do need the prefix. An imported asset gets it from webpack's - // publicPath, so `defaultSocialImage` is already prefixed and must not be - // passed through `withPrefix` again; a caller-supplied path out of - // `static/` does not, so it goes through `withPrefix`. - image: `${siteUrl}${image ? withPrefix(image) : defaultSocialImage}`, + url: path && `${siteUrl}${path}`, + // Only a root-relative path is ours to qualify. `withPrefix` returns an + // absolute URL unchanged, and webpack inlines a small enough asset as a + // `data:` URI, so prepending siteUrl unconditionally would corrupt both. + image: imageSrc.startsWith('/') ? `${siteUrl}${imageSrc}` : imageSrc, twitter: metadata.social?.twitter, }; @@ -58,13 +66,13 @@ const Seo = ({ title, description, image, pathname, children }) => { <> {seo.title} - + {seo.url && } - + {seo.url && } {!image && (