Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>;
Expand Down
3 changes: 1 addition & 2 deletions lib/base-path.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
5 changes: 1 addition & 4 deletions lib/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
43 changes: 43 additions & 0 deletions lib/remark-doc-images.ts
Original file line number Diff line number Diff line change
@@ -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("/")}`;
});
};
}
42 changes: 31 additions & 11 deletions mdx-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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") ||
Expand All @@ -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);
}
}

Expand All @@ -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")) {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions scripts/.env.publish-image.example
Original file line number Diff line number Diff line change
@@ -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=
54 changes: 54 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -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 <local-file> <public-path>
```

`<public-path>` 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
```
132 changes: 132 additions & 0 deletions scripts/publish-image.mjs
Original file line number Diff line number Diff line change
@@ -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 <local-file> <public-path>
//
// <public-path> 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 <local-file> <public-path>\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}`);
4 changes: 2 additions & 2 deletions source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -43,7 +44,6 @@ export default defineConfig({
"mermaid",
],
},
remarkPlugins: [remarkAdmonition, remarkMdxMermaid],
remarkPlugins: [remarkAdmonition, remarkMdxMermaid, remarkDocImages],
},
});

Loading
Loading