chore(site): serve OG Studio link cards from the files bucket, add docs previews (#63560)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
@@ -9,7 +9,7 @@ The site's heavy media (product videos, arcade sounds, 3D models and download zi
|
||||
- The build (`script/build.ts`) drops every `mediaFile` from the Worker's assets, so a local copy in `public/` never ships.
|
||||
- In development, `vite dev` proxies `/files` to `https://dev.opencode.ai`, so pages play the dev bucket's copies.
|
||||
- Images (`png`, `jpg`, `webp`, `avif`, `svg`), posters, fonts and the 3D decoders (`arcade3d/draco`, `arcade3d/basis`) stay in git and in the static assets.
|
||||
- The link-card videos in `og/` (`og:video`, from `script/og.ts`) are the exception: they are small and ship as static assets with their cards.
|
||||
- OG Studio's link cards are in the bucket too, under `site/og/` (see [Link cards](#link-cards)). `inBucket` leaves `og/` in the static assets only for a version 1 manifest, which copies its files there.
|
||||
- `videoSources()` offers a video's AV1 and HEVC copies (`media/<base>.<width>.<codec>.<hash>.mp4`, listed in `src/component/site/media-manifest.json`) before the H.264 original. The manifest lists only copies that are in the bucket; a video without copies plays its original.
|
||||
|
||||
| Folder | Files |
|
||||
@@ -34,3 +34,42 @@ curl -sI https://dev.opencode.ai/files/site/2026-11/arcade3d/cabinet.glb # exp
|
||||
```
|
||||
|
||||
A changed file gets a new dated prefix (for example `site/2026-12`) and a new `mediaBase`, so cached copies never go stale.
|
||||
|
||||
## Link cards
|
||||
|
||||
OG Studio (the local design tool that makes the link cards) uploads every card PNG and `og:video` MP4 to both files buckets under `site/og/`, and commits only `src/asset/og-studio/manifest.json`. File names end in a hash of their content (`home-98637499f6.png`, `home-uk-7dbf00d466.png`, `home-ec8331a4d6.mp4`), so an object never changes and a redesign only adds new objects; nothing is overwritten and nothing grows the repo.
|
||||
|
||||
The manifest (version 2) names the files and where they are served:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"generator": "og-studio",
|
||||
"width": 1200,
|
||||
"height": 630,
|
||||
"base": "/files/site/og",
|
||||
"cards": {
|
||||
"/": {
|
||||
"image": "home-98637499f6.png",
|
||||
"alt": "…",
|
||||
"video": "home-ec8331a4d6.mp4",
|
||||
"locales": { "uk": { "image": "home-uk-7dbf00d466.png", "alt": "…" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`script/og.ts` writes `${base}/<file>` into `src/lib/og-images.json`, and `ShareMeta` prefixes `https://opencode.ai`, so every stage's `og:image` and `og:video` load from the production bucket. Pages that are not in the manifest still get a card rendered by `script/og.ts` into `public/og/`.
|
||||
|
||||
Upload new files to both buckets before you merge the manifest that names them. The buckets are in different accounts (`services/files/wrangler.jsonc`): `opencode-dev-files` in the dev account and `opencode-production-files` in the production account. To upload by hand, from a folder without a `cloudflare.config.ts` (so `cf` doesn't load this one):
|
||||
|
||||
```sh
|
||||
CLOUDFLARE_ACCOUNT_ID=d8e944f537ceef3f06a5bd3aec1d7a53 cf r2 objects put site/og/home-98637499f6.png \
|
||||
--bucket-name opencode-dev-files --file home-98637499f6.png --content-type image/png
|
||||
CLOUDFLARE_ACCOUNT_ID=15d29c8639fd3733b1b5486a2acfd968 cf r2 objects put site/og/home-98637499f6.png \
|
||||
--bucket-name opencode-production-files --file home-98637499f6.png --content-type image/png
|
||||
curl -sI https://dev.opencode.ai/files/site/og/home-98637499f6.png # expect 200 and image/png
|
||||
curl -sI https://opencode.ai/files/site/og/home-98637499f6.png # expect 200 and image/png
|
||||
```
|
||||
|
||||
Use `--content-type video/mp4` for videos. Never delete an object that a merged manifest names: old link previews keep loading it.
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
// title, image or template gives it a new URL and link previews refresh. Cards are English at
|
||||
// every locale.
|
||||
//
|
||||
// OG Studio (a local design tool) can replace any card. It writes finished files and a manifest to
|
||||
// src/asset/og-studio/: for each page an English PNG, an optional MP4 for og:video and an optional
|
||||
// PNG for each locale. Pages in that manifest use its files; every other page renders here.
|
||||
// OG Studio (a local design tool) can replace any card. For each page it makes an English PNG, an
|
||||
// optional MP4 for og:video and an optional PNG for each locale, and writes a manifest to
|
||||
// src/asset/og-studio/manifest.json. Pages in that manifest use its files; every other page renders
|
||||
// here. A version 2 manifest has a `base` (/files/site/og): OG Studio uploads the files to the
|
||||
// files bucket there (MEDIA.md) and only the manifest is in git, so pages link `${base}/<file>`
|
||||
// and nothing is copied. A manifest without `base` (version 1) keeps its files beside it, and they
|
||||
// are copied into public/og/.
|
||||
//
|
||||
// This file is the cards' source of truth. The layout is David's template frame in Paper (the
|
||||
// "OG images" file, frame "/ — dark"): a grid of #2e2e2e lines at x 72, 900 and 1128 and y 72,
|
||||
@@ -202,19 +206,25 @@ const fonts = [
|
||||
await mkdir(out, { recursive: true })
|
||||
const manifest: Record<string, OgEntry> = {}
|
||||
const studio = await readStudio()
|
||||
const studioBase = studio?.base?.replace(/\/+$/, "") ?? "/og"
|
||||
for (const [page, entry] of Object.entries(studio?.cards ?? {})) {
|
||||
const files = [entry.image, entry.video, ...Object.values(entry.locales ?? {}).map((l) => l.image)].filter(
|
||||
(file): file is string => Boolean(file),
|
||||
)
|
||||
await Promise.all(files.map((file) => Bun.write(path.join(out, file), Bun.file(path.join(root, STUDIO, file)))))
|
||||
if (!studio?.base) {
|
||||
const files = [entry.image, entry.video, ...Object.values(entry.locales ?? {}).map((l) => l.image)].filter(
|
||||
(file): file is string => Boolean(file),
|
||||
)
|
||||
await Promise.all(files.map((file) => Bun.write(path.join(out, file), Bun.file(path.join(root, STUDIO, file)))))
|
||||
}
|
||||
manifest[page] = {
|
||||
src: `/og/${entry.image}`,
|
||||
src: `${studioBase}/${entry.image}`,
|
||||
alt: entry.alt,
|
||||
...(entry.video ? { video: `/og/${entry.video}` } : {}),
|
||||
...(entry.video ? { video: `${studioBase}/${entry.video}` } : {}),
|
||||
...(entry.locales
|
||||
? {
|
||||
locales: Object.fromEntries(
|
||||
Object.entries(entry.locales).map(([locale, l]) => [locale, { src: `/og/${l.image}`, alt: l.alt }]),
|
||||
Object.entries(entry.locales).map(([locale, l]) => [
|
||||
locale,
|
||||
{ src: `${studioBase}/${l.image}`, alt: l.alt },
|
||||
]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
@@ -244,11 +254,15 @@ await Promise.all(
|
||||
(await readdir(out)).filter((file) => !keep.has(file)).map((file) => rm(path.join(out, file), { force: true })),
|
||||
)
|
||||
await Bun.write(path.join(root, "src/lib/og-images.json"), JSON.stringify(manifest, null, 2) + "\n")
|
||||
console.log(`og: ${Object.keys(manifest).length} cards in public/og (${Object.keys(studio?.cards ?? {}).length} from OG Studio)`)
|
||||
console.log(
|
||||
`og: ${Object.keys(manifest).length} cards (${Object.keys(studio?.cards ?? {}).length} from OG Studio${studio?.base ? ` at ${studioBase}` : ""})`,
|
||||
)
|
||||
|
||||
type OgEntry = { src: string; alt: string; video?: string; locales?: Record<string, { src: string; alt: string }> }
|
||||
|
||||
type Studio = {
|
||||
/** Version 2: where the files are served (the files bucket's /files/site/og). */
|
||||
base?: string
|
||||
cards: Record<string, { image: string; alt: string; video?: string; locales?: Record<string, { image: string; alt: string }> }>
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 437 KiB |
|
Before Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 429 KiB |
|
Before Width: | Height: | Size: 437 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 425 KiB |
|
Before Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 154 KiB |
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"generator": "og-studio",
|
||||
"width": 1200,
|
||||
"height": 630,
|
||||
"base": "/files/site/og",
|
||||
"cards": {
|
||||
"/": {
|
||||
"image": "home-98637499f6.png",
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 322 KiB |