bookmark: fix up double request and rework extractor (#3856)

This code has started to bitrot a bit and this freshens it up a bit.
- there's a double request happening for every bookmark paste at the
moment, yikes! One request originates from the paste logic, and the
other originates from the `onBeforeCreate` in `BookmarkShapeUtil`. They
both see that an asset is missing and race to make the request at the
same time. It _seems_ like we don't need the `onBeforeCreate` anymore.
But, if I'm mistaken on some edge case here lemme know and we can
address this in a different way.
- the extractor is really crusty (the grabity code is from 5 yrs ago and
hasn't been updated) and we don't have control over it. i've worked on
unfurling stuff before with Paper and my other projects and this reworks
things to use Cheerio, which is a more robust library.
- this adds `favicon` to the response request which should usually
default to the apple-touch-icon. this helps with some better bookmark
displays (e.g. like Wikipedia if an image is empty)

In general, this'll start to make this more maintainable and improvable
on our end.

Double request:
<img width="1496" alt="Screenshot 2024-05-31 at 17 54 49"
src="https://github.com/tldraw/tldraw/assets/469604/22033170-caaa-4fd2-854f-f19b61611978">

Before:
<img width="355" alt="Screenshot 2024-05-31 at 17 55 02"
src="https://github.com/tldraw/tldraw/assets/469604/fd272669-ee52-4cc7-bed7-72a8ed8d53a0">

After:
<img width="351" alt="Screenshot 2024-05-31 at 17 55 44"
src="https://github.com/tldraw/tldraw/assets/469604/87d27342-0d49-4cfc-a811-356370562d19">


### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [x] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Test pasting links in, and pasting again.

### Release Notes

- Bookmarks: fix up double request and rework extractor code.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Mime Čuvalo 2024-06-10 11:50:49 +01:00 committed by GitHub
parent 4dab25be57
commit ccb6b918c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1016 additions and 4084 deletions

View file

@ -1,25 +1,18 @@
// @ts-expect-error
import grabity from 'grabity'
import { unfurl } from '../lib/unfurl'
import { runCorsMiddleware } from './_cors'
interface RequestBody {
url: string
}
interface ResponseBody {
title?: string
description?: string
image?: string
}
export default async function handler(req: any, res: any) {
try {
await runCorsMiddleware(req, res)
const { url } = typeof req.body === 'string' ? JSON.parse(req.body) : (req.body as RequestBody)
const it = await grabity.grabIt(url)
res.send(it)
const results = await unfurl(url)
res.send(results)
} catch (error: any) {
console.error(error)
res.status(500).send(error.message)
res.status(422).send(error.message)
}
}

View file

@ -0,0 +1,40 @@
import cheerio from 'cheerio'
export async function unfurl(url: string) {
const response = await fetch(url)
if (response.status >= 400) {
throw new Error(`Error fetching url: ${response.status}`)
}
const contentType = response.headers.get('content-type')
if (!contentType?.includes('text/html')) {
throw new Error(`Content-type not right: ${contentType}`)
}
const content = await response.text()
const $ = cheerio.load(content)
const og: { [key: string]: string | undefined } = {}
const twitter: { [key: string]: string | undefined } = {}
$('meta[property^=og:]').each((_, el) => (og[$(el).attr('property')!] = $(el).attr('content')))
$('meta[name^=twitter:]').each((_, el) => (twitter[$(el).attr('name')!] = $(el).attr('content')))
const title = og['og:title'] ?? twitter['twitter:title'] ?? $('title').text() ?? undefined
const description =
og['og:description'] ??
twitter['twitter:description'] ??
$('meta[name="description"]').attr('content') ??
undefined
const image = og['og:image:secure_url'] ?? og['og:image'] ?? twitter['twitter:image'] ?? undefined
const favicon =
$('link[rel="apple-touch-icon"]').attr('href') ??
$('link[rel="icon"]').attr('href') ??
undefined
return {
title,
description,
image,
favicon,
}
}

View file

@ -8,16 +8,19 @@
"email": "hello@tldraw.com"
},
"scripts": {
"run-local": "vercel dev",
"lint": "yarn run -T tsx ../../scripts/lint.ts"
},
"dependencies": {
"@types/cors": "^2.8.15",
"cors": "^2.8.5",
"grabity": "^1.0.5",
"tslib": "^2.6.2"
"cheerio": "1.0.0-rc.12",
"cors": "^2.8.5"
},
"devDependencies": {
"@types/cheerio": "0.22.33",
"@types/cors": "^2.8.15",
"lazyrepo": "0.0.0-alpha.27",
"typescript": "^5.3.3"
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vercel": "^34.2.4"
}
}

View file

@ -1,5 +1,4 @@
{
"include": ["api"],
"exclude": ["node_modules", "dist", ".tsbuild*", ".vercel"],
"compilerOptions": {
"composite": true,

View file

@ -1,10 +1,11 @@
import { AssetRecordType, TLAsset, getHashForString, truncateStringWithEllipsis } from 'tldraw'
import { AssetRecordType, TLAsset, getHashForString } from 'tldraw'
import { BOOKMARK_ENDPOINT } from './config'
interface ResponseBody {
title?: string
description?: string
image?: string
favicon?: string
}
export async function createAssetFromUrl({ url }: { type: 'url'; url: string }): Promise<TLAsset> {
@ -30,14 +31,15 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
src: url,
description: meta.description ?? '',
image: meta.image ?? '',
title: meta.title ?? truncateStringWithEllipsis(url, 32),
favicon: meta.favicon ?? '',
title: meta.title ?? '',
},
meta: {},
}
} catch (error) {
// Otherwise, fallback to fetching data from the url
let meta: { image: string; title: string; description: string }
let meta: { image: string; favicon: string; title: string; description: string }
try {
const resp = await fetch(url, {
@ -49,15 +51,22 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
const doc = new DOMParser().parseFromString(html, 'text/html')
meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title:
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
truncateStringWithEllipsis(url, 32),
favicon:
doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
'',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
}
// Resolve relative URLs
if (meta.image.startsWith('/')) {
const urlObj = new URL(url)
meta.image = `${urlObj.origin}${meta.image}`
}
} catch (error) {
console.error(error)
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
meta = { image: '', favicon: '', title: '', description: '' }
}
// Create the bookmark asset from the meta
@ -68,6 +77,7 @@ export async function createAssetFromUrl({ url }: { type: 'url'; url: string }):
props: {
src: url,
image: meta.image,
favicon: meta.favicon,
title: meta.title,
description: meta.description,
},

View file

@ -1,10 +1,6 @@
import { AssetRecordType, TLAsset, TLExternalAssetContent, getHashForString } from 'tldraw'
import { rpc } from './rpc'
export const truncateStringWithEllipsis = (str: string, maxLength: number) => {
return str.length <= maxLength ? str : str.substring(0, maxLength - 3) + '...'
}
export async function onCreateAssetFromUrl({
url,
}: TLExternalAssetContent & { type: 'url' }): Promise<TLAsset> {
@ -20,14 +16,15 @@ export async function onCreateAssetFromUrl({
src: url,
description: meta.description ?? '',
image: meta.image ?? '',
title: meta.title ?? truncateStringWithEllipsis(url, 32),
favicon: meta.favicon ?? '',
title: meta.title ?? '',
},
meta: {},
}
} catch (error) {
// Otherwise, fallback to fetching data from the url
let meta: { image: string; title: string; description: string }
let meta: { image: string; favicon: string; title: string; description: string }
try {
const resp = await fetch(url, {
@ -39,15 +36,17 @@ export async function onCreateAssetFromUrl({
const doc = new DOMParser().parseFromString(html, 'text/html')
meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title:
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
truncateStringWithEllipsis(url, 32),
favicon:
doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
'',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? '',
description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
}
} catch (error) {
console.error(error)
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
meta = { image: '', favicon: '', title: '', description: '' }
}
// Create the bookmark asset from the meta
@ -58,6 +57,7 @@ export async function onCreateAssetFromUrl({
props: {
src: url,
image: meta.image,
favicon: meta.favicon,
title: meta.title,
description: meta.description,
},

View file

@ -97,6 +97,7 @@ export class WebViewMessageHandler {
title: json.title,
description: json.description,
image: json.image,
favicon: json.favicon,
},
})
})

View file

@ -13,6 +13,7 @@ type BookmarkResponse = {
title?: string
description?: string
image?: string
favicon?: string
}
}

View file

@ -103,7 +103,7 @@
"rimraf": "^4.4.0",
"tsx": "^4.0.0",
"typescript": "^5.3.3",
"vercel": "^28.16.15"
"vercel": "^34.2.4"
},
"// resolutions.canvas": [
"our examples app depenends on pdf.js which pulls in canvas as an optional dependency.",

View file

@ -973,6 +973,7 @@ input,
height: 100%;
object-fit: cover;
object-position: center;
border-bottom: 1px solid var(--color-muted-2);
}
.tl-bookmark__copy_container {
@ -991,27 +992,63 @@ input,
.tl-bookmark__heading {
font-size: 16px;
line-height: 1.5;
font-weight: bold;
padding-bottom: var(--space-2);
margin: 8px 0px;
overflow: hidden;
max-height: calc((16px * 1.5) * 2);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
display: -webkit-box;
}
.tl-bookmark__description {
font-size: 12px;
padding-bottom: var(--space-4);
line-height: 1.5;
overflow: hidden;
max-height: calc((12px * 1.5) * 3);
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
text-overflow: ellipsis;
display: -webkit-box;
color: var(--color-text-2);
margin: var(--space-2) 0px;
}
.tl-bookmark__heading + .tl-bookmark__link,
.tl-bookmark__description + .tl-bookmark__link {
margin-top: var(--space-3);
}
.tl-bookmark__link {
font-size: 14px;
font-size: 12px;
pointer-events: all;
z-index: var(--layer-bookmark-link);
overflow: hidden;
display: block;
color: var(--color-text);
text-overflow: ellipsis;
text-decoration: none;
color: var(--color-text-1);
display: flex;
color: var(--color-text-2);
align-items: center;
cursor: var(--tl-cursor-pointer);
width: fit-content;
max-width: 100%;
}
.tl-bookmark__link > span {
flex-shrink: 0px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tl-bookmark__link > .tl-hyperlink__icon {
margin-right: 8px;
flex-shrink: 0;
}
.tl-bookmark__link > .tl-bookmark__favicon {
margin-right: 8px;
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* ---------------- Hyperlink Button ---------------- */
@ -1056,9 +1093,9 @@ input,
color: var(--color-selected);
}
.tl-hyperlink-button__icon {
width: 18px;
height: 18px;
.tl-hyperlink__icon {
width: 16px;
height: 16px;
background-color: currentColor;
pointer-events: none;
}

View file

@ -91,7 +91,6 @@ import { TLKeyboardEvent } from '@tldraw/editor';
import { TLKeyboardEventInfo } from '@tldraw/editor';
import { TLLineShape } from '@tldraw/editor';
import { TLNoteShape } from '@tldraw/editor';
import { TLOnBeforeCreateHandler } from '@tldraw/editor';
import { TLOnBeforeUpdateHandler } from '@tldraw/editor';
import { TLOnDoubleClickHandler } from '@tldraw/editor';
import { TLOnEditEndHandler } from '@tldraw/editor';
@ -261,7 +260,25 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
// (undocumented)
static migrations: TLPropsMigrations;
// (undocumented)
onBeforeCreate?: TLOnBeforeCreateHandler<TLBookmarkShape>;
onBeforeCreate: (next: TLBookmarkShape) => {
id: TLShapeId;
index: IndexKey;
isLocked: boolean;
meta: JsonObject;
opacity: number;
parentId: TLParentId;
props: {
assetId: null | TLAssetId;
h: number;
url: string;
w: number;
};
rotation: number;
type: "bookmark";
typeName: "shape";
x: number;
y: number;
};
// (undocumented)
onBeforeUpdate?: TLOnBeforeUpdateHandler<TLBookmarkShape>;
// (undocumented)

View file

@ -24,7 +24,7 @@ import { TLUiToastsContextType } from './ui/context/toasts'
import { useTranslation } from './ui/hooks/useTranslation/useTranslation'
import { containBoxSize, downsizeImage } from './utils/assets/assets'
import { getEmbedInfo } from './utils/embeds/embeds'
import { cleanupText, isRightToLeftLanguage, truncateStringWithEllipsis } from './utils/text/text'
import { cleanupText, isRightToLeftLanguage } from './utils/text/text'
/** @public */
export interface TLExternalContentProps {
@ -110,7 +110,7 @@ export function registerDefaultExternalContentHandlers(
// urls -> bookmark asset
editor.registerExternalAssetHandler('url', async ({ url }) => {
let meta: { image: string; title: string; description: string }
let meta: { image: string; favicon: string; title: string; description: string }
try {
const resp = await fetch(url, {
@ -122,9 +122,11 @@ export function registerDefaultExternalContentHandlers(
const doc = new DOMParser().parseFromString(html, 'text/html')
meta = {
image: doc.head.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '',
title:
doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ??
truncateStringWithEllipsis(url, 32),
favicon:
doc.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href') ??
doc.head.querySelector('link[rel="icon"]')?.getAttribute('href') ??
'',
title: doc.head.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? url,
description:
doc.head.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '',
}
@ -134,7 +136,7 @@ export function registerDefaultExternalContentHandlers(
title: msg('assets.url.failed'),
severity: 'error',
})
meta = { image: '', title: truncateStringWithEllipsis(url, 32), description: '' }
meta = { image: '', favicon: '', title: '', description: '' }
}
// Create the bookmark asset from the meta
@ -146,6 +148,7 @@ export function registerDefaultExternalContentHandlers(
src: url,
description: meta.description,
image: meta.image,
favicon: meta.favicon,
title: meta.title,
},
meta: {},

View file

@ -7,7 +7,6 @@ import {
TLAssetId,
TLBookmarkAsset,
TLBookmarkShape,
TLOnBeforeCreateHandler,
TLOnBeforeUpdateHandler,
bookmarkShapeMigrations,
bookmarkShapeProps,
@ -16,10 +15,15 @@ import {
stopEventPropagation,
toDomPrecision,
} from '@tldraw/editor'
import { truncateStringWithEllipsis } from '../../utils/text/text'
import { HyperlinkButton } from '../shared/HyperlinkButton'
import { LINK_ICON } from '../shared/icons-editor'
import { getRotatedBoxShadow } from '../shared/rotated-box-shadow'
const BOOKMARK_WIDTH = 300
const BOOKMARK_HEIGHT = 320
const BOOKMARK_JUST_URL_HEIGHT = 46
const SHORT_BOOKMARK_HEIGHT = 110
/** @public */
export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
static override type = 'bookmark' as const
@ -33,8 +37,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
override getDefaultProps(): TLBookmarkShape['props'] {
return {
url: '',
w: 300,
h: 320,
w: BOOKMARK_WIDTH,
h: BOOKMARK_HEIGHT,
assetId: null,
}
}
@ -56,8 +60,9 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
boxShadow: getRotatedBoxShadow(pageRotation),
}}
>
{(!asset || asset.props.image) && (
<div className="tl-bookmark__image_container">
{asset?.props.image ? (
{asset ? (
<img
className="tl-bookmark__image"
draggable={false}
@ -68,19 +73,18 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
) : (
<div className="tl-bookmark__placeholder" />
)}
{asset?.props.image && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
)}
</div>
)}
<div className="tl-bookmark__copy_container">
{asset?.props.title && (
<h2 className="tl-bookmark__heading">
{truncateStringWithEllipsis(asset?.props.title || '', 54)}
</h2>
)}
{asset?.props.description && (
<p className="tl-bookmark__description">
{truncateStringWithEllipsis(asset?.props.description || '', 128)}
</p>
)}
{asset?.props.title ? (
<h2 className="tl-bookmark__heading">{asset.props.title}</h2>
) : null}
{asset?.props.description && asset?.props.image ? (
<p className="tl-bookmark__description">{asset.props.description}</p>
) : null}
<a
className="tl-bookmark__link"
href={shape.props.url || ''}
@ -90,7 +94,23 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
onPointerUp={stopEventPropagation}
onClick={stopEventPropagation}
>
{truncateStringWithEllipsis(address, 45)}
{asset?.props.favicon ? (
<img
className="tl-bookmark__favicon"
src={asset?.props.favicon}
referrerPolicy="strict-origin-when-cross-origin"
alt={`favicon of ${address}`}
/>
) : (
<div
className="tl-hyperlink__icon"
style={{
mask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
WebkitMask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
}}
/>
)}
<span>{address}</span>
</a>
</div>
</div>
@ -109,8 +129,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
)
}
override onBeforeCreate?: TLOnBeforeCreateHandler<TLBookmarkShape> = (shape) => {
updateBookmarkAssetOnUrlChange(this.editor, shape)
override onBeforeCreate = (next: TLBookmarkShape) => {
return getBookmarkSize(this.editor, next)
}
override onBeforeUpdate?: TLOnBeforeUpdateHandler<TLBookmarkShape> = (prev, shape) => {
@ -121,6 +141,36 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
updateBookmarkAssetOnUrlChange(this.editor, shape)
}
}
if (prev.props.assetId !== shape.props.assetId) {
return getBookmarkSize(this.editor, shape)
}
}
}
function getBookmarkSize(editor: Editor, shape: TLBookmarkShape) {
const asset = (
shape.props.assetId ? editor.getAsset(shape.props.assetId) : null
) as TLBookmarkAsset
let h = BOOKMARK_HEIGHT
if (asset) {
if (!asset.props.image) {
if (!asset.props.title) {
h = BOOKMARK_JUST_URL_HEIGHT
} else {
h = SHORT_BOOKMARK_HEIGHT
}
}
}
return {
...shape,
props: {
...shape.props,
h,
},
}
}
@ -128,8 +178,8 @@ export class BookmarkShapeUtil extends BaseBoxShapeUtil<TLBookmarkShape> {
export const getHumanReadableAddress = (shape: TLBookmarkShape) => {
try {
const url = new URL(shape.props.url)
const path = url.pathname.replace(/\/*$/, '')
return `${url.hostname}${path}`
// we want the hostname without any www
return url.hostname.replace(/^www\./, '')
} catch (e) {
return shape.props.url
}

View file

@ -19,7 +19,7 @@ export function HyperlinkButton({ url, zoomLevel }: { url: string; zoomLevel: nu
draggable={false}
>
<div
className="tl-hyperlink-button__icon"
className="tl-hyperlink__icon"
style={{
mask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
WebkitMask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,

View file

@ -0,0 +1,2 @@
export const LINK_ICON =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' fill='none'%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M13 5H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6M19 5h6m0 0v6m0-6L13 17'/%3E%3C/svg%3E"

View file

@ -16,7 +16,7 @@ export async function pasteUrl(
point?: VecLike,
sources?: TLExternalContentSource[]
) {
// Lets see if its an image and we have CORs
// Lets see if its an image and we have CORS
try {
// skip this step if the url doesn't contain an image extension, treat it as a regular bookmark
if (new URL(url).pathname.match(/\.(png|jpe?g|gif|svg|webp)$/i)) {

View file

@ -81,12 +81,12 @@ describe('The URL formatter', () => {
const e = editor.getShape<TLBookmarkShape>(ids.e)!
const f = editor.getShape<TLBookmarkShape>(ids.f)!
expect(getHumanReadableAddress(a)).toBe('www.github.com')
expect(getHumanReadableAddress(b)).toBe('www.github.com')
expect(getHumanReadableAddress(c)).toBe('www.github.com/TodePond')
expect(getHumanReadableAddress(d)).toBe('www.github.com/TodePond')
expect(getHumanReadableAddress(e)).toBe('www.github.com')
expect(getHumanReadableAddress(f)).toBe('www.github.com/TodePond/DreamBerd')
expect(getHumanReadableAddress(a)).toBe('github.com')
expect(getHumanReadableAddress(b)).toBe('github.com')
expect(getHumanReadableAddress(c)).toBe('github.com')
expect(getHumanReadableAddress(d)).toBe('github.com')
expect(getHumanReadableAddress(e)).toBe('github.com')
expect(getHumanReadableAddress(f)).toBe('github.com')
})
it("Doesn't resize bookmarks", () => {

View file

@ -976,6 +976,7 @@ export type TLBindingUpdate<T extends TLBinding = TLBinding> = Expand<{
// @public
export type TLBookmarkAsset = TLBaseAsset<'bookmark', {
description: string;
favicon: string;
image: string;
src: null | string;
title: string;

View file

@ -13,6 +13,7 @@ export type TLBookmarkAsset = TLBaseAsset<
title: string
description: string
image: string
favicon: string
src: string | null
}
>
@ -24,12 +25,14 @@ export const bookmarkAssetValidator: T.Validator<TLBookmarkAsset> = createAssetV
title: T.string,
description: T.string,
image: T.string,
favicon: T.string,
src: T.srcUrl.nullable(),
})
)
const Versions = createMigrationIds('com.tldraw.asset.bookmark', {
MakeUrlsValid: 1,
AddFavicon: 2,
} as const)
export { Versions as bookmarkAssetVersions }
@ -51,5 +54,16 @@ export const bookmarkAssetMigrations = createRecordMigrationSequence({
// noop
},
},
{
id: Versions.AddFavicon,
up: (asset: any) => {
if (!T.srcUrl.isValid(asset.props.favicon)) {
asset.props.favicon = ''
}
},
down: (asset: any) => {
delete asset.props.favicon
},
},
],
})

View file

@ -1314,6 +1314,32 @@ describe('Make urls valid for all the assets', () => {
}
})
describe('Ensure favicons are on bookmarks', () => {
const { up, down } = getTestMigration(bookmarkAssetVersions.AddFavicon)
it('up works as expected', () => {
expect(
up({
props: {},
})
).toEqual({
props: {
favicon: '',
},
})
})
it('down works as expected', () => {
expect(
down({
props: {
favicon: 'https://tldraw.com/favicon.ico',
},
})
).toEqual({
props: {},
})
})
})
describe('Add duplicate props to instance', () => {
const { up, down } = getTestMigration(instanceVersions.AddDuplicateProps)
it('up works as expected', () => {

4711
yarn.lock

File diff suppressed because it is too large Load diff