Skip to content

Commit 43a7cc4

Browse files
committed
WIP page meta open graph image generation
1 parent 3268f11 commit 43a7cc4

File tree

12 files changed

+1467
-312
lines changed

12 files changed

+1467
-312
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
}

integrations/page-meta/integration.ts

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
addVirtualImports,
33
createResolver,
4-
defineIntegration
4+
defineIntegration,
5+
injectDevRoute
56
} from "astro-integration-kit";
67
import * as Fs from "node:fs/promises";
78

@@ -15,6 +16,11 @@ import * as Fs from "node:fs/promises";
1516
// TODO expose transformation as global option
1617
// TODO typing (have an option for lax mode i.e. where no requirements enforced, opinions ignored); users pass own schema, set via injectTypes?
1718
// TODO Possible to lint if addPageMeta not called?
19+
// TODO drawback to export approach, no typing; export type, set on meta / use satisfies?
20+
// TODO in page template, how to tell people need to size canvas by image dimensions, place in top-left? or make screenshotting range configurable?
21+
// TODO test aspect ratio handling, would need to support configuring
22+
// TODO Export middleware functions, allow disabling, importing directly w/in user's own middleware file, for clarity of execution order
23+
// TODO note, expected warning re: mismatched rendering modes? No, would need to provide a page aligned with user's target, to support keeping route into prod
1824

1925
export default defineIntegration({
2026
name: "@page-meta",
@@ -23,18 +29,56 @@ export default defineIntegration({
2329

2430
return {
2531
hooks: {
26-
"astro:config:setup": (params) => {
32+
"astro:routes:resolved": ({ routes }) => {
33+
// TODO stash route tree
34+
// TODO expose as types? expose routing utils for matching logic w/in components? Or already available
35+
// on render context? set typesafe links, type anchor elements, enforce route type on other links, allow matching
36+
// stash for latter, for using patternRegex to determine how to consolidate images by path
37+
},
38+
// eslint-disable-next-line perfectionist/sort-objects
39+
"astro:config:setup": async (params) => {
40+
// if build, interpolate resolved og-image path ... no, because can't know ahead of time? unless can use the same transform given a route pattern for
41+
// url setting and generation
42+
2743
addVirtualImports(params, {
2844
imports: [
2945
{
3046
content: `
3147
export const localsKey = Symbol("page-meta::add");
3248
3349
export const addPageMeta = (ctx, data) => {
34-
3550
ctx.locals[localsKey] = {
36-
...data,
37-
origin: '${params.config.site || ""}'
51+
...data
52+
};
53+
};
54+
55+
export const resolveMeta = (ctx) => {
56+
const meta = ctx.locals[localsKey] ?? {};
57+
58+
const base = {
59+
image: {
60+
height: 630,
61+
url: "/og?route=" + encodeURIComponent(ctx.routePattern),
62+
width: 1200
63+
},
64+
og: true,
65+
origin: '${params.config.site || ""}',
66+
type: "website"
67+
};
68+
69+
if (ctx.routePattern !== "/") {
70+
base.name = "GrepCo"; // TODO exclude site name and type from addPageMeta interface
71+
base.separator = " | ";
72+
base.ogNameInTitle = false;
73+
}
74+
75+
return {
76+
...base,
77+
...meta,
78+
image: {
79+
...base.image,
80+
...meta.image
81+
}
3882
};
3983
};
4084
`,
@@ -49,15 +93,35 @@ export default defineIntegration({
4993
entrypoint: resolve("./middleware"),
5094
order: "post"
5195
});
96+
97+
injectDevRoute(params, {
98+
entrypoint: resolve("./og.astro"),
99+
pattern: "/og",
100+
prerender: false
101+
});
102+
103+
if (params.command === "dev") {
104+
// TODO rename to preview
105+
params.injectScript(
106+
"page",
107+
await Fs.readFile(resolve("./script.ts"), {
108+
encoding: "utf8"
109+
})
110+
);
111+
}
52112
},
53-
// eslint-disable-next-line perfectionist/sort-objects
113+
// eslint-disable-next-line perfectionist/sort-objects -- align with hook call order
54114
"astro:config:done": async (params) => {
55115
params.injectTypes({
56116
content: await Fs.readFile(resolve("./virtual.d.ts"), {
57117
encoding: "utf8"
58118
}),
59119
filename: "page-meta.d.ts"
60120
});
121+
},
122+
// eslint-disable-next-line perfectionist/sort-objects
123+
"astro:build:done": ({ pages }) => {
124+
console.log("PENGUIN", pages);
61125
}
62126
}
63127
};
Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { MiddlewareHandler } from "astro";
1+
import type { APIContext } from "astro";
22
import type { Options as MetaOptions } from "rehype-meta";
33

4-
import { localsKey } from "@page-meta/add";
4+
import { resolveMeta } from "@page-meta/add";
5+
import { defineMiddleware, sequence } from "astro:middleware";
56
import { rehype } from "rehype";
67
import rehypeMeta from "rehype-meta";
78

@@ -16,19 +17,41 @@ import rehypeMeta from "rehype-meta";
1617
- what happens if tags already set? overwritten, right? would need to document if published
1718
*/
1819

19-
export const onRequest: MiddlewareHandler = async (context, next) => {
20+
const isPage = (ctx: APIContext, response: Response) => {
21+
return (
22+
ctx.routePattern &&
23+
ctx.routePattern !== "/og" && // TODO configurable endpoint path
24+
response.headers.get("content-type")?.includes("text/html") // excludes /_image, TODO but would catch server islands ...
25+
);
26+
};
27+
28+
export const preview = defineMiddleware(async (context, next) => {
2029
const response = await next();
2130
// TODO this is insufficient, falls down for server islands, need to verify that;
2231
// TODO if bundling routes integration, would need to document how to opt out of types augmentation / injection? i.e. ignore visible side-effect
2332
// TODO for rewrites, handle in transforms, i think the only possible way (tho on rewrite, wouldn't you want canonical url set to pre-rewrite?)
24-
if (!response.headers.get("content-type")?.includes("text/html")) {
33+
if (!isPage(context, response)) {
2534
return response;
2635
}
2736

28-
// @ts-expect-error -- ts complaining about indexing with symbol; not going to augment Locals with a symbol index signature, this is fine
29-
const meta = context.locals[localsKey] as MetaOptions | undefined;
37+
const isPreview = context.url.searchParams.has("og-img");
3038

31-
if (!meta) {
39+
if (!isPreview) {
40+
return response;
41+
}
42+
43+
const searchParams = new URLSearchParams();
44+
searchParams.append("route", context.url.pathname);
45+
46+
return context.rewrite("/og?" + searchParams.toString());
47+
});
48+
49+
export const process = defineMiddleware(async (context, next) => {
50+
const response = await next();
51+
// TODO this is insufficient, falls down for server islands, need to verify that;
52+
// TODO if bundling routes integration, would need to document how to opt out of types augmentation / injection? i.e. ignore visible side-effect
53+
// TODO for rewrites, handle in transforms, i think the only possible way (tho on rewrite, wouldn't you want canonical url set to pre-rewrite?)
54+
if (!isPage(context, response)) {
3255
return response;
3356
}
3457

@@ -43,22 +66,24 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
4366
https://yoast.com/rel-canonical/#when-canonical
4467
http://www.thesempost.com/using-rel-canonical-on-all-pages-for-duplicate-content-protection/
4568
*/
69+
const meta = resolveMeta(context);
70+
71+
// TODO Differentiate meta undefined vs. globally defined
72+
if (!meta) {
73+
return response;
74+
}
75+
4676
const opts: MetaOptions = {
4777
...meta,
48-
og: true,
49-
pathname: context.url.pathname, // TODO Test how this ends up formatted against origin set e.g. warnings about trailing slashes (warn w/ typescript, template literal?)
50-
type: "website"
78+
pathname: context.url.pathname // TODO Test how this ends up formatted against origin set e.g. warnings about trailing slashes (warn w/ typescript, template literal?)
5179
};
52-
if (context.url.pathname !== "/") {
53-
opts.name = "GrepCo"; // TODO exclude site name and type from addPageMeta interface
54-
opts.separator = " | ";
55-
opts.ogNameInTitle = true;
56-
}
5780

5881
const html = await response.text();
5982

6083
const processed = await rehype().use(rehypeMeta, opts).process(html);
6184

6285
// TODO What does this do?
6386
return new Response(String(processed), response);
64-
};
87+
});
88+
89+
export const onRequest = sequence(preview, process);

0 commit comments

Comments
 (0)