feat: add ogImagePlugin to build and upload og images (#213)

This commit is contained in:
woolen-sheep
2026-01-10 22:49:39 +08:00
committed by GitHub
parent e795a998e4
commit 0261e06f8d
20 changed files with 816 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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'

View File

@@ -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 读写和管理

View File

@@ -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"
},

View File

@@ -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'

View File

@@ -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.

View File

@@ -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<StorageConfig, { provider: 'eagle' }>
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<string>
urlCache: Map<string, string>
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 | null | undefined>): 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<string, unknown>): PluginRunState {
let state = container.get(RUN_STATE_KEY) as PluginRunState | undefined
if (!state) {
state = {
uploaded: new Set<string>(),
urlCache: new Map<string, string>(),
fonts: null,
}
container.set(RUN_STATE_KEY, state)
}
return state
}
async function loadFontFile(fileName: string): Promise<Buffer | null> {
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<SatoriOptions['fonts'] | null> {
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<SiteMeta> {
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<string | null> {
// 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 }

View File

@@ -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

View File

@@ -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/**/*"
]
}

View File

@@ -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:"
}
}

View File

@@ -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'

View File

@@ -1,4 +1,5 @@
/** @jsxImportSource hono/jsx */
/** @jsxRuntime automatic */
import { Buffer } from 'node:buffer'
import { Resvg } from '@resvg/resvg-js'

View File

@@ -1,4 +1,5 @@
/** @jsxImportSource hono/jsx */
/** @jsxRuntime automatic */
export interface PhotoDimensions {
width: number

View File

@@ -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()
}

View File

@@ -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"
]
}

122
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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