Give every post a real social preview card
Adds OpenGraph/Twitter Card meta (title, description, type, locale, og:image dimensions/alt) via a new opengraph.html partial, wired into baseof.html alongside the existing <meta name="description">. og:image is a genuine Chromium screenshot of the post itself, taken by flake.nix's buildPhase after hugo renders the site: each post is served over loopback HTTP and captured at a 700px-wide viewport with Chromium's device-scale-factor set so it renders natively at 1200x630 (no blur from upscaling a smaller capture afterward). The capture uses a `?ogcard` query param, read only by baseof.html's boot script, which forces dark mode and marks the root so style.scss's [data-ogcard] rule can hide the theme toggle, reveal an avatar badge (masthead.html), and freeze entrance animations at rest - all invisible to and unfetched by real visitors.
45af4c3 parent: c5710d9 modified
blog/assets/css/style.scss +42 -0 | @@ -490,6 +490,48 @@ body { | ||
| 490 | 490 | } |
| 491 | 491 | } |
| 492 | 492 | |
| 493 | +// [data-ogcard] is baseof.html's own boot script reacting to a ?ogcard query | |
| 494 | +// param, forced on only by flake.nix's buildPhase screenshotting a page for | |
| 495 | +// og:image (see single.ogcard.svg's replacement). A button with nothing to | |
| 496 | +// toggle has no business in a still image, and an entrance animation caught | |
| 497 | +// mid-sweep would make the screenshot depend on exactly when it was taken - | |
| 498 | +// both undone here rather than by waiting the capture out. | |
| 499 | +// Always in the markup (masthead.html), never fetched by a real visitor: | |
| 500 | +// display:none means the background-image on this span is never requested | |
| 501 | +// at all, unlike <img src> which fetches regardless of display. Only the | |
| 502 | +// [data-ogcard] rule below turns it on, at which point the screenshot | |
| 503 | +// requesting the page is the only thing that ever asks for avatar.jpg here. | |
| 504 | +.masthead__ogcard-avatar { | |
| 505 | + display: none; | |
| 506 | + flex: none; | |
| 507 | + width: 1.75rem; | |
| 508 | + height: 1.75rem; | |
| 509 | + border-radius: 50%; | |
| 510 | + background-size: cover; | |
| 511 | + background-position: center; | |
| 512 | +} | |
| 513 | + | |
| 514 | +[data-ogcard] { | |
| 515 | + .theme-toggle { | |
| 516 | + display: none; | |
| 517 | + } | |
| 518 | + | |
| 519 | + .masthead__ogcard-avatar { | |
| 520 | + display: block; | |
| 521 | + } | |
| 522 | + | |
| 523 | + .rise { | |
| 524 | + animation: none; | |
| 525 | + opacity: 1; | |
| 526 | + transform: none; | |
| 527 | + } | |
| 528 | + | |
| 529 | + .masthead__signal path { | |
| 530 | + animation: none; | |
| 531 | + stroke-dashoffset: 0; | |
| 532 | + } | |
| 533 | +} | |
| 534 | + | |
| 493 | 535 | // The theme change spreads out of the switch as a growing circle instead of |
| 494 | 536 | // landing everywhere at once. The moving part is a view transition: its new |
| 495 | 537 | // snapshot, clipped to that circle, over its old one held still underneath. |
| @@ -490,6 +490,48 @@ body { | |||
| 490 | } | 490 | } |
| 491 | } | 491 | } |
| 492 | 492 | ||
| 493 | +// [data-ogcard] is baseof.html's own boot script reacting to a ?ogcard query | ||
| 494 | +// param, forced on only by flake.nix's buildPhase screenshotting a page for | ||
| 495 | +// og:image (see single.ogcard.svg's replacement). A button with nothing to | ||
| 496 | +// toggle has no business in a still image, and an entrance animation caught | ||
| 497 | +// mid-sweep would make the screenshot depend on exactly when it was taken - | ||
| 498 | +// both undone here rather than by waiting the capture out. | ||
| 499 | +// Always in the markup (masthead.html), never fetched by a real visitor: | ||
| 500 | +// display:none means the background-image on this span is never requested | ||
| 501 | +// at all, unlike <img src> which fetches regardless of display. Only the | ||
| 502 | +// [data-ogcard] rule below turns it on, at which point the screenshot | ||
| 503 | +// requesting the page is the only thing that ever asks for avatar.jpg here. | ||
| 504 | +.masthead__ogcard-avatar { | ||
| 505 | + display: none; | ||
| 506 | + flex: none; | ||
| 507 | + width: 1.75rem; | ||
| 508 | + height: 1.75rem; | ||
| 509 | + border-radius: 50%; | ||
| 510 | + background-size: cover; | ||
| 511 | + background-position: center; | ||
| 512 | +} | ||
| 513 | + | ||
| 514 | +[data-ogcard] { | ||
| 515 | + .theme-toggle { | ||
| 516 | + display: none; | ||
| 517 | + } | ||
| 518 | + | ||
| 519 | + .masthead__ogcard-avatar { | ||
| 520 | + display: block; | ||
| 521 | + } | ||
| 522 | + | ||
| 523 | + .rise { | ||
| 524 | + animation: none; | ||
| 525 | + opacity: 1; | ||
| 526 | + transform: none; | ||
| 527 | + } | ||
| 528 | + | ||
| 529 | + .masthead__signal path { | ||
| 530 | + animation: none; | ||
| 531 | + stroke-dashoffset: 0; | ||
| 532 | + } | ||
| 533 | +} | ||
| 534 | + | ||
| 493 | // The theme change spreads out of the switch as a growing circle instead of | 535 | // The theme change spreads out of the switch as a growing circle instead of |
| 494 | // landing everywhere at once. The moving part is a view transition: its new | 536 | // landing everywhere at once. The moving part is a view transition: its new |
| 495 | // snapshot, clipped to that circle, over its old one held still underneath. | 537 | // snapshot, clipped to that circle, over its old one held still underneath. |
modified
blog/layouts/_default/baseof.html +14 -0 | @@ -58,6 +58,19 @@ | ||
| 58 | 58 | try { stored = localStorage.getItem(KEY); } catch {} |
| 59 | 59 | if (stored === "light" || stored === "dark") apply(stored); |
| 60 | 60 | |
| 61 | + {{- /* ?ogcard forces dark and marks the root, regardless of any | |
| 62 | + stored preference - style.scss hides the theme toggle, reveals the | |
| 63 | + avatar, and skips entrance animations under that marker, so the | |
| 64 | + screenshot flake.nix's buildPhase takes of this same page for | |
| 65 | + og:image comes out the same every build: no button that has | |
| 66 | + nothing to toggle in a still image, no animation frozen mid-sweep | |
| 67 | + depending on when the shot was taken. Never linked to; nothing a | |
| 68 | + reader's own navigation reaches. */}} | |
| 69 | + if (new URLSearchParams(location.search).has("ogcard")) { | |
| 70 | + root.dataset.ogcard = ""; | |
| 71 | + apply("dark"); | |
| 72 | + } | |
| 73 | + | |
| 61 | 74 | {{- /* The new theme spreads out from the switch rather than landing |
| 62 | 75 | everywhere at once: a view transition in which the *new* snapshot is |
| 63 | 76 | clipped to a circle growing out of the button, over the old one left |
| @@ -136,6 +149,7 @@ | ||
| 136 | 149 | {{ with $desc -}} |
| 137 | 150 | <meta name="description" content="{{ . }}"> |
| 138 | 151 | {{- end }} |
| 152 | + {{ partial "opengraph.html" (dict "page" . "desc" $desc) }} | |
| 139 | 153 | |
| 140 | 154 | <!-- Fonts are self-hosted, so there is no third-party DNS + TLS before first paint. --> |
| 141 | 155 | <link rel="preload" href="/fonts/Newsreader-latin.woff2" as="font" type="font/woff2" crossorigin> |
| @@ -58,6 +58,19 @@ | |||
| 58 | try { stored = localStorage.getItem(KEY); } catch {} | 58 | try { stored = localStorage.getItem(KEY); } catch {} |
| 59 | if (stored === "light" || stored === "dark") apply(stored); | 59 | if (stored === "light" || stored === "dark") apply(stored); |
| 60 | 60 | ||
| 61 | + {{- /* ?ogcard forces dark and marks the root, regardless of any | ||
| 62 | + stored preference - style.scss hides the theme toggle, reveals the | ||
| 63 | + avatar, and skips entrance animations under that marker, so the | ||
| 64 | + screenshot flake.nix's buildPhase takes of this same page for | ||
| 65 | + og:image comes out the same every build: no button that has | ||
| 66 | + nothing to toggle in a still image, no animation frozen mid-sweep | ||
| 67 | + depending on when the shot was taken. Never linked to; nothing a | ||
| 68 | + reader's own navigation reaches. */}} | ||
| 69 | + if (new URLSearchParams(location.search).has("ogcard")) { | ||
| 70 | + root.dataset.ogcard = ""; | ||
| 71 | + apply("dark"); | ||
| 72 | + } | ||
| 73 | + | ||
| 61 | {{- /* The new theme spreads out from the switch rather than landing | 74 | {{- /* The new theme spreads out from the switch rather than landing |
| 62 | everywhere at once: a view transition in which the *new* snapshot is | 75 | everywhere at once: a view transition in which the *new* snapshot is |
| 63 | clipped to a circle growing out of the button, over the old one left | 76 | clipped to a circle growing out of the button, over the old one left |
| @@ -136,6 +149,7 @@ | |||
| 136 | {{ with $desc -}} | 149 | {{ with $desc -}} |
| 137 | <meta name="description" content="{{ . }}"> | 150 | <meta name="description" content="{{ . }}"> |
| 138 | {{- end }} | 151 | {{- end }} |
| 152 | + {{ partial "opengraph.html" (dict "page" . "desc" $desc) }} | ||
| 139 | 153 | ||
| 140 | <!-- Fonts are self-hosted, so there is no third-party DNS + TLS before first paint. --> | 154 | <!-- Fonts are self-hosted, so there is no third-party DNS + TLS before first paint. --> |
| 141 | <link rel="preload" href="/fonts/Newsreader-latin.woff2" as="font" type="font/woff2" crossorigin> | 155 | <link rel="preload" href="/fonts/Newsreader-latin.woff2" as="font" type="font/woff2" crossorigin> |
modified
blog/layouts/partials/masthead.html +11 -0 | @@ -9,6 +9,17 @@ | ||
| 9 | 9 | <a class="masthead__about" href="#about">{{ i18n "about" }}</a> |
| 10 | 10 | {{- end }} |
| 11 | 11 | {{ partial "theme-toggle.html" . }} |
| 12 | + {{- /* Hidden on the real site (display:none in style.scss) and shown | |
| 13 | + only under [data-ogcard] - a background-image rather than <img src> | |
| 14 | + specifically so that display:none means what it should: a real | |
| 15 | + visitor's browser never fetches avatar.jpg here at all, only the | |
| 16 | + build-time screenshot (where the [data-ogcard] rule makes it visible | |
| 17 | + before the image is ever requested) does. */}} | |
| 18 | + {{- with site.Params.profile.avatar }} | |
| 19 | + {{- with resources.Get . }} | |
| 20 | + <span class="masthead__ogcard-avatar" style="background-image:url('{{ .RelPermalink }}')" aria-hidden="true"></span> | |
| 21 | + {{- end }} | |
| 22 | + {{- end }} | |
| 12 | 23 | </div> |
| 13 | 24 | <div |
| 14 | 25 | class="masthead__signal" |
| @@ -9,6 +9,17 @@ | |||
| 9 | <a class="masthead__about" href="#about">{{ i18n "about" }}</a> | 9 | <a class="masthead__about" href="#about">{{ i18n "about" }}</a> |
| 10 | {{- end }} | 10 | {{- end }} |
| 11 | {{ partial "theme-toggle.html" . }} | 11 | {{ partial "theme-toggle.html" . }} |
| 12 | + {{- /* Hidden on the real site (display:none in style.scss) and shown | ||
| 13 | + only under [data-ogcard] - a background-image rather than <img src> | ||
| 14 | + specifically so that display:none means what it should: a real | ||
| 15 | + visitor's browser never fetches avatar.jpg here at all, only the | ||
| 16 | + build-time screenshot (where the [data-ogcard] rule makes it visible | ||
| 17 | + before the image is ever requested) does. */}} | ||
| 18 | + {{- with site.Params.profile.avatar }} | ||
| 19 | + {{- with resources.Get . }} | ||
| 20 | + <span class="masthead__ogcard-avatar" style="background-image:url('{{ .RelPermalink }}')" aria-hidden="true"></span> | ||
| 21 | + {{- end }} | ||
| 22 | + {{- end }} | ||
| 12 | </div> | 23 | </div> |
| 13 | <div | 24 | <div |
| 14 | class="masthead__signal" | 25 | class="masthead__signal" |
added
blog/layouts/partials/opengraph.html +91 -0 | new file mode 100644 | ||
| @@ -0,0 +1,91 @@ | ||
| 1 | +{{- /* | |
| 2 | + OpenGraph + Twitter Card meta - without it, a link shared on Mastodon, | |
| 3 | + Bluesky, Slack or X renders as a bare URL instead of a title/description/ | |
| 4 | + image card. Takes a dict of `page` (the page being rendered) and `desc` | |
| 5 | + (baseof.html's own <meta name="description"> text) so the two descriptions | |
| 6 | + can't drift apart by being computed twice. | |
| 7 | +*/ -}} | |
| 8 | +{{- $page := .page }} | |
| 9 | +{{- $desc := .desc }} | |
| 10 | +{{- $title := cond $page.IsHome site.Title $page.Title }} | |
| 11 | + | |
| 12 | +{{- /* Front matter `images` (Hugo's own convention) wins over everything, in | |
| 13 | + case a post ever wants to hand-pick its own; then the generated card every | |
| 14 | + post gets (og-card.png, a real Chromium screenshot of this same page taken | |
| 15 | + by flake.nix's buildPhase - not present under plain `hugo`/`hugo server`, | |
| 16 | + only the Nix build, so a local preview falls through to the avatar | |
| 17 | + instead); the site avatar is the last resort so a card never ships with no | |
| 18 | + image at all. */}} | |
| 19 | +{{- $image := "" }} | |
| 20 | +{{- $imageWidth := 0 }} | |
| 21 | +{{- $imageHeight := 0 }} | |
| 22 | +{{- with $page.Params.images }} | |
| 23 | + {{- with index . 0 }}{{ $image = . | absURL }}{{ end }} | |
| 24 | +{{- end }} | |
| 25 | +{{- if and (not $image) $page.IsPage }} | |
| 26 | + {{- $image = printf "%sog-card.png" $page.Permalink }} | |
| 27 | + {{- $imageWidth = 1200 }} | |
| 28 | + {{- $imageHeight = 630 }} | |
| 29 | +{{- end }} | |
| 30 | +{{- if not $image }} | |
| 31 | + {{- with site.Params.profile.avatar }} | |
| 32 | + {{- with resources.Get . }} | |
| 33 | + {{- $image = .Permalink }} | |
| 34 | + {{- $imageWidth = .Width }} | |
| 35 | + {{- $imageHeight = .Height }} | |
| 36 | + {{- end }} | |
| 37 | + {{- end }} | |
| 38 | +{{- end }} | |
| 39 | + | |
| 40 | +{{- /* og:locale wants en_US, not the "en-us" languages.*.locale in hugo.toml. */}} | |
| 41 | +{{- $localeParts := split site.Language.Locale "-" }} | |
| 42 | +{{- $locale := site.Language.Locale }} | |
| 43 | +{{- if eq (len $localeParts) 2 }} | |
| 44 | + {{- $locale = printf "%s_%s" (index $localeParts 0) (upper (index $localeParts 1)) }} | |
| 45 | +{{- end }} | |
| 46 | + | |
| 47 | +<meta property="og:title" content="{{ $title }}"> | |
| 48 | +{{- with $desc }} | |
| 49 | +<meta property="og:description" content="{{ . }}"> | |
| 50 | +{{- end }} | |
| 51 | +<meta property="og:type" content="{{ if $page.IsPage }}article{{ else }}website{{ end }}"> | |
| 52 | +<meta property="og:url" content="{{ $page.Permalink }}"> | |
| 53 | +<meta property="og:site_name" content="{{ site.Title }}"> | |
| 54 | +<meta property="og:locale" content="{{ $locale }}"> | |
| 55 | +{{- range $page.Translations }} | |
| 56 | + {{- $tParts := split .Language.Locale "-" }} | |
| 57 | + {{- $tLocale := .Language.Locale }} | |
| 58 | + {{- if eq (len $tParts) 2 }} | |
| 59 | + {{- $tLocale = printf "%s_%s" (index $tParts 0) (upper (index $tParts 1)) }} | |
| 60 | + {{- end }} | |
| 61 | +<meta property="og:locale:alternate" content="{{ $tLocale }}"> | |
| 62 | +{{- end }} | |
| 63 | +{{- with $image }} | |
| 64 | +<meta property="og:image" content="{{ . }}"> | |
| 65 | +{{- with $imageWidth }} | |
| 66 | +<meta property="og:image:width" content="{{ . }}"> | |
| 67 | +{{- end }} | |
| 68 | +{{- with $imageHeight }} | |
| 69 | +<meta property="og:image:height" content="{{ . }}"> | |
| 70 | +{{- end }} | |
| 71 | +<meta property="og:image:alt" content="{{ $title }}"> | |
| 72 | +<meta name="twitter:card" content="summary_large_image"> | |
| 73 | +<meta name="twitter:image" content="{{ . }}"> | |
| 74 | +{{- else }} | |
| 75 | +<meta name="twitter:card" content="summary"> | |
| 76 | +{{- end }} | |
| 77 | +<meta name="twitter:title" content="{{ $title }}"> | |
| 78 | +{{- with $desc }} | |
| 79 | +<meta name="twitter:description" content="{{ . }}"> | |
| 80 | +{{- end }} | |
| 81 | +{{- with site.Params.profile }} | |
| 82 | + {{- with (index (where .links "id" "linkTweetsTwitter") 0) }} | |
| 83 | +<meta name="twitter:creator" content="{{ .value }}"> | |
| 84 | + {{- end }} | |
| 85 | +{{- end }} | |
| 86 | +{{- if $page.IsPage }} | |
| 87 | +<meta property="article:published_time" content="{{ $page.Date.Format "2006-01-02T15:04:05Z07:00" }}"> | |
| 88 | + {{- if ne $page.Lastmod $page.Date }} | |
| 89 | +<meta property="article:modified_time" content="{{ $page.Lastmod.Format "2006-01-02T15:04:05Z07:00" }}"> | |
| 90 | + {{- end }} | |
| 91 | +{{- end }} | |
| new file mode 100644 | |||
| @@ -0,0 +1,91 @@ | |||
| 1 | +{{- /* | ||
| 2 | + OpenGraph + Twitter Card meta - without it, a link shared on Mastodon, | ||
| 3 | + Bluesky, Slack or X renders as a bare URL instead of a title/description/ | ||
| 4 | + image card. Takes a dict of `page` (the page being rendered) and `desc` | ||
| 5 | + (baseof.html's own <meta name="description"> text) so the two descriptions | ||
| 6 | + can't drift apart by being computed twice. | ||
| 7 | +*/ -}} | ||
| 8 | +{{- $page := .page }} | ||
| 9 | +{{- $desc := .desc }} | ||
| 10 | +{{- $title := cond $page.IsHome site.Title $page.Title }} | ||
| 11 | + | ||
| 12 | +{{- /* Front matter `images` (Hugo's own convention) wins over everything, in | ||
| 13 | + case a post ever wants to hand-pick its own; then the generated card every | ||
| 14 | + post gets (og-card.png, a real Chromium screenshot of this same page taken | ||
| 15 | + by flake.nix's buildPhase - not present under plain `hugo`/`hugo server`, | ||
| 16 | + only the Nix build, so a local preview falls through to the avatar | ||
| 17 | + instead); the site avatar is the last resort so a card never ships with no | ||
| 18 | + image at all. */}} | ||
| 19 | +{{- $image := "" }} | ||
| 20 | +{{- $imageWidth := 0 }} | ||
| 21 | +{{- $imageHeight := 0 }} | ||
| 22 | +{{- with $page.Params.images }} | ||
| 23 | + {{- with index . 0 }}{{ $image = . | absURL }}{{ end }} | ||
| 24 | +{{- end }} | ||
| 25 | +{{- if and (not $image) $page.IsPage }} | ||
| 26 | + {{- $image = printf "%sog-card.png" $page.Permalink }} | ||
| 27 | + {{- $imageWidth = 1200 }} | ||
| 28 | + {{- $imageHeight = 630 }} | ||
| 29 | +{{- end }} | ||
| 30 | +{{- if not $image }} | ||
| 31 | + {{- with site.Params.profile.avatar }} | ||
| 32 | + {{- with resources.Get . }} | ||
| 33 | + {{- $image = .Permalink }} | ||
| 34 | + {{- $imageWidth = .Width }} | ||
| 35 | + {{- $imageHeight = .Height }} | ||
| 36 | + {{- end }} | ||
| 37 | + {{- end }} | ||
| 38 | +{{- end }} | ||
| 39 | + | ||
| 40 | +{{- /* og:locale wants en_US, not the "en-us" languages.*.locale in hugo.toml. */}} | ||
| 41 | +{{- $localeParts := split site.Language.Locale "-" }} | ||
| 42 | +{{- $locale := site.Language.Locale }} | ||
| 43 | +{{- if eq (len $localeParts) 2 }} | ||
| 44 | + {{- $locale = printf "%s_%s" (index $localeParts 0) (upper (index $localeParts 1)) }} | ||
| 45 | +{{- end }} | ||
| 46 | + | ||
| 47 | +<meta property="og:title" content="{{ $title }}"> | ||
| 48 | +{{- with $desc }} | ||
| 49 | +<meta property="og:description" content="{{ . }}"> | ||
| 50 | +{{- end }} | ||
| 51 | +<meta property="og:type" content="{{ if $page.IsPage }}article{{ else }}website{{ end }}"> | ||
| 52 | +<meta property="og:url" content="{{ $page.Permalink }}"> | ||
| 53 | +<meta property="og:site_name" content="{{ site.Title }}"> | ||
| 54 | +<meta property="og:locale" content="{{ $locale }}"> | ||
| 55 | +{{- range $page.Translations }} | ||
| 56 | + {{- $tParts := split .Language.Locale "-" }} | ||
| 57 | + {{- $tLocale := .Language.Locale }} | ||
| 58 | + {{- if eq (len $tParts) 2 }} | ||
| 59 | + {{- $tLocale = printf "%s_%s" (index $tParts 0) (upper (index $tParts 1)) }} | ||
| 60 | + {{- end }} | ||
| 61 | +<meta property="og:locale:alternate" content="{{ $tLocale }}"> | ||
| 62 | +{{- end }} | ||
| 63 | +{{- with $image }} | ||
| 64 | +<meta property="og:image" content="{{ . }}"> | ||
| 65 | +{{- with $imageWidth }} | ||
| 66 | +<meta property="og:image:width" content="{{ . }}"> | ||
| 67 | +{{- end }} | ||
| 68 | +{{- with $imageHeight }} | ||
| 69 | +<meta property="og:image:height" content="{{ . }}"> | ||
| 70 | +{{- end }} | ||
| 71 | +<meta property="og:image:alt" content="{{ $title }}"> | ||
| 72 | +<meta name="twitter:card" content="summary_large_image"> | ||
| 73 | +<meta name="twitter:image" content="{{ . }}"> | ||
| 74 | +{{- else }} | ||
| 75 | +<meta name="twitter:card" content="summary"> | ||
| 76 | +{{- end }} | ||
| 77 | +<meta name="twitter:title" content="{{ $title }}"> | ||
| 78 | +{{- with $desc }} | ||
| 79 | +<meta name="twitter:description" content="{{ . }}"> | ||
| 80 | +{{- end }} | ||
| 81 | +{{- with site.Params.profile }} | ||
| 82 | + {{- with (index (where .links "id" "linkTweetsTwitter") 0) }} | ||
| 83 | +<meta name="twitter:creator" content="{{ .value }}"> | ||
| 84 | + {{- end }} | ||
| 85 | +{{- end }} | ||
| 86 | +{{- if $page.IsPage }} | ||
| 87 | +<meta property="article:published_time" content="{{ $page.Date.Format "2006-01-02T15:04:05Z07:00" }}"> | ||
| 88 | + {{- if ne $page.Lastmod $page.Date }} | ||
| 89 | +<meta property="article:modified_time" content="{{ $page.Lastmod.Format "2006-01-02T15:04:05Z07:00" }}"> | ||
| 90 | + {{- end }} | ||
| 91 | +{{- end }} | ||
modified
flake.nix +70 -1 | @@ -12,12 +12,15 @@ | ||
| 12 | 12 | dart-sass = pin.version "dart-sass" "1.104.0"; |
| 13 | 13 | opentofu = pin.version "opentofu" "1.12.6"; |
| 14 | 14 | wrangler = pin.version "wrangler" "4.129.0"; |
| 15 | + chromium = pin.version "chromium" "153.0.8010.36"; | |
| 16 | + imagemagick = pkgs.imagemagick; | |
| 17 | + python3 = pkgs.python3; | |
| 15 | 18 | in |
| 16 | 19 | { |
| 17 | 20 | packages.${system}.default = pkgs.stdenvNoCC.mkDerivation { |
| 18 | 21 | name = "mykiwi.blog"; |
| 19 | 22 | src = ./blog; |
| 20 | - nativeBuildInputs = [ hugo dart-sass ]; | |
| 23 | + nativeBuildInputs = [ hugo dart-sass chromium imagemagick python3 ]; | |
| 21 | 24 | buildPhase = '' |
| 22 | 25 | # self.rev is the revision this flake was built from - it only exists |
| 23 | 26 | # when the tree is clean, so a local `nix build` with uncommitted |
| @@ -33,6 +36,72 @@ | ||
| 33 | 36 | HUGO_PARAMS_REPOCOMMIT="${self.rev or "main"}" \ |
| 34 | 37 | HUGO_PARAMS_PAGECOMMITS='${builtins.getEnv "PAGE_COMMITS"}' \ |
| 35 | 38 | hugo --minify --destination "$out" |
| 39 | + | |
| 40 | + # og:image needs a raster image, not the HTML $out already has, and | |
| 41 | + # the most faithful source for it is the page itself: a real | |
| 42 | + # Chromium screenshot of each post at 700px width (baseof.html's | |
| 43 | + # dark-mode media query kicks in below that, the same width a | |
| 44 | + # narrow phone actually sees - see the comment above $signalY in | |
| 45 | + # git history for how this width was chosen) with the ?ogcard query | |
| 46 | + # param (read by baseof.html's boot script; see style.scss's | |
| 47 | + # [data-ogcard] rule) forcing dark mode, hiding the theme toggle | |
| 48 | + # and avatar-revealing, and skipping the entrance animations so the | |
| 49 | + # capture doesn't depend on exactly when it was taken. | |
| 50 | + # | |
| 51 | + # --force-device-scale-factor=1200/700 makes Chromium render text | |
| 52 | + # and vectors natively at the 1200x630 every scraper (Facebook, | |
| 53 | + # Slack, Discord, X) crops or pads to - not 700x368 upscaled to | |
| 54 | + # that size afterward, which blurred every glyph in the card. A | |
| 55 | + # browser's own renderer is DPI-aware; ImageMagick's -resize on a | |
| 56 | + # already-rasterized image has no extra detail to work from. | |
| 57 | + # 368*1200/700 isn't a whole number, so ImageMagick still runs | |
| 58 | + # once at the end - only to crop the 1px of excess height that | |
| 59 | + # falls out of that rounding, not to rescale anything. | |
| 60 | + # | |
| 61 | + # Chromium needs the page served over HTTP, not file://: the site's | |
| 62 | + # own CSS/JS/font references are root-relative ("/fonts/...") and | |
| 63 | + # resolve against the filesystem root rather than $out/$lang under | |
| 64 | + # file://, so a plain static server (one per language, since each | |
| 65 | + # language's $out subdirectory is a complete, separately-rooted | |
| 66 | + # site under multihost - see hugo.toml's own comment on that) fills | |
| 67 | + # in for the real one. Loopback-only, which is all a Nix build's | |
| 68 | + # network-less sandbox allows anyway. | |
| 69 | + # | |
| 70 | + # $langs can't be read from ../languages.txt (outside $src) the way | |
| 71 | + # Makefile's LANGS is - hugo.toml's own [languages.*] blocks are | |
| 72 | + # already a hand-kept copy of that file for the same reason, so | |
| 73 | + # this is one more, not a new problem. | |
| 74 | + langs="en es fr pt ja" | |
| 75 | + export HOME="$TMPDIR" | |
| 76 | + port=8971 | |
| 77 | + for lang in $langs; do | |
| 78 | + root="$out/$lang" | |
| 79 | + python3 -m http.server "$port" --bind 127.0.0.1 --directory "$root" \ | |
| 80 | + >/dev/null 2>&1 & | |
| 81 | + server=$! | |
| 82 | + # No curl in nativeBuildInputs to poll with - a static file | |
| 83 | + # server this small is listening well within this. | |
| 84 | + sleep 0.5 | |
| 85 | + | |
| 86 | + find "$root" -mindepth 4 -maxdepth 4 -type d \ | |
| 87 | + -regextype posix-extended \ | |
| 88 | + -regex '.*/[0-9]{4}/[0-9]{2}/[0-9]{2}/[^/]+' | | |
| 89 | + while IFS= read -r dir; do | |
| 90 | + url="http://127.0.0.1:$port''${dir#"$root"}/?ogcard" | |
| 91 | + raw="$TMPDIR/og-card-raw.png" | |
| 92 | + chromium \ | |
| 93 | + --headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage \ | |
| 94 | + --hide-scrollbars --window-size=700,368 \ | |
| 95 | + --force-device-scale-factor=1.7142857 \ | |
| 96 | + --user-data-dir="$TMPDIR/chrome-profile" \ | |
| 97 | + --screenshot="$raw" "$url" >/dev/null 2>&1 | |
| 98 | + convert "$raw" -crop 1200x630+0+0 +repage "$dir/og-card.png" | |
| 99 | + rm -f "$raw" | |
| 100 | + done | |
| 101 | + | |
| 102 | + kill "$server" 2>/dev/null | |
| 103 | + wait "$server" 2>/dev/null || true | |
| 104 | + done | |
| 36 | 105 | ''; |
| 37 | 106 | dontInstall = true; |
| 38 | 107 | }; |
| @@ -12,12 +12,15 @@ | |||
| 12 | dart-sass = pin.version "dart-sass" "1.104.0"; | 12 | dart-sass = pin.version "dart-sass" "1.104.0"; |
| 13 | opentofu = pin.version "opentofu" "1.12.6"; | 13 | opentofu = pin.version "opentofu" "1.12.6"; |
| 14 | wrangler = pin.version "wrangler" "4.129.0"; | 14 | wrangler = pin.version "wrangler" "4.129.0"; |
| 15 | + chromium = pin.version "chromium" "153.0.8010.36"; | ||
| 16 | + imagemagick = pkgs.imagemagick; | ||
| 17 | + python3 = pkgs.python3; | ||
| 15 | in | 18 | in |
| 16 | { | 19 | { |
| 17 | packages.${system}.default = pkgs.stdenvNoCC.mkDerivation { | 20 | packages.${system}.default = pkgs.stdenvNoCC.mkDerivation { |
| 18 | name = "mykiwi.blog"; | 21 | name = "mykiwi.blog"; |
| 19 | src = ./blog; | 22 | src = ./blog; |
| 20 | - nativeBuildInputs = [ hugo dart-sass ]; | 23 | + nativeBuildInputs = [ hugo dart-sass chromium imagemagick python3 ]; |
| 21 | buildPhase = '' | 24 | buildPhase = '' |
| 22 | # self.rev is the revision this flake was built from - it only exists | 25 | # self.rev is the revision this flake was built from - it only exists |
| 23 | # when the tree is clean, so a local `nix build` with uncommitted | 26 | # when the tree is clean, so a local `nix build` with uncommitted |
| @@ -33,6 +36,72 @@ | |||
| 33 | HUGO_PARAMS_REPOCOMMIT="${self.rev or "main"}" \ | 36 | HUGO_PARAMS_REPOCOMMIT="${self.rev or "main"}" \ |
| 34 | HUGO_PARAMS_PAGECOMMITS='${builtins.getEnv "PAGE_COMMITS"}' \ | 37 | HUGO_PARAMS_PAGECOMMITS='${builtins.getEnv "PAGE_COMMITS"}' \ |
| 35 | hugo --minify --destination "$out" | 38 | hugo --minify --destination "$out" |
| 39 | + | ||
| 40 | + # og:image needs a raster image, not the HTML $out already has, and | ||
| 41 | + # the most faithful source for it is the page itself: a real | ||
| 42 | + # Chromium screenshot of each post at 700px width (baseof.html's | ||
| 43 | + # dark-mode media query kicks in below that, the same width a | ||
| 44 | + # narrow phone actually sees - see the comment above $signalY in | ||
| 45 | + # git history for how this width was chosen) with the ?ogcard query | ||
| 46 | + # param (read by baseof.html's boot script; see style.scss's | ||
| 47 | + # [data-ogcard] rule) forcing dark mode, hiding the theme toggle | ||
| 48 | + # and avatar-revealing, and skipping the entrance animations so the | ||
| 49 | + # capture doesn't depend on exactly when it was taken. | ||
| 50 | + # | ||
| 51 | + # --force-device-scale-factor=1200/700 makes Chromium render text | ||
| 52 | + # and vectors natively at the 1200x630 every scraper (Facebook, | ||
| 53 | + # Slack, Discord, X) crops or pads to - not 700x368 upscaled to | ||
| 54 | + # that size afterward, which blurred every glyph in the card. A | ||
| 55 | + # browser's own renderer is DPI-aware; ImageMagick's -resize on a | ||
| 56 | + # already-rasterized image has no extra detail to work from. | ||
| 57 | + # 368*1200/700 isn't a whole number, so ImageMagick still runs | ||
| 58 | + # once at the end - only to crop the 1px of excess height that | ||
| 59 | + # falls out of that rounding, not to rescale anything. | ||
| 60 | + # | ||
| 61 | + # Chromium needs the page served over HTTP, not file://: the site's | ||
| 62 | + # own CSS/JS/font references are root-relative ("/fonts/...") and | ||
| 63 | + # resolve against the filesystem root rather than $out/$lang under | ||
| 64 | + # file://, so a plain static server (one per language, since each | ||
| 65 | + # language's $out subdirectory is a complete, separately-rooted | ||
| 66 | + # site under multihost - see hugo.toml's own comment on that) fills | ||
| 67 | + # in for the real one. Loopback-only, which is all a Nix build's | ||
| 68 | + # network-less sandbox allows anyway. | ||
| 69 | + # | ||
| 70 | + # $langs can't be read from ../languages.txt (outside $src) the way | ||
| 71 | + # Makefile's LANGS is - hugo.toml's own [languages.*] blocks are | ||
| 72 | + # already a hand-kept copy of that file for the same reason, so | ||
| 73 | + # this is one more, not a new problem. | ||
| 74 | + langs="en es fr pt ja" | ||
| 75 | + export HOME="$TMPDIR" | ||
| 76 | + port=8971 | ||
| 77 | + for lang in $langs; do | ||
| 78 | + root="$out/$lang" | ||
| 79 | + python3 -m http.server "$port" --bind 127.0.0.1 --directory "$root" \ | ||
| 80 | + >/dev/null 2>&1 & | ||
| 81 | + server=$! | ||
| 82 | + # No curl in nativeBuildInputs to poll with - a static file | ||
| 83 | + # server this small is listening well within this. | ||
| 84 | + sleep 0.5 | ||
| 85 | + | ||
| 86 | + find "$root" -mindepth 4 -maxdepth 4 -type d \ | ||
| 87 | + -regextype posix-extended \ | ||
| 88 | + -regex '.*/[0-9]{4}/[0-9]{2}/[0-9]{2}/[^/]+' | | ||
| 89 | + while IFS= read -r dir; do | ||
| 90 | + url="http://127.0.0.1:$port''${dir#"$root"}/?ogcard" | ||
| 91 | + raw="$TMPDIR/og-card-raw.png" | ||
| 92 | + chromium \ | ||
| 93 | + --headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage \ | ||
| 94 | + --hide-scrollbars --window-size=700,368 \ | ||
| 95 | + --force-device-scale-factor=1.7142857 \ | ||
| 96 | + --user-data-dir="$TMPDIR/chrome-profile" \ | ||
| 97 | + --screenshot="$raw" "$url" >/dev/null 2>&1 | ||
| 98 | + convert "$raw" -crop 1200x630+0+0 +repage "$dir/og-card.png" | ||
| 99 | + rm -f "$raw" | ||
| 100 | + done | ||
| 101 | + | ||
| 102 | + kill "$server" 2>/dev/null | ||
| 103 | + wait "$server" 2>/dev/null || true | ||
| 104 | + done | ||
| 36 | ''; | 105 | ''; |
| 37 | dontInstall = true; | 106 | dontInstall = true; |
| 38 | }; | 107 | }; |