diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx index 348304f..099f738 100644 --- a/app/[[...slug]]/page.tsx +++ b/app/[[...slug]]/page.tsx @@ -12,7 +12,6 @@ import { toSiteUrl } from "@/lib/base-path"; import { baseUrl } from "@/lib/metadata"; import { getPageImage, source } from "@/lib/source"; import { getMDXComponents } from "@/mdx-components"; -import { RootRedirect } from "./root-redirect"; type ParamProps = { params: Promise<{ slug?: string[] }>; diff --git a/lib/base-path.ts b/lib/base-path.ts index 3eb75b0..0b86159 100644 --- a/lib/base-path.ts +++ b/lib/base-path.ts @@ -1,5 +1,4 @@ -export const BASE_PATH = - process.env.NEXT_PUBLIC_BASE_PATH || "/docs/synth"; +export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || "/docs/synth"; export function withBasePath(path = "") { if (!path || path === "/") return BASE_PATH; diff --git a/lib/metadata.ts b/lib/metadata.ts index 3a44d78..ac27d0e 100644 --- a/lib/metadata.ts +++ b/lib/metadata.ts @@ -11,10 +11,7 @@ function resolveTitle(title: Metadata["title"]): string { return "Synth — Open-Source EDA & Hardware Synthesis Platform"; } -export function createMetadata( - override: Metadata, - ogSlug = "synth", -): Metadata { +export function createMetadata(override: Metadata, ogSlug = "synth"): Metadata { const ogUrl = `${baseUrl.toString()}/og/${ogSlug}/image.webp`; const resolvedTitle = resolveTitle(override.title); const canonicalUrl = diff --git a/lib/remark-doc-images.ts b/lib/remark-doc-images.ts new file mode 100644 index 0000000..751fac0 --- /dev/null +++ b/lib/remark-doc-images.ts @@ -0,0 +1,43 @@ +import { dirname, join, normalize, relative } from "node:path"; + +interface MdastNode { + type?: string; + url?: string; + children?: MdastNode[]; +} +interface CompileFile { + path: string; +} + +// Doc content images live in the shared R2 bucket (see workers/image-proxy.ts). +// Authors keep writing plain markdown image syntax; this rewrites image URLs at +// compile time into the canonical same-origin Worker path. +const CONTENT_ROOT = join(process.cwd(), "content/docs"); +const IMAGE_BASE_PATH = "/docs/synth"; + +function walk(node: MdastNode, visitor: (node: MdastNode) => void) { + if (node.type === "image") visitor(node); + if (Array.isArray(node.children)) { + for (const child of node.children) walk(child, visitor); + } +} + +export function remarkDocImages() { + return (tree: MdastNode, file: CompileFile) => { + walk(tree, (node) => { + if (typeof node.url !== "string" || node.url.length === 0) return; + if (/^https?:\/\//.test(node.url)) return; + if (node.url.startsWith(IMAGE_BASE_PATH)) return; + + if (node.url.startsWith("/")) { + node.url = `${IMAGE_BASE_PATH}${node.url}`; + return; + } + + const fileDir = dirname(file.path); + const absolute = normalize(join(fileDir, node.url)); + const relativeToContent = relative(CONTENT_ROOT, absolute); + node.url = `${IMAGE_BASE_PATH}/${relativeToContent.split("\\").join("/")}`; + }); + }; +} diff --git a/mdx-components.tsx b/mdx-components.tsx index e23bf7a..092a6f2 100644 --- a/mdx-components.tsx +++ b/mdx-components.tsx @@ -15,17 +15,36 @@ const DefaultPre = defaultMdxComponents.pre as const DOC_IMAGE_PATTERN = /^\/docs\/synth\/(img|diagrams|screenshots)\//; +type MdxNodeWithProps = { + props?: { + children?: ReactNode; + className?: string; + "data-language"?: string; + lang?: string; + }; +}; + +type PreProps = ComponentPropsWithoutRef<"pre"> & { + "data-language"?: string; +}; + +function getNodeProps(node: ReactNode): MdxNodeWithProps["props"] | undefined { + if (node && typeof node === "object" && "props" in node) { + return (node as MdxNodeWithProps).props; + } + return undefined; +} + function extractText(node: ReactNode): string { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(extractText).join(""); - if (node && typeof node === "object" && "props" in node) { - return extractText((node as any).props?.children); - } + const props = getNodeProps(node); + if (props) return extractText(props.children); return ""; } -function findMermaid(node: any): { isMermaid: boolean; text: string } { +function findMermaid(node: ReactNode): { isMermaid: boolean; text: string } { if (!node) return { isMermaid: false, text: "" }; if (Array.isArray(node)) { @@ -36,9 +55,10 @@ function findMermaid(node: any): { isMermaid: boolean; text: string } { return { isMermaid: false, text: "" }; } - if (typeof node === "object" && node && "props" in node) { - const className = String(node.props?.className || ""); - const lang = String(node.props?.["data-language"] || node.props?.lang || ""); + const props = getNodeProps(node); + if (props) { + const className = String(props.className || ""); + const lang = String(props["data-language"] || props.lang || ""); if ( className.includes("language-mermaid") || @@ -47,12 +67,12 @@ function findMermaid(node: any): { isMermaid: boolean; text: string } { ) { return { isMermaid: true, - text: extractText(node.props?.children || node), + text: extractText(props.children || node), }; } - if (node.props?.children) { - return findMermaid(node.props.children); + if (props.children) { + return findMermaid(props.children); } } @@ -63,7 +83,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { return { ...defaultMdxComponents, pre: (props) => { - const preLang = String((props as any)?.["data-language"] || ""); + const preLang = String((props as PreProps)?.["data-language"] || ""); const preClass = String(props?.className || ""); if (preLang === "mermaid" || preClass.includes("language-mermaid")) { diff --git a/package.json b/package.json index 1e0fcc3..4bfe053 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "clean": "rm -rf .next .source out", "lint": "biome check", "format": "biome format --write", - "lint:fix": "biome check --write" + "lint:fix": "biome check --write", + "publish-image": "node scripts/publish-image.mjs" }, "dependencies": { "@orama/orama": "^3.1.18", diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..71a3ec6 --- /dev/null +++ b/public/_headers @@ -0,0 +1,15 @@ +/docs/synth/robots.txt + Cache-Control: no-store, no-cache, must-revalidate, max-age=0 + CDN-Cache-Control: no-store + +/docs/synth/sitemap.xml + Cache-Control: no-store, no-cache, must-revalidate, max-age=0 + CDN-Cache-Control: no-store + +/docs/synth/community/robots.txt + Cache-Control: no-store, no-cache, must-revalidate, max-age=0 + CDN-Cache-Control: no-store + +/docs/synth/community/sitemap.xml + Cache-Control: no-store, no-cache, must-revalidate, max-age=0 + CDN-Cache-Control: no-store diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..fbb9ef7 --- /dev/null +++ b/public/_redirects @@ -0,0 +1,5 @@ +/ /docs/synth/user-guide/quick-start/ 301 +/docs/synth /docs/synth/user-guide/quick-start/ 301 +/docs/synth/ /docs/synth/user-guide/quick-start/ 301 +/docs/synth/community /docs/synth/community/user-guide/quick-start/ 301 +/docs/synth/community/ /docs/synth/community/user-guide/quick-start/ 301 diff --git a/scripts/.env.publish-image.example b/scripts/.env.publish-image.example new file mode 100644 index 0000000..0757a19 --- /dev/null +++ b/scripts/.env.publish-image.example @@ -0,0 +1,10 @@ +# Copy to scripts/.env.publish-image (gitignored) and fill in the token. +# Create the token under My Profile -> API Tokens -> Create Token -> Custom Token, with: +# - Workers R2 Storage: Edit +# - Zone -> Cache Purge -> Purge (scoped to the absmach.eu zone) +CLOUDFLARE_API_TOKEN= + +# Not secret - the absmach.eu zone ID (same zone the main website uses, since +# this site is served from a path under absmach.eu), visible on the domain's +# Overview page. +CLOUDFLARE_ZONE_ID= diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d8db3cc --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,54 @@ +# Publishing images (maintainers only) + +This repo can serve images from a shared Cloudflare R2 bucket (`websites-images`) +instead of committing them to git, via a small Worker that sits in front of the +static export. + +- [`workers/image-proxy.ts`](../workers/image-proxy.ts) routes + `/docs/synth/img/*`, `/docs/synth/diagrams/*`, and + `/docs/synth/screenshots/*` to R2. +- `wrangler.jsonc`'s `assets.run_worker_first` sends only those path prefixes + to the Worker before static asset matching. + +## One-time setup + +Create `scripts/.env.publish-image` from the template: + +```bash +cp scripts/.env.publish-image.example scripts/.env.publish-image +``` + +Create a Cloudflare API token with: + +- `Workers R2 Storage: Edit` +- `Zone -> Cache Purge -> Purge`, scoped to the `absmach.eu` zone + +Paste the token into `CLOUDFLARE_API_TOKEN`. The zone ID is already filled in +and is not secret. + +## Publishing an image + +```bash +pnpm run publish-image +``` + +`` is everything after the domain and must start with +`docs/synth/img/`, `docs/synth/diagrams/`, or `docs/synth/screenshots/`. + +```bash +pnpm run publish-image ./architecture.svg docs/synth/img/architecture.svg +pnpm run publish-image ./board.png docs/synth/screenshots/board.png +``` + +The script uploads to the real R2 bucket with `--remote`, then purges that exact +URL from Cloudflare's edge cache. + +## Local preview + +Plain `pnpm run dev` runs Next.js only, so the image Worker is not active. +Preview the deployed shape locally with: + +```bash +pnpm run build +npx wrangler dev --port 8789 +``` diff --git a/scripts/publish-image.mjs b/scripts/publish-image.mjs new file mode 100644 index 0000000..b5fcca4 --- /dev/null +++ b/scripts/publish-image.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// Maintainer-only. Uploads an image or diagram to the shared R2 bucket and +// purges it from Cloudflare's edge cache, so it's live right after this +// finishes. Requires CLOUDFLARE_API_TOKEN (scoped: R2 Edit on +// websites-images + Zone Cache Purge on absmach.eu) and CLOUDFLARE_ZONE_ID. +// +// Usage: +// pnpm run publish-image +// +// is everything after the domain, starting with +// "docs/synth/img/", "docs/synth/diagrams/", or +// "docs/synth/screenshots/" to match the route that will serve it back. + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { extname } from "node:path"; +import process from "node:process"; + +const BUCKET_NAME = "websites-images"; +const SITE_ORIGIN = "https://absmach.eu"; +const BASE_PATH = "docs/synth"; + +const ROUTES = { + img: { keyPrefix: "synth-docs/img" }, + diagrams: { keyPrefix: "synth-docs/diagrams" }, + screenshots: { keyPrefix: "synth-docs/screenshots" }, +}; + +const MIME_TYPES = { + ".webp": "image/webp", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".gif": "image/gif", + ".avif": "image/avif", +}; + +try { + process.loadEnvFile(new URL("./.env.publish-image", import.meta.url)); +} catch { + // No local env file -- assume CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID + // are already exported. +} + +const cliArgs = process.argv.slice(2).filter((arg) => arg !== "--"); +const [localFile, publicPath] = cliArgs; + +if (!localFile || !publicPath) { + console.error( + "Usage: pnpm run publish-image \n" + + "Example: pnpm run publish-image ./architecture.svg docs/synth/img/architecture.svg", + ); + process.exit(1); +} + +if (!existsSync(localFile)) { + console.error(`Local file not found: ${localFile}`); + process.exit(1); +} + +const destKey = publicPath.replace(/^\/+/, ""); +if (!destKey.startsWith(`${BASE_PATH}/`)) { + console.error(`Destination must start with "${BASE_PATH}/", got: ${destKey}`); + process.exit(1); +} +const afterBase = destKey.slice(BASE_PATH.length + 1); +const [routeName, ...restParts] = afterBase.split("/"); +const route = ROUTES[routeName]; +if (!route || restParts.length === 0) { + console.error( + `Destination must start with "${BASE_PATH}/img/", "${BASE_PATH}/diagrams/", ` + + `or "${BASE_PATH}/screenshots/" and include a path, got: ${destKey}`, + ); + process.exit(1); +} +const restPath = restParts.join("/"); + +const contentType = MIME_TYPES[extname(restPath).toLowerCase()]; +if (!contentType) { + console.error(`Unrecognized file extension for: ${destKey}`); + process.exit(1); +} + +const { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID } = process.env; +if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ZONE_ID) { + console.error( + "Missing CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_ZONE_ID.\n" + + "Copy scripts/.env.publish-image.example to scripts/.env.publish-image and fill in the token.", + ); + process.exit(1); +} + +const objectPath = `${BUCKET_NAME}/${route.keyPrefix}/${restPath}`; + +console.log(`Uploading ${localFile} -> r2://${objectPath}`); +execFileSync( + "wrangler", + [ + "r2", + "object", + "put", + objectPath, + `--file=${localFile}`, + `--content-type=${contentType}`, + "--remote", + ], + { stdio: "inherit", env: process.env }, +); + +const publicUrl = `${SITE_ORIGIN}/${destKey}`; + +console.log(`Purging edge cache for ${publicUrl}`); +const purgeResponse = await fetch( + `https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache`, + { + method: "POST", + headers: { + Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ files: [publicUrl] }), + }, +); + +const purgeResult = await purgeResponse.json(); +if (!purgeResponse.ok || !purgeResult.success) { + console.error("Cache purge failed:", JSON.stringify(purgeResult, null, 2)); + process.exit(1); +} + +console.log(`Done. Live at ${publicUrl}`); diff --git a/source.config.ts b/source.config.ts index 13d1db5..a5215cc 100644 --- a/source.config.ts +++ b/source.config.ts @@ -2,6 +2,7 @@ import { remarkAdmonition, remarkMdxMermaid } from "fumadocs-core/mdx-plugins"; import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; import { defineConfig, defineDocs } from "fumadocs-mdx/config"; import { z } from "zod"; +import { remarkDocImages } from "./lib/remark-doc-images"; export const docs = defineDocs({ dir: "content/docs", @@ -43,7 +44,6 @@ export default defineConfig({ "mermaid", ], }, - remarkPlugins: [remarkAdmonition, remarkMdxMermaid], + remarkPlugins: [remarkAdmonition, remarkMdxMermaid, remarkDocImages], }, }); - diff --git a/workers/image-proxy.ts b/workers/image-proxy.ts new file mode 100644 index 0000000..cee446b --- /dev/null +++ b/workers/image-proxy.ts @@ -0,0 +1,56 @@ +// Worker entry point for serving this site's images/diagrams/screenshots +// out of the shared R2 bucket ("websites-images") instead of committing +// them to git. +// +// This repo deploys as a Next.js static export (`output: "export"`) served +// by Cloudflare Workers Static Assets. This proxy is routed by +// `wrangler.jsonc`'s `assets.run_worker_first` for the path prefixes below; +// every other request goes straight to static asset serving. +// +// Keep this in sync with scripts/publish-image.mjs's ROUTES map. +import { + createR2ProxyHandler, + type Env, + type ExecutionContext, +} from "./r2-proxy"; + +const BASE_PATH = "/docs/synth"; + +const ROUTES = [ + { segment: "img", handler: createR2ProxyHandler("synth-docs/img") }, + { + segment: "diagrams", + handler: createR2ProxyHandler("synth-docs/diagrams"), + }, + { + segment: "screenshots", + handler: createR2ProxyHandler("synth-docs/screenshots"), + }, +] as const; + +const notFound = () => + new Response("Not found", { + status: 404, + headers: { "cache-control": "no-store" }, + }); + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { + const { pathname } = new URL(request.url); + + const withoutBase = pathname.startsWith(BASE_PATH) + ? pathname.slice(BASE_PATH.length) + : pathname; + const [, segment, ...rest] = withoutBase.split("/"); + + const route = ROUTES.find((r) => r.segment === segment); + if (!route || rest.length === 0) return notFound(); + + const path = decodeURIComponent(rest.join("/")); + return route.handler(path, env, request, ctx); + }, +}; diff --git a/workers/r2-proxy.ts b/workers/r2-proxy.ts new file mode 100644 index 0000000..f8c5fa6 --- /dev/null +++ b/workers/r2-proxy.ts @@ -0,0 +1,73 @@ +// Shared handler factory for serving files out of the R2 bucket that backs +// this site's images/diagrams/screenshots. +// +// Minimal local R2 typings on purpose (no `@cloudflare/workers-types` +// dependency) -- this file is type-checked by the site's own tsconfig (DOM +// lib), and pulling in the Workers global types there would conflict with +// DOM's Request/Response/Headers types across the rest of the app. + +export interface R2ObjectBody { + body: ReadableStream; + size: number; + httpEtag: string; + writeHttpMetadata(headers: Headers): void; +} + +export interface R2Bucket { + get(key: string): Promise; +} + +export interface Env { + IMAGES_BUCKET: R2Bucket; +} + +export interface ExecutionContext { + waitUntil(promise: Promise): void; +} + +interface CFCache { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; +} +interface CFCacheStorage { + readonly default: CFCache; +} + +const notFound = () => + new Response("Not found", { + status: 404, + headers: { "cache-control": "no-store" }, + }); + +export function createR2ProxyHandler(keyPrefix: string) { + return async ( + path: string, + env: Env, + request: Request, + ctx: ExecutionContext, + ): Promise => { + if (!path) return notFound(); + + const bucket = env.IMAGES_BUCKET; + if (!bucket) return notFound(); + + const cache = (caches as unknown as CFCacheStorage).default; + const cacheKey = new Request(request.url, request); + + const cached = await cache.match(cacheKey); + if (cached) return cached; + + const object = await bucket.get(`${keyPrefix}/${path}`); + if (!object) return notFound(); + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set("etag", object.httpEtag); + headers.set("content-length", String(object.size)); + headers.set("cache-control", "public, max-age=3600, s-maxage=31536000"); + + const response = new Response(object.body, { headers }); + ctx.waitUntil(cache.put(cacheKey, response.clone())); + return response; + }; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index d48e519..8a406de 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,12 +1,30 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "synth-docs", + "main": "workers/image-proxy.ts", "compatibility_date": "2026-05-26", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "observability": { "enabled": true }, "assets": { - "directory": "./out" - } + "directory": "./out", + // Only these paths are routed to the Worker (workers/image-proxy.ts); + // everything else (docs pages, JS/CSS, existing public/ files) is + // matched against the static export in ./out first, unchanged. + "run_worker_first": [ + "/docs/synth/img/*", + "/docs/synth/diagrams/*", + "/docs/synth/screenshots/*" + ] + }, + "r2_buckets": [ + { + "binding": "IMAGES_BUCKET", + "bucket_name": "websites-images", + // Local dev hits the real bucket instead of an empty simulator, so + // images published via publish-image.mjs are visible immediately. + "remote": true + } + ] }