Skip to content

Commit

Permalink
feat: Add experimental onNodeDetected event (#457)
Browse files Browse the repository at this point in the history
Relates to #223 and partially solves it. There're two categories of
discoverable nodes: HTML nodes and SVG nodes. The HTML nodes are just
the same as the input, which is easier to reason about: we just emit the
input nodes with resolved layout and styling information. This can be
thought as the "preprocessing step" of the data.

Then, the SVG nodes are a little bit tricky because they're not a 1:1
mapping from the input nodes. For example a `<div style={{ boxShadow:
'...' }}>` might be generated as a collection of SVG nodes including
`<defs>`, `<filter>` and `<rect>`.

However, in the future we can still emit structured data for these in a
`onNodeGenerated` event, containing the mapping information via a `from`
field:

```html
<div key="k" id="i" className="c" style={{ boxShadow: '...' }}>
```

Emits:

```js
onNodeDetected: #0 = { type: 'div', key: 'k', width, height, left, top, props }
```

And:

```js
onNodeGenerated: {
  from: #0,
  defs: DefNode[],
  elements: SVGElementNode[]
}
```

It apparently needs more discussion to get the best structured data
design. The generated node can be converted to SVG easily but not
necessarily (i.e. can be rendered in an imperative way such as canvas
too). Then we can get #233 fully supported.
  • Loading branch information
shuding authored Apr 23, 2023
1 parent 44ce1a7 commit c1469ee
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 1 deletion.
31 changes: 30 additions & 1 deletion src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ export interface LayoutContext {
canLoadAdditionalAssets: boolean
locale?: Locale
getTwStyles: (tw: string, style: any) => any
onNodeDetected?: (node: SatoriNode) => void
}

export interface SatoriNode {
// Layout information.
left: number
top: number
width: number
height: number
type: string
key?: string | number
props: Record<string, any>
textContent?: string
}

export default async function* layout(
Expand Down Expand Up @@ -64,7 +77,7 @@ export default async function* layout(
return ''
}

// Not a normal element.
// Not a regular element.
if (!isReactElement(element) || typeof element.type === 'function') {
let iter: ReturnType<typeof layout>

Expand Down Expand Up @@ -160,6 +173,7 @@ export default async function* layout(
canLoadAdditionalAssets,
locale: newLocale,
getTwStyles,
onNodeDetected: context.onNodeDetected,
})
if (canLoadAdditionalAssets) {
segmentsMissingFont.push(...(((await iter.next()).value as any) || []))
Expand All @@ -182,6 +196,21 @@ export default async function* layout(
let baseRenderResult = ''
let depsRenderResult = ''

// Emit event for the current node. We don't pass the children prop to the
// event handler because everything is already flattened, unless it's a text
// node.
const { children: childrenNode, ...restProps } = props
context.onNodeDetected?.({
left,
top,
width,
height,
type,
props: restProps,
key: element.key,
textContent: isReactElement(childrenNode) ? undefined : childrenNode,
})

// Generate the rendered markup for the current node.
if (type === 'img') {
const src = computedStyle.__src as string
Expand Down
4 changes: 4 additions & 0 deletions src/satori.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import type { TwConfig } from 'twrnc'
import type { SatoriNode } from './layout.js'

import getYoga, { init } from './yoga/index.js'
import layout from './layout.js'
Expand Down Expand Up @@ -33,7 +34,9 @@ export type SatoriOptions = (
segment: string
) => Promise<string | Array<FontOptions>>
tailwindConfig?: TwConfig
onNodeDetected?: (node: SatoriNode) => void
}
export type { SatoriNode }

export { init }

Expand Down Expand Up @@ -107,6 +110,7 @@ export default async function satori(
debug: options.debug,
graphemeImages,
canLoadAdditionalAssets: !!options.loadAdditionalAsset,
onNodeDetected: options.onNodeDetected,
getTwStyles: (tw, style) => {
const twToStyles = getTw({
width: definedWidth,
Expand Down
67 changes: 67 additions & 0 deletions test/event.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { it, describe, expect } from 'vitest'

import { initFonts } from './utils.js'
import satori from '../src/index.js'

describe('Event', () => {
let fonts
initFonts((f) => (fonts = f))

it('should trigger the onNodeDetected callback', async () => {
const nodes = []
await satori(
<div style={{ width: '100%', height: 50, display: 'flex' }}>
<div>Hello</div>
<div>World</div>
</div>,
{
width: 100,
height: 100,
fonts,
onNodeDetected: (node) => {
nodes.push(node)
},
}
)
expect(nodes).toMatchInlineSnapshot(`
[
{
"height": 50,
"key": null,
"left": 0,
"props": {
"style": {
"display": "flex",
"height": 50,
"width": "100%",
},
},
"textContent": undefined,
"top": 0,
"type": "div",
"width": 100,
},
{
"height": 50,
"key": null,
"left": 0,
"props": {},
"textContent": "Hello",
"top": 0,
"type": "div",
"width": 37,
},
{
"height": 50,
"key": null,
"left": 37,
"props": {},
"textContent": "World",
"top": 0,
"type": "div",
"width": 41,
},
]
`)
})
})

1 comment on commit c1469ee

@vercel
Copy link

@vercel vercel bot commented on c1469ee Apr 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.