bskyogcard: support emoji, more languages, long starter pack names (#4668)
parent
f6b138f709
commit
49396451ec
|
@ -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 }}
|
||||||
|
|
|
@ -115,3 +115,6 @@ src/locale/locales/**/*.js
|
||||||
*.apk
|
*.apk
|
||||||
*.aab
|
*.aab
|
||||||
*.ipa
|
*.ipa
|
||||||
|
|
||||||
|
# ogcard assets
|
||||||
|
bskyogcard/src/assets/fonts/noto-*
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue