1
0
Fork 0

feat(backend): Support for generating images

This commit is contained in:
joostdecock 2023-11-03 12:58:58 +01:00
parent 705b6bcecd
commit 41e3cd9cb9
17 changed files with 704 additions and 104 deletions

56
artwork/img/square.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

72
artwork/img/tall.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

58
artwork/img/wide.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

View file

@ -30,6 +30,7 @@
"dependencies": {
"@aws-sdk/client-sesv2": "3.428.0",
"@prisma/client": "5.4.2",
"@vercel/og": "^0.5.20",
"bcryptjs": "2.4.3",
"cors": "2.8.5",
"dotenv": "16.3.1",

View file

@ -115,6 +115,39 @@ const baseConfig = {
uk: crowdinProject + 'invite?h=' + process.env.BACKEND_CROWDIN_INVITE_UK,
},
},
img: {
sites: ['org', 'dev', 'social'],
templates: {
folder: ['..', '..', 'artwork', 'img'],
sizes: {
square: 2000,
tall: 1080,
wide: 2400,
},
chars: {
wide: {
title_1: 24,
title_2: 26,
title_3: 26,
intro: 58,
},
square: {
title_1: 20,
title_2: 20,
title_3: 20,
intro: 52,
},
tall: {
title_1: 20,
title_2: 20,
title_3: 20,
title_4: 20,
title_5: 20,
intro: 52,
},
},
},
},
jwt: {
secretOrKey: encryptionKey,
issuer: api,
@ -277,6 +310,7 @@ export const github = config.github
export const instance = config.instance
export const exports = config.exports
export const oauth = config.oauth
export const imgConfig = config.img
const vars = {
BACKEND_DB_URL: ['required', 'db.url'],

View file

@ -0,0 +1,167 @@
import sharp from 'sharp'
import fs from 'fs'
import path from 'path'
import { imgConfig } from '../config.mjs'
/*
* Load SVG templates once at startup
*/
const templates = {}
for (const type of Object.keys(imgConfig.templates.sizes)) {
templates[type] = fs.readFileSync(
path.resolve(...imgConfig.templates.folder, `${type}.svg`),
'utf-8'
)
}
/* Find longest possible place to split a string */
const splitLine = (line, chars) => {
const words = line.split(' ')
if (words[0].length > chars) {
// Force a word break
return [line.slice(0, chars - 1) + '-', line.slice(chars - 1)]
}
// Glue chunks together until it's too long
let firstLine = ''
let max = false
for (const word of words) {
if (!max && `${firstLine}${word}`.length <= chars) firstLine += `${word} `
else max = true
}
return [firstLine.trim(), words.join(' ').slice(firstLine.length).trim()]
}
/* Divide title into lines to fit on image */
const titleAsLines = (title, type) => {
// Does it fit on one line?
if (title.length <= imgConfig.templates.chars[type].title_1) return [title]
// Does it fit on two lines?
let lines = splitLine(title, imgConfig.templates.chars[type].title_1)
if (lines[1].length <= imgConfig.templates.chars[type].title_2) return lines
// Does it fit on three lines?
lines = [lines[0], ...splitLine(lines[1], imgConfig.templates.chars[type].title_2)]
if (lines[2].length <= imgConfig.templates.chars[type].title_3) return lines
// Does it fit on four lines?
lines = [lines[0], lines[1], ...splitLine(lines[2], imgConfig.templates.chars[type].title_3)]
if (lines[3].length <= imgConfig.templates.chars[type].title_4) return lines
// Five lines it is
return [
lines[0],
lines[1],
lines[2],
...splitLine(lines[3], imgConfig.templates.chars[type].title_4),
]
}
/* Divive intro into lines to fit on image */
const introAsLines = (intro, type) => {
// Does it fit on one line?
if (intro.length <= imgConfig.templates.chars[type].intro) return [intro]
// Two lines it is
return splitLine(intro, imgConfig.templates.chars[type].intro)
}
/* Hide unused placeholders */
const hidePlaceholders = (list, type) => {
let svg = templates[type]
for (const i of list) {
svg = svg
.replace(`${i}title_1`, '')
.replace(`${i}title_2`, '')
.replace(`${i}title_3`, '')
.replace(`${i}title_4`, '')
.replace(`${i}title_5`, '')
}
return svg
}
/* Place text in SVG template */
const decorateSvg = (data) => {
let svg
// Single title line
if (data.title.length === 1) {
svg = hidePlaceholders([2, 3, 4, 5], data.type).replace(`1title_1`, data.title[0])
}
// Double title line
else if (data.title.length === 2) {
svg = hidePlaceholders([1, 3, 4, 5], data.type)
.replace(`2title_1`, data.title[0])
.replace(`2title_2`, data.title[1])
}
// Triple title line
else if (data.title.length === 3) {
svg = hidePlaceholders([1, 2, 4, 5], data.type)
.replace(`3title_1`, data.title[0])
.replace(`3title_2`, data.title[1])
.replace(`3title_3`, data.title[2])
}
// Quadruple title line
else if (data.title.length === 4) {
svg = hidePlaceholders([1, 2, 3, 5], data.type)
.replace(`4title_1`, data.title[0])
.replace(`4title_2`, data.title[1])
.replace(`4title_3`, data.title[2])
.replace(`4title_4`, data.title[3])
}
// Quintuple title line
else if (data.title.length === 5) {
svg = hidePlaceholders([1, 2, 3, 4], data.type)
.replace(`5title_1`, data.title[0])
.replace(`5title_2`, data.title[1])
.replace(`5title_3`, data.title[2])
.replace(`5title_4`, data.title[3])
.replace(`5title_5`, data.title[4])
}
return svg
.replace(`intro_1`, data.intro[0] || '')
.replace(`intro_2`, data.intro[1] || '')
.replace('site', data.site || '')
}
export function ImgController() {}
/*
* Generate an Open Graph image
* See: https://freesewing.dev/reference/backend/api
*/
ImgController.prototype.generate = async (req, res, tools) => {
/*
* Extract body parameters
*/
const {
site = false,
title = 'Please provide a title',
intro = 'Please provide an intro',
type = 'wide',
} = req.body
if (site && imgConfig.sites.indexOf(site) === -1)
return res.status(400).send({ error: 'invalidSite' })
/*
* Preformat data for SVG template
*/
const data = {
title: titleAsLines(title, type),
intro: introAsLines(intro, type),
site: site ? 'FreeSewing.' + site : '',
type,
}
/*
* Inject data into SVG template
*/
const svg = decorateSvg(data)
/*
* Convert to PNG and return
*/
sharp(Buffer.from(svg, 'utf-8'))
.resize({ width: imgConfig.templates.sizes[type] })
.toBuffer((err, data, info) => {
if (err) console.log(err)
return res.type('png').send(data)
})
}

View file

@ -0,0 +1,10 @@
import { ImgController } from '../controllers/img.mjs'
const Img = new ImgController()
export function imgRoutes(tools) {
const { app } = tools
// Generate an image
app.post('/img', (req, res) => Img.generate(req, res, tools))
}

View file

@ -8,6 +8,7 @@ import { curatedSetsRoutes } from './curated-sets.mjs'
import { optionPacksRoutes } from './option-packs.mjs'
import { subscribersRoutes } from './subscribers.mjs'
import { flowsRoutes } from './flows.mjs'
import { imgRoutes } from './img.mjs'
import { adminRoutes } from './admin.mjs'
export const routes = {
@ -21,5 +22,6 @@ export const routes = {
optionPacksRoutes,
subscribersRoutes,
flowsRoutes,
imgRoutes,
adminRoutes,
}

2
sites/backend/test.sh Normal file
View file

@ -0,0 +1,2 @@
curl -d '{ "title": "I am the title", "intro": "And I am the intro", "site": "dev" }' http://localhost:3000/og

View file

@ -0,0 +1,19 @@
title: Title
titleMsg: This will be the main text on the image
intro: Intro / Byline / Footer
introMsg: This will appear smaller at the bottom
type: Variant
typeMsg: Pick the variant that best suits your needs
site: Site
siteMsg: This format can optionally include the site name
generate: Generate image
generateAgain: Generate another image
preview: Preview
save: Save Image
tall: Tall
tallMsg: Generates a tall image, optimized for Instagram stories, TikTok, and other places that prefer portrait mode.
wide: Wide
wideMsg: Generates a wide image, optimized for posting on a variety of platforms including Facebook, Mastodon, Reddit, and so on. Also suitable as Open Graph image.
square: Square
squareMsg: Generate a square image optimized for Instagram posts and other places where a square aspect ratio works best.
none: None

View file

@ -0,0 +1,114 @@
import { nsMerge } from 'shared/utils.mjs'
import { useState } from 'react'
import { useBackend } from 'shared/hooks/use-backend.mjs'
import { useTranslation } from 'next-i18next'
import { StringInput, ListInput, ns as inputNs } from 'shared/components/inputs.mjs'
export const ns = nsMerge('genimg', inputNs)
const binaryToData = (binary) => {
const img = new Image()
}
export const GenerateImage = () => {
const backend = useBackend()
const { t } = useTranslation(ns)
const [title, setTitle] = useState('')
const [intro, setIntro] = useState('')
const [type, setType] = useState('tall')
const [site, setSite] = useState(false)
const [preview, setPreview] = useState(false)
const generate = async () => {
let result
try {
result = await backend.img({ title, intro, type, site })
if (result.success) {
const uint8Array = new Uint8Array(result.data)
const base64String = btoa(String.fromCharCode.apply(null, uint8Array))
setPreview(`data:image/png;base64,${base64String}`)
}
} catch (err) {
console.log(err)
}
}
return (
<div className="max-w-xl">
{preview ? (
<>
<img src={preview} />
<button className="btn btn-primary w-full mt-4" onClick={() => setPreview(false)}>
{t('genimg:generateAgain')}
</button>
</>
) : (
<>
<StringInput
label={t('genimg:title')}
current={title}
update={setTitle}
valid={() => true}
/>
<StringInput
label={t('genimg:intro')}
current={intro}
update={setIntro}
valid={() => true}
/>
<ListInput
label={t('genimg:type')}
current={type}
update={setType}
list={[
{
val: 'tall',
label: t('genimg:tall'),
desc: t('genimg:tallMsg'),
},
{
val: 'square',
label: t('genimg:square'),
desc: t('genimg:squareMsg'),
},
{
val: 'wide',
label: t('genimg:wide'),
desc: t('genimg:wideMsg'),
},
]}
/>
{type === 'wide' ? (
<ListInput
label={t('genimg:site')}
current={site}
update={setSite}
list={[
{
val: false,
label: t('genimg:none'),
},
{
val: 'org',
label: 'FreeSewing.org',
},
{
val: 'dev',
label: 'FreeSewing.dev',
},
{
val: 'social',
label: 'FreeSewing.social',
},
]}
/>
) : null}
<button onClick={generate} className="btn btn-primary w-full mt-4">
{t('genimg:generate')}
</button>
</>
)}
</div>
)
}

View file

@ -0,0 +1,43 @@
// Dependencies
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import { nsMerge } from 'shared/utils.mjs'
// Hooks
import { useTranslation } from 'next-i18next'
// Components
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
import { BareLayout } from 'site/components/layouts/bare.mjs'
import { GenerateImage, ns as genImgNs } from 'site/components/genimg/index.mjs'
// Translation namespaces used on this page
const ns = nsMerge(authNs, pageNs, genImgNs, 'account')
/*
* Each page MUST be wrapped in the PageWrapper component.
* You also MUST spread props.page into this wrapper component
* when path and locale come from static props (as here)
* or set them manually.
*/
const NewBlogPage = ({ page }) => {
const { t } = useTranslation(ns)
return (
<PageWrapper {...page} title={t('account:imgNew')}>
<GenerateImage />
</PageWrapper>
)
}
export default NewBlogPage
export async function getStaticProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ns)),
page: {
locale,
path: ['new', 'img'],
},
},
}
}

View file

@ -16,6 +16,7 @@ import {
RssIcon,
CsetIcon,
OpackIcon,
KioskIcon,
} from 'shared/components/icons.mjs'
// Translation namespaces used on this page
@ -47,6 +48,14 @@ const Box = ({ title, Icon, description, href }) => {
<Link {...linkProps}>{inner}</Link>
)
}
/*
<Box
title={t('opackNew')}
Icon={OpackIcon}
description={t('opackNewInfo')}
href="/new/opack"
/>
*/
/*
* Each page MUST be wrapped in the PageWrapper component.
@ -76,24 +85,24 @@ const NewIndexPage = ({ page }) => {
<>
<h2>{t('newShare')}</h2>
<div className="w-full max-w-7xl flex flex-row flex-wrap gap-4">
<Box
title={t('csetNew')}
Icon={CsetIcon}
description={t('csetNewInfo')}
href="/new/cset"
/>
<Box
title={t('opackNew')}
Icon={OpackIcon}
description={t('opackNewInfo')}
href="/new/opack"
/>
<Box
title={t('showcaseNew')}
Icon={ShowcaseIcon}
description={t('showcaseNewInfo')}
href="/new/showcase"
/>
<Box
title={t('imgNew')}
Icon={KioskIcon}
description={t('imgNewInfo')}
href="/new/img"
/>
<Box
title={t('csetNew')}
Icon={CsetIcon}
description={t('csetNewInfo')}
href="/new/cset"
/>
<Box
title={t('blogNew')}
Icon={RssIcon}

View file

@ -274,6 +274,9 @@ showcaseNewInfo: If you would like to share something you (or someone else) made
blogNew: Create a new blog post
blogNewInfo: If you would like to write on the FreeSewing blog, you can start a draft blog post here.
imgNew: Generate a social media image
imgNewInfo: Use our generator to create an image you can share on social media, supports wide (classic), square (Instagram), or tall (stories/TikTok) formats.
csetNew: Suggest a new curated measurements set
csetNewInfo: We curate a collection of vetted measurments sets that we use to test patterns. You can suggest a measurements set here.

View file

@ -591,10 +591,10 @@ Backend.prototype.adminPing = async function (token) {
}
/*
* Migrate a v2 account
* backend.img: Generate a social media image
*/
Backend.prototype.migrate = async function (data) {
return responseHandler(await api.post(`/migrate`, data))
Backend.prototype.img = async function (data) {
return responseHandler(await api.post('/img', data, { responseType: 'arraybuffer' }))
}
export function useBackend() {

103
yarn.lock
View file

@ -2055,6 +2055,11 @@
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
"@resvg/resvg-js-win32-x64-msvc" "2.4.1"
"@resvg/resvg-wasm@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@resvg/resvg-wasm/-/resvg-wasm-2.6.0.tgz#fa4db659b8c2519715f7f7dacfbb327aad193935"
integrity sha512-iDkBM6Ivex8nULtBu8cX670/lfsGxq8U1cuqE+qS9xFpPQP1enPdVm/33Kq3+B+bAldA+AHNZnCgpmlHo/fZrQ==
"@rushstack/eslint-patch@^1.3.3":
version "1.5.1"
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.5.1.tgz#5f1b518ec5fa54437c0b7c4a821546c64fed6922"
@ -2068,6 +2073,14 @@
domhandler "^5.0.3"
selderee "^0.11.0"
"@shuding/opentype.js@1.4.0-beta.0":
version "1.4.0-beta.0"
resolved "https://registry.yarnpkg.com/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz#5d1e7e9e056f546aad41df1c5043f8f85d39e24b"
integrity sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==
dependencies:
fflate "^0.7.3"
string.prototype.codepointat "^0.2.1"
"@sigstore/bundle@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-1.1.0.tgz#17f8d813b09348b16eeed66a8cf1c3d6bd3d04f1"
@ -2825,6 +2838,15 @@
resolved "https://registry.yarnpkg.com/@use-it/event-listener/-/event-listener-0.1.7.tgz#443a9b6df87f2f2961b74d42997ce723a7078623"
integrity sha512-hgfExDzUU9uTRTPDCpw2s9jWTxcxmpJya3fK5ADpf5VDpSy8WYwY/kh28XE0tUcbsljeP8wfan48QvAQTSSa3Q==
"@vercel/og@^0.5.20":
version "0.5.20"
resolved "https://registry.yarnpkg.com/@vercel/og/-/og-0.5.20.tgz#dedd4b433bc3c1fec67d70a577b5ce8569a67838"
integrity sha512-zi+ZXSx/peXA+1lq7s/5Vzmm/TTfTSf/5P1qNYnh42+7X+pZmahWoXt0i7SWiq3WagfsNUNA4hUDapDiHRoXqA==
dependencies:
"@resvg/resvg-wasm" "2.6.0"
satori "0.10.9"
yoga-wasm-web "0.3.3"
"@yarnpkg/lockfile@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
@ -3698,6 +3720,11 @@ camelcase@^6.0.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
camelize@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3"
integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==
caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001538, caniuse-lite@^1.0.30001541:
version "1.0.30001547"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz#d4f92efc488aab3c7f92c738d3977c2a3180472b"
@ -4047,7 +4074,7 @@ color-name@1.1.3:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
color-name@^1.0.0, color-name@~1.1.4:
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
@ -4389,6 +4416,21 @@ crypto-js@^4.0.0:
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
css-background-parser@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/css-background-parser/-/css-background-parser-0.1.0.tgz#48a17f7fe6d4d4f1bca3177ddf16c5617950741b"
integrity sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==
css-box-shadow@1.0.0-3:
version "1.0.0-3"
resolved "https://registry.yarnpkg.com/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz#9eaeb7140947bf5d649fc49a19e4bbaa5f602713"
integrity sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==
css-color-keywords@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05"
integrity sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==
css-select@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6"
@ -4408,6 +4450,15 @@ css-selector-tokenizer@^0.8:
cssesc "^3.0.0"
fastparse "^1.1.2"
css-to-react-native@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32"
integrity sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==
dependencies:
camelize "^1.0.0"
css-color-keywords "^1.0.0"
postcss-value-parser "^4.0.2"
css-what@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
@ -5443,7 +5494,7 @@ escalade@^3.1.1:
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
escape-html@~1.0.3:
escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
@ -6085,6 +6136,11 @@ feed@4.2.2:
dependencies:
xml-js "^1.6.11"
fflate@^0.7.3:
version "0.7.4"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.7.4.tgz#61587e5d958fdabb5a9368a302c25363f4f69f50"
integrity sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==
figures@3.2.0, figures@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
@ -7006,6 +7062,11 @@ heap@^0.2.6:
resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.7.tgz#1e6adf711d3f27ce35a81fe3b7bd576c2260a8fc"
integrity sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==
hex-rgb@^4.1.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/hex-rgb/-/hex-rgb-4.3.0.tgz#af5e974e83bb2fefe44d55182b004ec818c07776"
integrity sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==
hexoid@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18"
@ -8351,7 +8412,7 @@ lilconfig@2.1.0, lilconfig@^2.0.5, lilconfig@^2.1.0:
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
linebreak@^1.0.2:
linebreak@^1.0.2, linebreak@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/linebreak/-/linebreak-1.1.0.tgz#831cf378d98bced381d8ab118f852bd50d81e46b"
integrity sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==
@ -10934,6 +10995,14 @@ parent-module@^1.0.0:
dependencies:
callsites "^3.0.0"
parse-css-color@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/parse-css-color/-/parse-css-color-0.2.1.tgz#b687a583f2e42e66ffdfce80a570706966e807c9"
integrity sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==
dependencies:
color-name "^1.1.4"
hex-rgb "^4.1.0"
parse-entities@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8"
@ -11324,7 +11393,7 @@ postcss-simple-vars@^2.0.0:
dependencies:
postcss "^5.0.21"
postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
@ -12399,6 +12468,22 @@ safe-stable-stringify@^2.3.1:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
satori@0.10.9:
version "0.10.9"
resolved "https://registry.yarnpkg.com/satori/-/satori-0.10.9.tgz#efde2898ab4a5b09c072f0f4e112ac4a7d6a499c"
integrity sha512-XU9EELUEZuioT4acLIpCXxHcFzrsC8muvg0MY28d+TlqwxbkTzBmWbw+3+hnCzXT7YZ0Qm8k3eXktDaEu+qmEw==
dependencies:
"@shuding/opentype.js" "1.4.0-beta.0"
css-background-parser "^0.1.0"
css-box-shadow "1.0.0-3"
css-to-react-native "^3.0.0"
emoji-regex "^10.2.1"
escape-html "^1.0.3"
linebreak "^1.1.0"
parse-css-color "^0.2.1"
postcss-value-parser "^4.2.0"
yoga-wasm-web "^0.3.3"
sax@^1.2.4:
version "1.3.0"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0"
@ -12897,6 +12982,11 @@ string-width@^6.1.0:
emoji-regex "^10.2.1"
strip-ansi "^7.0.1"
string.prototype.codepointat@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz#004ad44c8afc727527b108cd462b4d971cd469bc"
integrity sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==
string.prototype.matchall@^4.0.8:
version "4.0.10"
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz#a1553eb532221d4180c51581d6072cd65d1ee100"
@ -14512,6 +14602,11 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
yoga-wasm-web@0.3.3, yoga-wasm-web@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz#eb8e9fcb18e5e651994732f19a220cb885d932ba"
integrity sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==
zrender@5.4.3:
version "5.4.3"
resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.4.3.tgz#41ffaf835f3a3210224abd9d6964b48ff01e79f5"