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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ apps/web/src/data/photos-manifest.json
apps/web/public/thumbnails
apps/web/public/photos
apps/web/public/originals

# og-image-storage plugin generated files
functions/
1 change: 1 addition & 0 deletions packages/builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"fast-xml-parser": "5.3.5",
"heic-convert": "2.1.0",
"heic-to": "1.4.2",
"nunjucks": "3.2.4",
"satori": "catalog:",
"sharp": "0.34.5",
"thumbhash": "0.1.1"
Expand Down
103 changes: 45 additions & 58 deletions packages/builder/src/plugins/og-image-storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,83 +5,68 @@ This plugin renders Open Graph (OG) images for each processed photo and uploads
## Examples

### Plugin usage

import `ogImagePlugin` and add it to your builder config's plugins array.
```ts
import { defineBuilderConfig, thumbnailStoragePlugin, ogImagePlugin } from '@afilmory/builder'

export default defineBuilderConfig(() => ({
storage: {
provider: 's3',
bucket: process.env.S3_BUCKET_NAME!,
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT, // Optional, defaults to AWS S3
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
prefix: process.env.S3_PREFIX || 'photos/',
customDomain: process.env.S3_CUSTOM_DOMAIN, // Optional CDN domain
excludeRegex: process.env.S3_EXCLUDE_REGEX,
downloadConcurrency: 16, // Adjust based on network
...
},
plugins: [
thumbnailStoragePlugin(),
ogImagePlugin({
siteName: "YOUR_SITE_NAME",
accentColor: '#fb7185',
}),
ogImagePlugin(
{
vendor: {
type: 'cloudflare-middleware', // OG image vendor type
storageURL: 'https://your-og-storage.example.com',
siteConfigPath: './config.json', // optional: defaults to repo root config.json
},
}
)
],
}))
```

### Example Cloudflare Pages Middleware
The first time you use the plugin, you need to force re-build your manifest to generate and upload OG images for all photos:
```bash
npm run build:manifest -- --force-manifest
```

If you are using Cloudflare Pages to host your Afilmory, you can use the following minimal middleware to rewrite the OG image meta tags on photo pages to point to the generated OG images stored in your storage.
You only need to change the `SITE_ORIGIN` and `OG_PATH` constants to match your site, and put the file in your `functions/_middleware.js` path.

```js
// Minimal middleware to rewrite OG image meta tags for photo pages on Cloudflare Pages.
// Assumes OG images are stored at https://your.afilmory.site/.afilmory/og-images/{slug}.png
### Use generated Cloudflare Pages Middleware

const SITE_ORIGIN = 'https://your.afilmory.site'
const OG_PATH = '/.afilmory/og-images'
const OG_PATTERN = /^https?:\/\/your\.afilmory\.site\/+og-image.*\.png$/i
If you are using Cloudflare Pages to host your Afilmory, you can use the generated middleware to rewrite the OG image meta tags on photo pages to point to the generated OG images stored in your storage.

const normalizeSlug = (slug) => {
try {
return encodeURIComponent(decodeURIComponent(slug))
} catch {
return encodeURIComponent(slug)
}
}
You only need to adjust the `storageURL` to your own OG image URL prefix and run `pnpm build`. This will generate the middleware code at `functions/_middleware.ts`. Then you can run `wrangler` to deploy your Cloudflare Pages site with the new middleware, it will automatically detect the middleware file:
```bash
npx wrangler pages deploy apps/web/dist/ --project-name=YOUR_PROJECT_NAME

# You should see outputs like:
# ...
# ✨ Uploading _routes.json
# ...
```

const buildOgUrl = (slug) => `${SITE_ORIGIN}${OG_PATH}/${normalizeSlug(slug)}.png`
const stripPhotosPrefix = (pathname) => pathname.replace(/^\/?photos\//, '').replace(/\/$/, '')
const isHtml = (response) => (response.headers.get('content-type') || '').includes('text/html')
const shouldRewrite = (content) => Boolean(content?.trim() && OG_PATTERN.test(content.trim()))

export const onRequest = async ({ request, next }) => {
const url = new URL(request.url)
if (!url.pathname.startsWith('/photos/')) return next()

const slug = stripPhotosPrefix(url.pathname)
if (!slug) return next()

const response = await next()
if (!isHtml(response)) return response

const ogUrl = buildOgUrl(slug)
const handler = {
element(element) {
const content = element.getAttribute('content')
if (shouldRewrite(content)) element.setAttribute('content', ogUrl)
},
}

return new HTMLRewriter()
.on('meta[property="og:image"]', handler)
.on('meta[property="twitter:image"]', handler)
.transform(response)
`storageURL` should be the origin/host where OG images are served (for example, a CDN or object storage domain without a path).

#### Cloudflare middleware vendor configuration

```ts
vendor: {
type: 'cloudflare-middleware',
storageURL: 'https://cdn.example.com', // OG images bucket/CDN origin
siteConfigPath: './config.json', // optional, defaults to repo root config.json
}
```

- The middleware is written to `functions/_middleware.ts` at repo root.
- `storageURL` points to the base URL that serves `.afilmory/og-images/*`.
- `siteConfigPath` (optional) is resolved from the repo root when relative; defaults to `config.json` at the repo root.
- To verify: run `pnpm build`, check that `functions/_middleware.ts` is generated, and ensure the OG base inside matches your `storageURL`.



## How it works
- Hooks into the builder after each photo is processed.
Expand All @@ -97,7 +82,9 @@ export const onRequest = async ({ request, next }) => {
- `storageConfig` (storage config): optional override; otherwise uses the builder's current storage.
- `contentType` (string): MIME type for uploads. Defaults to `image/png`.
- `siteName` / `accentColor` (strings): optional overrides for branding.
- `siteConfigPath` (string): path to a site config JSON; defaults to `config.json` in `process.cwd()`.
- `siteConfigPath` (string): path to a site config JSON; defaults to `config.json` at the repo root (relative paths are resolved from the repo root).
- `vendor` (object): optional vendor automation. Current vendor types:
- `cloudflare-middleware`: requires `storageURL`; optional `siteConfigPath` to override the repo-root `config.json` location. After the build finishes, it writes a Cloudflare Pages middleware to `functions/_middleware.ts` using `url` from `config.json` and `storageURL` for the OG host.

## Dependencies
- Uses fonts from `be/apps/core/src/modules/content/og/assets` (falls back to other repo paths). If fonts are missing, the plugin skips rendering for that run.
Expand Down
50 changes: 46 additions & 4 deletions packages/builder/src/plugins/og-image-storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import type { S3CompatibleConfig, StorageConfig } from '../../storage/interfaces
import type { ThumbnailPluginData } from '../thumbnail-storage/shared.js'
import { THUMBNAIL_PLUGIN_DATA_KEY } from '../thumbnail-storage/shared.js'
import type { BuilderPlugin } from '../types.js'
import type { CloudflareMiddlewareVendorConfig } from './vendors/cloudflare-moddleware.js'
import { CloudflareMiddlewareVendor } from './vendors/cloudflare-moddleware.js'
import type { OgVendor } from './vendors/types'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '../../../../..')
Expand All @@ -36,8 +39,11 @@ interface OgImagePluginOptions {
siteName?: string
accentColor?: string
siteConfigPath?: string
vendor?: OgVendorConfig
}

type OgVendorConfig = CloudflareMiddlewareVendorConfig

interface ResolvedPluginConfig {
directory: string
remotePrefix: string
Expand Down Expand Up @@ -78,6 +84,11 @@ function joinSegments(...segments: Array<string | null | undefined>): string {
return filtered.join('/')
}

function resolveSiteConfigPath(siteConfigPath: string | undefined): string {
if (!siteConfigPath) return path.resolve(repoRoot, 'config.json')
return path.isAbsolute(siteConfigPath) ? siteConfigPath : path.resolve(repoRoot, siteConfigPath)
}

function resolveRemotePrefix(config: UploadableStorageConfig, directory: string): string {
switch (config.provider) {
case 's3':
Expand Down Expand Up @@ -162,9 +173,7 @@ async function loadSiteMeta(options: OgImagePluginOptions, logger: Logger): Prom
accentColor: options.accentColor?.trim() || DEFAULT_ACCENT_COLOR,
}

const siteConfigPath = options.siteConfigPath
? path.resolve(process.cwd(), options.siteConfigPath)
: path.resolve(process.cwd(), 'config.json')
const siteConfigPath = resolveSiteConfigPath(options.siteConfigPath)

try {
const raw = await readFile(siteConfigPath, 'utf8')
Expand Down Expand Up @@ -285,6 +294,17 @@ function getPhotoDimensions(photo: PhotoManifestItem) {
}
}

function createVendor(config: OgVendorConfig): OgVendor {
switch (config.type) {
case 'cloudflare-middleware': {
return new CloudflareMiddlewareVendor(config)
}
default: {
throw new Error(`Unknown OG vendor type: ${String((config as { type?: string }).type)}`)
}
}
}

/**
* Render Open Graph images for processed photos and upload them to remote storage.
*
Expand All @@ -295,6 +315,7 @@ function getPhotoDimensions(photo: PhotoManifestItem) {
export default function ogImagePlugin(options: OgImagePluginOptions = {}): BuilderPlugin {
let resolved: ResolvedPluginConfig | null = null
let externalStorageManager: StorageManager | null = null
let vendor: OgVendor | null = null

return {
name: PLUGIN_NAME,
Expand All @@ -304,6 +325,15 @@ export default function ogImagePlugin(options: OgImagePluginOptions = {}): Build
const directory = normalizeDirectory(options.directory)
const contentType = options.contentType ?? DEFAULT_CONTENT_TYPE

if (options.vendor && !vendor) {
try {
vendor = createVendor(options.vendor)
} catch (error) {
logger.main.error('OG image plugin: failed to initialize vendor config.', error)
throw error
}
}

if (!enable) {
resolved = {
directory,
Expand Down Expand Up @@ -449,8 +479,20 @@ export default function ogImagePlugin(options: OgImagePluginOptions = {}): Build
logger.main.error(`OG image plugin: failed to render OG image for ${item.id}`, error)
}
},
afterBuild: async ({ logger }) => {
if (!vendor) return

try {
await vendor.build({ repoRoot, logger })
} catch (error) {
logger.main.error('OG image plugin: vendor build step failed.', error)
}
},
},
}
}

export type { OgImagePluginOptions }
export type { OgImagePluginOptions, OgVendorConfig }

export { type CloudflareMiddlewareVendorConfig } from './vendors/cloudflare-moddleware.js'
export { type OgVendorKind } from './vendors/types.js'
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

import nunjucks from 'nunjucks'

import type { Logger } from '../../../logger/index.js'
import type { OgVendorBuildContext } from './types.js'
import { OgVendor } from './types.js'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const templatesDir = path.join(__dirname, 'templates')
const nunjucksEnv = nunjucks.configure(templatesDir, { autoescape: false })

const DEFAULT_SITE_ORIGIN = 'https://example.com'

export interface CloudflareMiddlewareVendorConfig {
type: 'cloudflare-middleware'
storageURL: string
siteConfigPath?: string
}

function escapeRegexLiteral(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function normalizeUrlToOrigin(value: string | undefined | null): string | null {
if (!value) return null

const trimmed = value.trim()
if (!trimmed) return null

const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`

try {
const parsed = new URL(withProtocol)
return parsed.origin
} catch {
return null
}
}

function resolveSiteConfigPath(siteConfigPath: string | undefined, repoRoot: string): string {
if (!siteConfigPath) return path.resolve(repoRoot, 'config.json')
return path.isAbsolute(siteConfigPath) ? siteConfigPath : path.resolve(repoRoot, siteConfigPath)
}

async function loadSiteUrl(
siteConfigPath: string | undefined,
repoRoot: string,
logger: Logger,
): Promise<string | null> {
const target = resolveSiteConfigPath(siteConfigPath, repoRoot)

try {
const raw = await readFile(target, 'utf8')
const parsed = JSON.parse(raw) as Partial<{ url?: string }>
const normalized = normalizeUrlToOrigin(parsed.url)

if (!normalized) {
logger.main.info(`OG image plugin: missing or invalid site.url in ${target}, using fallback origin.`)
return null
}

return normalized
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.main.info(`OG image plugin: using fallback origin (cannot read ${target}: ${message}).`)
return null
}
}

function renderCloudflareMiddlewareTemplate(patternHost: string, ogBase: string): string {
return nunjucksEnv.render('cloudflare-middleware.njk', {
patternHost,
ogBase,
})
}

export class CloudflareMiddlewareVendor extends OgVendor {
readonly type = 'cloudflare-middleware' as const

constructor(private readonly options: CloudflareMiddlewareVendorConfig) {
super()
}

private normalizeStorageOrigin(): string {
const normalized = normalizeUrlToOrigin(this.options.storageURL)
if (!normalized) {
throw new Error('CloudflareMiddleware vendor requires a valid storageURL (e.g., https://cdn.example.com)')
}
return normalized
}

private async resolveSiteOrigin(logger: Logger, repoRoot: string): Promise<string> {
const loaded = await loadSiteUrl(this.options.siteConfigPath, repoRoot, logger)
return loaded ?? DEFAULT_SITE_ORIGIN
}

private renderTemplate(siteOrigin: string, storageOrigin: string): string {
const siteHost = normalizeUrlToOrigin(siteOrigin) ?? DEFAULT_SITE_ORIGIN

const patternHost = (() => {
try {
return escapeRegexLiteral(new URL(siteHost).host)
} catch {
return escapeRegexLiteral(new URL(DEFAULT_SITE_ORIGIN).host)
}
})()

const ogBase = `${storageOrigin}/.afilmory/og-images`

return renderCloudflareMiddlewareTemplate(patternHost, ogBase)
}

async build(context: OgVendorBuildContext): Promise<void> {
const siteOrigin = await this.resolveSiteOrigin(context.logger, context.repoRoot)
const storageOrigin = this.normalizeStorageOrigin()

const content = this.renderTemplate(siteOrigin, storageOrigin)

const functionsDir = path.join(context.repoRoot, 'functions')
const target = path.join(functionsDir, '_middleware.ts')

await mkdir(functionsDir, { recursive: true })
await writeFile(target, content, 'utf8')

context.logger.main.info(`OG image vendor (CloudflareMiddleware): wrote ${target}`)
}
}
Loading
Loading