mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
Add social preview card rendering and deployment
- Introduced `OgCard` React component as the default Open Graph preview card for social sharing (1200x630). - Added `BrandFonts` module to load web fonts for accurate rendering. - Integrated `OgCard` into the media creator pipeline as a `<Still>` with a new `still:og` script. - Updated upload script to version the output and ensure compatibility with caching policies. - Included new card meta tags across web pages, defaulting to this design for links without a custom preview. - Extended theme and README to document font usage, card details, and deployment workflow.
This commit is contained in:
@@ -26,4 +26,26 @@ export const SENTRY_DSN =
|
||||
|
||||
export const PNG_LOGO = 'https://www.compassmeet.com/icons/icon-512x512.png'
|
||||
|
||||
/**
|
||||
* The default social preview card — what WhatsApp, X, Slack, LinkedIn etc. show for a shared
|
||||
* compassmeet.com link. 1200×630, the 1.91:1 `summary_large_image` slot.
|
||||
*
|
||||
* Rendered from media-creator (`npm run still:og`), stored in Cloudflare R2, and pulled into
|
||||
* web/public/images at build time by web/scripts/fetch-media.mjs — so it is served same-origin.
|
||||
*
|
||||
* The filename is versioned deliberately: WhatsApp and X cache a preview image *by URL* and
|
||||
* effectively never revalidate it. A redesign has to ship as `-v2`, otherwise every link already
|
||||
* shared keeps showing the old card forever.
|
||||
*
|
||||
* Absolute and hardcoded to prod (like PNG_LOGO above) because crawlers resolve it from wherever
|
||||
* the page is served — a preview deployment linking to its own ephemeral URL would rot.
|
||||
*/
|
||||
export const OG_CARD = {
|
||||
url: 'https://www.compassmeet.com/images/og-card-v1.jpg',
|
||||
type: 'image/jpeg',
|
||||
width: '1200',
|
||||
height: '630',
|
||||
alt: "Compass — Don't Swipe. Search. Find your people based on your values and interests.",
|
||||
} as const
|
||||
|
||||
// console.log('IS_LOCAL_ANDROID', IS_LOCAL_ANDROID)
|
||||
|
||||
@@ -99,6 +99,50 @@ canvas. Formats are defined in `src/theme.ts` (`FORMATS`) and registered in `src
|
||||
|
||||
> Don't post the 9:16 story as a feed post — Instagram crops it to ~4:5. Use `IntroPost` for the feed.
|
||||
|
||||
## Stills
|
||||
|
||||
### Social preview card (`OgCard`)
|
||||
|
||||
The default link preview — what WhatsApp, X, Slack, LinkedIn and friends show for a shared
|
||||
compassmeet.com link. 1200×630, the 1.91:1 `summary_large_image` slot. A `<Still>`, not a
|
||||
`<Composition>`: there is no timeline.
|
||||
|
||||
```bash
|
||||
npm run still:og # -> out/compass-og-card.jpg (~80 KB, well under WhatsApp's ceiling)
|
||||
```
|
||||
|
||||
It covers every page that does not set its own image; pages that do (a profile) get the separate
|
||||
per-profile card generated at runtime by `web/pages/api/og/profile.tsx`. The two deliberately share
|
||||
a visual language — warm cream canvas, amber rules, serif voice — so a shared profile and a shared
|
||||
home link look like they come from the same place.
|
||||
|
||||
Publishing it is the usual R2 loop, plus one extra rule:
|
||||
|
||||
```bash
|
||||
npm run still:og
|
||||
npm run upload:media # uploads it as images/og-card-v1.jpg alongside the clips
|
||||
# then re-deploy: the web build pulls it into web/public/images
|
||||
```
|
||||
|
||||
> **The filename is versioned, and a redesign must bump the version.** WhatsApp and X cache a
|
||||
> preview image _by URL_ and effectively never revalidate it. Overwriting `og-card-v1.jpg` leaves
|
||||
> everyone who already shared a link looking at the old card indefinitely. To ship a new design,
|
||||
> bump `-v1` to `-v2` in all three places at once: `scripts/upload-media.sh`,
|
||||
> `web/scripts/fetch-media.mjs`, and `OG_CARD` in `common/src/hosting/constants.ts` (which is what
|
||||
> the meta tags in `web/pages/_app.tsx` read).
|
||||
|
||||
Copy on the card is the home page's own wording (`web/components/home/home.tsx`), so the preview
|
||||
cannot promise something the landing page does not say. If the home headline changes, re-render.
|
||||
|
||||
#### Fonts
|
||||
|
||||
This is the one scene that uses the real web faces — Newsreader for the headline, DM Sans for the
|
||||
eyebrow, Cormorant Garamond for the wordmark — rather than the system stack the videos use. The
|
||||
woff2 files are vendored in `public/fonts/` (both families are SIL OFL) and loaded by
|
||||
`src/components/BrandFonts.ts`, which holds the render open until they are ready. Vendoring rather
|
||||
than fetching from Google keeps the render reproducible offline; a missing network would otherwise
|
||||
silently produce a card in Georgia.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
@@ -106,12 +150,14 @@ media-creator/
|
||||
├── package.json standalone deps (npm)
|
||||
├── remotion.config.ts render settings (codec, quality)
|
||||
├── public/logo.svg Compass logo (copied from web/public/favicon.svg)
|
||||
├── public/fonts/ vendored web faces, used by the OG card only
|
||||
└── src/
|
||||
├── index.ts registerRoot
|
||||
├── Root.tsx composition registry
|
||||
├── theme.ts brand colors / fonts / output FORMATS
|
||||
├── components/ Background, Logo, reusable animations
|
||||
└── scenes/Intro.tsx the intro video (shared by both formats)
|
||||
├── components/ Background, Logo, BrandFonts, reusable animations
|
||||
├── scenes/Intro.tsx the intro video (shared by both formats)
|
||||
└── scenes/OgCard.tsx the default social preview card (a still)
|
||||
```
|
||||
|
||||
## Adding another video
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"render:tour:post": "remotion render ProfileTourPost out/compass-profile-tour-post.mp4",
|
||||
"still:alert": "remotion still SearchAlertLight out/compass-alert-poster-light.png --frame=0",
|
||||
"still:alert:dark": "remotion still SearchAlertDark out/compass-alert-poster-dark.png --frame=0",
|
||||
"still:og": "remotion still OgCard out/compass-og-card.jpg --image-format=jpeg --jpeg-quality=92",
|
||||
"still:search": "remotion still SearchDemoLight out/compass-search-poster-light.png --frame=0",
|
||||
"still:search:dark": "remotion still SearchDemoDark out/compass-search-poster-dark.png --frame=0",
|
||||
"studio": "remotion studio",
|
||||
|
||||
@@ -103,6 +103,11 @@ upload "$WEB_IMAGES/search-alert-poster-dark.jpg" "images/search-alert-poster-da
|
||||
for theme in light dark; do
|
||||
upload "$WEB_IMAGES/vote-tally-${theme}.png" "images/vote-tally-${theme}.png" "image/png"
|
||||
done
|
||||
# The default social preview card (`npm run still:og`). Uploaded under a versioned key: WhatsApp and
|
||||
# X cache a preview image by URL and effectively never revalidate, so a redesign must go up as
|
||||
# og-card-v2.jpg — bumped here, in web/scripts/fetch-media.mjs and in OG_CARD
|
||||
# (common/hosting/constants.ts) together — rather than overwrite this one.
|
||||
upload "$OUT_DIR/compass-og-card.jpg" "images/og-card-v1.jpg" "image/jpeg"
|
||||
|
||||
echo
|
||||
echo "Done. The next Vercel build pulls these into web/public/videos via MEDIA_SOURCE_BASE_URL."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Composition} from 'remotion'
|
||||
import {Composition, Still} from 'remotion'
|
||||
import {FORMATS} from './theme'
|
||||
import {Intro, INTRO_DURATION} from './scenes/Intro'
|
||||
import {OgCard} from './scenes/OgCard'
|
||||
import {ProfileTour, PROFILE_TOUR_DURATION} from './scenes/ProfileTour'
|
||||
import {SearchDemo, SearchDemoProps, calculateSearchDemoMetadata} from './scenes/SearchDemo'
|
||||
import {SearchAlert, SearchAlertProps, calculateSearchAlertMetadata} from './scenes/SearchAlert'
|
||||
@@ -95,6 +96,10 @@ export const RemotionRoot: React.FC = () => {
|
||||
defaultProps={{manifest: null, theme: 'dark'} as SearchAlertProps}
|
||||
calculateMetadata={calculateSearchAlertMetadata}
|
||||
/>
|
||||
{/* The default social preview card. A Still, not a Composition — there is no timeline, and
|
||||
`remotion still` is the only way it is ever rendered:
|
||||
npm run still:og -> out/compass-og-card.jpg */}
|
||||
<Still id="OgCard" component={OgCard} width={FORMATS.og.width} height={FORMATS.og.height} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
34
media-creator/src/components/BrandFonts.ts
Normal file
34
media-creator/src/components/BrandFonts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Loads the real Compass web faces so a render looks like the site rather than like the local
|
||||
// system-font stack.
|
||||
//
|
||||
// The woff2 files are vendored under public/fonts (both families are SIL OFL) rather than fetched
|
||||
// from Google at render time — theme.ts's rule is that a render must be reproducible offline, and a
|
||||
// missing network would otherwise silently fall back to Georgia mid-render.
|
||||
//
|
||||
// Importing this module registers the faces and holds the render open until the browser reports
|
||||
// them ready. Only scenes that need them should import it; the video scenes deliberately do not,
|
||||
// so their renders stay font-fetch free.
|
||||
import {cancelRender, continueRender, delayRender, staticFile} from 'remotion'
|
||||
|
||||
// Variable fonts: one file per family covers every weight, hence the `100 900` range.
|
||||
const FACES: {family: string; file: string; style?: string}[] = [
|
||||
{family: 'Newsreader', file: 'fonts/Newsreader-latin.woff2'},
|
||||
{family: 'Newsreader', file: 'fonts/Newsreader-Italic-latin.woff2', style: 'italic'},
|
||||
{family: 'DM Sans', file: 'fonts/DMSans-latin.woff2'},
|
||||
{family: 'Cormorant Garamond', file: 'fonts/CormorantGaramond-500-latin.woff2'},
|
||||
]
|
||||
|
||||
const handle = delayRender('Loading Compass brand fonts')
|
||||
|
||||
Promise.all(
|
||||
FACES.map(async ({family, file, style}) => {
|
||||
const face = new FontFace(family, `url(${staticFile(file)}) format('woff2')`, {
|
||||
weight: '100 900',
|
||||
style: style ?? 'normal',
|
||||
})
|
||||
document.fonts.add(await face.load())
|
||||
}),
|
||||
)
|
||||
.then(() => continueRender(handle))
|
||||
// Falling back silently would ship a card in the wrong typeface, which is worse than a red render.
|
||||
.catch((err) => cancelRender(err))
|
||||
264
media-creator/src/scenes/OgCard.tsx
Normal file
264
media-creator/src/scenes/OgCard.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import React from 'react'
|
||||
import {AbsoluteFill, Img, staticFile} from 'remotion'
|
||||
import '../components/BrandFonts'
|
||||
import {colors, fonts} from '../theme'
|
||||
|
||||
// The default social preview card — what WhatsApp, X, Slack, LinkedIn etc. show for a shared
|
||||
// compassmeet.com link. 1200×630 (the 1.91:1 `summary_large_image` slot).
|
||||
//
|
||||
// Rendered as a still, uploaded to R2, and pulled into web/public/images at build time; the meta
|
||||
// tags live in web/pages/_app.tsx. See the README for the full loop.
|
||||
//
|
||||
// Design notes, since a preview card is read in under a second at thumbnail size:
|
||||
// - It deliberately shares the amber rules, warm cream canvas and serif voice of the per-profile
|
||||
// card (web/pages/api/og/profile.tsx) so a shared profile and a shared home link look related.
|
||||
// - No faces, no photo grid, no stacked cards — those read as a swiping app at a glance, which is
|
||||
// the opposite of the pitch.
|
||||
// - Copy is the home page's own (web/components/home/home.tsx), so the card cannot promise
|
||||
// something the landing page does not say.
|
||||
|
||||
const EYEBROW = 'FREE DIRECTORY · NO ADS · NO ALGORITHMS'
|
||||
const HEADLINE_TOP = "Don't Swipe."
|
||||
const HEADLINE_BOTTOM = 'Search.'
|
||||
const SUBHEAD = 'Find your people based on your values and interests.'
|
||||
const DOMAIN = 'compassmeet.com'
|
||||
const FOOTNOTE = 'Non-profit · Open source'
|
||||
|
||||
// Paper grain. Inline SVG turbulence rather than an image file: it costs no asset, and at 3.5%
|
||||
// opacity it is what keeps the flat cream from looking like a screenshot of a blank div.
|
||||
const GRAIN = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
|
||||
<filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" /></filter>
|
||||
<rect width="200" height="200" filter="url(#n)" />
|
||||
</svg>`,
|
||||
)}`
|
||||
|
||||
// A hairline amber rule, top and bottom — the same device the per-profile card uses.
|
||||
const Rule: React.FC<{edge: 'top' | 'bottom'}> = ({edge}) => (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
[edge]: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 6,
|
||||
background: `linear-gradient(90deg, ${colors.amberDeep} 0%, ${colors.amber} 45%, ${colors.amberBright} 100%)`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
// The compass rose inside concentric rings and bearing ticks: the brand mark doubling as the one
|
||||
// piece of imagery, and a nod to orientation rather than romance.
|
||||
const RoseDial: React.FC = () => {
|
||||
const outer = 440
|
||||
const middle = 372
|
||||
const disc = 300
|
||||
const ticks = 16
|
||||
|
||||
const ring = (size: number, color: string): React.CSSProperties => ({
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: `1px solid ${color}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 56,
|
||||
right: 44,
|
||||
width: outer,
|
||||
height: outer,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Warm glow, so the dial sits in light rather than on top of the canvas */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: outer + 160,
|
||||
height: outer + 160,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${colors.amberPale} 0%, rgba(243,228,206,0) 68%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={ring(outer, colors.canvas200)} />
|
||||
<div style={ring(middle, colors.amberPale)} />
|
||||
|
||||
{/* Bearing ticks around the outer ring, N/E/S/W longer than the rest */}
|
||||
{Array.from({length: ticks}).map((_, i) => {
|
||||
const cardinal = i % 4 === 0
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1.5,
|
||||
height: cardinal ? 18 : 9,
|
||||
background: cardinal ? colors.amber : colors.canvas200,
|
||||
transform: `rotate(${(360 / ticks) * i}deg) translateY(${-outer / 2 + 1}px)`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<div
|
||||
style={{
|
||||
// Positioned on purpose: every sibling above is either absolute or transformed, which
|
||||
// puts them in the positioned-painting layer. A static disc would paint underneath them
|
||||
// and the glow would wash the logo out to a ghost.
|
||||
position: 'relative',
|
||||
width: disc,
|
||||
height: disc,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: `0 24px 60px ${colors.amberDeep}22, 0 0 0 8px ${colors.creamGlow}`,
|
||||
}}
|
||||
>
|
||||
<Img src={staticFile('logo.svg')} style={{width: disc, height: disc}} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const OgCard: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(145deg, ${colors.creamGlow} 0%, ${colors.cream} 52%, ${colors.canvas100} 100%)`,
|
||||
}}
|
||||
>
|
||||
{/* Warm light from the upper right, where the dial sits */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(70% 90% at 82% 18%, ${colors.amberPale}88 0%, rgba(243,228,206,0) 70%)`,
|
||||
}}
|
||||
/>
|
||||
{/* A cool counterweight from the logo's blue, low enough to read as shadow, not as color */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(55% 60% at 6% 96%, ${colors.logoBlue}14 0%, rgba(29,56,75,0) 72%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<RoseDial />
|
||||
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
padding: '74px 72px 64px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{display: 'flex', flexDirection: 'column', maxWidth: 660}}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 2.6,
|
||||
color: colors.amberEmber,
|
||||
}}
|
||||
>
|
||||
{EYEBROW}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 92,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.03,
|
||||
letterSpacing: -1.5,
|
||||
color: colors.ink,
|
||||
marginTop: 26,
|
||||
}}
|
||||
>
|
||||
<div>{HEADLINE_TOP}</div>
|
||||
<div style={{color: colors.amberDeep}}>{HEADLINE_BOTTOM}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.heading,
|
||||
fontSize: 33,
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.38,
|
||||
color: colors.ink600,
|
||||
marginTop: 28,
|
||||
// Tuned so the line breaks after "based on" into two balanced lines rather than
|
||||
// leaving "interests." alone on the second.
|
||||
maxWidth: 470,
|
||||
}}
|
||||
>
|
||||
{SUBHEAD}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', flexDirection: 'column'}}>
|
||||
<div style={{height: 1, background: colors.canvas200, marginBottom: 22}} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: 14}}>
|
||||
<div style={{width: 9, height: 9, borderRadius: '50%', background: colors.amber}} />
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.wordmark,
|
||||
fontSize: 32,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.03em',
|
||||
color: colors.ink,
|
||||
}}
|
||||
>
|
||||
{DOMAIN}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 20,
|
||||
fontWeight: 500,
|
||||
color: colors.ink500,
|
||||
}}
|
||||
>
|
||||
{FOOTNOTE}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundImage: `url("${GRAIN}")`,
|
||||
opacity: 0.035,
|
||||
mixBlendMode: 'multiply',
|
||||
}}
|
||||
/>
|
||||
{/* Barely-there vignette: keeps the corners from glowing brighter than the headline */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(85% 85% at 50% 45%, rgba(0,0,0,0) 58%, rgba(30,26,20,0.07) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Rule edge="top" />
|
||||
<Rule edge="bottom" />
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
@@ -17,20 +17,37 @@ export const colors = {
|
||||
// Accents pulled straight from the compass logo
|
||||
logoBlue: '#1D384B',
|
||||
logoRed: '#E94734',
|
||||
|
||||
// Light-surface ramp, matching the per-profile OG card (web/pages/api/og/profile.tsx)
|
||||
canvas100: '#EDE8E0', // canvas-100 — page background
|
||||
canvas200: '#E8D5BC', // canvas-200 — tag pills / warm dividers
|
||||
ink600: '#786C5C', // ink-600 — secondary text
|
||||
ink500: '#8C8070', // ink-500 — tertiary text
|
||||
amberPale: '#F3E4CE', // primary-100
|
||||
amberEmber: '#855022', // primary-700 — text-safe amber on cream
|
||||
} as const
|
||||
|
||||
// System-font stack only — no network fetch at render time, so renders are reproducible offline.
|
||||
export const fonts = {
|
||||
display: '"Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif',
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
|
||||
// The real web faces (see web/pages/_document.tsx and tailwind.config.js). Only usable in a
|
||||
// scene that imports ./components/BrandFonts — that module vendors the woff2 files from
|
||||
// public/fonts and blocks the render until they are ready. Without it these fall back.
|
||||
heading: '"Newsreader", Georgia, serif', // h1–h6 on the site
|
||||
body: '"DM Sans", "Segoe UI", sans-serif', // body copy
|
||||
wordmark: '"Cormorant Garamond", Georgia, serif', // the `.logo` wordmark
|
||||
} as const
|
||||
|
||||
// Output formats. Same scenes, different canvas — layouts adapt via useVideoConfig().
|
||||
// - post: 4:5 portrait, the tallest ratio Instagram allows in-feed (primary).
|
||||
// - story: 9:16 vertical, full-screen Stories / Reels (secondary).
|
||||
// - og: 1.91:1, the OpenGraph / Twitter `summary_large_image` card (a still, not a video).
|
||||
export const FORMATS = {
|
||||
post: {width: 1080, height: 1350, fps: 30},
|
||||
story: {width: 1080, height: 1920, fps: 30},
|
||||
og: {width: 1200, height: 630, fps: 30},
|
||||
} as const
|
||||
|
||||
// Design reference height (the story format). Scenes are tuned against this;
|
||||
|
||||
@@ -9,8 +9,21 @@ export function SEO<P extends Record<string, string | undefined>>(props: {
|
||||
url?: string
|
||||
ogProps?: {props: P; endpoint: string}
|
||||
image?: string
|
||||
/** Only needed for an image that is not a 1200×630 PNG card — see the note on the tags below. */
|
||||
imageType?: string
|
||||
imageWidth?: string
|
||||
imageHeight?: string
|
||||
imageAlt?: string
|
||||
}) {
|
||||
const {title, description, url, image, ogProps} = props
|
||||
const {
|
||||
// The /api/og/* routes answer with PNG (that is what @vercel/og's ImageResponse emits) at the
|
||||
// 1.91:1 card size, and that is the only image any caller passes today.
|
||||
imageType = 'image/png',
|
||||
imageWidth = '1200',
|
||||
imageHeight = '630',
|
||||
imageAlt,
|
||||
} = props
|
||||
|
||||
const imageUrl =
|
||||
image ?? (ogProps && buildOgUrl(removeUndefinedProps(ogProps.props) as any, ogProps.endpoint))
|
||||
@@ -33,11 +46,25 @@ export function SEO<P extends Record<string, string | undefined>>(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*OG tags (WhatsApp, Facebook, etc.)*/}
|
||||
{/*OG tags (WhatsApp, Facebook, etc.). Everything here overrides the site-wide defaults in
|
||||
_app.tsx via the shared `key`s.*/}
|
||||
<meta property="og:title" content={fullTitle} key="og-title" />
|
||||
<meta property="og:description" content={description} key="og-description" />
|
||||
{url && <meta property="og:url" content={absUrl} key="og-url" />}
|
||||
{imageUrl && <meta property="og:image" content={imageUrl} key="og-image" />}
|
||||
{imageUrl && (
|
||||
<>
|
||||
<meta property="og:image" content={imageUrl} key="og-image" />
|
||||
<meta property="og:image:secure_url" content={imageUrl} key="og-image-secure-url" />
|
||||
{/*Every image reaching this component is a 1200×630 card, so the dimensions are safe to
|
||||
state — and stating them is what makes WhatsApp reliably pick the large layout. The
|
||||
type has to be restated as well: _app.tsx's default says JPEG (its card is a .jpg),
|
||||
and without this the two would disagree on pages that override the image.*/}
|
||||
<meta property="og:image:type" content={imageType} key="og-image-type" />
|
||||
<meta property="og:image:width" content={imageWidth} key="og-image-width" />
|
||||
<meta property="og:image:height" content={imageHeight} key="og-image-height" />
|
||||
<meta property="og:image:alt" content={imageAlt ?? title} key="og-image-alt" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/*Twitter/X tags — separate!*/}
|
||||
<meta name="twitter:title" content={fullTitle} key="twitter-title" />
|
||||
|
||||
@@ -8,7 +8,7 @@ import {StatusBar} from '@capacitor/status-bar'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import clsx from 'clsx'
|
||||
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
|
||||
import {IS_VERCEL, PNG_LOGO} from 'common/hosting/constants'
|
||||
import {IS_VERCEL, OG_CARD} from 'common/hosting/constants'
|
||||
import {debug} from 'common/logger'
|
||||
import {isUrl} from 'common/parsing'
|
||||
import type {AppProps} from 'next/app'
|
||||
@@ -227,23 +227,34 @@ function MyApp(props: AppProps<PageProps>) {
|
||||
|
||||
<meta name="description" content={description} key="description" />
|
||||
|
||||
{/*OG tags (WhatsApp, Facebook, etc.)*/}
|
||||
{/*OG tags (WhatsApp, Facebook, etc.). These are the site-wide default: any page that
|
||||
renders <SEO> overrides them by `key`, and pages that don't (the home page among them)
|
||||
are previewed with the card below.
|
||||
Declaring width/height matters — several clients, WhatsApp in particular, are far more
|
||||
reliable about rendering the large layout when they don't have to fetch the image to
|
||||
learn its shape.*/}
|
||||
<meta property="og:site_name" content="Compass" />
|
||||
<meta property="og:type" content="website" key="og-type" />
|
||||
<meta property="og:title" content={title} key="og-title" />
|
||||
<meta property="og:description" content={description} key="og-description" />
|
||||
<meta property="og:url" content={DEPLOYED_WEB_URL} key="og-url" />
|
||||
<meta property="og:image" content={PNG_LOGO} key="og-image" />
|
||||
<meta property="og:image:type" content="image/png" key="og-image-type" />
|
||||
<meta property="og:image" content={OG_CARD.url} key="og-image" />
|
||||
<meta property="og:image:secure_url" content={OG_CARD.url} key="og-image-secure-url" />
|
||||
<meta property="og:image:type" content={OG_CARD.type} key="og-image-type" />
|
||||
<meta property="og:image:width" content={OG_CARD.width} key="og-image-width" />
|
||||
<meta property="og:image:height" content={OG_CARD.height} key="og-image-height" />
|
||||
<meta property="og:image:alt" content={OG_CARD.alt} key="og-image-alt" />
|
||||
|
||||
{/*Twitter/X tags — separate!*/}
|
||||
<meta name="twitter:title" content={title} key="twitter-title" />
|
||||
<meta name="twitter:description" content={description} key="twitter-description" />
|
||||
<meta name="twitter:card" content="summary" key="twitter-card" />
|
||||
<meta name="twitter:image" content={PNG_LOGO} key="twitter-image" />
|
||||
{/*`summary_large_image`, not `summary`: the latter renders the small square thumbnail,
|
||||
which is what a 512×512 app icon used to force here.*/}
|
||||
<meta name="twitter:card" content="summary_large_image" key="twitter-card" />
|
||||
<meta name="twitter:image" content={OG_CARD.url} key="twitter-image" />
|
||||
<meta name="twitter:image:alt" content={OG_CARD.alt} key="twitter-image-alt" />
|
||||
|
||||
{/*<meta name="twitter:site" content="@compassmeet"/>*/}
|
||||
{/*<meta property="og:image:width" content="192" />*/}
|
||||
{/*<meta property="og:image:height" content="192" />*/}
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
|
||||
@@ -47,6 +47,10 @@ const ASSETS = [
|
||||
{key: 'images/search-alert-poster-dark.jpg', dest: 'images/search-alert-poster-dark.jpg'},
|
||||
{key: 'images/vote-tally-light.png', dest: 'images/vote-tally-light.png'},
|
||||
{key: 'images/vote-tally-dark.png', dest: 'images/vote-tally-dark.png'},
|
||||
// The default social preview card (see OG_CARD in common/hosting/constants.ts). The filename is
|
||||
// versioned because WhatsApp and X cache a preview by URL and never revalidate it — a redesign
|
||||
// ships as -v2 on both sides, it never overwrites -v1.
|
||||
{key: 'images/og-card-v1.jpg', dest: 'images/og-card-v1.jpg'},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user