bskyogcard: support emoji, more languages, long starter pack names (#4668)

zio/stable
devin ivy 2024-06-27 13:02:29 -04:00 committed by GitHub
parent f6b138f709
commit 49396451ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 413 additions and 163 deletions

View File

@ -1,8 +1,6 @@
name: build-and-push-ogcard-aws name: build-and-push-ogcard-aws
on: on:
push: workflow_dispatch:
branches:
- divy/bskycard
env: env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}

3
.gitignore vendored
View File

@ -115,3 +115,6 @@ src/locale/locales/**/*.js
*.apk *.apk
*.aab *.aab
*.ipa *.ipa
# ogcard assets
bskyogcard/src/assets/fonts/noto-*

View File

@ -10,7 +10,7 @@ RUN yarn install --frozen-lockfile
COPY ./bskyogcard ./ COPY ./bskyogcard ./
# build then prune dev deps # build then prune dev deps
RUN yarn build RUN yarn install-fonts && yarn build
RUN yarn install --production --ignore-scripts --prefer-offline RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size # Uses assets from build stage to reduce build size

View File

@ -5,7 +5,9 @@
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --loader ts-node/esm ./src/bin.ts", "start": "node --loader ts-node/esm ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/assets" "dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/",
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.12.19-next.0", "@atproto/api": "0.12.19-next.0",
@ -15,10 +17,12 @@
"http-terminator": "^3.2.0", "http-terminator": "^3.2.0",
"pino": "^9.2.0", "pino": "^9.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"satori": "^0.10.13" "satori": "^0.10.13",
"twemoji": "^14.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.3", "@types/node": "^20.14.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
} }

View File

@ -0,0 +1,40 @@
import {writeFile} from 'node:fs/promises'
import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
const FONTS = [
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-700-normal.ttf',
]
async function main() {
await Promise.all(
FONTS.map(async urlStr => {
const url = new URL(urlStr)
const res = await fetch(url)
const font = await res.arrayBuffer()
const filename = url.pathname
.split('/')
.slice(-2)
.join('/')
.replace(/@[\d.]+\//, '-')
if (!res.ok) {
throw new Error(`HTTP ${res.status}: fetching failed for ${filename}`)
}
await writeFile(
path.join(__DIRNAME, '..', 'src', 'assets', 'fonts', filename),
Buffer.from(font),
)
}),
)
}
main()

View File

@ -43,6 +43,7 @@ export function StarterPack(props: {
} else { } else {
imagesAcross.push(...imagesExceptCreator.slice(0, 7)) imagesAcross.push(...imagesExceptCreator.slice(0, 7))
} }
const isLongTitle = record ? record.name.length > 30 : false
return ( return (
<div <div
style={{ style={{
@ -130,7 +131,9 @@ export function StarterPack(props: {
<div <div
style={{ style={{
padding: '75px 30px 0px', padding: '75px 30px 0px',
fontSize: 65, fontSize: isLongTitle ? 55 : 65,
display: 'flex',
textAlign: 'center',
}}> }}>
{record?.name || 'Starter Pack'} {record?.name || 'Starter Pack'}
</div> </div>

View File

@ -1,8 +1,8 @@
import {readFileSync} from 'node:fs' import {readdirSync, readFileSync} from 'node:fs'
import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
import {AtpAgent} from '@atproto/api' import {AtpAgent} from '@atproto/api'
import * as path from 'path'
import {fileURLToPath} from 'url'
import {Config} from './config.js' import {Config} from './config.js'
@ -28,12 +28,14 @@ export class AppContext {
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) { static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl}) const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
const fonts = [ const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
{ const fontFiles = readdirSync(fontDirectory)
name: 'Inter', const fonts = fontFiles.map(file => {
data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')), return {
}, name: path.basename(file, path.extname(file)),
] data: readFileSync(path.join(fontDirectory, file)),
}
})
return new AppContext({ return new AppContext({
cfg, cfg,
appviewAgent, appviewAgent,

View File

@ -1,3 +1,4 @@
import {subsystemLogger} from '@atproto/common' import {subsystemLogger} from '@atproto/common'
export const httpLogger = subsystemLogger('bskyogcard') export const httpLogger = subsystemLogger('bskyogcard')
export const renderLogger = subsystemLogger('bskyogcard:render')

View File

@ -13,6 +13,7 @@ import {
} from '../components/StarterPack.js' } from '../components/StarterPack.js'
import {AppContext} from '../context.js' import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js' import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {handler, originVerifyMiddleware} from './util.js' import {handler, originVerifyMiddleware} from './util.js'
export default function (ctx: AppContext, app: Express) { export default function (ctx: AppContext, app: Express) {
@ -65,6 +66,11 @@ export default function (ctx: AppContext, app: Express) {
fonts: ctx.fonts, fonts: ctx.fonts,
height: STARTERPACK_HEIGHT, height: STARTERPACK_HEIGHT,
width: STARTERPACK_WIDTH, width: STARTERPACK_WIDTH,
loadAdditionalAsset: async (code, text) => {
if (code === 'emoji') {
return await loadEmojiAsSvg(text)
}
},
}, },
) )
const output = await resvg.renderAsync(svg) const output = await resvg.renderAsync(svg)

View File

@ -0,0 +1,37 @@
import twemoji from 'twemoji'
import {renderLogger} from './logger.js'
const U200D = String.fromCharCode(0x200d)
const UFE0F_REGEXP = /\uFE0F/g
export async function loadEmojiAsSvg(chars: string) {
const cached = emojiCache.get(chars)
if (cached) return cached
const iconCode = twemoji.convert.toCodePoint(
chars.indexOf(U200D) < 0 ? chars.replace(UFE0F_REGEXP, '') : chars,
)
const res = await fetch(getEmojiUrl(iconCode))
const body = await res.arrayBuffer()
if (!res.ok) {
renderLogger.warn(
{status: res.status, err: Buffer.from(body).toString()},
'could not fetch emoji',
)
return
}
const svg =
'data:image/svg+xml;base64,' + Buffer.from(body).toString('base64')
emojiCache.set(chars, svg)
return svg
}
const emojiCache = new Map<string, string>()
function getEmojiUrl(code: string) {
return (
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
code.toLowerCase() +
'.svg'
)
}

File diff suppressed because it is too large Load Diff