diff --git a/www/blog/2025-02-02-welcome-to-effection-blog/index.md b/www/blog/2025-02-02-welcome-to-effection-blog/index.md index d67369084..73bbdfbbb 100644 --- a/www/blog/2025-02-02-welcome-to-effection-blog/index.md +++ b/www/blog/2025-02-02-welcome-to-effection-blog/index.md @@ -3,6 +3,7 @@ title: "Welcome to the Effection Blog" description: "Introducing the new Effection blog - your source for tutorials, release announcements, and insights about structured concurrency in JavaScript." author: "Taras Mankovski" tags: ["announcement", "effection"] +image: meta-effection.png --- Welcome to the official Effection blog! This is where we'll share tutorials, diff --git a/www/blog/2025-02-02-welcome-to-effection-blog/meta-effection.png b/www/blog/2025-02-02-welcome-to-effection-blog/meta-effection.png new file mode 100644 index 000000000..6268ca9f6 Binary files /dev/null and b/www/blog/2025-02-02-welcome-to-effection-blog/meta-effection.png differ diff --git a/www/deno.json b/www/deno.json index cf9792517..c3fdfc601 100644 --- a/www/deno.json +++ b/www/deno.json @@ -94,6 +94,7 @@ "hast-util-select": "npm:hast-util-select@6.0.1", "unist-util-visit": "npm:unist-util-visit@5.0.0", "vfile": "npm:vfile@6.0.3", - "zod": "npm:zod@3.23.8" + "zod": "npm:zod@3.23.8", + "@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@2.6.2" } } diff --git a/www/main.tsx b/www/main.tsx index 42e49c1e1..cc185571f 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -19,9 +19,12 @@ import { initJSRClient } from "./context/jsr.ts"; import { initWorktrees } from "./lib/worktrees.ts"; import { initGuides } from "./resources/guides.ts"; import { initBlog } from "./resources/blog.ts"; +import { initFonts } from "./resources/fonts.ts"; +import { initImageStore } from "./resources/image-store.ts"; import { apiIndexRoute } from "./routes/api-index-route.tsx"; import { blogIndexRoute } from "./routes/blog-index-route.tsx"; import { blogPostRoute } from "./routes/blog-post-route.tsx"; +import { blogImageRoute } from "./routes/blog-image-route.ts"; import { blogTagRoute } from "./routes/blog-tag-route.tsx"; import { blogFeedRoute } from "./routes/blog-feed-route.tsx"; import { llmsTxtRoute } from "./routes/llms-txt-route.ts"; @@ -51,6 +54,8 @@ if (import.meta.main) { }); yield* initBlog(); + yield* initFonts(); + yield* initImageStore(); yield* initJSRClient(); yield* initFetch(); @@ -86,6 +91,7 @@ if (import.meta.main) { route("/llms.txt", llmsTxtRoute()), route("/blog/tags/:tag", blogTagRoute({ search: true })), route("/blog/:id", blogPostRoute({ search: true })), + route("/blog/:id/:name.png", blogImageRoute()), route("/blog{/*path}", assetsRoute("blog")), route( "/pagefind{/*path}", diff --git a/www/plugins/current-request.ts b/www/plugins/current-request.ts index a606744cf..6347074f7 100644 --- a/www/plugins/current-request.ts +++ b/www/plugins/current-request.ts @@ -25,16 +25,12 @@ export function* useAbsoluteUrl(path: string = "/"): Operation { export function* useAbsoluteUrlFactory(): Operation<(path: string) => string> { let request = yield* CurrentRequest.expect(); + let origin = new URL(request.url).origin; + return (path) => { - let normalizedPath = posixNormalize(path); - if (normalizedPath.startsWith("/")) { - let url = new URL(request.url); - url.pathname = normalizedPath; - url.search = ""; - return url.toString(); - } else { - return new URL(path, request.url).toString(); - } + let url = new URL(path, origin); + url.pathname = posixNormalize(url.pathname); + return url.toString(); }; } diff --git a/www/resources/blog.ts b/www/resources/blog.ts index da17a117c..19e6cd2e5 100644 --- a/www/resources/blog.ts +++ b/www/resources/blog.ts @@ -12,6 +12,7 @@ import rehypePrismPlus from "rehype-prism-plus"; import rehypeSlug from "rehype-slug"; import rehypeAddClasses from "rehype-add-classes"; import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import z from "zod"; export interface Blog { get(id: string): BlogPost | undefined; @@ -23,20 +24,20 @@ export interface BlogPost { id: string; title: string; description: string; - image: string | undefined; + image: string; date: Date; author: string; tags: string[]; content: () => JSXElement; } -interface Frontmatter { - title: string; - description: string; - author: string; - tags: string[]; - image?: string; -} +let Frontmatter = z.object({ + title: z.string(), + description: z.string(), + author: z.string(), + tags: z.array(z.string()).default([]), + image: z.string(), +}); const BlogContext = createContext("blog"); @@ -129,14 +130,14 @@ function* loadBlog(): Operation { }) ); - let frontmatter = mod.frontmatter as Frontmatter; + let frontmatter = Frontmatter.parse(mod.frontmatter); let post: BlogPost = { id, date, title: frontmatter.title, description: frontmatter.description, author: frontmatter.author, - tags: frontmatter.tags ?? [], + tags: frontmatter.tags, image: frontmatter.image, content: () => mod.default({}) as JSXElement, }; diff --git a/www/resources/fonts.ts b/www/resources/fonts.ts new file mode 100644 index 000000000..14369d512 --- /dev/null +++ b/www/resources/fonts.ts @@ -0,0 +1,47 @@ +import { all, createContext, type Operation, until } from "effection"; + +export interface Fonts { + buffers: Uint8Array[]; + defaultFamily: string; + monospaceFamily: string; +} + +const FontsContext = createContext("fonts"); + +export function* initFonts(): Operation { + let buffers = yield* all([ + // Proxima Nova: 400, 700, 800 + fetchFont( + "https://use.typekit.net/af/efe4a5/00000000000000007735e609/30/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3", + ), + fetchFont( + "https://use.typekit.net/af/2555e1/00000000000000007735e603/30/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n7&v=3", + ), + fetchFont( + "https://use.typekit.net/af/8738d8/00000000000000007735e611/30/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n8&v=3", + ), + // JetBrains Mono: 400, 600 + fetchFont( + "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPQ.ttf", + ), + fetchFont( + "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8FqtjPQ.ttf", + ), + ]); + + yield* FontsContext.set({ + buffers, + defaultFamily: "proxima-nova", + monospaceFamily: "JetBrains Mono", + }); +} + +export function* useFonts(): Operation { + return yield* FontsContext.expect(); +} + +function* fetchFont(url: string): Operation { + let response = yield* until(fetch(url)); + let buffer = yield* until(response.arrayBuffer()); + return new Uint8Array(buffer); +} diff --git a/www/resources/image-store.ts b/www/resources/image-store.ts new file mode 100644 index 000000000..6e067b18d --- /dev/null +++ b/www/resources/image-store.ts @@ -0,0 +1,52 @@ +import { createContext, type Operation, until } from "effection"; +import { initWasm, Resvg } from "@resvg/resvg-wasm"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { useFonts } from "./fonts.ts"; + +export interface ImageStore { + render(svg: string, width: number, key: string): Uint8Array; +} + +const ImageStoreContext = createContext("image-store"); + +export function* initImageStore(): Operation { + let existing = yield* ImageStoreContext.get(); + if (existing) { + throw new Error( + "initImageStore() called more than once — resvg wasm can only be initialized once per process", + ); + } + + let require = createRequire(import.meta.url); + let wasmPath = require.resolve("@resvg/resvg-wasm/index_bg.wasm"); + let wasm = yield* until(readFile(wasmPath)); + yield* until(initWasm(wasm)); + + let fonts = yield* useFonts(); + let cache = new Map(); + + yield* ImageStoreContext.set({ + render(svg, width, key) { + let cached = cache.get(key); + if (cached) { + return cached; + } + let resvg = new Resvg(svg, { + font: { + fontBuffers: fonts.buffers, + defaultFontFamily: fonts.defaultFamily, + monospaceFamily: fonts.monospaceFamily, + }, + fitTo: { mode: "width", value: width }, + }); + let png = resvg.render().asPng(); + cache.set(key, png); + return png; + }, + }); +} + +export function* useImageStore(): Operation { + return yield* ImageStoreContext.expect(); +} diff --git a/www/routes/app.html.tsx b/www/routes/app.html.tsx index a746ffb35..f32f5dbba 100644 --- a/www/routes/app.html.tsx +++ b/www/routes/app.html.tsx @@ -10,6 +10,7 @@ export type Options = { title: string; description: string; head?: JSXElement; + image?: string; } & HeaderProps; export interface AppHtmlProps { @@ -22,10 +23,9 @@ export function* useAppHtml({ description, hasLeftSidebar, head, + image = "/assets/images/meta-effection.png", }: Options): Operation<({ children, search }: AppHtmlProps) => JSX.Element> { - let twitterImageURL = yield* useAbsoluteUrl( - "/assets/images/meta-effection.png", - ); + let ogImageURL = yield* useAbsoluteUrl(image); let canonicalURL = yield* useCanonicalUrl({ base: "https://frontside.com/effection", @@ -38,12 +38,12 @@ export function* useAppHtml({ {title} - + - + diff --git a/www/routes/blog-image-route.ts b/www/routes/blog-image-route.ts new file mode 100644 index 000000000..f1a355e0d --- /dev/null +++ b/www/routes/blog-image-route.ts @@ -0,0 +1,80 @@ +import type { Operation } from "effection"; +import { call } from "effection"; +import { respondNotFound, useParams } from "revolution"; +import { CurrentRequest } from "../context/request.ts"; +import { useImageStore } from "../resources/image-store.ts"; + +export function blogImageRoute(): { + handler(): Operation; +} { + return { + *handler() { + let { id, name } = yield* useParams<{ id: string; name: string }>(); + let request = yield* CurrentRequest.expect(); + let url = new URL(request.url); + + let blogDir = new URL(`../blog/${id}/`, import.meta.url).pathname; + let pngPath = `${blogDir}${name}.png`; + + // if a static .png exists, serve it directly + try { + let png = yield* call(() => Deno.readFile(pngPath)); + return pngResponse(png); + } catch { + // no static png, continue + } + + let w = url.searchParams.get("w"); + let h = url.searchParams.get("h"); + + // no static png and no dimensions: 404 + if (!w || !h) { + return yield* respondNotFound(); + } + + let width = parseInt(w, 10); + let height = parseInt(h, 10); + + if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { + return yield* respondNotFound(); + } + + let svgPath = `${blogDir}${name}.svg`; + + let svg: string; + try { + svg = yield* call(() => Deno.readTextFile(svgPath)); + } catch { + return yield* respondNotFound(); + } + + let store = yield* useImageStore(); + + svg = stripAnimations(svg); + + let key = `${id}/${name}/${width}x${height}`; + let png = store.render(svg, width, key); + + return pngResponse(png); + }, + }; +} + +function pngResponse(png: Uint8Array): Response { + return new Response(png, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} + +function stripAnimations(svg: string): string { + return svg.replace( + /\.svg-anim-\w+\s*\{[^}]*opacity:\s*0[^}]*\}/g, + (match) => + match + .replace(/opacity:\s*0/, "opacity: 1") + .replace(/animation:[^;}]+;?/g, ""), + ); +} diff --git a/www/routes/blog-post-route.tsx b/www/routes/blog-post-route.tsx index 5d7aa666a..df911a168 100644 --- a/www/routes/blog-post-route.tsx +++ b/www/routes/blog-post-route.tsx @@ -44,6 +44,9 @@ export function blogPostRoute({ let AppHtml = yield* useAppHtml({ title: `${post.title} | Blog | Effection`, description: post.description, + image: `/blog/${post.id}/${ + post.image.replace(/\.svg$/, ".png") + }?w=2400&h=1260`, }); return (