|
| 1 | +import type { Element } from "hast"; |
| 2 | + |
| 3 | +import * as Astro from "astro"; |
| 4 | +import Fs from "node:fs/promises"; |
| 5 | +import Path from "node:path"; |
| 6 | +import Puppeteer from "puppeteer"; |
| 7 | +import RehypeParse from "rehype-parse"; |
| 8 | +import { unified as Unified } from "unified"; |
| 9 | +import { visit as Visit } from "unist-util-visit"; |
| 10 | + |
| 11 | +const browser = await Puppeteer.launch(); |
| 12 | + |
| 13 | +// root |
| 14 | +// outdir |
| 15 | + |
| 16 | +/* |
| 17 | + For each static page with image generation enabled |
| 18 | +
|
| 19 | + 1. generate the image (go to astro dev server, take screenshot) |
| 20 | +*/ |
| 21 | + |
| 22 | +// dev needed so og integration page still set |
| 23 | +const astroServer = await Astro.dev({ |
| 24 | + devToolbar: { |
| 25 | + // otherwise shows up in screenshots |
| 26 | + enabled: false |
| 27 | + }, |
| 28 | + logLevel: "warn", |
| 29 | + mode: "development", |
| 30 | + // Use a different port to avoid conflicts with regular dev server |
| 31 | + server: { port: 4322 } |
| 32 | +}); |
| 33 | + |
| 34 | +const pages = [ |
| 35 | + { pathname: "404/" }, |
| 36 | + { pathname: "about/" }, |
| 37 | + { pathname: "blog/ts-log-init/" }, |
| 38 | + { pathname: "blog/requiem-for-a-seltzer/" }, |
| 39 | + { pathname: "blog/" }, |
| 40 | + { pathname: "projects/nbastt/" }, |
| 41 | + { pathname: "projects/wine-cellar/" }, |
| 42 | + { pathname: "projects/" }, |
| 43 | + { pathname: "" } |
| 44 | +]; |
| 45 | + |
| 46 | +const outDir = Path.resolve(process.cwd(), "dist"); |
| 47 | +const protocol = "http"; // Astro dev server typically runs on http |
| 48 | +const host = astroServer.address.address; |
| 49 | +const port = astroServer.address.port; |
| 50 | + |
| 51 | +// Check if it's an IPv6 address |
| 52 | +const baseUrl = |
| 53 | + host.includes(":") ? |
| 54 | + `${protocol}://[${host}]:${port}` |
| 55 | + : `${protocol}://${host}:${port}`; |
| 56 | + |
| 57 | +const ogImageDir = Path.join(outDir, "og-images/"); |
| 58 | + |
| 59 | +const STATUS_PAGES = new Set(["404", "500"]); |
| 60 | + |
| 61 | +// by default, will generate images for all paths |
| 62 | +// but can optimize by telling generator how images vary |
| 63 | +const imgVary = [{ pattern: "/blog/[slug]", rx: /^\/blog\/([^/]+?)\/?$/ }]; |
| 64 | + |
| 65 | +const normalizePathname = (pathname: string) => { |
| 66 | + const normalized = pathname.replace(/\/$/, ""); |
| 67 | + return normalized.startsWith("/") ? normalized : `/${normalized}`; |
| 68 | +}; |
| 69 | + |
| 70 | +try { |
| 71 | + await Fs.rm(ogImageDir, { force: true, recursive: true }); |
| 72 | + await Fs.mkdir(ogImageDir); |
| 73 | + |
| 74 | + const page = await browser.newPage(); |
| 75 | + |
| 76 | + for (const pg of pages) { |
| 77 | + const normalizedPath = normalizePathname(pg.pathname); |
| 78 | + const varyPath = imgVary.find((pat) => pat.rx.test(normalizedPath)); |
| 79 | + const pathImgTpl = varyPath ? normalizedPath : "/"; // default to root; TODO allow setting different default? |
| 80 | + |
| 81 | + const cleanedPath = normalizedPath.replace(/^\//, ""); |
| 82 | + |
| 83 | + console.log({ cleanedPath, pathImgTpl, varyPath }); |
| 84 | + |
| 85 | + let filepath; |
| 86 | + if (cleanedPath === "/") { |
| 87 | + filepath = "index.html"; |
| 88 | + } else if (STATUS_PAGES.has(cleanedPath)) { |
| 89 | + filepath = `${cleanedPath}.html`; |
| 90 | + } else { |
| 91 | + filepath = Path.join(cleanedPath, "index.html"); |
| 92 | + } |
| 93 | + |
| 94 | + const outFile = Path.resolve(outDir, filepath); |
| 95 | + const built = await Fs.readFile(outFile, { encoding: "utf8" }); |
| 96 | + |
| 97 | + const ogImgDimensions = { |
| 98 | + height: 630, |
| 99 | + width: 1200 |
| 100 | + }; |
| 101 | + |
| 102 | + const tree = await Unified().use(RehypeParse).parse(built); |
| 103 | + |
| 104 | + Visit(tree, "element", (node: Element) => { |
| 105 | + // Visit "element" nodes |
| 106 | + if (node.tagName === "meta" && node.properties) { |
| 107 | + const property = node.properties.property; |
| 108 | + const content = node.properties.content; |
| 109 | + |
| 110 | + if ( |
| 111 | + property === "og:image:width" && |
| 112 | + typeof content === "string" |
| 113 | + ) { |
| 114 | + const width = Number.parseInt(content, 10); |
| 115 | + if (!Number.isNaN(width)) { |
| 116 | + ogImgDimensions.width = width; |
| 117 | + } |
| 118 | + } else if ( |
| 119 | + property === "og:image:height" && |
| 120 | + typeof content === "string" |
| 121 | + ) { |
| 122 | + const height = Number.parseInt(content, 10); |
| 123 | + if (!Number.isNaN(height)) { |
| 124 | + ogImgDimensions.height = height; |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + }); |
| 129 | + |
| 130 | + console.log(`Dimensions for ${pg.pathname}:`, ogImgDimensions); // You can log to verify |
| 131 | + |
| 132 | + const searchParams = new URLSearchParams(); |
| 133 | + // TODO do better? needed b/c og route expects route (loosen that expectation / normalize away?) |
| 134 | + // TODO normalizing on required leading slash, but no trailing (or optional trailing) i.e. patternRegex semantics |
| 135 | + // seems useful; seems exactly what og-tpl-astro expects |
| 136 | + searchParams.append("route", pathImgTpl); |
| 137 | + |
| 138 | + const url = new URL("/og?" + searchParams.toString(), baseUrl); |
| 139 | + |
| 140 | + // TODO better way to translate slugs to filenames? |
| 141 | + // TODO filename limit? |
| 142 | + // TODO provide way to configure? |
| 143 | + const filename = |
| 144 | + pathImgTpl === "/" ? |
| 145 | + "base" // TODO allow configuring default name (typescript tpl for url safe?) |
| 146 | + : pathImgTpl.replace(/^\//, "").replace("/", "-"); // TODO explain |
| 147 | + |
| 148 | + console.log({ filename }); |
| 149 | + |
| 150 | + await page.goto(url.href); |
| 151 | + await page.setViewport(ogImgDimensions); |
| 152 | + await page.screenshot({ |
| 153 | + path: Path.join(ogImageDir, `${filename}.png`), |
| 154 | + type: "png" |
| 155 | + }); |
| 156 | + } |
| 157 | +} finally { |
| 158 | + await browser?.close(); |
| 159 | + await astroServer?.stop(); |
| 160 | +} |
0 commit comments