diff --git a/apps/ssr/next-env.d.ts b/apps/ssr/next-env.d.ts index a3e4680c..c4e7c0eb 100644 --- a/apps/ssr/next-env.d.ts +++ b/apps/ssr/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/dev/types/routes.d.ts' +import './.next/types/routes.d.ts' // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/ssr/package.json b/apps/ssr/package.json index 4aed38da..4052b974 100644 --- a/apps/ssr/package.json +++ b/apps/ssr/package.json @@ -29,6 +29,7 @@ "linkedom": "0.18.12", "pg": "8.16.3", "postgres": "3.4.7", + "prop-types": "15.8.1", "react": "19.2.3", "react-dom": "19.2.3", "react-responsive-masonry": "2.7.1", diff --git a/be/apps/core/package.json b/be/apps/core/package.json index 5d27f75f..05d2321b 100644 --- a/be/apps/core/package.json +++ b/be/apps/core/package.json @@ -21,6 +21,7 @@ "@afilmory/db": "workspace:*", "@afilmory/env": "workspace:*", "@afilmory/framework": "workspace:*", + "@afilmory/og-renderer": "workspace:*", "@afilmory/redis": "workspace:*", "@afilmory/sdk": "workspace:*", "@afilmory/task-queue": "workspace:*", @@ -45,7 +46,7 @@ "picocolors": "1.1.1", "reflect-metadata": "0.2.2", "resend": "6.6.0", - "satori": "0.18.3", + "satori": "catalog:", "tsyringe": "4.10.0", "zod": "^4.2.1" }, diff --git a/be/apps/core/src/modules/content/og/og.service.ts b/be/apps/core/src/modules/content/og/og.service.ts index a8942de1..80d6d4da 100644 --- a/be/apps/core/src/modules/content/og/og.service.ts +++ b/be/apps/core/src/modules/content/og/og.service.ts @@ -3,6 +3,8 @@ import { resolve } from 'node:path' import type { PhotoManifestItem } from '@afilmory/builder' import type { OnModuleDestroy } from '@afilmory/framework' +import type { ExifInfo, HomepageOgTemplateProps, PhotoDimensions } from '@afilmory/og-renderer' +import { renderHomepageOgImage, renderOgImage } from '@afilmory/og-renderer' import { BizException, ErrorCode } from 'core/errors' import { SiteSettingService } from 'core/modules/configuration/site-setting/site-setting.service' import type { Context } from 'hono' @@ -12,8 +14,6 @@ import { injectable } from 'tsyringe' import { ManifestService } from '../manifest/manifest.service' import geistFontUrl from './assets/Geist-Medium.ttf?url' import harmonySansScMediumFontUrl from './assets/HarmonyOS_Sans_SC_Medium.ttf?url' -import { renderHomepageOgImage, renderOgImage } from './og.renderer' -import type { ExifInfo, HomepageOgTemplateProps, PhotoDimensions } from './og.template' const CACHE_CONTROL = 'public, max-age=31536000, stale-while-revalidate=31536000' diff --git a/packages/builder/README.md b/packages/builder/README.md index 16b09579..34b84b36 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -20,7 +20,11 @@ src/core/ │ └── exif.ts # EXIF 数据提取 ├── photo/ # 照片处理 │ ├── info-extractor.ts # 照片信息提取 -│ ├── processor.ts # 照片处理主逻辑 +│ └── processor.ts # 照片处理主逻辑 +├── plugins/ # 插件系统 +│ ├── og-image-storage/ # OG 图片存储插件 +│ │ └── index.ts # 生成并上传 OG 图片 +│ │ └── README.md # 使用说明与集成示例 │ └── geocoding.ts # 反向地理编码 ├── manifest/ # Manifest 管理 │ └── manager.ts # Manifest 读写和管理 diff --git a/packages/builder/package.json b/packages/builder/package.json index 1a94e390..cf747dbf 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -13,6 +13,8 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@afilmory/og-renderer": "workspace:*", + "@resvg/resvg-js": "2.6.2", "@vingle/bmp-js": "^0.2.5", "blurhash": "2.0.5", "c12": "^3.3.3", @@ -23,6 +25,7 @@ "fast-xml-parser": "5.3.3", "heic-convert": "2.1.0", "heic-to": "1.3.0", + "satori": "catalog:", "sharp": "0.34.5", "thumbhash": "0.1.1" }, diff --git a/packages/builder/src/index.ts b/packages/builder/src/index.ts index 7266fed2..d94d50b6 100644 --- a/packages/builder/src/index.ts +++ b/packages/builder/src/index.ts @@ -17,6 +17,8 @@ export type { GeocodingPluginOptions } from './plugins/geocoding.js' export { default as geocodingPlugin } from './plugins/geocoding.js' export type { GitHubRepoSyncPluginOptions } from './plugins/github-repo-sync.js' export { createGitHubRepoSyncPlugin, default as githubRepoSyncPlugin } from './plugins/github-repo-sync.js' +export type { OgImagePluginOptions } from './plugins/og-image-storage/index.js' +export { default as ogImagePlugin } from './plugins/og-image-storage/index.js' export type { B2StoragePluginOptions } from './plugins/storage/b2.js' export { default as b2StoragePlugin } from './plugins/storage/b2.js' export type { EagleStoragePluginOptions } from './plugins/storage/eagle.js' diff --git a/packages/builder/src/plugins/og-image-storage/README.md b/packages/builder/src/plugins/og-image-storage/README.md new file mode 100644 index 00000000..7d3f1295 --- /dev/null +++ b/packages/builder/src/plugins/og-image-storage/README.md @@ -0,0 +1,109 @@ +# OG Image Storage Plugin + +This plugin renders Open Graph (OG) images for each processed photo and uploads them to remote storage. + +## Examples + +### Plugin usage +```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', + }), + ], +})) +``` + +### Example Cloudflare Pages Middleware + +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 + +const SITE_ORIGIN = 'https://your.afilmory.site' +const OG_PATH = '/.afilmory/og-images' +const OG_PATTERN = /^https?:\/\/your\.afilmory\.site\/+og-image.*\.png$/i + +const normalizeSlug = (slug) => { + try { + return encodeURIComponent(decodeURIComponent(slug)) + } catch { + return encodeURIComponent(slug) + } +} + +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) +} +``` + + +## How it works +- Hooks into the builder after each photo is processed. +- Loads site branding from `config.json` (or provided path) and falls back to simple defaults. +- Reuses the generated thumbnail (buffer or URL) as the image source, avoiding extra reads of the original file. +- Injects light EXIF/context (title, date, focal length, aperture, ISO, shutter speed, camera) into the card. +- Renders the card with `@afilmory/og-renderer` (Satori + resvg) using bundled fonts, then uploads the PNG to storage. +- Caches uploads and public URLs within a single run so repeated work is skipped. + +## Configuration +- `enable` (boolean): turn the plugin on/off. Defaults to `true`. +- `directory` (string): remote path prefix. Defaults to `.afilmory/og-images`. +- `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()`. + +## 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. +- Relies on the thumbnail storage plugin to provide in-memory thumbnail data when available; otherwise it will read a local/public or remote thumbnail URL. + +## Notes +- Unsupported storage provider: `eagle` (the plugin disables itself in this case). +- Remote keys are cached; forcing (`isForceMode` or `isForceManifest`) re-renders and re-uploads. +- OG URLs are attached to `item.ogImageUrl` on the manifest entries. diff --git a/packages/builder/src/plugins/og-image-storage/index.ts b/packages/builder/src/plugins/og-image-storage/index.ts new file mode 100644 index 00000000..f9c26455 --- /dev/null +++ b/packages/builder/src/plugins/og-image-storage/index.ts @@ -0,0 +1,456 @@ +import { readFile, stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { ExifInfo } from '@afilmory/og-renderer' +import { renderOgImage } from '@afilmory/og-renderer' +import type { SatoriOptions } from 'satori' + +import type { Logger } from '../../logger/index.js' +import { workdir } from '../../path.js' +import { StorageManager } from '../../storage/index.js' +import type { S3CompatibleConfig, StorageConfig } from '../../storage/interfaces.js' +import type { PhotoManifestItem } from '../../types/photo.js' +import type { ThumbnailPluginData } from '../thumbnail-storage/shared.js' +import { THUMBNAIL_PLUGIN_DATA_KEY } from '../thumbnail-storage/shared.js' +import type { BuilderPlugin } from '../types.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '../../../../..') +const ogAssetsDir = path.join(repoRoot, 'be/apps/core/src/modules/content/og/assets') + +const PLUGIN_NAME = 'afilmory:og-image' +const RUN_STATE_KEY = 'state' +const DEFAULT_DIRECTORY = '.afilmory/og-images' +const DEFAULT_CONTENT_TYPE = 'image/png' +const DEFAULT_SITE_NAME = 'Photo Gallery' +const DEFAULT_ACCENT_COLOR = '#007bff' + +type UploadableStorageConfig = Exclude + +interface OgImagePluginOptions { + enable?: boolean + directory?: string + storageConfig?: UploadableStorageConfig + contentType?: string + siteName?: string + accentColor?: string + siteConfigPath?: string +} + +interface ResolvedPluginConfig { + directory: string + remotePrefix: string + contentType: string + useDefaultStorage: boolean + storageConfig: UploadableStorageConfig | null + enabled: boolean +} + +interface SiteMeta { + siteName: string + accentColor?: string +} + +interface PluginRunState { + uploaded: Set + urlCache: Map + fonts?: SatoriOptions['fonts'] | null + siteMeta?: SiteMeta +} + +function normalizeDirectory(directory: string | undefined): string { + const value = directory?.trim() || DEFAULT_DIRECTORY + const normalized = value.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + return normalized || DEFAULT_DIRECTORY +} + +function trimSlashes(value: string | undefined | null): string | null { + if (!value) return null + const normalized = value.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '') + return normalized.length > 0 ? normalized : null +} + +function joinSegments(...segments: Array): string { + const filtered = segments + .map((segment) => (segment ?? '').replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')) + .filter((segment) => segment.length > 0) + return filtered.join('/') +} + +function resolveRemotePrefix(config: UploadableStorageConfig, directory: string): string { + switch (config.provider) { + case 's3': + case 'oss': + case 'cos': { + const base = trimSlashes((config as S3CompatibleConfig).prefix) + return joinSegments(base, directory) + } + default: { + return joinSegments(directory) + } + } +} + +/** + * Get or initialize per-run caches to dedupe uploads and URL lookups. + */ +function getOrCreateRunState(container: Map): PluginRunState { + let state = container.get(RUN_STATE_KEY) as PluginRunState | undefined + if (!state) { + state = { + uploaded: new Set(), + urlCache: new Map(), + fonts: null, + } + container.set(RUN_STATE_KEY, state) + } + return state +} + +async function loadFontFile(fileName: string): Promise { + const candidates = [ + path.join(ogAssetsDir, fileName), + path.join(repoRoot, 'apps/core/src/modules/content/og/assets', fileName), + path.join(repoRoot, 'core/src/modules/content/og/assets', fileName), + ] + + for (const candidate of candidates) { + const stats = await stat(candidate).catch(() => null) + if (stats?.isFile()) { + return await readFile(candidate) + } + } + + return null +} + +/** + * Load required fonts for Satori/resvg. Missing fonts cause the plugin to skip rendering. + */ +async function loadFonts(logger: Logger): Promise { + const geist = await loadFontFile('Geist-Medium.ttf') + const harmony = await loadFontFile('HarmonyOS_Sans_SC_Medium.ttf') + + if (!geist || !harmony) { + logger.main.warn('OG image plugin: fonts not found, skip rendering for this run.') + return null + } + + return [ + { + name: 'Geist', + data: geist, + style: 'normal', + weight: 400, + }, + { + name: 'HarmonyOS Sans SC', + data: harmony, + style: 'normal', + weight: 400, + }, + ] +} + +/** + * Resolve site branding from a JSON config file, with sane fallbacks when the file is absent. + */ +async function loadSiteMeta(options: OgImagePluginOptions, logger: Logger): Promise { + const fallback: SiteMeta = { + siteName: options.siteName?.trim() || DEFAULT_SITE_NAME, + accentColor: options.accentColor?.trim() || DEFAULT_ACCENT_COLOR, + } + + const siteConfigPath = options.siteConfigPath + ? path.resolve(process.cwd(), options.siteConfigPath) + : path.resolve(process.cwd(), 'config.json') + + try { + const raw = await readFile(siteConfigPath, 'utf8') + const parsed = JSON.parse(raw) as Partial<{ name: string; title: string; accentColor: string }> + + return { + siteName: parsed.name?.trim() || parsed.title?.trim() || fallback.siteName, + accentColor: parsed.accentColor?.trim() || fallback.accentColor, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.main.info(`OG image plugin: using fallback site meta (${siteConfigPath} not readable: ${message}).`) + return fallback + } +} + +function bufferToDataUrl(buffer: Buffer, contentType: string): string { + const base64 = buffer.toString('base64') + return `data:${contentType};base64,${base64}` +} + +function guessContentType(thumbnailUrl: string): string { + const lowered = thumbnailUrl.toLowerCase() + if (lowered.endsWith('.png')) return 'image/png' + if (lowered.endsWith('.webp')) return 'image/webp' + return 'image/jpeg' +} + +async function resolveThumbnailDataUrl( + item: PhotoManifestItem, + pluginData: ThumbnailPluginData | undefined, + logger: Logger, +): Promise { + // Prefer the in-memory thumbnail to avoid extra reads; fall back to URLs when needed. + if (pluginData?.buffer) { + return bufferToDataUrl(pluginData.buffer, 'image/jpeg') + } + + const thumbnailUrl = pluginData?.localUrl || item.thumbnailUrl + if (!thumbnailUrl) return null + + const contentType = guessContentType(thumbnailUrl) + + if (/^https?:\/\//i.test(thumbnailUrl)) { + try { + const response = await fetch(thumbnailUrl) + if (response.ok) { + const arrayBuffer = await response.arrayBuffer() + return bufferToDataUrl(Buffer.from(arrayBuffer), response.headers.get('content-type') ?? contentType) + } + } catch (error) { + logger.thumbnail?.warn?.(`OG image plugin: failed to fetch remote thumbnail ${thumbnailUrl}`, error) + } + } + + const normalized = thumbnailUrl.replace(/^\/+/, '') + const localPath = path.join(workdir, 'public', normalized) + + try { + const localBuffer = await readFile(localPath) + return bufferToDataUrl(localBuffer, contentType) + } catch (error) { + logger.thumbnail?.debug?.(`OG image plugin: could not read local thumbnail ${localPath}`, error) + return null + } +} + +function formatDate(input?: string | null): string | undefined { + if (!input) { + return undefined + } + + const timestamp = Date.parse(input) + if (Number.isNaN(timestamp)) { + return undefined + } + + return new Date(timestamp).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +/** + * Build a lightweight EXIF summary for display; returns null when nothing meaningful is present. + */ +function buildExifInfo(photo: PhotoManifestItem): ExifInfo | null { + const { exif } = photo + if (!exif) { + return null + } + + const focalLength = exif.FocalLengthIn35mmFormat || exif.FocalLength + const aperture = exif.FNumber ? `f/${exif.FNumber}` : null + const iso = exif.ISO ?? null + const shutterSpeed = exif.ExposureTime ? `${exif.ExposureTime}s` : null + const camera = + exif.Make && exif.Model ? `${exif.Make.trim()} ${exif.Model.trim()}`.trim() : (exif.Model ?? exif.Make ?? null) + + if (!focalLength && !aperture && !iso && !shutterSpeed && !camera) { + return null + } + + return { + focalLength: focalLength ?? null, + aperture, + iso, + shutterSpeed, + camera, + } +} + +function getPhotoDimensions(photo: PhotoManifestItem) { + return { + width: photo.width || 1, + height: photo.height || 1, + } +} + +/** + * Render Open Graph images for processed photos and upload them to remote storage. + * + * The plugin reuses generated thumbnails as the image source, injects light EXIF + * metadata, and caches uploads/URLs during a single builder run to reduce storage + * churn. + */ +export default function ogImagePlugin(options: OgImagePluginOptions = {}): BuilderPlugin { + let resolved: ResolvedPluginConfig | null = null + let externalStorageManager: StorageManager | null = null + + return { + name: PLUGIN_NAME, + hooks: { + onInit: ({ builder, config, logger }) => { + const enable = options.enable ?? true + const directory = normalizeDirectory(options.directory) + const contentType = options.contentType ?? DEFAULT_CONTENT_TYPE + + if (!enable) { + resolved = { + directory, + remotePrefix: '', + contentType, + useDefaultStorage: true, + storageConfig: null, + enabled: false, + } + return + } + + const fallbackStorage = config.user?.storage ?? builder.getStorageConfig() + const storageConfig = (options.storageConfig ?? fallbackStorage) as StorageConfig + + if (storageConfig.provider === 'eagle') { + logger.main.warn('OG image plugin does not support Eagle storage provider; plugin disabled.') + resolved = { + directory, + remotePrefix: '', + contentType, + useDefaultStorage: !options.storageConfig, + storageConfig: null, + enabled: false, + } + return + } + + const uploadableConfig = storageConfig as UploadableStorageConfig + const remotePrefix = resolveRemotePrefix(uploadableConfig, directory) + + resolved = { + directory, + remotePrefix, + contentType, + useDefaultStorage: !options.storageConfig, + storageConfig: uploadableConfig, + enabled: true, + } + + if (!options.storageConfig) { + builder.getStorageManager().addExcludePrefix(remotePrefix) + } else { + externalStorageManager = new StorageManager(uploadableConfig) + } + }, + afterPhotoProcess: async ({ builder, payload, runShared, logger }) => { + if (!resolved || !resolved.enabled) { + return + } + + const { item, type } = payload.result + if (!item) { + return + } + + const storageManager = resolved.useDefaultStorage ? builder.getStorageManager() : externalStorageManager + if (!storageManager) { + logger.main.warn('OG image plugin could not resolve storage manager. Skipping upload.') + return + } + + const state = getOrCreateRunState(runShared) + + if (!state.siteMeta) { + state.siteMeta = await loadSiteMeta(options, logger) + } + + if (!state.fonts) { + state.fonts = await loadFonts(logger) + } + + const { fonts } = state + if (!fonts) { + return + } + + const shouldRender = type !== 'skipped' || payload.options.isForceMode || payload.options.isForceManifest + + const remoteKey = joinSegments(resolved.remotePrefix, `${item.id}.png`) + + if (!shouldRender) { + try { + const remoteUrl = await storageManager.generatePublicUrl(remoteKey) + state.urlCache.set(remoteKey, remoteUrl) + item.ogImageUrl = remoteUrl + } catch (error) { + logger.main.info(`OG image plugin: skipped rendering and could not resolve URL for ${remoteKey}.`, error) + } + return + } + + const thumbnailData = payload.context.pluginData[THUMBNAIL_PLUGIN_DATA_KEY] as ThumbnailPluginData | undefined + const thumbnailSrc = await resolveThumbnailDataUrl(item, thumbnailData, logger) + const exifInfo = buildExifInfo(item) + const formattedDate = formatDate(item.exif?.DateTimeOriginal ?? item.lastModified) + + try { + const png = await renderOgImage({ + template: { + photoTitle: item.title || item.id || 'Untitled Photo', + siteName: state.siteMeta.siteName || DEFAULT_SITE_NAME, + tags: (item.tags ?? []).slice(0, 3), + formattedDate, + exifInfo, + thumbnailSrc, + photoDimensions: getPhotoDimensions(item), + accentColor: state.siteMeta.accentColor ?? DEFAULT_ACCENT_COLOR, + }, + fonts, + }) + + const stateForUpload = state + if ( + !stateForUpload.uploaded.has(remoteKey) || + payload.options.isForceMode || + payload.options.isForceManifest + ) { + try { + await storageManager.uploadFile(remoteKey, Buffer.from(png), { + contentType: resolved.contentType, + }) + stateForUpload.uploaded.add(remoteKey) + } catch (error) { + logger.main.error(`OG image plugin: failed to upload ${remoteKey}`, error) + return + } + } + + let remoteUrl = stateForUpload.urlCache.get(remoteKey) + if (!remoteUrl) { + try { + remoteUrl = await storageManager.generatePublicUrl(remoteKey) + stateForUpload.urlCache.set(remoteKey, remoteUrl) + } catch (error) { + logger.main.error(`OG image plugin: failed to generate URL for ${remoteKey}`, error) + return + } + } + + item.ogImageUrl = remoteUrl + } catch (error) { + logger.main.error(`OG image plugin: failed to render OG image for ${item.id}`, error) + } + }, + }, + } +} + +export type { OgImagePluginOptions } diff --git a/packages/builder/src/types/photo.ts b/packages/builder/src/types/photo.ts index daea03cd..7f147486 100644 --- a/packages/builder/src/types/photo.ts +++ b/packages/builder/src/types/photo.ts @@ -60,6 +60,7 @@ export interface PhotoManifestItem extends PhotoInfo { originalUrl: string format: string thumbnailUrl: string + ogImageUrl?: string | null thumbHash: string | null width: number height: number diff --git a/packages/builder/tsconfig.json b/packages/builder/tsconfig.json index 69ef2f1b..9fc56143 100644 --- a/packages/builder/tsconfig.json +++ b/packages/builder/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ESNext", - "lib": ["DOM", "DOM.Iterable", "ESNext"], + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], "allowJs": false, "skipLibCheck": true, "esModuleInterop": false, @@ -15,12 +19,22 @@ "skipDefaultLibCheck": true, "noImplicitAny": false, "noEmit": true, - "jsx": "preserve", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", "paths": { - "@afilmory/builder/*": ["./src/*"], - "@pkg": ["./package.json"], - "@env": ["../../env.ts"] + "@afilmory/builder/*": [ + "./src/*" + ], + "@pkg": [ + "./package.json" + ], + "@env": [ + "../../env.ts" + ], } }, - "include": ["./src/**/*", "./scripts/**/*"] + "include": [ + "./src/**/*", + "./scripts/**/*" + ] } diff --git a/packages/renderer/package.json b/packages/renderer/package.json new file mode 100644 index 00000000..f782eace --- /dev/null +++ b/packages/renderer/package.json @@ -0,0 +1,15 @@ +{ + "name": "@afilmory/og-renderer", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts", + "./renderer": "./src/og.renderer.tsx", + "./template": "./src/og.template.tsx" + }, + "devDependencies": { + "@resvg/resvg-js": "2.6.2", + "hono": "4.11.1", + "satori": "catalog:" + } +} diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts new file mode 100644 index 00000000..36a5f1a0 --- /dev/null +++ b/packages/renderer/src/index.ts @@ -0,0 +1,3 @@ +export { renderHomepageOgImage, renderOgImage } from './og/og.renderer' +export type { ExifInfo, HomepageOgTemplateProps, OgTemplateProps, PhotoDimensions } from './og/og.template' +export { HomepageOgTemplate, OgTemplate } from './og/og.template' diff --git a/be/apps/core/src/modules/content/og/og.renderer.tsx b/packages/renderer/src/og/og.renderer.tsx similarity index 98% rename from be/apps/core/src/modules/content/og/og.renderer.tsx rename to packages/renderer/src/og/og.renderer.tsx index 77a68acf..62bd4e56 100644 --- a/be/apps/core/src/modules/content/og/og.renderer.tsx +++ b/packages/renderer/src/og/og.renderer.tsx @@ -1,4 +1,5 @@ /** @jsxImportSource hono/jsx */ +/** @jsxRuntime automatic */ import { Buffer } from 'node:buffer' import { Resvg } from '@resvg/resvg-js' diff --git a/be/apps/core/src/modules/content/og/og.template.tsx b/packages/renderer/src/og/og.template.tsx similarity index 99% rename from be/apps/core/src/modules/content/og/og.template.tsx rename to packages/renderer/src/og/og.template.tsx index 4714d15f..a9c3fd06 100644 --- a/be/apps/core/src/modules/content/og/og.template.tsx +++ b/packages/renderer/src/og/og.template.tsx @@ -1,4 +1,5 @@ /** @jsxImportSource hono/jsx */ +/** @jsxRuntime automatic */ export interface PhotoDimensions { width: number diff --git a/be/apps/core/src/modules/content/og/tweemoji.ts b/packages/renderer/src/og/tweemoji.ts similarity index 100% rename from be/apps/core/src/modules/content/og/tweemoji.ts rename to packages/renderer/src/og/tweemoji.ts diff --git a/packages/renderer/tmp-og.js b/packages/renderer/tmp-og.js new file mode 100644 index 00000000..752b129c --- /dev/null +++ b/packages/renderer/tmp-og.js @@ -0,0 +1,44 @@ +'use strict' +import { Buffer } from 'node:buffer' + +import { Resvg } from '@resvg/resvg-js' +import { jsx } from 'hono/jsx/jsx-runtime' +import satori from 'satori' + +import { HomepageOgTemplate, OgTemplate } from './og.template' +import { get_icon_code, load_emoji } from './tweemoji' + +export async function renderOgImage({ template, fonts }) { + const svg = await satori(/* @__PURE__ */ jsx(OgTemplate, { ...template }), { + width: 1200, + height: 628, + fonts, + embedFont: true, + }) + const svgInput = typeof svg === 'string' ? svg : Buffer.from(svg) + const renderer = new Resvg(svgInput, { + fitTo: { mode: 'width', value: 1200 }, + background: 'rgba(0,0,0,0)', + }) + return renderer.render().asPng() +} +export async function renderHomepageOgImage({ template, fonts }) { + const svg = await satori(/* @__PURE__ */ jsx(HomepageOgTemplate, { ...template }), { + width: 1200, + height: 628, + fonts, + embedFont: true, + async loadAdditionalAsset(code, segment) { + if (code === 'emoji' && segment) { + return `data:image/svg+xml;base64,${btoa(await load_emoji(get_icon_code(segment)))}` + } + return '' + }, + }) + const svgInput = typeof svg === 'string' ? svg : Buffer.from(svg) + const renderer = new Resvg(svgInput, { + fitTo: { mode: 'width', value: 1200 }, + background: 'rgba(0,0,0,0)', + }) + return renderer.render().asPng() +} diff --git a/packages/renderer/tsconfig.json b/packages/renderer/tsconfig.json new file mode 100644 index 00000000..6a3df11d --- /dev/null +++ b/packages/renderer/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "skipDefaultLibCheck": true, + "noImplicitAny": false, + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + }, + "include": [ + "src" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67bc14b1..f0e6f3a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: dotenv-expand: specifier: 12.0.3 version: 12.0.3 + satori: + specifier: 0.18.3 + version: 0.18.3 tailwind-scrollbar: specifier: 4.0.2 version: 4.0.2 @@ -521,6 +524,9 @@ importers: postgres: specifier: 3.4.7 version: 3.4.7 + prop-types: + specifier: 15.8.1 + version: 15.8.1 react: specifier: 19.2.3 version: 19.2.3 @@ -929,6 +935,9 @@ importers: '@afilmory/framework': specifier: workspace:* version: link:../../packages/framework + '@afilmory/og-renderer': + specifier: workspace:* + version: link:../../../packages/renderer '@afilmory/redis': specifier: workspace:* version: link:../../packages/redis @@ -1002,7 +1011,7 @@ importers: specifier: 6.6.0 version: 6.6.0 satori: - specifier: 0.18.3 + specifier: 'catalog:' version: 0.18.3 tsyringe: specifier: 4.10.0 @@ -1428,6 +1437,12 @@ importers: packages/builder: dependencies: + '@afilmory/og-renderer': + specifier: workspace:* + version: link:../renderer + '@resvg/resvg-js': + specifier: 2.6.2 + version: 2.6.2 '@vingle/bmp-js': specifier: ^0.2.5 version: 0.2.5 @@ -1458,6 +1473,9 @@ importers: heic-to: specifier: 1.3.0 version: 1.3.0 + satori: + specifier: 'catalog:' + version: 0.18.3 sharp: specifier: 0.34.5 version: 0.34.5 @@ -1500,6 +1518,18 @@ importers: specifier: ^19.2.3 version: 19.2.3(react@19.2.3) + packages/renderer: + devDependencies: + '@resvg/resvg-js': + specifier: 2.6.2 + version: 2.6.2 + hono: + specifier: 4.11.1 + version: 4.11.1 + satori: + specifier: 'catalog:' + version: 0.18.3 + packages/sdk: dependencies: zod: @@ -3413,89 +3443,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -3680,24 +3726,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.0.10': resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.0.10': resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.0.10': resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.0.10': resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} @@ -3767,36 +3817,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -4775,24 +4831,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.6.2': resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.6.2': resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.6.2': resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.6.2': resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} @@ -4855,24 +4915,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53': resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53': resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.53': resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.53': resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==} @@ -4987,56 +5051,67 @@ packages: resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.3': resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.3': resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.3': resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.3': resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.3': resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.3': resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.3': resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.3': resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.3': resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.3': resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.3': resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} @@ -5385,24 +5460,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.5': resolution: {integrity: sha512-Of+wmVh5h47tTpN9ghHVjfL0CJrgn99XmaJjmzWFW7agPdVY6gTDgkk6zQ6q4hcDQ7hXb0BGw6YFpuanBzNPow==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.5': resolution: {integrity: sha512-98kuPS0lZVgjmc/2uTm39r1/OfwKM0PM13ZllOAWi5avJVjRd/j1xA9rKeUzHDWt+ocH9mTCQsAT1jjKSq45bg==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.5': resolution: {integrity: sha512-Rk+OtNQP3W/dZExL74LlaakXAQn6/vbrgatmjFqJPO4RZkq+nLo5g7eDUVjyojuERh7R2yhqNvZ/ZZQe8JQqqA==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.5': resolution: {integrity: sha512-e3RTdJ769+PrN25iCAlxmsljEVu6iIWS7sE21zmlSiipftBQvSAOWuCDv2A8cH9lm5pSbZtwk8AUpIYCNsj2oQ==} @@ -5520,24 +5599,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -6051,41 +6134,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -6377,12 +6468,14 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] autocorrect-node-linux-x64-musl@2.14.0: resolution: {integrity: sha512-3LifiLG61VIXBrOITpD/REcg/ZEUuLz/P2XUWMCfGqhzoUb8oFgM3o0A6extvIH2NefiHMndB9kNbfQjXCl3zA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] autocorrect-node-win32-x64-msvc@2.14.0: resolution: {integrity: sha512-OM6TeGUW0+4R9KtUYTYNEwlInd+b2kumw/B1HCbCzb1F7HpfaNxnZiMk1li0bdPPBDzD5YuzwM30VJZMCWIi5A==} @@ -8666,24 +8759,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -8762,6 +8859,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -9239,6 +9340,10 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -9664,6 +9769,9 @@ packages: peerDependencies: react: '>=16.0.0' + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -19808,6 +19916,10 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -20664,6 +20776,8 @@ snapshots: pkg-types: 2.3.0 tinyexec: 1.0.1 + object-assign@4.1.1: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -21037,6 +21151,12 @@ snapshots: clsx: 2.1.1 react: 19.2.3 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-information@7.1.0: {} protocol-buffers-schema@3.6.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3e3f11ba..f303e57b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ catalog: '@tailwindcss/postcss': 4.1.18 '@tailwindcss/typography': 0.5.19 dotenv-expand: 12.0.3 + satori: 0.18.3 tailwind-scrollbar: 4.0.2 tailwind-variants: 3.2.2 tailwindcss: 4.1.18