diff --git a/server/nuxt3/server/utils/WebCard/CommonCard.vue b/server/nuxt3/server/utils/WebCard/CommonCard.vue deleted file mode 100644 index cddc395..0000000 --- a/server/nuxt3/server/utils/WebCard/CommonCard.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/server/nuxt3/server/utils/WebCard/TweetCard.vue b/server/nuxt3/server/utils/WebCard/TweetCard.vue deleted file mode 100644 index 4ad256e..0000000 --- a/server/nuxt3/server/utils/WebCard/TweetCard.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - diff --git a/server/nuxt3/server/utils/WebCard/font.ts b/server/nuxt3/server/utils/WebCard/font.ts deleted file mode 100644 index 5d35546..0000000 --- a/server/nuxt3/server/utils/WebCard/font.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { $fetch } from 'ofetch' -import type { SatoriOptions } from 'satori' -import type { apis } from './twemoji' -import { getIconCode, loadEmoji } from './twemoji' - -type UnicodeRange = Array - -export class FontDetector { - private rangesByLang: { - [font: string]: UnicodeRange - } = {} - - public async detect( - text: string, - fonts: string[], - ): Promise<{ - [lang: string]: string - }> { - await this.load(fonts) - - const result: { - [lang: string]: string - } = {} - - for (const segment of text) { - const lang = this.detectSegment(segment, fonts) - if (lang) { - result[lang] = result[lang] || '' - result[lang] += segment - } - } - - return result - } - - private detectSegment(segment: string, fonts: string[]): string | null { - for (const font of fonts) { - const range = this.rangesByLang[font] - if (range && checkSegmentInRange(segment, range)) - return font - } - - return null - } - - private async load(fonts: string[]): Promise { - let params = '' - - const existingLang = Object.keys(this.rangesByLang) - const langNeedsToLoad = fonts.filter(font => !existingLang.includes(font)) - - if (langNeedsToLoad.length === 0) - return - - for (const font of langNeedsToLoad) - params += `family=${font}&` - - params += 'display=swap' - - const API = `https://fonts.googleapis.com/css2?${params}` - - const fontFace: string = await $fetch(API, { - headers: { - // Make sure it returns TTF. - 'User-Agent': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', - }, - }) - - this.addDetectors(fontFace) - } - - private addDetectors(input: string) { - const regex = /font-family:\s*'(.+?)';.+?unicode-range:\s*(.+?);/gms - const matches = input.matchAll(regex) - - for (const [, _lang, range] of matches) { - const lang = _lang.replaceAll(' ', '+') - if (!this.rangesByLang[lang]) - this.rangesByLang[lang] = [] - - this.rangesByLang[lang].push(...convert(range)) - } - } -} - -function convert(input: string): UnicodeRange { - return input.split(', ').map((range) => { - range = range.replaceAll('U+', '') - const [start, end] = range.split('-').map(hex => parseInt(hex, 16)) - - if (isNaN(end)) - return start - - return [start, end] - }) -} - -function checkSegmentInRange(segment: string, range: UnicodeRange): boolean { - const codePoint = segment.codePointAt(0) - - if (!codePoint) - return false - - return range.some((val) => { - if (typeof val === 'number') { - return codePoint === val - } - else { - const [start, end] = val - return start <= codePoint && codePoint <= end - } - }) -} - -// @TODO: Support font style and weights, and make this option extensible rather -// than built-in. -// @TODO: Cover most languages with Noto Sans. -export const languageFontMap = { - 'zh-CN': 'Noto+Sans+SC', - 'zh-TW': 'Noto+Sans+TC', - 'zh-HK': 'Noto+Sans+HK', - 'ja-JP': 'Noto+Sans+JP', - 'ko-KR': 'Noto+Sans+KR', - 'th-TH': 'Noto+Sans+Thai', - 'bn-IN': 'Noto+Sans+Bengali', - 'ar-AR': 'Noto+Sans+Arabic', - 'ta-IN': 'Noto+Sans+Tamil', - 'ml-IN': 'Noto+Sans+Malayalam', - 'he-IL': 'Noto+Sans+Hebrew', - 'te-IN': 'Noto+Sans+Telugu', - 'devanagari': 'Noto+Sans+Devanagari', - 'kannada': 'Noto+Sans+Kannada', - 'symbol': ['Noto+Sans+Symbols', 'Noto+Sans+Symbols+2'], - 'math': 'Noto+Sans+Math', - 'unknown': 'Noto+Sans', -} - -const detector = new FontDetector() - -// Our own encoding of multiple fonts and their code, so we can fetch them in one request. The structure is: -// [1 byte = X, length of language code][X bytes of language code string][4 bytes = Y, length of font][Y bytes of font data] -// Note that: -// - The language code can't be longer than 255 characters. -// - The language code can't contain non-ASCII characters. -// - The font data can't be longer than 4GB. -// When there are multiple fonts, they are concatenated together. -function encodeFontInfoAsArrayBuffer(code: string, fontData: ArrayBuffer) { - // 1 byte per char - const buffer = new ArrayBuffer(1 + code.length + 4 + fontData.byteLength) - const bufferView = new Uint8Array(buffer) - // 1 byte for the length of the language code - bufferView[0] = code.length - // X bytes for the language code - for (let i = 0; i < code.length; i++) - bufferView[i + 1] = code.charCodeAt(i) - - // 4 bytes for the length of the font data - new DataView(buffer).setUint32(1 + code.length, fontData.byteLength, false) - - // Y bytes for the font data - bufferView.set(new Uint8Array(fontData), 1 + code.length + 4) - - return buffer -} - -async function fetchFont( - text: string, - font: string, -): Promise { - const API = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent( - text, - )}` - - const css: string = await $fetch(API, { - headers: { - // Make sure it returns TTF. - 'User-Agent': - 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1', - }, - }) - - const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/) - - if (!resource) - return null - - const res: ArrayBuffer = await $fetch(resource[1], { responseType: 'arrayBuffer' }) - - return res // arrayBuffer() -} - -export async function loadDynamicFont(text: string, fonts: string[]) { - const textByFont = await detector.detect(text, fonts) - - const _fonts = Object.keys(textByFont) - - const encodedFontBuffers: ArrayBuffer[] = [] - let fontBufferByteLength = 0 - ;( - await Promise.all(_fonts.map(font => fetchFont(textByFont[font], font))) - ).forEach((fontData, i) => { - if (fontData) { - // TODO: We should be able to directly get the language code here :) - const langCode = Object.entries(languageFontMap).find( - ([, v]) => v === _fonts[i], - )?.[0] - - if (langCode) { - const buffer = encodeFontInfoAsArrayBuffer(langCode, fontData) - encodedFontBuffers.push(buffer) - fontBufferByteLength += buffer.byteLength - } - } - }) - - const responseBuffer = new ArrayBuffer(fontBufferByteLength) - const responseBufferView = new Uint8Array(responseBuffer) - let offset = 0 - encodedFontBuffers.forEach((buffer) => { - responseBufferView.set(new Uint8Array(buffer), offset) - offset += buffer.byteLength - }) - - return responseBuffer -} - -const cache = new Map() - -function withCache(fn: Function) { - return async (emojiType: string, code: string, text: string) => { - const key = `${emojiType}:${code}:${text}` - if (cache.has(key)) - return cache.get(key) - const result = await fn(emojiType, code, text) - cache.set(key, result) - return result - } -} - -async function loadAsset(emojiType: keyof typeof apis, code: string, text: string) { - if (code === 'emoji') { - return ( - `data:image/svg+xml;base64,${btoa(await loadEmoji(emojiType, getIconCode(text)))}` - ) - } - // return fonts - const codes = code.split('|') - - // Try to load from Google Fonts. - const names = codes - .map(code => languageFontMap[code as keyof typeof languageFontMap]) - .filter(Boolean) - - if (names.length === 0) - return [] as SatoriOptions['fonts'] - - const fontsName: string[] = [] - for (const name of names.flat()) - fontsName.push(name) - - try { - const fonts: SatoriOptions['fonts'] = [] - // const data = await loadDynamicFont(text, fontsName) - // Decode the encoded font format. - const decodeFontInfoFromArrayBuffer = (buffer: ArrayBuffer) => { - let offset = 0 - const bufferView = new Uint8Array(buffer) - - while (offset < bufferView.length) { - // 1 byte for font name length. - const languageCodeLength = bufferView[offset] - - offset += 1 - let languageCode = '' - for (let i = 0; i < languageCodeLength; i++) - languageCode += String.fromCharCode(bufferView[offset + i]) - - offset += languageCodeLength - - // 4 bytes for font data length. - const fontDataLength = new DataView(buffer).getUint32(offset, false) - offset += 4 - const fontData = buffer.slice(offset, offset + fontDataLength) - offset += fontDataLength - - fonts.push({ - name: `satori_${languageCode}_fallback_${text}`, - data: fontData, - weight: 400, - style: 'normal', - lang: languageCode === 'unknown' ? undefined : languageCode, - }) - } - } - - const data = await loadDynamicFont(text, fontsName) - decodeFontInfoFromArrayBuffer(data) - - return fonts - } - catch (e) { - console.error('Failed to load dynamic font for', text, '. Error:', e) - return [] - } -} - -export const loadDynamicAsset = withCache(loadAsset) - -const basicFonts: SatoriOptions['fonts'] = [] -export async function initBasicFonts() { - if (basicFonts.length) - return basicFonts - // const interRegPath = join(process.cwd(), 'public', 'fonts', 'Inter-Regular.ttf') - // const InterReg = await fs.readFile(resolve(__dirname, '../../../assets/fonts/Inter-Regular.ttf')) - // const interBoldPath = join(process.cwd(), 'public', 'fonts', 'Inter-Bold.ttf') - // const InterBold = await fs.readFile(resolve(__dirname, '../../../assets/fonts/Inter-Bold.ttf')) - // const scPath = join(process.cwd(), 'public', 'fonts', 'NotoSansSC-Regular.otf') - // const NotoSansSC = await fs.readFile(resolve(__dirname, '../../../assets/fonts/NotoSansSC-Regular.otf')) - // const jpPath = join(process.cwd(), 'public', 'fonts', 'NotoSansJP-Regular.ttf') - // const NotoSansJP = await fs.readFile(jpPath) - // const uniPath = join(process.cwd(), 'public', 'fonts', 'unifont-15.0.01.otf') - // const Unifont = await fs.readFile(uniPath) - // const path = join(process.cwd(), 'public', 'fonts', 'MPLUS1p-Regular.ttf') - // const fontData = await fs.readFile(path) - - // https://unpkg.com/browse/@fontsource/inter@4.5.2/files/ - const interRegular = await $fetch('https://unpkg.com/@fontsource/inter@4.5.2/files/inter-all-400-normal.woff', { responseType: 'arrayBuffer' }) - const InterBold = await $fetch('https://unpkg.com/@fontsource/inter@4.5.2/files/inter-all-700-normal.woff', { responseType: 'arrayBuffer' }) - - basicFonts.push({ - name: 'Inter', - data: interRegular, - weight: 400, - style: 'normal', - }) - basicFonts.push({ - name: 'Inter', - data: InterBold, - weight: 700, - style: 'normal', - }) - return basicFonts as SatoriOptions['fonts'] -} diff --git a/server/nuxt3/server/utils/WebCard/index.ts b/server/nuxt3/server/utils/WebCard/index.ts deleted file mode 100644 index 17c9f77..0000000 --- a/server/nuxt3/server/utils/WebCard/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { GithubRepoMeta, TwitterTweetMeta, WebCardData, WebInfoData } from '@starnexus/core' -import { createClient } from '@supabase/supabase-js' -import { unfurl } from 'unfurl.js' - -import { errorMessage } from '@starnexus/core' -import TweetCard from './TweetCard.vue' -import CommonCard from './CommonCard.vue' - -const SUPABASE_URL = process.env.SUPABASE_URL -const STORAGE_URL = `${SUPABASE_URL}/storage/v1/object/public/pics-bed` - -export async function createWebCard(webInfo: WebInfoData): Promise { - try { - const webMeta = webInfo.meta - let imgPath = '' - let meta - let props - let card: Component | undefined - let svg = '' - let png: Blob | undefined - const res = await unfurl(webInfo.url) - - if (webMeta.siteName === 'Github') { - meta = webMeta as GithubRepoMeta - imgPath = `${webMeta.domain}/${meta.username}/${meta.reponame}.png` - if (res.open_graph && res.open_graph.images) - png = await $fetch(res.open_graph.images[0].url, { responseType: 'blob' }) - } - else if (webMeta.siteName === 'Twitter') { - card = TweetCard - meta = webMeta as TwitterTweetMeta - const screenName = meta.screenName! - const status = meta.status! - imgPath = `${webMeta.domain}/${screenName}/${status}.svg` - const content = meta.content! - let contentArr = content.split('\n').filter((l: string) => l !== '').map((l: string, i: number) => - i < 7 ? l : '...') - - if (contentArr.length > 7) - contentArr = contentArr.slice(0, 8) - - props = { - avator: meta.avator, - name: meta.name, - screenName: meta.screenName, - content: contentArr, - pubTime: meta.pubTime, - lang: meta.lang, // TODO: change to satori lang type - } - } - else { - card = CommonCard - const content = webInfo.content || res.description || 'No Content' - let contentArr = content.split('\n').filter((l: string) => l !== '').map((l: string, i: number) => - i < 7 ? l : '...') - - if (contentArr.length > 7) - contentArr = contentArr.slice(0, 8) - - const faviconPath = res.favicon?.split('/') - const favicon = (faviconPath && !faviconPath[faviconPath.length - 1].includes('.ico')) ? res.favicon : '' - props = { - title: res.title || webInfo.title, - content: contentArr, - favicon, - } - const url = webInfo.url.replace(/https?:\/\/[^/]+\/?/, '') - const filename = url.replace(/[<|>|:|"|\\|\/|\.|?|*|#|&|%|~|'|"]/g, '') - imgPath = `${webMeta.domain}/${filename}.svg` - } - - if (!imgPath) - throw new Error(`No image path for ${webMeta.siteName}`) - - // const storageResponse = await $fetch(`${STORAGE_URL}/${imgPath}?v=starnexus`) - // .then((_) => { - // return `${STORAGE_URL}/${imgPath}?v=starnexus` - // }) - // .catch((_) => { - // return '' - // }) - // if (storageResponse) { - // return { - // url: storageResponse, - // } - // } - if (!png) { - if (!card) - throw new Error(`No WebCard template for ${webMeta.siteName}`) - - const fonts = await initBasicFonts() - svg = await satori(card, { - props, - width: 1200, - height: 630, - fonts, - loadAdditionalAsset: async (code, text) => loadDynamicAsset('twemoji', code, text), - }) - } - // setHeader(event, 'Content-Type', 'image/svg+xml') - - // return svg - - const supabaseAdminClient = createClient( - process.env.SUPABASE_URL ?? '', - process.env.SUPABASE_ANON_KEY ?? '', - ) - - // Upload image to storage. - if (svg) { - const { error } = await supabaseAdminClient.storage - .from(process.env.SUPABASE_STORAGE_BUCKET || 'pics-bed') - .upload(imgPath, svg, { - contentType: 'image/svg+xml', - cacheControl: '31536000', - upsert: true, - }) - - if (error) - throw error - - return { - url: `${STORAGE_URL}/${imgPath}?v=starnexus`, - } - } - else if (png) { - const { error } = await supabaseAdminClient.storage - .from(process.env.SUPABASE_STORAGE_BUCKET || 'pics-bed') - .upload(imgPath, png, { - contentType: 'image/svg+xml', - cacheControl: '31536000', - upsert: true, - }) - - if (error) - throw error - - return { - url: `${STORAGE_URL}/${imgPath}?v=starnexus`, - } - } - else { - return { - url: `${STORAGE_URL}/star-nexus.png?v=3`, - } - } - } - catch (error) { - const message = errorMessage(error) - throw new Error(message) - } -} diff --git a/server/nuxt3/server/utils/WebCard/satori.ts b/server/nuxt3/server/utils/WebCard/satori.ts deleted file mode 100644 index f168bd3..0000000 --- a/server/nuxt3/server/utils/WebCard/satori.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AllowedComponentProps, Component, VNodeProps } from 'vue' -import { createApp, h } from 'vue' -import { renderToString } from 'vue/server-renderer' -import { html as _html } from 'satori-html' -import _satori from 'satori' -import type { SatoriOptions } from 'satori' -import { replaceHtmlReservedCharacters } from '@starnexus/core' - -export type ExtractComponentProps = - TComponent extends new () => { - $props: infer P - } - ? Omit - : never - -export async function html(component: Component, props: any) { - let Root - if (props) - Root = createApp(h(component, props)) - else - Root = createApp(component) - let strComponent = await renderToString(Root) - //
- const regex = /
<\/div>/gi - const matchs = strComponent.matchAll(regex) - for (const match of matchs) { - const [collection, name] = match[1].split(':') - const width = match[3] || 48 - const height = match[4] || 48 - let icon = await $fetch(`${process.env.ICONIFY_API}/${collection}:${name}.svg`, { responseType: 'text' }) - if (icon) { - icon = icon.replace(/width="(\d+(em)?)" height="(\d+(em)?)"/, (_) => { - return `width="${width}" height="${height}"` - }) - strComponent = strComponent.replaceAll(match[0], icon) - // console.log(strComponent) - } - } - return _html(replaceHtmlReservedCharacters(strComponent)) -} - -export async function satori(component: T, options: SatoriOptions & { - props?: ExtractComponentProps -}) { - const markup = await html(component, options.props) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - const result = await _satori(markup, options) - return result -} diff --git a/server/nuxt3/server/utils/WebCard/twemoji.ts b/server/nuxt3/server/utils/WebCard/twemoji.ts deleted file mode 100644 index 62604ad..0000000 --- a/server/nuxt3/server/utils/WebCard/twemoji.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js. - */ - -/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ -import { $fetch } from 'ofetch' - -const U200D = String.fromCharCode(8205) -const UFE0Fg = /\uFE0F/g - -export function getIconCode(char: string) { - return toCodePoint(!char.includes(U200D) ? char.replace(UFE0Fg, '') : char) -} - -function toCodePoint(unicodeSurrogates: string) { - const r = [] - let c = 0 - let p = 0 - let i = 0 - - while (i < unicodeSurrogates.length) { - c = unicodeSurrogates.charCodeAt(i++) - if (p) { - r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16)) - p = 0 - } - else if (c >= 55296 && c <= 56319) { - p = c - } - else { - r.push(c.toString(16)) - } - } - return r.join('-') -} - -type EmojiAPI = (code: string) => string -type EmojiAPIString = string -type EmojiType = 'twemoji' | 'openmoji' | 'blobmoji' | 'noto' | 'fluent' | 'fluentFlat' - -export const apis: Record = { - twemoji: (code: string) => - `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${ - code.toLowerCase() - }.svg`, - openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/', - blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/', - noto: 'https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/', - fluent: (code: string) => - `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${ - code.toLowerCase() - }_color.svg`, - fluentFlat: (code: string) => - `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${ - code.toLowerCase() - }_flat.svg`, -} - -const emojiCache: Record> = {} - -export function loadEmoji(type: EmojiType, code: string) { - const key = `${type}:${code}` - if (key in emojiCache) - return emojiCache[key] - - if (!type || !apis[type]) - type = 'twemoji' - - const api = apis[type] - if (typeof api === 'function') - return (emojiCache[key] = $fetch(api(code), { responseType: 'text' })) - - return (emojiCache[key] = $fetch(`${api}${code.toUpperCase()}.svg`, { responseType: 'text' })) -} diff --git a/server/nuxt3/server/utils/webCard/CommonCard.vue b/server/nuxt3/server/utils/webCard/CommonCard.vue deleted file mode 100644 index cddc395..0000000 --- a/server/nuxt3/server/utils/webCard/CommonCard.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/server/nuxt3/server/utils/webCard/TweetCard.vue b/server/nuxt3/server/utils/webCard/TweetCard.vue deleted file mode 100644 index 4ad256e..0000000 --- a/server/nuxt3/server/utils/webCard/TweetCard.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - diff --git a/server/nuxt3/server/utils/webCard/font.ts b/server/nuxt3/server/utils/webCard/font.ts deleted file mode 100644 index 5d35546..0000000 --- a/server/nuxt3/server/utils/webCard/font.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { $fetch } from 'ofetch' -import type { SatoriOptions } from 'satori' -import type { apis } from './twemoji' -import { getIconCode, loadEmoji } from './twemoji' - -type UnicodeRange = Array - -export class FontDetector { - private rangesByLang: { - [font: string]: UnicodeRange - } = {} - - public async detect( - text: string, - fonts: string[], - ): Promise<{ - [lang: string]: string - }> { - await this.load(fonts) - - const result: { - [lang: string]: string - } = {} - - for (const segment of text) { - const lang = this.detectSegment(segment, fonts) - if (lang) { - result[lang] = result[lang] || '' - result[lang] += segment - } - } - - return result - } - - private detectSegment(segment: string, fonts: string[]): string | null { - for (const font of fonts) { - const range = this.rangesByLang[font] - if (range && checkSegmentInRange(segment, range)) - return font - } - - return null - } - - private async load(fonts: string[]): Promise { - let params = '' - - const existingLang = Object.keys(this.rangesByLang) - const langNeedsToLoad = fonts.filter(font => !existingLang.includes(font)) - - if (langNeedsToLoad.length === 0) - return - - for (const font of langNeedsToLoad) - params += `family=${font}&` - - params += 'display=swap' - - const API = `https://fonts.googleapis.com/css2?${params}` - - const fontFace: string = await $fetch(API, { - headers: { - // Make sure it returns TTF. - 'User-Agent': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', - }, - }) - - this.addDetectors(fontFace) - } - - private addDetectors(input: string) { - const regex = /font-family:\s*'(.+?)';.+?unicode-range:\s*(.+?);/gms - const matches = input.matchAll(regex) - - for (const [, _lang, range] of matches) { - const lang = _lang.replaceAll(' ', '+') - if (!this.rangesByLang[lang]) - this.rangesByLang[lang] = [] - - this.rangesByLang[lang].push(...convert(range)) - } - } -} - -function convert(input: string): UnicodeRange { - return input.split(', ').map((range) => { - range = range.replaceAll('U+', '') - const [start, end] = range.split('-').map(hex => parseInt(hex, 16)) - - if (isNaN(end)) - return start - - return [start, end] - }) -} - -function checkSegmentInRange(segment: string, range: UnicodeRange): boolean { - const codePoint = segment.codePointAt(0) - - if (!codePoint) - return false - - return range.some((val) => { - if (typeof val === 'number') { - return codePoint === val - } - else { - const [start, end] = val - return start <= codePoint && codePoint <= end - } - }) -} - -// @TODO: Support font style and weights, and make this option extensible rather -// than built-in. -// @TODO: Cover most languages with Noto Sans. -export const languageFontMap = { - 'zh-CN': 'Noto+Sans+SC', - 'zh-TW': 'Noto+Sans+TC', - 'zh-HK': 'Noto+Sans+HK', - 'ja-JP': 'Noto+Sans+JP', - 'ko-KR': 'Noto+Sans+KR', - 'th-TH': 'Noto+Sans+Thai', - 'bn-IN': 'Noto+Sans+Bengali', - 'ar-AR': 'Noto+Sans+Arabic', - 'ta-IN': 'Noto+Sans+Tamil', - 'ml-IN': 'Noto+Sans+Malayalam', - 'he-IL': 'Noto+Sans+Hebrew', - 'te-IN': 'Noto+Sans+Telugu', - 'devanagari': 'Noto+Sans+Devanagari', - 'kannada': 'Noto+Sans+Kannada', - 'symbol': ['Noto+Sans+Symbols', 'Noto+Sans+Symbols+2'], - 'math': 'Noto+Sans+Math', - 'unknown': 'Noto+Sans', -} - -const detector = new FontDetector() - -// Our own encoding of multiple fonts and their code, so we can fetch them in one request. The structure is: -// [1 byte = X, length of language code][X bytes of language code string][4 bytes = Y, length of font][Y bytes of font data] -// Note that: -// - The language code can't be longer than 255 characters. -// - The language code can't contain non-ASCII characters. -// - The font data can't be longer than 4GB. -// When there are multiple fonts, they are concatenated together. -function encodeFontInfoAsArrayBuffer(code: string, fontData: ArrayBuffer) { - // 1 byte per char - const buffer = new ArrayBuffer(1 + code.length + 4 + fontData.byteLength) - const bufferView = new Uint8Array(buffer) - // 1 byte for the length of the language code - bufferView[0] = code.length - // X bytes for the language code - for (let i = 0; i < code.length; i++) - bufferView[i + 1] = code.charCodeAt(i) - - // 4 bytes for the length of the font data - new DataView(buffer).setUint32(1 + code.length, fontData.byteLength, false) - - // Y bytes for the font data - bufferView.set(new Uint8Array(fontData), 1 + code.length + 4) - - return buffer -} - -async function fetchFont( - text: string, - font: string, -): Promise { - const API = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent( - text, - )}` - - const css: string = await $fetch(API, { - headers: { - // Make sure it returns TTF. - 'User-Agent': - 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1', - }, - }) - - const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/) - - if (!resource) - return null - - const res: ArrayBuffer = await $fetch(resource[1], { responseType: 'arrayBuffer' }) - - return res // arrayBuffer() -} - -export async function loadDynamicFont(text: string, fonts: string[]) { - const textByFont = await detector.detect(text, fonts) - - const _fonts = Object.keys(textByFont) - - const encodedFontBuffers: ArrayBuffer[] = [] - let fontBufferByteLength = 0 - ;( - await Promise.all(_fonts.map(font => fetchFont(textByFont[font], font))) - ).forEach((fontData, i) => { - if (fontData) { - // TODO: We should be able to directly get the language code here :) - const langCode = Object.entries(languageFontMap).find( - ([, v]) => v === _fonts[i], - )?.[0] - - if (langCode) { - const buffer = encodeFontInfoAsArrayBuffer(langCode, fontData) - encodedFontBuffers.push(buffer) - fontBufferByteLength += buffer.byteLength - } - } - }) - - const responseBuffer = new ArrayBuffer(fontBufferByteLength) - const responseBufferView = new Uint8Array(responseBuffer) - let offset = 0 - encodedFontBuffers.forEach((buffer) => { - responseBufferView.set(new Uint8Array(buffer), offset) - offset += buffer.byteLength - }) - - return responseBuffer -} - -const cache = new Map() - -function withCache(fn: Function) { - return async (emojiType: string, code: string, text: string) => { - const key = `${emojiType}:${code}:${text}` - if (cache.has(key)) - return cache.get(key) - const result = await fn(emojiType, code, text) - cache.set(key, result) - return result - } -} - -async function loadAsset(emojiType: keyof typeof apis, code: string, text: string) { - if (code === 'emoji') { - return ( - `data:image/svg+xml;base64,${btoa(await loadEmoji(emojiType, getIconCode(text)))}` - ) - } - // return fonts - const codes = code.split('|') - - // Try to load from Google Fonts. - const names = codes - .map(code => languageFontMap[code as keyof typeof languageFontMap]) - .filter(Boolean) - - if (names.length === 0) - return [] as SatoriOptions['fonts'] - - const fontsName: string[] = [] - for (const name of names.flat()) - fontsName.push(name) - - try { - const fonts: SatoriOptions['fonts'] = [] - // const data = await loadDynamicFont(text, fontsName) - // Decode the encoded font format. - const decodeFontInfoFromArrayBuffer = (buffer: ArrayBuffer) => { - let offset = 0 - const bufferView = new Uint8Array(buffer) - - while (offset < bufferView.length) { - // 1 byte for font name length. - const languageCodeLength = bufferView[offset] - - offset += 1 - let languageCode = '' - for (let i = 0; i < languageCodeLength; i++) - languageCode += String.fromCharCode(bufferView[offset + i]) - - offset += languageCodeLength - - // 4 bytes for font data length. - const fontDataLength = new DataView(buffer).getUint32(offset, false) - offset += 4 - const fontData = buffer.slice(offset, offset + fontDataLength) - offset += fontDataLength - - fonts.push({ - name: `satori_${languageCode}_fallback_${text}`, - data: fontData, - weight: 400, - style: 'normal', - lang: languageCode === 'unknown' ? undefined : languageCode, - }) - } - } - - const data = await loadDynamicFont(text, fontsName) - decodeFontInfoFromArrayBuffer(data) - - return fonts - } - catch (e) { - console.error('Failed to load dynamic font for', text, '. Error:', e) - return [] - } -} - -export const loadDynamicAsset = withCache(loadAsset) - -const basicFonts: SatoriOptions['fonts'] = [] -export async function initBasicFonts() { - if (basicFonts.length) - return basicFonts - // const interRegPath = join(process.cwd(), 'public', 'fonts', 'Inter-Regular.ttf') - // const InterReg = await fs.readFile(resolve(__dirname, '../../../assets/fonts/Inter-Regular.ttf')) - // const interBoldPath = join(process.cwd(), 'public', 'fonts', 'Inter-Bold.ttf') - // const InterBold = await fs.readFile(resolve(__dirname, '../../../assets/fonts/Inter-Bold.ttf')) - // const scPath = join(process.cwd(), 'public', 'fonts', 'NotoSansSC-Regular.otf') - // const NotoSansSC = await fs.readFile(resolve(__dirname, '../../../assets/fonts/NotoSansSC-Regular.otf')) - // const jpPath = join(process.cwd(), 'public', 'fonts', 'NotoSansJP-Regular.ttf') - // const NotoSansJP = await fs.readFile(jpPath) - // const uniPath = join(process.cwd(), 'public', 'fonts', 'unifont-15.0.01.otf') - // const Unifont = await fs.readFile(uniPath) - // const path = join(process.cwd(), 'public', 'fonts', 'MPLUS1p-Regular.ttf') - // const fontData = await fs.readFile(path) - - // https://unpkg.com/browse/@fontsource/inter@4.5.2/files/ - const interRegular = await $fetch('https://unpkg.com/@fontsource/inter@4.5.2/files/inter-all-400-normal.woff', { responseType: 'arrayBuffer' }) - const InterBold = await $fetch('https://unpkg.com/@fontsource/inter@4.5.2/files/inter-all-700-normal.woff', { responseType: 'arrayBuffer' }) - - basicFonts.push({ - name: 'Inter', - data: interRegular, - weight: 400, - style: 'normal', - }) - basicFonts.push({ - name: 'Inter', - data: InterBold, - weight: 700, - style: 'normal', - }) - return basicFonts as SatoriOptions['fonts'] -} diff --git a/server/nuxt3/server/utils/webCard/index.ts b/server/nuxt3/server/utils/webCard/index.ts deleted file mode 100644 index 17c9f77..0000000 --- a/server/nuxt3/server/utils/webCard/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { GithubRepoMeta, TwitterTweetMeta, WebCardData, WebInfoData } from '@starnexus/core' -import { createClient } from '@supabase/supabase-js' -import { unfurl } from 'unfurl.js' - -import { errorMessage } from '@starnexus/core' -import TweetCard from './TweetCard.vue' -import CommonCard from './CommonCard.vue' - -const SUPABASE_URL = process.env.SUPABASE_URL -const STORAGE_URL = `${SUPABASE_URL}/storage/v1/object/public/pics-bed` - -export async function createWebCard(webInfo: WebInfoData): Promise { - try { - const webMeta = webInfo.meta - let imgPath = '' - let meta - let props - let card: Component | undefined - let svg = '' - let png: Blob | undefined - const res = await unfurl(webInfo.url) - - if (webMeta.siteName === 'Github') { - meta = webMeta as GithubRepoMeta - imgPath = `${webMeta.domain}/${meta.username}/${meta.reponame}.png` - if (res.open_graph && res.open_graph.images) - png = await $fetch(res.open_graph.images[0].url, { responseType: 'blob' }) - } - else if (webMeta.siteName === 'Twitter') { - card = TweetCard - meta = webMeta as TwitterTweetMeta - const screenName = meta.screenName! - const status = meta.status! - imgPath = `${webMeta.domain}/${screenName}/${status}.svg` - const content = meta.content! - let contentArr = content.split('\n').filter((l: string) => l !== '').map((l: string, i: number) => - i < 7 ? l : '...') - - if (contentArr.length > 7) - contentArr = contentArr.slice(0, 8) - - props = { - avator: meta.avator, - name: meta.name, - screenName: meta.screenName, - content: contentArr, - pubTime: meta.pubTime, - lang: meta.lang, // TODO: change to satori lang type - } - } - else { - card = CommonCard - const content = webInfo.content || res.description || 'No Content' - let contentArr = content.split('\n').filter((l: string) => l !== '').map((l: string, i: number) => - i < 7 ? l : '...') - - if (contentArr.length > 7) - contentArr = contentArr.slice(0, 8) - - const faviconPath = res.favicon?.split('/') - const favicon = (faviconPath && !faviconPath[faviconPath.length - 1].includes('.ico')) ? res.favicon : '' - props = { - title: res.title || webInfo.title, - content: contentArr, - favicon, - } - const url = webInfo.url.replace(/https?:\/\/[^/]+\/?/, '') - const filename = url.replace(/[<|>|:|"|\\|\/|\.|?|*|#|&|%|~|'|"]/g, '') - imgPath = `${webMeta.domain}/${filename}.svg` - } - - if (!imgPath) - throw new Error(`No image path for ${webMeta.siteName}`) - - // const storageResponse = await $fetch(`${STORAGE_URL}/${imgPath}?v=starnexus`) - // .then((_) => { - // return `${STORAGE_URL}/${imgPath}?v=starnexus` - // }) - // .catch((_) => { - // return '' - // }) - // if (storageResponse) { - // return { - // url: storageResponse, - // } - // } - if (!png) { - if (!card) - throw new Error(`No WebCard template for ${webMeta.siteName}`) - - const fonts = await initBasicFonts() - svg = await satori(card, { - props, - width: 1200, - height: 630, - fonts, - loadAdditionalAsset: async (code, text) => loadDynamicAsset('twemoji', code, text), - }) - } - // setHeader(event, 'Content-Type', 'image/svg+xml') - - // return svg - - const supabaseAdminClient = createClient( - process.env.SUPABASE_URL ?? '', - process.env.SUPABASE_ANON_KEY ?? '', - ) - - // Upload image to storage. - if (svg) { - const { error } = await supabaseAdminClient.storage - .from(process.env.SUPABASE_STORAGE_BUCKET || 'pics-bed') - .upload(imgPath, svg, { - contentType: 'image/svg+xml', - cacheControl: '31536000', - upsert: true, - }) - - if (error) - throw error - - return { - url: `${STORAGE_URL}/${imgPath}?v=starnexus`, - } - } - else if (png) { - const { error } = await supabaseAdminClient.storage - .from(process.env.SUPABASE_STORAGE_BUCKET || 'pics-bed') - .upload(imgPath, png, { - contentType: 'image/svg+xml', - cacheControl: '31536000', - upsert: true, - }) - - if (error) - throw error - - return { - url: `${STORAGE_URL}/${imgPath}?v=starnexus`, - } - } - else { - return { - url: `${STORAGE_URL}/star-nexus.png?v=3`, - } - } - } - catch (error) { - const message = errorMessage(error) - throw new Error(message) - } -} diff --git a/server/nuxt3/server/utils/webCard/satori.ts b/server/nuxt3/server/utils/webCard/satori.ts deleted file mode 100644 index f168bd3..0000000 --- a/server/nuxt3/server/utils/webCard/satori.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AllowedComponentProps, Component, VNodeProps } from 'vue' -import { createApp, h } from 'vue' -import { renderToString } from 'vue/server-renderer' -import { html as _html } from 'satori-html' -import _satori from 'satori' -import type { SatoriOptions } from 'satori' -import { replaceHtmlReservedCharacters } from '@starnexus/core' - -export type ExtractComponentProps = - TComponent extends new () => { - $props: infer P - } - ? Omit - : never - -export async function html(component: Component, props: any) { - let Root - if (props) - Root = createApp(h(component, props)) - else - Root = createApp(component) - let strComponent = await renderToString(Root) - //
- const regex = /
<\/div>/gi - const matchs = strComponent.matchAll(regex) - for (const match of matchs) { - const [collection, name] = match[1].split(':') - const width = match[3] || 48 - const height = match[4] || 48 - let icon = await $fetch(`${process.env.ICONIFY_API}/${collection}:${name}.svg`, { responseType: 'text' }) - if (icon) { - icon = icon.replace(/width="(\d+(em)?)" height="(\d+(em)?)"/, (_) => { - return `width="${width}" height="${height}"` - }) - strComponent = strComponent.replaceAll(match[0], icon) - // console.log(strComponent) - } - } - return _html(replaceHtmlReservedCharacters(strComponent)) -} - -export async function satori(component: T, options: SatoriOptions & { - props?: ExtractComponentProps -}) { - const markup = await html(component, options.props) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - const result = await _satori(markup, options) - return result -} diff --git a/server/nuxt3/server/utils/webCard/twemoji.ts b/server/nuxt3/server/utils/webCard/twemoji.ts deleted file mode 100644 index 62604ad..0000000 --- a/server/nuxt3/server/utils/webCard/twemoji.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js. - */ - -/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ -import { $fetch } from 'ofetch' - -const U200D = String.fromCharCode(8205) -const UFE0Fg = /\uFE0F/g - -export function getIconCode(char: string) { - return toCodePoint(!char.includes(U200D) ? char.replace(UFE0Fg, '') : char) -} - -function toCodePoint(unicodeSurrogates: string) { - const r = [] - let c = 0 - let p = 0 - let i = 0 - - while (i < unicodeSurrogates.length) { - c = unicodeSurrogates.charCodeAt(i++) - if (p) { - r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16)) - p = 0 - } - else if (c >= 55296 && c <= 56319) { - p = c - } - else { - r.push(c.toString(16)) - } - } - return r.join('-') -} - -type EmojiAPI = (code: string) => string -type EmojiAPIString = string -type EmojiType = 'twemoji' | 'openmoji' | 'blobmoji' | 'noto' | 'fluent' | 'fluentFlat' - -export const apis: Record = { - twemoji: (code: string) => - `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${ - code.toLowerCase() - }.svg`, - openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/', - blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/', - noto: 'https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/', - fluent: (code: string) => - `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${ - code.toLowerCase() - }_color.svg`, - fluentFlat: (code: string) => - `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${ - code.toLowerCase() - }_flat.svg`, -} - -const emojiCache: Record> = {} - -export function loadEmoji(type: EmojiType, code: string) { - const key = `${type}:${code}` - if (key in emojiCache) - return emojiCache[key] - - if (!type || !apis[type]) - type = 'twemoji' - - const api = apis[type] - if (typeof api === 'function') - return (emojiCache[key] = $fetch(api(code), { responseType: 'text' })) - - return (emojiCache[key] = $fetch(`${api}${code.toUpperCase()}.svg`, { responseType: 'text' })) -}