Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 17 additions & 21 deletions apps/docs/app/[lang]/blog/[[...slug]]/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { blog } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
import { absoluteUrl } from '@/lib/site';
import { absoluteUrl, HERO_COVER } from '@/lib/site';
import {
compact,
JsonLd,
Expand DownExpand Up@@ -78,7 +78,7 @@ function postGraph(url: string, data: BlogPostData): JsonLdNode[] {
url: canonical,
mainEntityOfPage: canonical,
inLanguage: 'en',
image: absoluteUrl(BLOG_CARD.url),
image: absoluteUrl(HERO_COVER.url),
datePublished: isoDate(data.date),
dateModified: sitemapLastModified(url),
keywords: data.tags,
Expand DownExpand Up@@ -251,29 +251,25 @@ export async function generateStaticParams() {
}));
}

const BLOG_INDEX_TITLE = 'Blog';
const BLOG_INDEX_DESCRIPTION =
'Insights, updates, and best practices from the ObjectStack team.';

/**
* The blog's social card.
* Metadata for the blog index and for every post.
*
* The social card in both branches is the shared hero cover, `HERO_COVER` from
* `lib/site.ts` — the same image the homepage presents itself with, and the same
* one `postGraph()` above puts in the `BlogPosting` node. It used to be a literal
* spelled here and again on the homepage; one declaration now, so a re-encode
* cannot leave half the site pointing at a file that no longer exists.
*
* ⚠️ The card generator at `app/og/docs/[...slug]/route.tsx` is docs-only: it
* renders from `source` (the `content/docs` loader) and has no branch for `blog`,
* so there is no per-post card to reference and this card is shared by the index
* and every post. Giving posts their own generated cards is a separate decision,
* not a gap in this wiring — it would mean a second `app/og/**` route.
*
* ⚠️ Same path as `apps/docs/app/[lang]/page.tsx`'s `HOME_CARD`; that file
* carries the note on why it is spelled twice and what must change together.
*/
const BLOG_CARD = {
url: '/hero-cover-dark.png',
width: 2400,
height: 1200,
alt: 'ObjectStack — the metadata framework for AI-written apps',
};

const BLOG_INDEX_TITLE = 'Blog';
const BLOG_INDEX_DESCRIPTION =
'Insights, updates, and best practices from the ObjectStack team.';

export async function generateMetadata({
params,
}: {
Expand All@@ -296,13 +292,13 @@ export async function generateMetadata({
title: BLOG_INDEX_TITLE,
description: BLOG_INDEX_DESCRIPTION,
url: canonical,
images: [BLOG_CARD],
images: [HERO_COVER],
},
twitter: {
card: 'summary_large_image',
title: BLOG_INDEX_TITLE,
description: BLOG_INDEX_DESCRIPTION,
images: [BLOG_CARD.url],
images: [HERO_COVER.url],
},
};
}
Expand All@@ -324,13 +320,13 @@ export async function generateMetadata({
title: page.data.title,
description: page.data.description,
url: canonical,
images: [BLOG_CARD],
images: [HERO_COVER],
},
twitter: {
card: 'summary_large_image',
title: page.data.title,
description: page.data.description,
images: [BLOG_CARD.url],
images: [HERO_COVER.url],
},
};
}
32 changes: 12 additions & 20 deletions apps/docs/app/[lang]/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { ArrowRight, Check } from 'lucide-react';
import { Bricolage_Grotesque, IBM_Plex_Mono } from 'next/font/google';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions, gitConfig } from '@/lib/layout.shared';
import { absoluteUrl } from '@/lib/site';
import { absoluteUrl, HERO_COVER } from '@/lib/site';
import {
APACHE_2_0_URL,
GITHUB_REPO_URL,
Expand DownExpand Up@@ -36,30 +36,22 @@ const HOME_DESCRIPTION =
'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.';

/**
* The homepage's social card.
* The homepage's metadata. Its social card is the shared hero cover,
* `HERO_COVER` from `lib/site.ts` — the same image the video poster below uses.
*
* ⚠️ Deliberately NOT the docs card generator. `app/og/docs/[...slug]/route.tsx`
* renders from a `source.getPage()` result, and the homepage has no MDX file
* behind it — there is no slug to hand it. It reuses the hero cover instead,
* which is already shipped and already the video poster on this page, so the
* shared card costs no extra bytes and no extra route.
*
* ⚠️ `apps/docs/app/[lang]/blog/[[...slug]]/page.tsx` spells this same path for
* the blog's card. Two spellings rather than one shared constant because
* `lib/site.ts` is the origin's home, not the asset manifest's; if a third page
* ever needs it, hoist it there. Anything that renames, re-encodes or deletes
* `public/hero-cover-dark.png` must update BOTH — an `og:image` that 404s is
* worse than none, because crawlers then scrape whatever else the page offers.
* The path used to be spelled as a literal here, again in the blog's card, and a
* third time as the `poster` further down this file — with a note asking whoever
* added a third consumer to hoist it. That happened; it lives in `lib/site.ts`
* now, and the reasons a re-encode has to be careful live with it.
*
* Left site-relative: `metadataBase` in `app/layout.tsx` absolutises it.
*/
const HOME_CARD = {
url: '/hero-cover-dark.png',
width: 2400,
height: 1200,
alt: 'ObjectStack — the metadata framework for AI-written apps',
};

export const metadata: Metadata = {
title: HOME_TITLE,
description: HOME_DESCRIPTION,
Expand All@@ -77,13 +69,13 @@ export const metadata: Metadata = {
// Same absolute URL as the canonical link — see the docs route for why the
// two must not drift.
url: absoluteUrl('/'),
images: [HOME_CARD],
images: [HERO_COVER],
},
twitter: {
card: 'summary_large_image',
title: HOME_TITLE,
description: HOME_DESCRIPTION,
images: [HOME_CARD.url],
images: [HERO_COVER.url],
},
};

Expand All@@ -104,7 +96,7 @@ export const metadata: Metadata = {
*
* Every field is drawn from something already on this page or in `lib/`: the
* title and description are the same constants `metadata` uses, the image is
* `HOME_CARD`, the licence is the repo's own, and the two `sameAs` links are the
* `HERO_COVER`, the licence is the repo's own, and the two `sameAs` links are the
* GitHub organisation and the YouTube channel this page links to in its hero.
*/
function homeGraph(): JsonLdNode[] {
Expand All@@ -119,7 +111,7 @@ function homeGraph(): JsonLdNode[] {
// itself one way to a crawler and another way to a social card.
headline: HOME_TITLE,
description: HOME_DESCRIPTION,
image: absoluteUrl(HOME_CARD.url),
image: absoluteUrl(HERO_COVER.url),
codeRepository: GITHUB_REPO_URL,
programmingLanguage: 'TypeScript',
runtimePlatform: 'Node.js',
Expand DownExpand Up@@ -279,7 +271,7 @@ export default function HomePage() {
<YouTubeEmbed
videoId={OVERVIEW_VIDEO_ID}
title="ObjectStack in 90 Seconds"
poster="/hero-cover-dark.png"
poster={HERO_COVER.url}
/>
<figcaption
className="mt-3 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-[12px] text-fd-muted-foreground"
Expand Down
11 changes: 6 additions & 5 deletions apps/docs/components/youtube-embed.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,11 +18,12 @@ type YouTubeEmbedProps = {
* A facade, not a bare `<iframe>`: nothing is requested from YouTube until the
* viewer actually clicks play, so the homepage costs no third-party frame,
* script or cookie on load. The cover is the same one the README links from
* (`docs/screenshots/hero-cover-dark.png`, copied into `public/`), which is 2:1
* rather than the video's 16:9 — hence the fixed 2:1 frame with the player
* centred inside it at its own aspect ratio. Cropping the cover to 16:9 would
* cut through the logo on the left edge, and letting the frame change shape on
* click would shove the rest of the page down mid-interaction.
* (`docs/screenshots/hero-cover-dark.png`, re-encoded to WebP into `public/` —
* `HERO_COVER` in `lib/site.ts` owns the path and the re-encode recipe), which
* is 2:1 rather than the video's 16:9 — hence the fixed 2:1 frame with the
* player centred inside it at its own aspect ratio. Cropping the cover to 16:9
* would cut through the logo on the left edge, and letting the frame change
* shape on click would shove the rest of the page down mid-interaction.
*/
export function YouTubeEmbed({ videoId, title, poster }: YouTubeEmbedProps) {
const [playing, setPlaying] = useState(false);
Expand Down
51 changes: 50 additions & 1 deletion apps/docs/lib/site.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
/**
* Canonical identity of the documentation site.
* Canonical identity of the documentation site — the origin it names itself by,
* and the shared cover asset it presents itself with.
*
* The origin is a maintainer ruling, not configuration. Every absolute URL this
* site emits — sitemap entries, the `Sitemap:` line in `robots.txt`, and (as the
Expand DownExpand Up@@ -36,3 +37,51 @@ export function absoluteUrl(path: string): string {

return new URL(path, SITE_ORIGIN).toString();
}

/**
* The hero cover — the one image this site presents itself with.
*
* Three consumers, one declaration: the homepage's click-to-play video poster
* (`app/[lang]/page.tsx`), the homepage's Open Graph / Twitter card and JSON-LD
* `image` (same file), and the blog's card for the index and every post
* (`app/[lang]/blog/[[...slug]]/page.tsx`). Before this constant the path was
* spelled as a literal in all three, and two of them do not read as image
* references at all — they read as SEO config — so a re-encode had to find them
* by memory. It is declared here, next to `SITE_ORIGIN`, because the cover is
* part of the site's identity in the same way the origin is: one asset, named
* once, that every self-description reads.
*
* ⚠️ `width` / `height` are emitted as `og:image:width` / `og:image:height`. A
* declared size that disagrees with the bytes is worse than no declaration — a
* crawler reserves the wrong box and some renderers reject the card outright —
* so anything that changes the asset's pixel dimensions must change these in the
* same commit. They are correct only because the re-encode below deliberately
* kept 2400x1200.
*
* ## Provenance — how to re-encode it
*
* The master is `docs/screenshots/hero-cover-dark.png` (2400x1200, 406,703 B),
* which the README embeds directly from the repo and which is NOT served by this
* site. This file is a lossy WebP derived from that master, and the derivation is
* the whole reason it is small enough to sit above the fold:
*
* sharp('docs/screenshots/hero-cover-dark.png')
* .webp({ quality: 80, effort: 6 }) // sharp 0.35.3 / libwebp 1.6.0
* .toFile('apps/docs/public/hero-cover-dark.webp')
*
* 83,272 B — 20.5% of the PNG. Re-encode from the master, never from this file:
* a lossy re-encode of a lossy source compounds. And do not "restore" the PNG
* alongside it — a `public/` holding both is how the next re-encode picks the
* wrong one.
*
* Format is a constraint, not a preference. It is a plain `<img>` with no
* `<picture>` fallback and it is also the og:image, so it must be a format every
* browser AND every unfurler decodes. WebP is; AVIF is measurably better per byte
* on this image and was rejected because it is not.
*/
export const HERO_COVER = {
url: '/hero-cover-dark.webp',
width: 2400,
height: 1200,
alt: 'ObjectStack — the metadata framework for AI-written apps',
} as const;
Binary file removedapps/docs/public/hero-cover-dark.png
Binary file not shown.
Binary file addedapps/docs/public/hero-cover-dark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading