mykiwi/mykiwi.blogpublic Fork
⑂ 45af4c3
Commits
⬇ Clone ▾
git clone https://git.rickub.com/mykiwi/mykiwi.blog.git
git clone ssh://git@rickub.com/mykiwi/mykiwi.blog.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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.
Romain Gautier committed 2026-09-23T09:00:09+02:00 Browse files
45af4c3 parent: c5710d9
modified blog/assets/css/style.scss +42 -0
@@ -490,6 +490,48 @@ body {
490490 }
491491 }
492492
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+
493535 // The theme change spreads out of the switch as a growing circle instead of
494536 // landing everywhere at once. The moving part is a view transition: its new
495537 // 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 of535 // 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 new536 // 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 @@
5858 try { stored = localStorage.getItem(KEY); } catch {}
5959 if (stored === "light" || stored === "dark") apply(stored);
6060
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+
6174 {{- /* The new theme spreads out from the switch rather than landing
6275 everywhere at once: a view transition in which the *new* snapshot is
6376 clipped to a circle growing out of the button, over the old one left
@@ -136,6 +149,7 @@
136149 {{ with $desc -}}
137150 <meta name="description" content="{{ . }}">
138151 {{- end }}
152+ {{ partial "opengraph.html" (dict "page" . "desc" $desc) }}
139153
140154 <!-- Fonts are self-hosted, so there is no third-party DNS + TLS before first paint. -->
141155 <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 landing74 {{- /* The new theme spreads out from the switch rather than landing
62 everywhere at once: a view transition in which the *new* snapshot is75 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 left76 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 @@
99 <a class="masthead__about" href="#about">{{ i18n "about" }}</a>
1010 {{- end }}
1111 {{ 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 }}
1223 </div>
1324 <div
1425 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 <div24 <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 @@
1212 dart-sass = pin.version "dart-sass" "1.104.0";
1313 opentofu = pin.version "opentofu" "1.12.6";
1414 wrangler = pin.version "wrangler" "4.129.0";
15+ chromium = pin.version "chromium" "153.0.8010.36";
16+ imagemagick = pkgs.imagemagick;
17+ python3 = pkgs.python3;
1518 in
1619 {
1720 packages.${system}.default = pkgs.stdenvNoCC.mkDerivation {
1821 name = "mykiwi.blog";
1922 src = ./blog;
20- nativeBuildInputs = [ hugo dart-sass ];
23+ nativeBuildInputs = [ hugo dart-sass chromium imagemagick python3 ];
2124 buildPhase = ''
2225 # self.rev is the revision this flake was built from - it only exists
2326 # when the tree is clean, so a local `nix build` with uncommitted
@@ -33,6 +36,72 @@
3336 HUGO_PARAMS_REPOCOMMIT="${self.rev or "main"}" \
3437 HUGO_PARAMS_PAGECOMMITS='${builtins.getEnv "PAGE_COMMITS"}' \
3538 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
36105 '';
37106 dontInstall = true;
38107 };
@@ -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 in18 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 exists25 # 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 uncommitted26 # 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 };