diff --git a/app/(main)/docs/[[...slug]]/page.tsx b/app/(main)/docs/[[...slug]]/page.tsx index 34bbd5a9..ec2cdb6c 100644 --- a/app/(main)/docs/[[...slug]]/page.tsx +++ b/app/(main)/docs/[[...slug]]/page.tsx @@ -12,6 +12,7 @@ import { DashboardTableOfContents } from "@/components/toc"; import { badgeVariants } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { siteConfig } from "@/config/site"; +import ogManifest from "@/lib/og-manifest.json"; import { getTableOfContents } from "@/lib/toc"; import { absoluteUrl, cn } from "@/lib/utils"; @@ -41,6 +42,11 @@ export async function generateMetadata({ params }: DocPageProps): Promise)[doc.slug]?.url ?? siteConfig.ogImage; + return { title: doc.title, description: doc.description, @@ -51,10 +57,10 @@ export async function generateMetadata({ params }: DocPageProps): Promise + +Build the previews + +```bash +pnpm storybook:build +pnpm og:fonts:sync # once after install — copies self-hosted fonts to public/og-fonts/ +``` + +Preview the card without uploading + +Renders to `.og-out/` so you can open the PNG and confirm it looks right. + +```bash +pnpm og:build --only=background-shooting-stars --dry-run +``` + +Upload and update the manifest + +Uploads to R2 and writes the slug → URL into `lib/og-manifest.json`. Commit that file. + +```bash +pnpm og:build --only=background-shooting-stars +``` + + + +`--only` matches a story id or doc slug, so `--only=shooting-stars` works too. Do several new +components in one pass with a comma-separated list: `pnpm og:build --only=card-a,card-b`. Re-running +is cheap — unchanged components are skipped via a content hash, so only what actually changed is +re-shot and re-uploaded. + +To upload to R2 yourself instead of through `wrangler` (e.g. a first bulk backfill), run +`pnpm og:build --no-upload`. It renders every card into `.og-out/og/` and writes the manifest +URLs, but uploads nothing — sync `.og-out/og/` to the bucket root (the filenames are the keys), then +commit `lib/og-manifest.json`. Upload the objects **before** deploying the manifest: a committed +entry is used even if its URL 404s, so deploying first would serve broken images until the upload +lands. + +If you uploaded the dry-run flat files instead (`background__animated-beam__b_v1.png` at the bucket +root rather than `og/background/animated-beam.v1..png`), run `pnpm og:migrate` to re-key them +on R2 and regenerate the manifest. Use `--dry-run` first to inspect, or `--no-upload` to write +`.og-out/og/` locally and sync that tree yourself. + ## Proposing pull requests - **Making changes**: implement your bug fix or feature and write stories to cover different use cases. Then commit your changes, push your bug fix/feature branch to the origin (your forked repo) and open a pull request to the upstream (the repository you originally forked)‘s main branch. diff --git a/lib/og-manifest.json b/lib/og-manifest.json new file mode 100644 index 00000000..57c4cf45 --- /dev/null +++ b/lib/og-manifest.json @@ -0,0 +1,582 @@ +{ + "/docs/background/animated-beam": { + "url": "https://assets.animata.design/og/background/animated-beam.v1.ae48cf2b3fdf05d8.png", + "hash": "ae48cf2b3fdf05d8" + }, + "/docs/background/blurry-blob": { + "url": "https://assets.animata.design/og/background/blurry-blob.v1.6a722a2d82bebce9.png", + "hash": "6a722a2d82bebce9" + }, + "/docs/background/boids-ecosystem": { + "url": "https://assets.animata.design/og/background/boids-ecosystem.v1.d874c192798b3e08.png", + "hash": "d874c192798b3e08" + }, + "/docs/background/diagonal-lines": { + "url": "https://assets.animata.design/og/background/diagonal-lines.v1.26d7cff26745315b.png", + "hash": "26d7cff26745315b" + }, + "/docs/background/dot": { + "url": "https://assets.animata.design/og/background/dot.v1.512911de884126c8.png", + "hash": "512911de884126c8" + }, + "/docs/background/grid": { + "url": "https://assets.animata.design/og/background/grid.v1.2cd975309dedaac5.png", + "hash": "2cd975309dedaac5" + }, + "/docs/background/interactive-grid": { + "url": "https://assets.animata.design/og/background/interactive-grid.v1.dae360847231eb1a.png", + "hash": "dae360847231eb1a" + }, + "/docs/background/moving-gradient": { + "url": "https://assets.animata.design/og/background/moving-gradient.v1.c05a803d21c8aaf4.png", + "hash": "c05a803d21c8aaf4" + }, + "/docs/background/shooting-stars": { + "url": "https://assets.animata.design/og/background/shooting-stars.v1.999e612365f08912.png", + "hash": "999e612365f08912" + }, + "/docs/background/zigzag": { + "url": "https://assets.animata.design/og/background/zigzag.v1.dd47b3ecd353a975.png", + "hash": "dd47b3ecd353a975" + }, + "/docs/bento-grid/eight": { + "url": "https://assets.animata.design/og/bento-grid/eight.v1.06916f50d294a508.png", + "hash": "06916f50d294a508" + }, + "/docs/bento-grid/gradient": { + "url": "https://assets.animata.design/og/bento-grid/gradient.v1.18131ed683164226.png", + "hash": "18131ed683164226" + }, + "/docs/bento-grid/three": { + "url": "https://assets.animata.design/og/bento-grid/three.v1.5bd711191aa94329.png", + "hash": "5bd711191aa94329" + }, + "/docs/button/ai-button": { + "url": "https://assets.animata.design/og/button/ai-button.v1.2284998c388fc11a.png", + "hash": "2284998c388fc11a" + }, + "/docs/button/algolia-blue-button": { + "url": "https://assets.animata.design/og/button/algolia-blue-button.v1.849fc8d0bfa3977a.png", + "hash": "849fc8d0bfa3977a" + }, + "/docs/button/algolia-white-button": { + "url": "https://assets.animata.design/og/button/algolia-white-button.v1.2131c27782effcc7.png", + "hash": "2131c27782effcc7" + }, + "/docs/button/animated-follow-button": { + "url": "https://assets.animata.design/og/button/animated-follow-button.v1.07413c7a12569483.png", + "hash": "07413c7a12569483" + }, + "/docs/button/duolingo": { + "url": "https://assets.animata.design/og/button/duolingo.v1.4eeafa138c01b4bf.png", + "hash": "4eeafa138c01b4bf" + }, + "/docs/button/get-started-button": { + "url": "https://assets.animata.design/og/button/get-started-button.v1.f9ee569ca5e7f870.png", + "hash": "f9ee569ca5e7f870" + }, + "/docs/button/ripple-button": { + "url": "https://assets.animata.design/og/button/ripple-button.v1.1972e9446fcaec54.png", + "hash": "1972e9446fcaec54" + }, + "/docs/button/shining-button": { + "url": "https://assets.animata.design/og/button/shining-button.v1.b5512f3c6621d19d.png", + "hash": "b5512f3c6621d19d" + }, + "/docs/button/slide-arrow-button": { + "url": "https://assets.animata.design/og/button/slide-arrow-button.v1.f7f84a94b4fae4d9.png", + "hash": "f7f84a94b4fae4d9" + }, + "/docs/button/status-button": { + "url": "https://assets.animata.design/og/button/status-button.v1.0ba11817a71d9211.png", + "hash": "0ba11817a71d9211" + }, + "/docs/button/swipe-button": { + "url": "https://assets.animata.design/og/button/swipe-button.v1.562b7d0fb65f6052.png", + "hash": "562b7d0fb65f6052" + }, + "/docs/button/toggle-switch": { + "url": "https://assets.animata.design/og/button/toggle-switch.v1.9d9d22f3a651395d.png", + "hash": "9d9d22f3a651395d" + }, + "/docs/button/work-button": { + "url": "https://assets.animata.design/og/button/work-button.v1.7ce147dc8057d9b7.png", + "hash": "7ce147dc8057d9b7" + }, + "/docs/card/card-comment": { + "url": "https://assets.animata.design/og/card/card-comment.v1.0e9673a248d33a8d.png", + "hash": "0e9673a248d33a8d" + }, + "/docs/card/card-spread": { + "url": "https://assets.animata.design/og/card/card-spread.v1.2ea24b641c14f149.png", + "hash": "2ea24b641c14f149" + }, + "/docs/card/card-stack": { + "url": "https://assets.animata.design/og/card/card-stack.v1.5540b04f60e29f96.png", + "hash": "5540b04f60e29f96" + }, + "/docs/card/case-study-card": { + "url": "https://assets.animata.design/og/card/case-study-card.v1.3d519137bff400e0.png", + "hash": "3d519137bff400e0" + }, + "/docs/card/collab-card": { + "url": "https://assets.animata.design/og/card/collab-card.v1.bb1c22f43b5b3c1b.png", + "hash": "bb1c22f43b5b3c1b" + }, + "/docs/card/flip-card": { + "url": "https://assets.animata.design/og/card/flip-card.v1.94fcbf1ca7e58572.png", + "hash": "94fcbf1ca7e58572" + }, + "/docs/card/github-card-shiny": { + "url": "https://assets.animata.design/og/card/github-card-shiny.v1.c8a37cfa6118f904.png", + "hash": "c8a37cfa6118f904" + }, + "/docs/card/github-card-skew": { + "url": "https://assets.animata.design/og/card/github-card-skew.v1.7427e912ca6c2601.png", + "hash": "7427e912ca6c2601" + }, + "/docs/card/glowing-card": { + "url": "https://assets.animata.design/og/card/glowing-card.v1.d1eb6a73b4a4d2fc.png", + "hash": "d1eb6a73b4a4d2fc" + }, + "/docs/card/led-board": { + "url": "https://assets.animata.design/og/card/led-board.v1.6d715c320342ded7.png", + "hash": "6d715c320342ded7" + }, + "/docs/card/swap-text-card": { + "url": "https://assets.animata.design/og/card/swap-text-card.v1.e16bb7418e3c284f.png", + "hash": "e16bb7418e3c284f" + }, + "/docs/container/animated-border-trail": { + "url": "https://assets.animata.design/og/container/animated-border-trail.v1.d25c4546f118aea5.png", + "hash": "d25c4546f118aea5" + }, + "/docs/container/announcement-ribbon": { + "url": "https://assets.animata.design/og/container/announcement-ribbon.v1.31cf1e6f77f7da70.png", + "hash": "31cf1e6f77f7da70" + }, + "/docs/container/cursor-tracker": { + "url": "https://assets.animata.design/og/container/cursor-tracker.v1.f8c5a4cb430ea95c.png", + "hash": "f8c5a4cb430ea95c" + }, + "/docs/container/marquee": { + "url": "https://assets.animata.design/og/container/marquee.v1.7af90ca72ccc2511.png", + "hash": "7af90ca72ccc2511" + }, + "/docs/container/sibling-focus-nav": { + "url": "https://assets.animata.design/og/container/sibling-focus-nav.v1.3992924871615978.png", + "hash": "3992924871615978" + }, + "/docs/fabs/flower-menu": { + "url": "https://assets.animata.design/og/fabs/flower-menu.v1.084a4041231fff85.png", + "hash": "084a4041231fff85" + }, + "/docs/fabs/speed-dial": { + "url": "https://assets.animata.design/og/fabs/speed-dial.v1.6a31866584b06d30.png", + "hash": "6a31866584b06d30" + }, + "/docs/graphs/bar-chart": { + "url": "https://assets.animata.design/og/graphs/bar-chart.v1.701d82612154c9a6.png", + "hash": "701d82612154c9a6" + }, + "/docs/graphs/donut-chart": { + "url": "https://assets.animata.design/og/graphs/donut-chart.v1.5e8fba4b1becb2f8.png", + "hash": "5e8fba4b1becb2f8" + }, + "/docs/graphs/gauge-chart": { + "url": "https://assets.animata.design/og/graphs/gauge-chart.v1.646005097a7f2189.png", + "hash": "646005097a7f2189" + }, + "/docs/graphs/progress": { + "url": "https://assets.animata.design/og/graphs/progress.v1.afa82a187ae6f975.png", + "hash": "afa82a187ae6f975" + }, + "/docs/graphs/ring-chart": { + "url": "https://assets.animata.design/og/graphs/ring-chart.v1.af5d7a80898d13fd.png", + "hash": "af5d7a80898d13fd" + }, + "/docs/hero/hero-section-text-hover": { + "url": "https://assets.animata.design/og/hero/hero-section-text-hover.v1.ed085490bd40a06d.png", + "hash": "ed085490bd40a06d" + }, + "/docs/hero/product-features": { + "url": "https://assets.animata.design/og/hero/product-features.v1.4a30bf58a5b054c3.png", + "hash": "4a30bf58a5b054c3" + }, + "/docs/hero/shape-shifter": { + "url": "https://assets.animata.design/og/hero/shape-shifter.v1.c51923dafef8a0e7.png", + "hash": "c51923dafef8a0e7" + }, + "/docs/hero/slack-intro": { + "url": "https://assets.animata.design/og/hero/slack-intro.v1.431c1ea256f8b2bf.png", + "hash": "431c1ea256f8b2bf" + }, + "/docs/icon/hover-interaction": { + "url": "https://assets.animata.design/og/icon/hover-interaction.v1.5451e6751fc01dea.png", + "hash": "5451e6751fc01dea" + }, + "/docs/icon/icon-ripple": { + "url": "https://assets.animata.design/og/icon/icon-ripple.v1.4c09bdd797b66b00.png", + "hash": "4c09bdd797b66b00" + }, + "/docs/image/disclose-image": { + "url": "https://assets.animata.design/og/image/disclose-image.v1.fb61dc8a6fb6fd59.png", + "hash": "fb61dc8a6fb6fd59" + }, + "/docs/image/images-reveal": { + "url": "https://assets.animata.design/og/image/images-reveal.v1.8b6f11078871ee26.png", + "hash": "8b6f11078871ee26" + }, + "/docs/image/skew-image": { + "url": "https://assets.animata.design/og/image/skew-image.v1.81854536a8a37f47.png", + "hash": "81854536a8a37f47" + }, + "/docs/image/tilted-cover": { + "url": "https://assets.animata.design/og/image/tilted-cover.v1.ffd484377ee3cad9.png", + "hash": "ffd484377ee3cad9" + }, + "/docs/image/trailing-image": { + "url": "https://assets.animata.design/og/image/trailing-image.v1.cc4cd06e1158a2cc.png", + "hash": "cc4cd06e1158a2cc" + }, + "/docs/list/avatar-list": { + "url": "https://assets.animata.design/og/list/avatar-list.v1.491668bcb77ed919.png", + "hash": "491668bcb77ed919" + }, + "/docs/list/flipping-cards": { + "url": "https://assets.animata.design/og/list/flipping-cards.v1.544a7b2283389ca5.png", + "hash": "544a7b2283389ca5" + }, + "/docs/list/menu-animation": { + "url": "https://assets.animata.design/og/list/menu-animation.v1.37a62e206cee7a0c.png", + "hash": "37a62e206cee7a0c" + }, + "/docs/list/orbiting-items": { + "url": "https://assets.animata.design/og/list/orbiting-items.v1.6aa35e57003dbfa0.png", + "hash": "6aa35e57003dbfa0" + }, + "/docs/list/orbiting-items-3-d": { + "url": "https://assets.animata.design/og/list/orbiting-items-3-d.v1.256fe3c90b7a6572.png", + "hash": "256fe3c90b7a6572" + }, + "/docs/list/reveal-image": { + "url": "https://assets.animata.design/og/list/reveal-image.v1.03faed4cf835385d.png", + "hash": "03faed4cf835385d" + }, + "/docs/list/transaction-list": { + "url": "https://assets.animata.design/og/list/transaction-list.v1.f3e358f4fb957e13.png", + "hash": "f3e358f4fb957e13" + }, + "/docs/preloader/split-reveal": { + "url": "https://assets.animata.design/og/preloader/split-reveal.v1.380bbb28fff03495.png", + "hash": "380bbb28fff03495" + }, + "/docs/preloader/vertical-tiles": { + "url": "https://assets.animata.design/og/preloader/vertical-tiles.v1.b2edf2a94cd951de.png", + "hash": "b2edf2a94cd951de" + }, + "/docs/progress/animatedtimeline": { + "url": "https://assets.animata.design/og/progress/animatedtimeline.v1.86ed8a25f34ddf2c.png", + "hash": "86ed8a25f34ddf2c" + }, + "/docs/progress/spinner": { + "url": "https://assets.animata.design/og/progress/spinner.v1.ece1a027e121202f.png", + "hash": "ece1a027e121202f" + }, + "/docs/scroll/stacked-sections": { + "url": "https://assets.animata.design/og/scroll/stacked-sections.v1.3f0c72de6a796de6.png", + "hash": "3f0c72de6a796de6" + }, + "/docs/tabs/fluid-tabs": { + "url": "https://assets.animata.design/og/tabs/fluid-tabs.v1.c21f86e5eb384729.png", + "hash": "c21f86e5eb384729" + }, + "/docs/tabs/gooey-tabs": { + "url": "https://assets.animata.design/og/tabs/gooey-tabs.v1.36a1879e192f4eb7.png", + "hash": "36a1879e192f4eb7" + }, + "/docs/tabs/shift-tabs": { + "url": "https://assets.animata.design/og/tabs/shift-tabs.v1.1626ab53aebbe39a.png", + "hash": "1626ab53aebbe39a" + }, + "/docs/text/animated-gradient-text": { + "url": "https://assets.animata.design/og/text/animated-gradient-text.v1.936ffad1a3d21e17.png", + "hash": "936ffad1a3d21e17" + }, + "/docs/text/blur-out-up": { + "url": "https://assets.animata.design/og/text/blur-out-up.v1.472459e7dc0933dd.png", + "hash": "472459e7dc0933dd" + }, + "/docs/text/bold-copy": { + "url": "https://assets.animata.design/og/text/bold-copy.v1.137d7ac0fbc87bff.png", + "hash": "137d7ac0fbc87bff" + }, + "/docs/text/bottom-up-letters": { + "url": "https://assets.animata.design/og/text/bottom-up-letters.v1.9a0b02f961ee0647.png", + "hash": "9a0b02f961ee0647" + }, + "/docs/text/circular-text": { + "url": "https://assets.animata.design/og/text/circular-text.v1.5581dff13b395b2b.png", + "hash": "5581dff13b395b2b" + }, + "/docs/text/counter": { + "url": "https://assets.animata.design/og/text/counter.v1.69b39f1530ed1b38.png", + "hash": "69b39f1530ed1b38" + }, + "/docs/text/cycle-text": { + "url": "https://assets.animata.design/og/text/cycle-text.v1.f4632ee598545271.png", + "hash": "f4632ee598545271" + }, + "/docs/text/double-underline": { + "url": "https://assets.animata.design/og/text/double-underline.v1.281bc22ab52c6f47.png", + "hash": "281bc22ab52c6f47" + }, + "/docs/text/fade-through": { + "url": "https://assets.animata.design/og/text/fade-through.v1.a9bfa891372a8bd0.png", + "hash": "a9bfa891372a8bd0" + }, + "/docs/text/focus-blur-resolve": { + "url": "https://assets.animata.design/og/text/focus-blur-resolve.v1.787f7d6485a203e5.png", + "hash": "787f7d6485a203e5" + }, + "/docs/text/gibberish-text": { + "url": "https://assets.animata.design/og/text/gibberish-text.v1.bd6e587b2b81934b.png", + "hash": "bd6e587b2b81934b" + }, + "/docs/text/glitch-text": { + "url": "https://assets.animata.design/og/text/glitch-text.v1.de2c73e79aecf11e.png", + "hash": "de2c73e79aecf11e" + }, + "/docs/text/jitter-text": { + "url": "https://assets.animata.design/og/text/jitter-text.v1.585c426757b61ba4.png", + "hash": "585c426757b61ba4" + }, + "/docs/text/jumping-text-instagram": { + "url": "https://assets.animata.design/og/text/jumping-text-instagram.v1.2889b16a9f7b7824.png", + "hash": "2889b16a9f7b7824" + }, + "/docs/text/kinetic-center-build": { + "url": "https://assets.animata.design/og/text/kinetic-center-build.v1.9d1bb057cf02e402.png", + "hash": "9d1bb057cf02e402" + }, + "/docs/text/line-by-line-slide": { + "url": "https://assets.animata.design/og/text/line-by-line-slide.v1.c8a7c53d3efea7be.png", + "hash": "c8a7c53d3efea7be" + }, + "/docs/text/mask-reveal-up": { + "url": "https://assets.animata.design/og/text/mask-reveal-up.v1.5c0390a331cfd30e.png", + "hash": "5c0390a331cfd30e" + }, + "/docs/text/micro-scale-fade": { + "url": "https://assets.animata.design/og/text/micro-scale-fade.v1.60601e354ff96106.png", + "hash": "60601e354ff96106" + }, + "/docs/text/mirror-text": { + "url": "https://assets.animata.design/og/text/mirror-text.v1.bef6afa9db5bec9b.png", + "hash": "bef6afa9db5bec9b" + }, + "/docs/text/per-character-rise": { + "url": "https://assets.animata.design/og/text/per-character-rise.v1.77187d51c613cba6.png", + "hash": "77187d51c613cba6" + }, + "/docs/text/per-word-crossfade": { + "url": "https://assets.animata.design/og/text/per-word-crossfade.v1.9f3960f1ae6b6f09.png", + "hash": "9f3960f1ae6b6f09" + }, + "/docs/text/scale-down-fade": { + "url": "https://assets.animata.design/og/text/scale-down-fade.v1.45ac1a3d4450abaf.png", + "hash": "45ac1a3d4450abaf" + }, + "/docs/text/scroll-reveal": { + "url": "https://assets.animata.design/og/text/scroll-reveal.v1.cf315eabf6e00a8a.png", + "hash": "cf315eabf6e00a8a" + }, + "/docs/text/shared-axis-y": { + "url": "https://assets.animata.design/og/text/shared-axis-y.v1.6756d69b14aff685.png", + "hash": "6756d69b14aff685" + }, + "/docs/text/shared-axis-z": { + "url": "https://assets.animata.design/og/text/shared-axis-z.v1.a9b311eb80c46462.png", + "hash": "a9b311eb80c46462" + }, + "/docs/text/shimmer-sweep": { + "url": "https://assets.animata.design/og/text/shimmer-sweep.v1.5bf1cf54f568d748.png", + "hash": "5bf1cf54f568d748" + }, + "/docs/text/short-slide-down": { + "url": "https://assets.animata.design/og/text/short-slide-down.v1.83a0663d0c2c1f58.png", + "hash": "83a0663d0c2c1f58" + }, + "/docs/text/short-slide-right": { + "url": "https://assets.animata.design/og/text/short-slide-right.v1.7646526f5430aa4e.png", + "hash": "7646526f5430aa4e" + }, + "/docs/text/soft-blur-in": { + "url": "https://assets.animata.design/og/text/soft-blur-in.v1.110f20d5aa79d067.png", + "hash": "110f20d5aa79d067" + }, + "/docs/text/split-text": { + "url": "https://assets.animata.design/og/text/split-text.v1.9ab1d573348e8f3c.png", + "hash": "9ab1d573348e8f3c" + }, + "/docs/text/spring-scale-in": { + "url": "https://assets.animata.design/og/text/spring-scale-in.v1.cd0ac9beff379892.png", + "hash": "cd0ac9beff379892" + }, + "/docs/text/swap-text": { + "url": "https://assets.animata.design/og/text/swap-text.v1.27fa4c0128c7b588.png", + "hash": "27fa4c0128c7b588" + }, + "/docs/text/text-border-animation": { + "url": "https://assets.animata.design/og/text/text-border-animation.v1.5ef78cdb7267ca29.png", + "hash": "5ef78cdb7267ca29" + }, + "/docs/text/text-explode-imessage": { + "url": "https://assets.animata.design/og/text/text-explode-imessage.v1.d9c8e5f4ef0489f6.png", + "hash": "d9c8e5f4ef0489f6" + }, + "/docs/text/text-flip": { + "url": "https://assets.animata.design/og/text/text-flip.v1.bbc9a6e73dc30a42.png", + "hash": "bbc9a6e73dc30a42" + }, + "/docs/text/ticker": { + "url": "https://assets.animata.design/og/text/ticker.v1.b75902794dbd9209.png", + "hash": "b75902794dbd9209" + }, + "/docs/text/top-down-letters": { + "url": "https://assets.animata.design/og/text/top-down-letters.v1.32098344591ac347.png", + "hash": "32098344591ac347" + }, + "/docs/text/typing-text": { + "url": "https://assets.animata.design/og/text/typing-text.v1.3caa1c8a48d58f5e.png", + "hash": "3caa1c8a48d58f5e" + }, + "/docs/text/underline-hover-text": { + "url": "https://assets.animata.design/og/text/underline-hover-text.v1.ae732ee09e3bc6f1.png", + "hash": "ae732ee09e3bc6f1" + }, + "/docs/text/wave-reveal": { + "url": "https://assets.animata.design/og/text/wave-reveal.v1.b66748d321f3caa6.png", + "hash": "b66748d321f3caa6" + }, + "/docs/widget/alarm-clock": { + "url": "https://assets.animata.design/og/widget/alarm-clock.v1.5781977f6813c2a1.png", + "hash": "5781977f6813c2a1" + }, + "/docs/widget/battery": { + "url": "https://assets.animata.design/og/widget/battery.v1.0ac6e24e1d6ed958.png", + "hash": "0ac6e24e1d6ed958" + }, + "/docs/widget/battery-level": { + "url": "https://assets.animata.design/og/widget/battery-level.v1.d2d7ab1fb71ca70d.png", + "hash": "d2d7ab1fb71ca70d" + }, + "/docs/widget/calendar-event": { + "url": "https://assets.animata.design/og/widget/calendar-event.v1.f3346441ef686220.png", + "hash": "f3346441ef686220" + }, + "/docs/widget/calorie-counter": { + "url": "https://assets.animata.design/og/widget/calorie-counter.v1.f7bb14dbb6f96a26.png", + "hash": "f7bb14dbb6f96a26" + }, + "/docs/widget/clock-with-photo": { + "url": "https://assets.animata.design/og/widget/clock-with-photo.v1.b11e171a4c0b0ca7.png", + "hash": "b11e171a4c0b0ca7" + }, + "/docs/widget/cycling": { + "url": "https://assets.animata.design/og/widget/cycling.v1.c659c03e60ba1b74.png", + "hash": "c659c03e60ba1b74" + }, + "/docs/widget/delivery-card": { + "url": "https://assets.animata.design/og/widget/delivery-card.v1.b1a5765f54312405.png", + "hash": "b1a5765f54312405" + }, + "/docs/widget/direction-card": { + "url": "https://assets.animata.design/og/widget/direction-card.v1.fcaa395730f6dee3.png", + "hash": "fcaa395730f6dee3" + }, + "/docs/widget/expense-tracker": { + "url": "https://assets.animata.design/og/widget/expense-tracker.v1.738a550ccec20af2.png", + "hash": "738a550ccec20af2" + }, + "/docs/widget/flight-widget": { + "url": "https://assets.animata.design/og/widget/flight-widget.v1.baf8eac69ed8a4db.png", + "hash": "baf8eac69ed8a4db" + }, + "/docs/widget/fund-widget": { + "url": "https://assets.animata.design/og/widget/fund-widget.v1.8375b15dae1a7738.png", + "hash": "8375b15dae1a7738" + }, + "/docs/widget/live-score": { + "url": "https://assets.animata.design/og/widget/live-score.v1.ac25b4e6fc8f0f8e.png", + "hash": "ac25b4e6fc8f0f8e" + }, + "/docs/widget/mobile-detail": { + "url": "https://assets.animata.design/og/widget/mobile-detail.v1.2dbf7e347a37bffd.png", + "hash": "2dbf7e347a37bffd" + }, + "/docs/widget/music-widget": { + "url": "https://assets.animata.design/og/widget/music-widget.v1.be3581db35d59437.png", + "hash": "be3581db35d59437" + }, + "/docs/widget/notes": { + "url": "https://assets.animata.design/og/widget/notes.v1.c0b9225490d062e2.png", + "hash": "c0b9225490d062e2" + }, + "/docs/widget/profile": { + "url": "https://assets.animata.design/og/widget/profile.v1.1b93a635a7deb06d.png", + "hash": "1b93a635a7deb06d" + }, + "/docs/widget/reminder": { + "url": "https://assets.animata.design/og/widget/reminder.v1.97c3b49f28dac2c8.png", + "hash": "97c3b49f28dac2c8" + }, + "/docs/widget/reminder-widget": { + "url": "https://assets.animata.design/og/widget/reminder-widget.v1.a14e391c01784b56.png", + "hash": "a14e391c01784b56" + }, + "/docs/widget/score-board": { + "url": "https://assets.animata.design/og/widget/score-board.v1.2e090320b93fedcb.png", + "hash": "2e090320b93fedcb" + }, + "/docs/widget/security-alert": { + "url": "https://assets.animata.design/og/widget/security-alert.v1.0b41d4a2ee8ba7df.png", + "hash": "0b41d4a2ee8ba7df" + }, + "/docs/widget/shopping-list": { + "url": "https://assets.animata.design/og/widget/shopping-list.v1.bab80bde60cc4b46.png", + "hash": "bab80bde60cc4b46" + }, + "/docs/widget/sleep-tracker": { + "url": "https://assets.animata.design/og/widget/sleep-tracker.v1.e3574eae18445634.png", + "hash": "e3574eae18445634" + }, + "/docs/widget/storage-status": { + "url": "https://assets.animata.design/og/widget/storage-status.v1.f0181448ff681717.png", + "hash": "f0181448ff681717" + }, + "/docs/widget/storage-widget": { + "url": "https://assets.animata.design/og/widget/storage-widget.v1.63355b9993541dff.png", + "hash": "63355b9993541dff" + }, + "/docs/widget/study-timer": { + "url": "https://assets.animata.design/og/widget/study-timer.v1.2faebeb00aa9b22d.png", + "hash": "2faebeb00aa9b22d" + }, + "/docs/widget/vpn-widget": { + "url": "https://assets.animata.design/og/widget/vpn-widget.v1.659181e5f2e32a45.png", + "hash": "659181e5f2e32a45" + }, + "/docs/widget/water-tracker": { + "url": "https://assets.animata.design/og/widget/water-tracker.v1.fbfe2a8e860d252b.png", + "hash": "fbfe2a8e860d252b" + }, + "/docs/widget/weather-card": { + "url": "https://assets.animata.design/og/widget/weather-card.v1.35f1ed8a1c06473e.png", + "hash": "35f1ed8a1c06473e" + }, + "/docs/widget/weekly-progress": { + "url": "https://assets.animata.design/og/widget/weekly-progress.v1.372f86cacb6f95d7.png", + "hash": "372f86cacb6f95d7" + } +} diff --git a/package.json b/package.json index 7f2ae44b..0688d8b9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "format": "biome format --write .", "storybook": "storybook dev -p 6006", "storybook:build": "storybook build -o public/preview", + "og:build": "node ./scripts/build-og-images.mjs", + "og:fonts:sync": "node ./scripts/sync-og-fonts.mjs", + "og:migrate": "node ./scripts/build-og-images.mjs --migrate-from-flat", + "og:upload": "node ./scripts/upload-og-r2.mjs", "animata:new": "node ./scripts/create-new.js", "registry:build": "node ./scripts/build-registry.js && node ./scripts/build-docs-markdown.js && node ./scripts/build-llms-txt.js", "shadcn:add": "npx shadcn@latest add", @@ -86,6 +90,9 @@ "@biomejs/biome": "^2.4.6", "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@fontsource/outfit": "^5.2.8", + "@fontsource/young-serif": "^5.2.7", "@mdx-js/mdx": "^3.1.1", "@storybook/addon-docs": "^10.2.15", "@storybook/addon-themes": "^10.2.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5da95fdd..52fb63f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,15 @@ importers: '@commitlint/config-conventional': specifier: ^19.2.2 version: 19.8.1 + '@fontsource/ibm-plex-sans': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/outfit': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/young-serif': + specifier: ^5.2.7 + version: 5.2.7 '@mdx-js/mdx': specifier: ^3.1.1 version: 3.1.1 @@ -1515,6 +1524,15 @@ packages: '@fontsource-variable/lilex@5.2.1': resolution: {integrity: sha512-l+cYywL7TTDffmiaZEPG98Er8eIJhqImV9ZSwOqxE3dC+oS8G/erBvIkQO6T87J+i7+9HJwr7gSVfcq7deMuAw==} + '@fontsource/ibm-plex-sans@5.2.8': + resolution: {integrity: sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==} + + '@fontsource/outfit@5.2.8': + resolution: {integrity: sha512-rXC6g0MqD7cOBjht0bMqc43qM6lRqDLML9KXsmg9uykz0wLQhy8Z/ajrMG6iyoT3NJR+MYgU+OEHp7uHoTb+Yw==} + + '@fontsource/young-serif@5.2.7': + resolution: {integrity: sha512-BltTmAlXLWQpu9XCQa+dfDJHQXt0EHUA4h9Q53VToDW3Onq0dkWzCPM1EyhinqfuwD9tb/5cEcGoRcRwiyiUUA==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2709,49 +2727,63 @@ packages: '@tsparticles/engine@3.9.1': resolution: {integrity: sha512-DpdgAhWMZ3Eh2gyxik8FXS6BKZ8vyea+Eu5BC4epsahqTGY9V3JGGJcXC6lRJx6cPMAx1A0FaQAojPF3v6rkmQ==} - deprecated: tsParticles v4 is out, please upgrade for better performances and more features + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-attract@3.9.1': resolution: {integrity: sha512-5AJGmhzM9o4AVFV24WH5vSqMBzOXEOzIdGLIr+QJf4fRh9ZK62snsusv/ozKgs2KteRYQx+L7c5V3TqcDy2upg==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-bounce@3.9.1': resolution: {integrity: sha512-bv05+h70UIHOTWeTsTI1AeAmX6R3s8nnY74Ea6p6AbQjERzPYIa0XY19nq/hA7+Nrg+EissP5zgoYYeSphr85A==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-bubble@3.9.1': resolution: {integrity: sha512-tbd8ox/1GPl+zr+KyHQVV1bW88GE7OM6i4zql801YIlCDrl9wgTDdDFGIy9X7/cwTvTrCePhrfvdkUamXIribQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-connect@3.9.1': resolution: {integrity: sha512-sq8YfUNsIORjXHzzW7/AJQtfi/qDqLnYG2qOSE1WOsog39MD30RzmiOloejOkfNeUdcGUcfsDgpUuL3UhzFUOA==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-grab@3.9.1': resolution: {integrity: sha512-QwXza+sMMWDaMiFxd8y2tJwUK6c+nNw554+/9+tEZeTTk2fCbB0IJ7p/TH6ZGWDL0vo2muK54Njv2fEey191ow==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-pause@3.9.1': resolution: {integrity: sha512-Gzv4/FeNir0U/tVM9zQCqV1k+IAgaFjDU3T30M1AeAsNGh/rCITV2wnT7TOGFkbcla27m4Yxa+Fuab8+8pzm+g==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-push@3.9.1': resolution: {integrity: sha512-GvnWF9Qy4YkZdx+WJL2iy9IcgLvzOIu3K7aLYJFsQPaxT8d9TF8WlpoMlWKnJID6H5q4JqQuMRKRyWH8aAKyQw==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-remove@3.9.1': resolution: {integrity: sha512-yPThm4UDWejDOWW5Qc8KnnS2EfSo5VFcJUQDWc1+Wcj17xe7vdSoiwwOORM0PmNBzdDpSKQrte/gUnoqaUMwOA==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-repulse@3.9.1': resolution: {integrity: sha512-/LBppXkrMdvLHlEKWC7IykFhzrz+9nebT2fwSSFXK4plEBxDlIwnkDxd3FbVOAbnBvx4+L8+fbrEx+RvC8diAw==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-slow@3.9.1': resolution: {integrity: sha512-1ZYIR/udBwA9MdSCfgADsbDXKSFS0FMWuPWz7bm79g3sUxcYkihn+/hDhc6GXvNNR46V1ocJjrj0u6pAynS1KQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-external-trail@3.9.1': resolution: {integrity: sha512-Au0v2oiqfKTemI/4bzjD4dUXzIngB5Q2T4nJcMCYpP24uZfwZh5xTjUMH7gyJyyaRTdMl9IJrp8ySjyYbLfeGg==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-particles-attract@3.9.1': resolution: {integrity: sha512-CYYYowJuGwRLUixQcSU/48PTKM8fCUYThe0hXwQ+yRMLAn053VHzL7NNZzKqEIeEyt5oJoy9KcvubjKWbzMBLQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-particles-collisions@3.9.1': resolution: {integrity: sha512-ggGyjW/3v1yxvYW1IF1EMT15M6w31y5zfNNUPkqd/IXRNPYvm0Z0ayhp+FKmz70M5p0UxxPIQHTvAv9Jqnuj8w==} + deprecated: Please update to version 4.1.1 '@tsparticles/interaction-particles-links@3.9.1': resolution: {integrity: sha512-MsLbMjy1vY5M5/hu/oa5OSRZAUz49H3+9EBMTIOThiX+a+vpl3sxc9AqNd9gMsPbM4WJlub8T6VBZdyvzez1Vg==} + deprecated: Please update to version 4.1.1 '@tsparticles/move-base@3.9.1': resolution: {integrity: sha512-X4huBS27d8srpxwOxliWPUt+NtCwY+8q/cx1DvQxyqmTA8VFCGpcHNwtqiN+9JicgzOvSuaORVqUgwlsc7h4pQ==} @@ -2763,9 +2795,11 @@ packages: '@tsparticles/plugin-absorbers@3.9.1': resolution: {integrity: sha512-q9SQllpbPPgw1+euxHPYCFawOVUazQkkwnleiIgpYSiimlCyjIdwGnFPSNe1Sypzqmr2h6oOyX2vkK5ZVNEu8A==} + deprecated: Please update to version 4.1.1 '@tsparticles/plugin-easing-quad@3.9.1': resolution: {integrity: sha512-C2UJOca5MTDXKUTBXj30Kiqr5UyID+xrY/LxicVWWZPczQW2bBxbIbfq9ULvzGDwBTxE2rdvIB8YFKmDYO45qw==} + deprecated: Please update to version 4.1.1 '@tsparticles/plugin-emitters-shape-circle@3.9.1': resolution: {integrity: sha512-z+9MsAPWr++sNz6N6303rRDjusW0BIPhHY51E5eXGDcRdOqrESDs6y99AJ/6Kdb/PpibCIYjFY9jVi2JJADPRA==} @@ -2781,13 +2815,15 @@ packages: '@tsparticles/plugin-hsl-color@3.9.1': resolution: {integrity: sha512-jJd1iGgRwX6eeNjc1zUXiJivaqC5UE+SC2A3/NtHwwoQrkfxGWmRHOsVyLnOBRcCPgBp/FpdDe6DIDjCMO715w==} + deprecated: Please update to version 4.1.1 '@tsparticles/plugin-rgb-color@3.9.1': resolution: {integrity: sha512-SBxk7f1KBfXeTnnklbE2Hx4jBgh6I6HOtxb+Os1gTp0oaghZOkWcCD2dP4QbUu7fVNCMOcApPoMNC8RTFcy9wQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/react@3.0.0': resolution: {integrity: sha512-hjGEtTT1cwv6BcjL+GcVgH++KYs52bIuQGW3PWv7z3tMa8g0bd6RI/vWSLj7p//NZ3uTjEIeilYIUPBh7Jfq/Q==} - deprecated: tsParticles v4 is out, please upgrade for better performances and more features + deprecated: Please update to version 4.1.1 peerDependencies: '@tsparticles/engine': ^3.0.2 react: '>=16.8.0' @@ -2795,31 +2831,39 @@ packages: '@tsparticles/shape-circle@3.9.1': resolution: {integrity: sha512-DqZFLjbuhVn99WJ+A9ajz9YON72RtCcvubzq6qfjFmtwAK7frvQeb6iDTp6Ze9FUipluxVZWVRG4vWTxi2B+/g==} + deprecated: Please update to version 4.1.1 '@tsparticles/shape-emoji@3.9.1': resolution: {integrity: sha512-ifvY63usuT+hipgVHb8gelBHSeF6ryPnMxAAEC1RGHhhXfpSRWMtE6ybr+pSsYU52M3G9+TF84v91pSwNrb9ZQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/shape-image@3.9.1': resolution: {integrity: sha512-fCA5eme8VF3oX8yNVUA0l2SLDKuiZObkijb0z3Ky0qj1HUEVlAuEMhhNDNB9E2iELTrWEix9z7BFMePp2CC7AA==} + deprecated: Please update to version 4.1.1 '@tsparticles/shape-line@3.9.1': resolution: {integrity: sha512-wT8NSp0N9HURyV05f371cHKcNTNqr0/cwUu6WhBzbshkYGy1KZUP9CpRIh5FCrBpTev34mEQfOXDycgfG0KiLQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/shape-polygon@3.9.1': resolution: {integrity: sha512-dA77PgZdoLwxnliH6XQM/zF0r4jhT01pw5y7XTeTqws++hg4rTLV9255k6R6eUqKq0FPSW1/WBsBIl7q/MmrqQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/shape-square@3.9.1': resolution: {integrity: sha512-DKGkDnRyZrAm7T2ipqNezJahSWs6xd9O5LQLe5vjrYm1qGwrFxJiQaAdlb00UNrexz1/SA7bEoIg4XKaFa7qhQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/shape-star@3.9.1': resolution: {integrity: sha512-kdMJpi8cdeb6vGrZVSxTG0JIjCwIenggqk0EYeKAwtOGZFBgL7eHhF2F6uu1oq8cJAbXPujEoabnLsz6mW8XaA==} + deprecated: Please update to version 4.1.1 '@tsparticles/shape-text@3.9.1': resolution: {integrity: sha512-oNsLHI0lGkIXoUw3W598iwd7dtoHCDrwpwJRGnQzgfk6T5a9dCpSD5vDeQN89lr3BUbVui4lhxq+/TyC64oAqA==} + deprecated: Please update to version 4.1.1 '@tsparticles/slim@3.9.1': resolution: {integrity: sha512-CL5cDmADU7sDjRli0So+hY61VMbdroqbArmR9Av+c1Fisa5ytr6QD7Jv62iwU2S6rvgicEe9OyRmSy5GIefwZw==} - deprecated: tsParticles v4 is out, please upgrade for better performances and more features + deprecated: Please update to version 4.1.1 '@tsparticles/updater-color@3.9.1': resolution: {integrity: sha512-XGWdscrgEMA8L5E7exsE0f8/2zHKIqnTrZymcyuFBw2DCB6BIV+5z6qaNStpxrhq3DbIxxhqqcybqeOo7+Alpg==} @@ -2827,24 +2871,31 @@ packages: '@tsparticles/updater-destroy@3.9.1': resolution: {integrity: sha512-MjMzEhZwCQIbxO6ZRM0eXsHVwmlXuUqwC43WCPZCpjhK3AJrMu3KR4xsJieFTWIbVNguAvbgoTB10FfJOUU5VA==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-life@3.9.1': resolution: {integrity: sha512-Oi8aF2RIwMMsjssUkCB6t3PRpENHjdZf6cX92WNfAuqXtQphr3OMAkYFJFWkvyPFK22AVy3p/cFt6KE5zXxwAA==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-opacity@3.9.1': resolution: {integrity: sha512-w778LQuRZJ+IoWzeRdrGykPYSSaTeWfBvLZ2XwYEkh/Ss961InOxZKIpcS6i5Kp/Zfw0fS1ZAuqeHwuj///Osw==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-out-modes@3.9.1': resolution: {integrity: sha512-cKQEkAwbru+hhKF+GTsfbOvuBbx2DSB25CxOdhtW2wRvDBoCnngNdLw91rs+0Cex4tgEeibkebrIKFDDE6kELg==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-roll@3.9.1': resolution: {integrity: sha512-zl4JeM3gUBJ0uttmIsond3lrZ3f3AkItFeS0Lhj/7jiCKfUoRyyOMrcBk8R1AhW7lI+7ko1iBs3jhO0jnxz9vg==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-rotate@3.9.1': resolution: {integrity: sha512-9BfKaGfp28JN82MF2qs6Ae/lJr9EColMfMTHqSKljblwbpVDHte4umuwKl3VjbRt87WD9MGtla66NTUYl+WxuQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-size@3.9.1': resolution: {integrity: sha512-3NSVs0O2ApNKZXfd+y/zNhTXSFeG1Pw4peI8e6z/q5+XLbmue9oiEwoPy/tQLaark3oNj3JU7Q903ZijPyXSzw==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-stroke-color@3.9.1': resolution: {integrity: sha512-3x14+C2is9pZYTg9T2TiA/aM1YMq4wLdYaZDcHm3qO30DZu5oeQq0rm/6w+QOGKYY1Z3Htg9rlSUZkhTHn7eDA==} @@ -2852,12 +2903,15 @@ packages: '@tsparticles/updater-tilt@3.9.1': resolution: {integrity: sha512-PB2yaoyXRmSk4iIVgjtRrzOxXMK9mjeAQHIJGtT4faq46Z8cbIIEFgjTwqrUV8qOrNg/h4sm5NE/s0qsTYjp1Q==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-twinkle@3.9.1': resolution: {integrity: sha512-xgTcYr6LmP44IPIBeQmEExN2Y5Nfl3ikmC08eOh5nZy/ta6ORP+JTsprrnfuv/O2DwTyoqFLkZ16hZfkdc1yOQ==} + deprecated: Please update to version 4.1.1 '@tsparticles/updater-wobble@3.9.1': resolution: {integrity: sha512-c99Ogy9q4QWO+zsDXol0UnpUwZiY2UucFb8ltuDv9AlbGUeprygoub8jhgT5pEDv+GdzWOJGSgq7rfgv9cHBrg==} + deprecated: Please update to version 4.1.1 '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -5854,7 +5908,7 @@ packages: tsparticles@3.9.1: resolution: {integrity: sha512-Y780IGSL4qjkZj7+fI92PV/cziHqLR/s6nnYri4K6vH3NQRmDK5D6pfskDO8T4Y96ChCWHY3uxPtOb/hKQ83Qg==} - deprecated: tsParticles v4 is out, please upgrade for better performances and more features + deprecated: Please update to version 4.1.1 tty-browserify@0.0.1: resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==} @@ -7399,6 +7453,12 @@ snapshots: '@fontsource-variable/lilex@5.2.1': {} + '@fontsource/ibm-plex-sans@5.2.8': {} + + '@fontsource/outfit@5.2.8': {} + + '@fontsource/young-serif@5.2.7': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.33.5': diff --git a/public/og-fonts/ibm-plex-sans-latin-400-normal.woff2 b/public/og-fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 00000000..f0ee65dd Binary files /dev/null and b/public/og-fonts/ibm-plex-sans-latin-400-normal.woff2 differ diff --git a/public/og-fonts/ibm-plex-sans-latin-500-normal.woff2 b/public/og-fonts/ibm-plex-sans-latin-500-normal.woff2 new file mode 100644 index 00000000..6d5527ee Binary files /dev/null and b/public/og-fonts/ibm-plex-sans-latin-500-normal.woff2 differ diff --git a/public/og-fonts/ibm-plex-sans-latin-600-normal.woff2 b/public/og-fonts/ibm-plex-sans-latin-600-normal.woff2 new file mode 100644 index 00000000..08c0d5ad Binary files /dev/null and b/public/og-fonts/ibm-plex-sans-latin-600-normal.woff2 differ diff --git a/public/og-fonts/outfit-latin-400-normal.woff2 b/public/og-fonts/outfit-latin-400-normal.woff2 new file mode 100644 index 00000000..2c401f59 Binary files /dev/null and b/public/og-fonts/outfit-latin-400-normal.woff2 differ diff --git a/public/og-fonts/outfit-latin-500-normal.woff2 b/public/og-fonts/outfit-latin-500-normal.woff2 new file mode 100644 index 00000000..264ce742 Binary files /dev/null and b/public/og-fonts/outfit-latin-500-normal.woff2 differ diff --git a/public/og-fonts/outfit-latin-600-normal.woff2 b/public/og-fonts/outfit-latin-600-normal.woff2 new file mode 100644 index 00000000..aa32da24 Binary files /dev/null and b/public/og-fonts/outfit-latin-600-normal.woff2 differ diff --git a/public/og-fonts/outfit-latin-700-normal.woff2 b/public/og-fonts/outfit-latin-700-normal.woff2 new file mode 100644 index 00000000..8674e735 Binary files /dev/null and b/public/og-fonts/outfit-latin-700-normal.woff2 differ diff --git a/public/og-fonts/young-serif-latin-400-normal.woff2 b/public/og-fonts/young-serif-latin-400-normal.woff2 new file mode 100644 index 00000000..af601231 Binary files /dev/null and b/public/og-fonts/young-serif-latin-400-normal.woff2 differ diff --git a/scripts/build-og-images.mjs b/scripts/build-og-images.mjs new file mode 100644 index 00000000..6897790c --- /dev/null +++ b/scripts/build-og-images.mjs @@ -0,0 +1,737 @@ +// Generate per-component-doc OG images: screenshot each component's primary Storybook story +// inside a branded 1200x630 card, upload to Cloudflare R2 via wrangler, and write the +// slug -> { url, hash } manifest (lib/og-manifest.json), which is committed. +// +// Run this LOCALLY and eyeball the result before pushing — screenshots can be flaky (a bad +// canvas frame, a slow render), so a human verifies. It is intentionally NOT in `pnpm build` or +// CI; the build just reads the committed manifest. Unchanged components are skipped via the hash +// recorded in the manifest, so re-runs only re-shoot what actually changed. +// +// Requires: Playwright's chromium (`pnpm exec playwright install chromium`) and — only for +// --animate — `ffmpeg` on PATH. +// +// Typical flow when you add or change a component: +// pnpm storybook:build # (re)build the previews +// pnpm og:build --only= --dry-run # render to ./.og-out — open and eyeball it +// pnpm og:build --only= # upload to R2 + update lib/og-manifest.json +// git add lib/og-manifest.json # commit the new URL +// +// A FULL run (no --only) also prunes manifest entries whose doc no longer resolves (deleted, +// renamed, unpublished). The command exits non-zero if any item fails (it lists them as an +// --only= line to re-run), so failures aren't missed before you commit. +// +// Backfill without wrangler — render everything and upload to R2 by hand: +// pnpm storybook:build +// pnpm og:build --no-upload # writes ./.og-out/og/ + the manifest URLs +// # sync ./.og-out/og/ to the bucket root (keys match), THEN commit lib/og-manifest.json +// +// If you accidentally uploaded the dry-run flat files (e.g. background__animated-beam__b_v1.png at +// the bucket root) instead of the og//... keys, migrate them in place: +// pnpm og:migrate # re-key on R2 + write lib/og-manifest.json +// pnpm og:migrate --dry-run # show what would move, no uploads +// pnpm og:migrate --no-upload # write ./.og-out/og/ + manifest only +// Upload the objects BEFORE deploying the manifest: a committed entry is used even if its URL 404s +// (only a MISSING entry falls back to og.png), so a manifest that points at not-yet-uploaded keys +// would serve broken OG images until the upload lands. +// +// og:image is a STILL PNG by default — no major link-unfurler (X, Facebook, Pinterest, Reddit, +// Slack) animates og:image; they all render a static frame, and animated formats only add file +// size + compatibility risk (Slack drops WebP entirely). Pass --animate to opt into looping GIFs +// (motion auto-detected) for contexts that do animate, e.g. Discord or the site itself. +// Keys are content-addressed: og//... — the hash is the same digest used +// to skip unchanged items, so any change to a component (or variant/template/format) produces a NEW +// immutable URL that the CDN and social scrapers actually refetch. (OG_VERSION, default v1) is a +// manual "re-render everything" lever for a wholesale redesign. Flags: --variant=a|b|c|d|e (default b). +// +// Auth: uploads shell out to wrangler, so it uses your existing wrangler setup. The resolved +// Cloudflare token must have R2 (Object Read & Write) permission. +// Config (optional, has defaults; read from .env / shell): +// R2_BUCKET (default "animata"), R2_PUBLIC_BASE (default "https://assets.animata.design") + +import { execFile, execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { chromium } from "playwright"; +import sharp from "sharp"; + +import { brandedTemplate, ogFontBaseUrl, ogTemplate, pickBrandedFlavor, TEMPLATE_VERSION } from "./lib/og-template.mjs"; +import { previewServerPort, startPreviewServer } from "./lib/preview-server.mjs"; + +const pexec = promisify(execFile); +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const PREVIEW_DIR = join(ROOT, "public", "preview"); +const DOCS_JSON = join(ROOT, ".velite", "docs.json"); +const MANIFEST = join(ROOT, "lib", "og-manifest.json"); +const OUT_DIR = join(ROOT, ".og-out"); + +// Load .env / .env.local for local runs (the shell still wins if a var is already set). +function loadEnv() { + for (const file of [".env.local", ".env"]) { + const p = join(ROOT, file); + if (!existsSync(p)) continue; + for (const line of readFileSync(p, "utf8").split("\n")) { + const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line); + if (!m) continue; + let val = m[2].trim(); + if (/^(".*"|'.*')$/.test(val)) val = val.slice(1, -1); + if (process.env[m[1]] === undefined) process.env[m[1]] = val; + } + } +} +loadEnv(); + +const BUCKET = process.env.R2_BUCKET || "animata"; +const PUBLIC_BASE = (process.env.R2_PUBLIC_BASE || "https://assets.animata.design").replace( + /\/$/, + "", +); +const WRANGLER = existsSync(join(ROOT, "node_modules", ".bin", "wrangler")) + ? join(ROOT, "node_modules", ".bin", "wrangler") + : "wrangler"; + +const args = process.argv.slice(2); +const DRY_RUN = args.includes("--dry-run"); +// --no-upload renders into an upload-ready ./.og-out/og/ tree AND writes the manifest with the +// matching URLs, but skips the wrangler upload — for backfills where you upload to R2 by hand. The +// local files are named by their exact R2 key, so syncing ./.og-out/og/ to the bucket root lands +// every object where the manifest already points. +const NO_UPLOAD = args.includes("--no-upload"); +// Re-key flat dry-run uploads (category__name__b_v1.ext at bucket root) to content-addressed +// og//... keys and write the manifest. No Playwright render. +const MIGRATE_FROM_FLAT = args.includes("--migrate-from-flat"); +const ONLY = args.find((a) => a.startsWith("--only="))?.slice("--only=".length); +const ONLY_TERMS = ONLY + ? ONLY.split(",") + .map((s) => s.trim()) + .filter(Boolean) + : null; +const CONCURRENCY = Number(process.env.OG_CONCURRENCY || 4); +const VARIANT = args.find((a) => a.startsWith("--variant="))?.slice("--variant=".length) || "b"; +// Asset version suffix — keys are og//.... Bump (v2, …) to +// re-render everything without colliding with the cached, immutable objects already on R2. +const OG_VERSION = process.env.OG_VERSION || "v1"; +// Still PNG by default (best for social og:image). --animate opts into looping GIFs where motion exists. +const ANIMATE = args.includes("--animate"); +const FRAMES = Number(process.env.OG_FRAMES || 30); +const FPS = Number(process.env.OG_FPS || 15); +const MOTION_MIN = Number(process.env.OG_MOTION_MIN || 0.006); // min mean grayscale delta = motion +const SETTLE_MS = 800; +const CLIP = { x: 0, y: 0, width: 1200, height: 630 }; + +const readJSON = (p) => JSON.parse(readFileSync(p, "utf8")); + +// Per-slug story id overrides for docs whose doesn't match any Storybook +// id (name drift) — the auto-resolver can't guess these. +const STORY_OVERRIDES = { + // doc references widget-vpn-widget--connected, but the story title is "VPN Connection" + "/docs/widget/vpn-widget": "widget-vpn-connection--connected", +}; + +// HYBRID FALLBACK — components that don't capture well as a still (scroll-driven, interaction-only, +// idle-empty) render the branded typographic template instead of a component screenshot. Add slugs +// here (or whole categories) as the gallery review surfaces weak captures. Flavor is auto-varied per +// slug. `--all-template` forces every item to the template (to preview the template set wholesale). +const TEMPLATE_SLUGS = new Set([ + "/docs/bento-grid/eight", // tall 8-row grid crops badly as a screenshot → branded template + "/docs/bento-grid/three", // tall stacked cards crop badly → branded template +]); +const TEMPLATE_CATEGORIES = new Set([ + "text", // text effects don't capture well as a still — render the branded typographic card instead +]); +const FORCE_TEMPLATE = args.includes("--all-template"); +const useTemplate = (slug, category) => + FORCE_TEMPLATE || TEMPLATE_SLUGS.has(slug) || TEMPLATE_CATEGORIES.has(category); + +// Categories whose demos are CONTAINED + centered in the square (side) frame instead of cover-filled +// — for short/wide elements (buttons, toggles) that look stretched or cropped when made to fill. +const CONTAIN_CATEGORIES = new Set(["button"]); +// On the light card, dark demo content is already readable, so no inner surface card is needed. +const SURFACE_CATEGORIES = new Set([]); + +// Collect published component docs (/docs//) that embed a . +function collectItems() { + const docs = readJSON(DOCS_JSON); + const index = readJSON(join(PREVIEW_DIR, "index.json")); + const entries = index.entries || index.stories || {}; + const ids = new Set(Object.keys(entries)); + + // Back-fill only components that are actually published — i.e. their doc source is committed in + // git. The `published` frontmatter flag alone is not enough: untracked WIP drafts in the working + // tree inherit published:true but aren't live yet, and must be skipped. + const tracked = new Set( + execSync("git ls-files content/docs", { cwd: ROOT, encoding: "utf8" }).split("\n").filter(Boolean), + ); + + const items = []; + for (const doc of docs) { + if (!doc.published) continue; + const parts = doc.slug.split("/").filter(Boolean); // ["docs","background","animated-beam"] + if (parts.length !== 3 || parts[0] !== "docs") continue; + const [, category, name] = parts; + if (!tracked.has(`content/docs/${category}/${name}.mdx`)) continue; // untracked WIP draft + + const match = / id.startsWith(`${category}-${name}--`) && entries[id].type === "story", + ); + } + if (!storyId) { + console.warn(`skip ${doc.slug}: no matching story for "${match[1]}"`); + continue; + } + + const entry = entries[storyId] || {}; + items.push({ + slug: doc.slug, + category, + name, + title: doc.title, + description: doc.description || "", + labels: doc.labels || [], + storyId, + componentPath: entry.componentPath, + importPath: entry.importPath, + template: useTemplate(doc.slug, category), + }); + } + return ONLY_TERMS + ? items.filter((i) => ONLY_TERMS.some((t) => i.storyId.includes(t) || i.slug.includes(t))) + : items; +} + +function hashItem(item) { + const h = createHash("sha256"); + h.update(TEMPLATE_VERSION); + h.update(VARIANT); + h.update(ANIMATE ? "anim" : "still"); + h.update(OG_VERSION); + h.update("gif"); // animated output format — bump invalidates webp-era hashes + h.update(item.template ? "tpl" : "comp"); + if (item.template) { + // Branded template depends on its text + flavor, not the component source. + h.update(pickBrandedFlavor(item.slug)); + h.update(`${item.title}|${item.category}|${item.description}`); + } else { + for (const rel of [item.componentPath, item.importPath]) { + if (!rel) continue; + const abs = join(ROOT, rel.replace(/^\.\//, "")); + if (existsSync(abs)) h.update(readFileSync(abs)); + } + } + return h.digest("hex").slice(0, 16); +} + +// Inside the story iframe: make the canvas transparent AND replace Storybook's shrink-wrapping +// `layout:"centered"` + the ThemeWrapper's 4rem padding with a definite full-size centered stage. +// Without this, `w-full`/container-query demos (collab-card) collapse to ~0 and short/wide demos +// (marquee) float in whitespace and clip. The stage gives every demo the full frame to fill. +const STAGE_CSS = ` + html,body,.sb-show-main,#storybook-root,#root{background:transparent !important} + html,body{width:100% !important;height:100% !important;margin:0 !important;overflow:hidden !important} + .sb-show-main{display:flex !important;align-items:center;justify-content:center;width:100% !important;height:100% !important;min-height:0 !important;padding:0 !important;box-sizing:border-box} + #storybook-root,#root{width:100% !important;height:100% !important;display:flex !important;align-items:center;justify-content:center} + /* ThemeWrapper decorator (its inline 4rem padding) — let demos fill the stage instead */ + #storybook-root>div,#root>div{width:100%;height:100%;padding:24px !important;display:flex;align-items:center;justify-content:center;box-sizing:border-box} +`; + +// Load the story, measure its aspect to pick a layout, render the branded card, and leave the +// page ready to screenshot (Storybook canvas transparent, brand fonts loaded). +async function prepareCard(page, item, port) { + const storyUrl = `http://127.0.0.1:${port}/iframe.html?id=${item.storyId}&viewMode=story`; + + // Pass 1 — load the story alone and measure its content's aspect ratio to pick a layout. + await page.goto(storyUrl, { waitUntil: "networkidle" }); + await page + .locator("#storybook-root, #root") + .first() + .waitFor({ state: "visible", timeout: 20000 }); + await page.waitForTimeout(250); + const dim = await page.evaluate(() => { + const root = document.querySelector("#storybook-root") || document.querySelector("#root"); + if (!root) return { w: 1, h: 1 }; + const wrap = root.firstElementChild; // ThemeWrapper (padding) decorator + const el = wrap?.firstElementChild || wrap || root; + const r = el.getBoundingClientRect(); + return { w: Math.max(1, r.width), h: Math.max(1, r.height) }; + }); + // Contain categories (buttons) always use the square side frame with the demo centered; + // everything else picks side vs stacked by the demo's aspect ratio. + const contain = CONTAIN_CATEGORIES.has(item.category); + const surface = SURFACE_CATEGORIES.has(item.category); + const layout = contain ? "side" : dim.w / dim.h >= 1.5 ? "stacked" : "side"; + + // Pass 2 — render the chosen card with the demo in a transparent-canvas iframe. + await page.setContent( + ogTemplate({ ...item, layout, variant: VARIANT, storyUrl, fontBaseUrl: ogFontBaseUrl(port) }), + { waitUntil: "load" }, + ); + await page + .frameLocator("iframe") + .locator("#storybook-root, #root") + .first() + .waitFor({ state: "visible", timeout: 20000 }); + // the child iframe only — after setContent the main frame's URL is also …/iframe.html + const frame = page + .frames() + .find((f) => f !== page.mainFrame() && f.url().includes("/iframe.html")); + await frame?.addStyleTag({ content: STAGE_CSS }).catch(() => {}); + + // Measure the demo's rendered aspect, then size its frame to match — so the demo fills the frame + // with NO crop and no dotted-canvas margin; the leftover region around it is the card's dark bg. + // Buttons (contain) keep the full square frame and center the demo inside it instead. + const aspect = contain + ? null + : await frame + ?.evaluate(() => { + const root = document.querySelector("#storybook-root") || document.querySelector("#root"); + const demo = root?.firstElementChild?.firstElementChild; + const r = demo?.getBoundingClientRect(); + return r && r.width && r.height ? r.width / r.height : null; + }) + .catch(() => null); + + await page + .evaluate(({ a, isContain, hasSurface }) => { + const region = document.querySelector(".frame-region"); + const frameEl = document.querySelector(".frame"); + if (!region || !frameEl) return; + if (isContain) { + // Contained demo sits in the square region. Surface categories (text, list) get a light + // rounded card so dark content reads on the dark bg; buttons stay transparent (no ghost box). + if (hasSurface) { + frameEl.style.background = "#f2f2f5"; // light neutral — readable for dark text, gives white-bg demos contrast + frameEl.style.borderRadius = "24px"; + } else { + frameEl.style.boxShadow = "none"; + } + return; + } + if (!a) return; + const rw = region.clientWidth; + const rh = region.clientHeight; + let w = rw; + let h = rw / a; + if (h > rh) { + h = rh; + w = rh * a; + } + frameEl.style.width = `${Math.round(w)}px`; + frameEl.style.height = `${Math.round(h)}px`; + }, { a: aspect, isContain: contain, hasSurface: surface }) + .catch(() => {}); + await page.waitForTimeout(150); // let the iframe reflow at the new frame size + + // Fill the (aspect-matched) frame, or — for buttons — contain the demo centered in the square frame. + await frame + ?.evaluate((contain) => { + const root = document.querySelector("#storybook-root") || document.querySelector("#root"); + const wrap = root?.firstElementChild; // ThemeWrapper (flex-centered, full-size stage) + const demo = wrap?.firstElementChild; // the demo's root element + if (!demo) return; + wrap.style.padding = "0"; + const r = demo.getBoundingClientRect(); + if (!r.width || !r.height) return; + const W = window.innerWidth; + const H = window.innerHeight; + if (contain) { + const scale = Math.min((W * 0.9) / r.width, (H * 0.9) / r.height); // fit + ~10% margin + demo.style.transformOrigin = "center center"; + demo.style.transform = `scale(${scale})`; + } else { + const scale = Math.max(W / r.width, H / r.height); // fill the aspect-matched frame + if (scale > 1.001) { + demo.style.transformOrigin = "center center"; + demo.style.transform = `scale(${scale})`; + } + } + }, contain) + .catch(() => {}); + // Wait for the demo's images (album art, photos, unsplash) to actually load before shooting — + // otherwise image-heavy widgets (music-widget, clock-with-photo, shape-shifter) render blank. + await frame + ?.evaluate(async () => { + await Promise.all( + Array.from(document.images).map((img) => + img.complete && img.naturalWidth + ? null + : new Promise((res) => { + img.addEventListener("load", res, { once: true }); + img.addEventListener("error", res, { once: true }); + setTimeout(res, 5000); + }), + ), + ); + }) + .catch(() => {}); + await page.evaluate(() => document.fonts?.ready).catch(() => {}); // brand fonts loaded + await page.waitForTimeout(SETTLE_MS); +} + +// Final PNG: captured at 2x (deviceScaleFactor 2) for crisp supersampled text, then downscaled to +// the 1200x630 OG spec and re-compressed losslessly. Drops ~4x of wasted pixels — e.g. 929KB→85KB — +// with no color loss or gradient banding. (For TinyPNG-style lossy palette, swap to +// `.png({ palette: true, quality: 82, dither: 1, effort: 10 })`.) +const still = async (page) => { + const buf = await page.screenshot({ clip: CLIP, animations: "disabled", type: "png" }); + return sharp(buf).resize(1200, 630, { fit: "fill" }).png({ compressionLevel: 9, effort: 10 }).toBuffer(); +}; + +// Mean grayscale delta between two frames (0–255). Sensitive to thin/faint motion. +async function motionScore(a, b) { + const small = (buf) => sharp(buf).greyscale().resize(200, 105, { fit: "fill" }).raw().toBuffer(); + const [ra, rb] = await Promise.all([small(a), small(b)]); + let sum = 0; + for (let i = 0; i < ra.length; i++) sum += Math.abs(ra[i] - rb[i]); + return sum / ra.length; +} + +// Render the card. In animate mode, probe for motion: static/interaction-only components fall back +// to a still PNG; genuinely animated ones are captured as a short looping GIF (ffmpeg). +async function renderItem(page, item, port) { + // Hybrid fallback: branded typographic card, no component screenshot (always a still PNG). + if (item.template) { + await page.setContent( + brandedTemplate({ + slug: item.slug, + title: item.title, + category: item.category, + description: item.description, + flavor: pickBrandedFlavor(item.slug), + fontBaseUrl: ogFontBaseUrl(port), + }), + { waitUntil: "load" }, + ); + await page.evaluate(() => document.fonts?.ready).catch(() => {}); + await page.waitForTimeout(300); + return { buf: await still(page), ext: "png" }; + } + + await prepareCard(page, item, port); + if (!ANIMATE) return { buf: await still(page), ext: "png" }; + + // Probe over ~1.2s so slow loops (faint beams, long eases) aren't mistaken for static. + const f0 = await page.screenshot({ clip: CLIP, type: "png" }); + let moved = 0; + for (let i = 0; i < 3; i++) { + await page.waitForTimeout(400); + const f = await page.screenshot({ clip: CLIP, type: "png" }); + moved = Math.max(moved, await motionScore(f0, f)); + } + if (moved <= MOTION_MIN) return { buf: await still(page), ext: "png" }; + + const dir = mkdtempSync(join(tmpdir(), "oganim-")); + try { + for (let i = 0; i < FRAMES; i++) { + const buf = await page.screenshot({ clip: CLIP, type: "png" }); // animations live (not frozen) + writeFileSync(join(dir, `f-${String(i).padStart(4, "0")}.png`), buf); + await page.waitForTimeout(Math.round(1000 / FPS)); + } + // Animated GIF via two-pass palette (palettegen + paletteuse) — broader social-card support + // than animated WebP. scale=…:flags=lanczos keeps edges crisp at the 1200x630 OG size. + const outGif = join(dir, "out.gif"); + const palette = join(dir, "palette.png"); + const scale = "scale=1200:630:flags=lanczos"; + const ff = (extra) => + pexec("ffmpeg", [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-framerate", + String(FPS), + "-i", + join(dir, "f-%04d.png"), + ...extra, + ]); + await ff(["-vf", `${scale},palettegen=stats_mode=diff`, palette]); + await ff([ + "-i", + palette, + "-lavfi", + `${scale}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle`, + "-loop", + "0", + outGif, + ]); + return { buf: readFileSync(outGif), ext: "gif" }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// Upload an object to R2 via wrangler (uses your wrangler auth; the token needs R2 write). +async function uploadToR2(key, buf, contentType) { + const dir = mkdtempSync(join(tmpdir(), "og-")); + const tmp = join(dir, "asset"); + writeFileSync(tmp, buf); + try { + await pexec(WRANGLER, [ + "r2", + "object", + "put", + `${BUCKET}/${key}`, + "-f", + tmp, + "--ct", + contentType, + "--cc", + "public,max-age=31536000,immutable", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// Fail fast BEFORE rendering anything: --animate needs ffmpeg, and real (non-dry) runs need a +// working wrangler auth — otherwise we'd render every card and only then fail on the first encode +// or the first upload, wasting the whole run. +async function preflight() { + const ogFontsDir = join(ROOT, "public", "og-fonts"); + if (!existsSync(join(ogFontsDir, "outfit-latin-600-normal.woff2"))) { + throw new Error( + "public/og-fonts/ missing — run `pnpm og:fonts:sync` after install to copy self-hosted fonts.", + ); + } + if (ANIMATE) { + try { + await pexec("ffmpeg", ["-version"]); + } catch { + throw new Error("--animate needs `ffmpeg` on PATH (not found). Install it, or drop --animate."); + } + } + if (!DRY_RUN && !NO_UPLOAD) { + try { + await pexec(WRANGLER, ["whoami"]); + } catch (err) { + throw new Error( + `wrangler auth check failed (\`wrangler whoami\`): ${err.message?.split("\n")[0]}. ` + + "Run `wrangler login` (token needs R2 Object Read & Write), or pass --dry-run / --no-upload.", + ); + } + } +} + +// Renders can flake (a bad canvas frame, a slow story load, a transient font fetch) — retry once +// before counting it a failure. +async function renderWithRetry(page, item, port) { + try { + return await renderItem(page, item, port); + } catch (err) { + console.warn(` retry ${item.storyId}: ${err.message?.split("\n")[0]}`); + return await renderItem(page, item, port); + } +} + +function flatAssetName(item, ext) { + return `${item.category}__${item.name}__${VARIANT}_${OG_VERSION}.${ext}`; +} + +function contentAddressedKey(item, hash, ext) { + return `og/${item.category}/${item.name}.${OG_VERSION}.${hash}.${ext}`; +} + +async function readFlatAsset(item) { + for (const ext of ["png", "gif"]) { + const local = join(OUT_DIR, flatAssetName(item, ext)); + if (existsSync(local)) { + return { buf: readFileSync(local), ext, source: local }; + } + const url = `${PUBLIC_BASE}/${flatAssetName(item, ext)}`; + try { + const head = await fetch(url, { method: "HEAD" }); + if (!head.ok) continue; + const res = await fetch(url); + if (!res.ok) continue; + return { buf: Buffer.from(await res.arrayBuffer()), ext, source: url }; + } catch { + /* try next ext */ + } + } + return null; +} + +function writeManifestFile(manifest, items) { + let pruned = 0; + if (!ONLY_TERMS) { + const live = new Set(items.map((i) => i.slug)); + for (const slug of Object.keys(manifest)) { + if (!live.has(slug)) { + delete manifest[slug]; + pruned++; + console.log(` pruned stale manifest entry ${slug}`); + } + } + } + mkdirSync(join(ROOT, "lib"), { recursive: true }); + const sorted = Object.fromEntries(Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b))); + writeFileSync(MANIFEST, `${JSON.stringify(sorted, null, 2)}\n`); + console.log(`OG: wrote ${MANIFEST}`); + return pruned; +} + +// Move legacy flat uploads (dry-run filenames at the bucket root) onto the content-addressed keys +// the site manifest expects. Prefers local ./.og-out/ over fetching from the CDN. +async function migrateFromFlat(items) { + console.log( + `OG migrate: ${items.length} component doc(s)${ONLY ? ` matching "${ONLY}"` : ""}${DRY_RUN ? " [dry-run]" : NO_UPLOAD ? " [no-upload]" : ""}`, + ); + await preflight(); + if (DRY_RUN || NO_UPLOAD) mkdirSync(OUT_DIR, { recursive: true }); + + const manifest = existsSync(MANIFEST) ? readJSON(MANIFEST) : {}; + let migrated = 0; + let skipped = 0; + const failedItems = []; + + for (const item of items) { + const hash = hashItem(item); + if (!DRY_RUN && manifest[item.slug]?.hash === hash) { + skipped++; + continue; + } + const asset = await readFlatAsset(item); + if (!asset) { + failedItems.push(item.slug); + console.warn(` MISSING flat asset for ${item.slug} (expected ${flatAssetName(item, "png")})`); + continue; + } + const key = contentAddressedKey(item, hash, asset.ext); + try { + if (DRY_RUN) { + console.log( + ` would migrate ${item.slug} <- ${asset.source} -> ${key} (${(asset.buf.length / 1024).toFixed(0)}KB)`, + ); + } else if (NO_UPLOAD) { + const out = join(OUT_DIR, key); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, asset.buf); + manifest[item.slug] = { url: `${PUBLIC_BASE}/${key}`, hash }; + console.log(` wrote ${key} from flat asset`); + } else { + await uploadToR2(key, asset.buf, asset.ext === "gif" ? "image/gif" : "image/png"); + manifest[item.slug] = { url: `${PUBLIC_BASE}/${key}`, hash }; + console.log(` migrated ${key} <- ${asset.source}`); + } + migrated++; + } catch (err) { + failedItems.push(item.slug); + console.warn(` FAILED ${item.slug}: ${err.message?.split("\n")[0]}`); + } + } + + let pruned = 0; + if (!DRY_RUN) pruned = writeManifestFile(manifest, items); + console.log( + `OG migrate done — migrated ${migrated}, skipped ${skipped}, pruned ${pruned}, failed ${failedItems.length}`, + ); + if (failedItems.length) { + console.warn(`OG: re-run the failures with --only=${failedItems.join(",")}`); + process.exitCode = 1; + } +} + +async function main() { + if (!existsSync(join(PREVIEW_DIR, "index.json"))) { + throw new Error("public/preview/index.json missing — run `pnpm storybook:build` first"); + } + const items = collectItems(); + if (MIGRATE_FROM_FLAT) { + await migrateFromFlat(items); + return; + } + console.log( + `OG: ${items.length} component doc(s)${ONLY ? ` matching "${ONLY}"` : ""}${DRY_RUN ? " [dry-run]" : ""}`, + ); + if (!items.length) return; + await preflight(); + if (DRY_RUN || NO_UPLOAD) mkdirSync(OUT_DIR, { recursive: true }); + + const server = await startPreviewServer(PREVIEW_DIR); + const port = previewServerPort(server); + const browser = await chromium.launch(); + const context = await browser.newContext({ + viewport: { width: 1200, height: 630 }, + deviceScaleFactor: 2, + }); + + const manifest = existsSync(MANIFEST) ? readJSON(MANIFEST) : {}; + let made = 0; + let skipped = 0; + const failedItems = []; + + const queue = [...items]; + const worker = async () => { + const page = await context.newPage(); + while (queue.length) { + const item = queue.shift(); + const hash = hashItem(item); + try { + if (!DRY_RUN && manifest[item.slug]?.hash === hash) { + skipped++; + continue; + } + const { buf, ext } = await renderWithRetry(page, item, port); + // Content-addressed: the hash is part of the key, so a changed component lands on a NEW + // immutable URL (the old object stays cached harmlessly) instead of overwriting one the + // CDN/scrapers will never refetch. + const key = contentAddressedKey(item, hash, ext); + if (DRY_RUN) { + const out = join(OUT_DIR, `${item.category}__${item.name}__${VARIANT}_${OG_VERSION}.${ext}`); + writeFileSync(out, buf); + console.log(` rendered ${item.storyId} -> ${out} (${(buf.length / 1024).toFixed(0)}KB)`); + } else if (NO_UPLOAD) { + // Mirror the R2 key under .og-out/ so the whole og/ tree can be synced to the bucket + // root as-is, and record the URL it will live at (you upload the files yourself). + const out = join(OUT_DIR, key); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, buf); + manifest[item.slug] = { url: `${PUBLIC_BASE}/${key}`, hash }; + console.log(` wrote ${key} (local — upload pending)`); + } else { + await uploadToR2(key, buf, ext === "gif" ? "image/gif" : "image/png"); + manifest[item.slug] = { url: `${PUBLIC_BASE}/${key}`, hash }; + console.log(` uploaded ${key}`); + } + made++; + } catch (err) { + failedItems.push(item.slug); + console.warn( + ` FAILED ${item.storyId}: ${err.message?.split("\n")[0]} (falls back to og.png)`, + ); + } + } + await page.close(); + }; + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); + + await browser.close(); + server.close(); + + let pruned = 0; + if (!DRY_RUN) pruned = writeManifestFile(manifest, items); + console.log( + `OG done — made ${made}, skipped ${skipped}, pruned ${pruned}, failed ${failedItems.length}`, + ); + if (failedItems.length) { + // Surface exactly what to re-run, and exit non-zero so the failures aren't missed before commit. + console.warn(`OG: re-run the failures with --only=${failedItems.join(",")}`); + process.exitCode = 1; + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/lib/og-template.mjs b/scripts/lib/og-template.mjs new file mode 100644 index 00000000..4cce9375 --- /dev/null +++ b/scripts/lib/og-template.mjs @@ -0,0 +1,441 @@ +// Branded 1200x630 OG card that frames a real component (loaded in an iframe from the +// statically-built Storybook preview). +// layout: "stacked" (logo top, full-width demo, title below — WIDE components) +// or "side" (tall demo left fills height, branding right — TALL/square components). +// variant: "a" warm editorial (smoke glow + serif title glow) +// or "b" textured minimal (vertical hairlines, crisp serif). +// Brand type: Young Serif (display title), Outfit (wordmark/eyebrow), IBM Plex Sans (body) — self-hosted +// under public/og-fonts/ (see scripts/sync-og-fonts.mjs) so renders are offline-deterministic. +// +// Bump TEMPLATE_VERSION when the design changes — it's part of the cache hash. +export const TEMPLATE_VERSION = "24"; + +const esc = (s) => + String(s ?? "").replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + +// animata mark (from components/icons.tsx) — #d4aa00 shadow layer under the #fc0 layer. +const LOGO = ` + + + + + + + + + + +`; + +export function ogFontBaseUrl(port) { + return `http://127.0.0.1:${port}/og-fonts`; +} + +export function ogFontsCss(fontBaseUrl) { + const base = fontBaseUrl.replace(/\/$/, ""); + const face = (family, file, weight) => + `@font-face{font-family:"${family}";font-style:normal;font-weight:${weight};font-display:swap;src:url("${base}/${file}") format("woff2");}`; + const faces = [ + face("Outfit", "outfit-latin-400-normal.woff2", 400), + face("Outfit", "outfit-latin-500-normal.woff2", 500), + face("Outfit", "outfit-latin-600-normal.woff2", 600), + face("Outfit", "outfit-latin-700-normal.woff2", 700), + face("Young Serif", "young-serif-latin-400-normal.woff2", 400), + face("IBM Plex Sans", "ibm-plex-sans-latin-400-normal.woff2", 400), + face("IBM Plex Sans", "ibm-plex-sans-latin-500-normal.woff2", 500), + face("IBM Plex Sans", "ibm-plex-sans-latin-600-normal.woff2", 600), + ]; + return ``; +} + +const COMMON = ` + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { width: 1200px; height: 630px; } + body { + font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color: #16140f; padding: 64px; background: #f8f7f3; + -webkit-font-smoothing: antialiased; + } + .brand { display: flex; align-items: center; gap: 13px; } + .mark { width: 38px; height: 38px; display: block; } + .word { font-family: "Outfit", sans-serif; font-size: 34px; font-weight: 600; letter-spacing: -0.01em; line-height: 1; } + .eyebrow { font-family: "Outfit", sans-serif; font-size: 21px; font-weight: 600; letter-spacing: 0.2em; text-transform: uppercase; color: #8c877d; } + .accent { width: 52px; height: 5px; border-radius: 3px; background: #ffcc00; } + .accent-row { display: flex; align-items: center; gap: 14px; } + .accent-row .eyebrow { line-height: 1; } + .title { font-family: "Young Serif", Georgia, serif; font-weight: 400; letter-spacing: -0.005em; line-height: 1.03; } + .desc { font-family: "IBM Plex Sans", -apple-system, sans-serif; font-size: 22px; line-height: 1.45; color: #6b665c; max-width: 760px; margin-top: 16px; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .chips { display: flex; gap: 9px; flex-wrap: wrap; } + .chip { font-size: 15px; font-weight: 500; color: #4a453c; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); padding: 6px 13px; border-radius: 999px; } + .frame-region { display: flex; align-items: center; justify-content: center; } + .frame { + width: 100%; + height: 100%; + overflow: hidden; + background: transparent; + } + .frame iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; } +`; + +const VARIANTS = { + // a — cinematic: warm light + film grain + glowing serif (SHADER vibe) + a: ` + body { + background: + radial-gradient(78% 120% at 50% 132%, rgba(165,140,185,0.16), transparent 55%), + radial-gradient(62% 88% at 88% -16%, rgba(255,190,105,0.26), transparent 60%), + #0a0807; + } + body::before { + content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 5; + mix-blend-mode: overlay; opacity: 0.5; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + } + .title { color: #f7efe2; text-shadow: 0 0 1px rgba(255,244,222,0.45), 0 2px 52px rgba(255,188,105,0.45); } + .desc { color: #b6afa3; } + `, + // b — clean white editorial (the component is the hero) + b: ` + body { background: #f8f7f3; } + .title { color: #16140f; } + `, + // c — light paper, dark text (bright editorial) + c: ` + body { + background: + repeating-linear-gradient(90deg, rgba(0,0,0,0.035) 0 1px, transparent 1px 4px), + radial-gradient(120% 120% at 80% -10%, #f7f5ef, #e8e5db 78%); + } + .word { color: #1a1814; } + .eyebrow { color: #877f72; } + .title { color: #15120c; } + .desc { color: #6b655a; } + .chip { color: #4a453c; background: rgba(0,0,0,0.05); border-color: rgba(0,0,0,0.12); } + .frame { border-color: rgba(0,0,0,0.14); box-shadow: 0 18px 50px rgba(0,0,0,0.16); } + `, + // d — blueprint grid across the whole card + d: ` + body { + background: + linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px), + radial-gradient(120% 120% at 80% -10%, #14151b, #0a0a0c 66%); + background-size: 40px 40px, 40px 40px, 100% 100%; + } + .title { color: #fafafa; } + `, + // e — dramatic spotlight on the demo + heavy vignette + e: ` + body { + background: + radial-gradient(44% 58% at 35% 44%, rgba(255,243,214,0.20), transparent 56%), + radial-gradient(110% 110% at 50% 50%, #1c1c24 0%, #050506 68%); + } + .title { color: #f7f3ec; text-shadow: 0 2px 42px rgba(255,214,150,0.30); } + .desc { color: #a9a39a; } + `, +}; + +export function ogTemplate({ + layout = "stacked", + variant = "b", + storyUrl, + title, + category, + description = "", + labels = [], + fontBaseUrl, +}) { + const fonts = ogFontsCss(fontBaseUrl); + const chips = labels + .slice(0, 4) + .map((l) => `${esc(l)}`) + .join(""); + const brand = `
${LOGO}animata
`; + const eyebrow = `
${esc(category)}
`; + const iframe = `
`; + const desc = description ? `
${esc(description)}
` : ""; + const vcss = VARIANTS[variant] || VARIANTS.b; + + if (layout === "side") { + return `${fonts} +
${iframe}
+
+
${brand}
+
${eyebrow}
${esc(title)}
${desc}
${chips}
+
+ `; + } + + return `${fonts} +
${brand}
+
${iframe}
+
+
${eyebrow}
${esc(title)}
${desc}
+
${chips}
+
+ `; +} + +// ─── Branded (no-component) template ──────────────────────────────────────────────────────────── +// Used in hybrid mode for components that don't capture well as a still (scroll/interaction/idle). +// Pure typographic card: logo + animata, category eyebrow, big serif name, description, url. +// Three layout flavors (a/b/c) for variety — picked per component, but stable for a given slug. + +const BRANDED_BASE = ` + * { margin:0; padding:0; box-sizing:border-box; } + html, body { width:1200px; height:630px; } + body { + font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color:#16140f; padding:72px; background:#f8f7f3; position:relative; overflow:hidden; + -webkit-font-smoothing:antialiased; + } + .brand { display:flex; align-items:center; gap:13px; } + .mark { width:40px; height:40px; display:block; } + .word { font-family:"Outfit", sans-serif; font-size:34px; font-weight:600; letter-spacing:-0.01em; line-height:1; } + .eyebrow { font-family:"Outfit", sans-serif; font-size:21px; font-weight:600; letter-spacing:0.18em; text-transform:uppercase; color:#8c877d; } + .accent { width:64px; height:6px; border-radius:3px; background:#ffcc00; } + .title { font-family:"Young Serif", Georgia, serif; font-weight:400; letter-spacing:-0.01em; line-height:1.0; color:#16140f; } + .desc { font-size:26px; line-height:1.45; color:#6b665c; max-width:880px; + display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } + .url { font-family:"IBM Plex Sans", sans-serif; font-size:21px; font-weight:500; letter-spacing:0.01em; color:#8c877d; } + .url b { color:#16140f; font-weight:600; } + .chip { display:inline-flex; font-size:15px; font-weight:500; color:#4a453c; background:rgba(0,0,0,0.05); + border:1px solid rgba(0,0,0,0.1); padding:6px 14px; border-radius:999px; } +`; + +// a — editorial: top brand row, big serif name anchored low-left, url footer. +const TPL_A = ` + body { display:flex; flex-direction:column; justify-content:space-between; background:#f8f7f3; } + .top { display:flex; align-items:center; justify-content:space-between; } + .mid { display:flex; flex-direction:column; gap:24px; } + .mid .accent { margin-bottom:6px; } + .mid .title { font-size:89px; } + .bot { display:flex; align-items:center; justify-content:space-between; } +`; +// b — centered spotlight: everything centered, glow behind the name. +const TPL_B = ` + body { text-align:center; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:26px; + background:#f8f7f3; } + .brand { position:absolute; top:64px; left:50%; transform:translateX(-50%); } + .url { position:absolute; bottom:64px; left:50%; transform:translateX(-50%); } + .accent { margin:0 auto; } + .title { font-size:89px; } + .desc { margin:0 auto; } +`; +// c — blueprint: light technical paper — faint grid + an inset dashed frame with corner ticks +// (OTHERKIND-style registration marks). Brand top-left + category chip top-right, giant name low. +const TPL_C = ` + body { display:flex; flex-direction:column; justify-content:space-between; + background:linear-gradient(rgba(0,0,0,0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,0,0,0.045) 1px, transparent 1px), + #f8f7f3; + background-size:44px 44px, 44px 44px, 100% 100%; } + body::before { content:""; position:absolute; inset:40px; border:1px dashed rgba(0,0,0,0.22); pointer-events:none; } + body::after { content:""; position:absolute; inset:32px; pointer-events:none; + background: + linear-gradient(#16140f,#16140f) left 0 top 8px/16px 1px no-repeat, + linear-gradient(#16140f,#16140f) left 8px top 0/1px 16px no-repeat, + linear-gradient(#16140f,#16140f) right 0 top 8px/16px 1px no-repeat, + linear-gradient(#16140f,#16140f) right 8px top 0/1px 16px no-repeat, + linear-gradient(#16140f,#16140f) left 0 bottom 8px/16px 1px no-repeat, + linear-gradient(#16140f,#16140f) left 8px bottom 0/1px 16px no-repeat, + linear-gradient(#16140f,#16140f) right 0 bottom 8px/16px 1px no-repeat, + linear-gradient(#16140f,#16140f) right 8px bottom 0/1px 16px no-repeat; } + .top, .mid, .bot { position:relative; z-index:1; } + .top { display:flex; align-items:center; justify-content:space-between; } + .mid { display:flex; flex-direction:column; gap:22px; } + .mid .title { font-size:89px; line-height:0.98; } + .bot { display:flex; align-items:flex-end; justify-content:space-between; gap:32px; } +`; + +// d–h: original GENERATIVE patterns built FROM math (not literal diagrams) — used as faint full-bleed +// texture behind editorial text. d phyllotaxis (golden-angle sunflower), e Truchet tiles, f flow +// field, g halftone gradient, h phyllotaxis on dark. Each is seeded by the slug so it's stable. +function pSeed(s) { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} +function pRng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +function patPhyllo(seed, dark) { + const fill = dark ? "rgba(150,225,200,0.16)" : "rgba(0,0,0,0.10)"; + const GA = Math.PI * (3 - Math.sqrt(5)); // golden angle ≈ 137.5° + const r0 = pRng(seed)(); + const cx = 840 + r0 * 40, + cy = 315, + n = 660, + c = 17; + let s = ""; + for (let i = 1; i < n; i++) { + const a = i * GA; + const r = c * Math.sqrt(i); + const x = cx + r * Math.cos(a); + const y = cy + r * Math.sin(a); + s += ``; + } + return s; +} +function patTruchet(seed) { + const r = pRng(seed), + cell = 84, + h = cell / 2; + let s = ``; + for (let y = 0; y < 630 + cell; y += cell) + for (let x = 0; x < 1200 + cell; x += cell) { + if (r() < 0.5) + s += ``; + else + s += ``; + } + return s + ``; +} +function patFlow(seed) { + const r = pRng(seed), + step = 44, + ph = r() * 6.283; + let s = ``; + for (let y = 26; y < 630; y += step) + for (let x = 26; x < 1200; x += step) { + const a = Math.sin(x * 0.006 + ph) + Math.cos(y * 0.008) + Math.sin((x + y) * 0.004); + s += ``; + } + return s + ``; +} +function patHalftone(seed) { + const r = pRng(seed), + dir = r() < 0.5; + let s = ``; + const step = 30; + for (let y = 20; y < 630; y += step) + for (let x = 20; x < 1200; x += step) { + const t = dir ? x / 1200 : y / 630; + s += ``; + } + return s + ``; +} +function patternSVG(kind, slug) { + const seed = pSeed(slug); + const inner = + kind === "truchet" + ? patTruchet(seed) + : kind === "flow" + ? patFlow(seed) + : kind === "halftone" + ? patHalftone(seed) + : patPhyllo(seed, kind === "phyllo-dark"); + return `${inner}`; +} +const PAT_KIND = { d: "phyllo", e: "truchet", f: "flow", g: "halftone", h: "phyllo-dark" }; + +// Editorial layout over a faint full-bleed generative pattern. +const PAT_CSS = ` + body { display:flex; flex-direction:column; justify-content:space-between; position:relative; overflow:hidden; background:#f8f7f3; } + .top { display:flex; align-items:center; justify-content:space-between; position:relative; z-index:2; } + .mid { display:flex; flex-direction:column; gap:18px; position:relative; z-index:2; max-width:680px; } + .mid .accent { margin-bottom:4px; } + .mid .title { font-size:89px; } + .bot { display:flex; align-items:center; justify-content:space-between; position:relative; z-index:2; } + .pat { position:absolute; inset:0; width:100%; height:100%; z-index:1; pointer-events:none; } +`; +const PAT_DARK = ` + body { display:flex; flex-direction:column; justify-content:space-between; position:relative; overflow:hidden; + background:radial-gradient(120% 120% at 70% 30%, #16181d 0%, #0b0c0f 76%); color:#eef0f2; } + .word{color:#f4f4f5;} .eyebrow{color:#9aa0a8;} .title{color:#f6f7f8;} .desc{color:#a6acb4;} + .url{color:#9aa0a8;} .url b{color:#f6f7f8;} + .top { display:flex; align-items:center; justify-content:space-between; position:relative; z-index:2; } + .mid { display:flex; flex-direction:column; gap:18px; position:relative; z-index:2; max-width:680px; } + .mid .accent { margin-bottom:4px; } .mid .title { font-size:89px; } + .bot { display:flex; align-items:center; justify-content:space-between; position:relative; z-index:2; } + .pat { position:absolute; inset:0; width:100%; height:100%; z-index:1; pointer-events:none; } +`; +const TPL_D = PAT_CSS; +const TPL_E = PAT_CSS; +const TPL_F = PAT_CSS; +const TPL_G = PAT_CSS; +const TPL_H = PAT_DARK; + +const BRANDED = { a: TPL_A, b: TPL_B, c: TPL_C, d: TPL_D, e: TPL_E, f: TPL_F, g: TPL_G, h: TPL_H }; + +// Deterministic flavor for a slug/category so the same component always renders the same way. +export function pickBrandedFlavor(key = "") { + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; + return ["a", "b", "c", "d", "e", "f", "g", "h"][h % 8]; +} + +export function brandedTemplate({ + slug = "", + title, + category, + description = "", + flavor, + fontBaseUrl, +}) { + const fonts = ogFontsCss(fontBaseUrl); + const tpl = BRANDED[flavor] || BRANDED.a; + const urlPath = String(slug).replace(/^\//, ""); + const url = `
animata.design/${esc(urlPath)}
`; + const brand = `
${LOGO}animata
`; + const eyebrow = `
${esc(category)}
`; + const name = `
${esc(title)}
`; + const desc = description ? `
${esc(description)}
` : ""; + const head = `${fonts}`; + + if (flavor === "b") { + return `${head}${brand}${eyebrow}
${name}${desc}${url}`; + } + if (flavor === "c") { + return `${head} +
${brand}${esc(category)}
+
${name}${desc}
+
${url}
+ `; + } + if (PAT_KIND[flavor]) { + // d–h: editorial layout over a faint generative math pattern (seeded by slug). + return `${head} + ${patternSVG(PAT_KIND[flavor], slug)} +
${brand}${eyebrow}
+
${name}${desc}
+
${url}
+ `; + } + return `${head} +
${brand}${eyebrow}
+
${name}${desc}
+
${url}
+ `; +} diff --git a/scripts/lib/preview-server.mjs b/scripts/lib/preview-server.mjs new file mode 100644 index 00000000..64ee54c4 --- /dev/null +++ b/scripts/lib/preview-server.mjs @@ -0,0 +1,88 @@ +import { createReadStream, existsSync, realpathSync } from "node:fs"; +import { createServer } from "node:http"; +import { dirname, extname, relative, resolve, sep } from "node:path"; + +const MIME = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".json": "application/json", + ".css": "text/css", + ".map": "application/json", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ttf": "font/ttf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".avif": "image/avif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".txt": "text/plain", +}; + +/** Parse pathname from a request URL; null on malformed input. */ +function parseRequestPath(req) { + try { + return new URL(req.url || "/", "http://127.0.0.1").pathname; + } catch { + return null; + } +} + +/** Resolve urlPath under a canonical root (realpath), or null if outside root / missing. */ +function resolveUnderRoot(rootReal, urlPath) { + if (!urlPath || urlPath.includes("\0")) return null; + const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, ""); + if (!rel || rel.includes("\\")) return null; + + const candidate = resolve(rootReal, rel); + const relToRoot = relative(rootReal, candidate); + if (relToRoot.startsWith(`..${sep}`) || relToRoot === "..") return null; + + let filePath; + try { + filePath = realpathSync(candidate); + } catch { + return null; + } + if (!filePath.startsWith(`${rootReal}${sep}`) && filePath !== rootReal) return null; + if (!existsSync(filePath)) return null; + return filePath; +} + +/** Static server for `public/preview` (Storybook build). Storybook iframes need http://, not file://. + * Falls back to the Next `public/` root so component demos that reference local assets like + * `/music.jpg` or `/jumping-man.png` (not copied into the Storybook build) still resolve. */ +export function startPreviewServer(previewDir) { + const publicRoot = dirname(previewDir); // public/preview -> public + const previewRoot = realpathSync(resolve(previewDir)); + const publicRootReal = realpathSync(resolve(publicRoot)); + + return new Promise((resolvePromise) => { + const server = createServer((req, res) => { + const urlPath = parseRequestPath(req); + if (!urlPath) { + res.writeHead(400); + res.end("bad request"); + return; + } + const filePath = + resolveUnderRoot(previewRoot, urlPath) ?? resolveUnderRoot(publicRootReal, urlPath); + if (!filePath) { + res.writeHead(404); + res.end("not found"); + return; + } + res.writeHead(200, { "content-type": MIME[extname(filePath)] || "application/octet-stream" }); + createReadStream(filePath).pipe(res); + }); + server.listen(0, "127.0.0.1", () => resolvePromise(server)); + }); +} + +export function previewServerPort(server) { + return server.address().port; +} diff --git a/scripts/og-template-preview.mjs b/scripts/og-template-preview.mjs new file mode 100644 index 00000000..7a0ae2f8 --- /dev/null +++ b/scripts/og-template-preview.mjs @@ -0,0 +1,79 @@ +// Design-review harness for the branded (no-component) OG template. Renders each flavor (a/b/c) +// for a few sample components to ./.og-out/templates/ so we can pick the look before wiring it into +// the hybrid pipeline. Not part of any build. Run: node ./scripts/og-template-preview.mjs +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +import { brandedTemplate, ogFontBaseUrl } from "./lib/og-template.mjs"; +import { previewServerPort, startPreviewServer } from "./lib/preview-server.mjs"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const OUT = join(ROOT, ".og-out", "templates"); +const PREVIEW_DIR = join(ROOT, "public", "preview"); + +const SAMPLES = [ + { + slug: "/docs/scroll/pinned-product-tour", + title: "Pinned Product Tour", + category: "scroll", + description: "Scroll-pinned sections that swap as the reader moves down the page.", + }, + { + slug: "/docs/card/collab-card", + title: "Collab Card", + category: "card", + description: "Bento tile for realtime collaboration — live presence and teammate cursors.", + }, + { + slug: "/docs/text/typing-text", + title: "Typing Text", + category: "text", + description: "Typewriter effect that types and deletes phrases on a loop.", + }, +]; + +async function main() { + if (!existsSync(join(ROOT, "public", "og-fonts", "outfit-latin-600-normal.woff2"))) { + throw new Error("public/og-fonts/ missing — run `pnpm og:fonts:sync` first."); + } + mkdirSync(OUT, { recursive: true }); + const server = await startPreviewServer(PREVIEW_DIR); + const port = previewServerPort(server); + const fontBaseUrl = ogFontBaseUrl(port); + const browser = await chromium.launch(); + const ctx = await browser.newContext({ + viewport: { width: 1200, height: 630 }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + + for (const s of SAMPLES) { + for (const flavor of ["a", "b", "c"]) { + await page.setContent(brandedTemplate({ ...s, flavor, fontBaseUrl }), { waitUntil: "load" }); + await page.evaluate(() => document.fonts?.ready).catch(() => {}); + await page.waitForTimeout(300); + const out = join( + OUT, + `${s.category}__${s.title.replace(/\s+/g, "-").toLowerCase()}__${flavor}.png`, + ); + await page.screenshot({ + path: out, + clip: { x: 0, y: 0, width: 1200, height: 630 }, + type: "png", + }); + console.log(" rendered", out); + } + } + + await browser.close(); + server.close(); + console.log(`templates -> ${OUT}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/sync-og-fonts.mjs b/scripts/sync-og-fonts.mjs new file mode 100644 index 00000000..88fa5cc1 --- /dev/null +++ b/scripts/sync-og-fonts.mjs @@ -0,0 +1,41 @@ +// Copy OG card fonts from @fontsource packages into public/og-fonts/ for offline Playwright renders. +// Run after `pnpm install` if packages change: pnpm og:fonts:sync + +import { copyFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const OUT = join(ROOT, "public", "og-fonts"); + +const FILES = [ + ["@fontsource/outfit/files/outfit-latin-400-normal.woff2", "outfit-latin-400-normal.woff2"], + ["@fontsource/outfit/files/outfit-latin-500-normal.woff2", "outfit-latin-500-normal.woff2"], + ["@fontsource/outfit/files/outfit-latin-600-normal.woff2", "outfit-latin-600-normal.woff2"], + ["@fontsource/outfit/files/outfit-latin-700-normal.woff2", "outfit-latin-700-normal.woff2"], + [ + "@fontsource/young-serif/files/young-serif-latin-400-normal.woff2", + "young-serif-latin-400-normal.woff2", + ], + [ + "@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-400-normal.woff2", + "ibm-plex-sans-latin-400-normal.woff2", + ], + [ + "@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-500-normal.woff2", + "ibm-plex-sans-latin-500-normal.woff2", + ], + [ + "@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-600-normal.woff2", + "ibm-plex-sans-latin-600-normal.woff2", + ], +]; + +mkdirSync(OUT, { recursive: true }); +for (const [from, name] of FILES) { + const src = join(ROOT, "node_modules", from); + const dest = join(OUT, name); + copyFileSync(src, dest); + console.log(` ${name}`); +} +console.log(`OG fonts synced -> ${OUT}`); diff --git a/scripts/upload-og-r2.mjs b/scripts/upload-og-r2.mjs new file mode 100644 index 00000000..78b2f00a --- /dev/null +++ b/scripts/upload-og-r2.mjs @@ -0,0 +1,96 @@ +// Upload every file under ./.og-out/og/ to the animata R2 bucket (keys match paths). +// Run after `pnpm og:build --no-upload` — the manifest is already written by that step. +// +// pnpm og:upload +// pnpm og:upload --dry-run + +import { execFile } from "node:child_process"; +import { readdirSync, statSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const pexec = promisify(execFile); +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const STAGING_DIR = join(ROOT, ".og-out"); +const OG_DIR = join(STAGING_DIR, "og"); +const BUCKET = process.env.R2_BUCKET || "animata"; +const CONCURRENCY = Number(process.env.OG_UPLOAD_CONCURRENCY || 8); +const DRY_RUN = process.argv.includes("--dry-run"); + +function walk(dir, files = []) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path, files); + else files.push(path); + } + return files; +} + +async function uploadOne(file) { + const key = relative(STAGING_DIR, file); + const contentType = key.endsWith(".gif") ? "image/gif" : "image/png"; + if (DRY_RUN) { + console.log(` would upload ${key}`); + return; + } + await pexec( + "npx", + [ + "wrangler", + "r2", + "object", + "put", + `${BUCKET}/${key}`, + "-f", + file, + "--ct", + contentType, + "--cc", + "public,max-age=31536000,immutable", + ], + { cwd: ROOT }, + ); + console.log(` uploaded ${key}`); +} + +async function main() { + if (!statSync(OG_DIR, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`${OG_DIR} missing — run \`pnpm og:build --no-upload\` first`); + } + const files = walk(OG_DIR); + if (!files.length) throw new Error(`No files under ${OG_DIR}`); + console.log( + `OG upload: ${files.length} object(s) -> ${BUCKET}${DRY_RUN ? " [dry-run]" : ""}`, + ); + + if (!DRY_RUN) { + try { + await pexec("npx", ["wrangler", "whoami"], { cwd: ROOT }); + } catch (err) { + throw new Error( + `wrangler auth failed: ${err.message?.split("\n")[0]}. Run \`npx wrangler login\`.`, + ); + } + } + + const queue = [...files]; + const workers = Array.from({ length: Math.min(CONCURRENCY, files.length) }, async () => { + while (queue.length) { + const file = queue.shift(); + try { + await uploadOne(file); + } catch (err) { + console.error(` FAILED ${relative(OG_DIR, file)}: ${err.message?.split("\n")[0]}`); + process.exitCode = 1; + } + } + }); + await Promise.all(workers); + console.log("OG upload done"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});