Skip to content

Style inline images properly #980

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "graph-docs",
"private": true,
"version": "1.0.0",
"packageManager": "pnpm@10.11.1",
"packageManager": "pnpm@10.12.1",
"scripts": {
"dev": "turbo run dev",
"build": "NODE_OPTIONS='--max_old_space_size=8192' turbo run build",
Expand All @@ -21,7 +21,7 @@
},
"devDependencies": {
"@edgeandnode/eslint-config": "^2.0.3",
"@types/node": "^22.15.29",
"@types/node": "^22.15.31",
"eslint": "^8.57.1",
"eslint-plugin-mdx": "^3.4.2",
"prettier": "^3.5.3",
Expand Down
4 changes: 2 additions & 2 deletions packages/og-image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
"dependencies": {
"@resvg/resvg-wasm": "^2.6.2",
"react": "^18.3.1",
"satori": "^0.13.2",
"satori": "^0.15.2",
"yoga-wasm-web": "^0.3.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250605.0",
"@cloudflare/workers-types": "^4.20250610.0",
"@types/react": "^18.3.23",
"jest-image-snapshot": "^6.5.1",
"tsx": "^4.19.4",
Expand Down
95 changes: 38 additions & 57 deletions packages/rehype-unwrap-images/index.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
/**
* @import {Element, Root} from 'hast'
* @import {Root} from 'hast'
*/

/**
* This is a private fork of https://github.com/rehypejs/rehype-unwrap-images to work around a Nextra bug.
* See https://github.com/rehypejs/rehype-unwrap-images/pull/1 for why this won't be merged upstream.
*/

import { interactive } from 'hast-util-interactive'
import { whitespace } from 'hast-util-whitespace'
import { SKIP, visit } from 'unist-util-visit'

const unknown = 1
const containsImage = 2
const containsOther = 3
const isImage = (node) =>
(node.type === 'element' && node.tagName === 'img') || (node.type === 'mdxJsxFlowElement' && node.name === 'img')

/**
* Remove the wrapping paragraph for images.
* This plugin does two things:
* 1. Removes the `p` tag when it only contains images (and whitespace)
* 2. Adds data attributes to help with image rendering:
* - `data-wrapping-image` to links that contain an image
* - `data-inline-image` to images that are in a paragraph with other content (text, links, etc.)
*
* @returns
* Transform.
Expand All @@ -32,54 +29,38 @@ export default function rehypeUnwrapImages() {
*/
return function (tree) {
visit(tree, 'element', function (node, index, parent) {
if (node.tagName === 'p' && parent && typeof index === 'number' && applicable(node, false) === containsImage) {
parent.children.splice(index, 1, ...node.children)
return [SKIP, index]
}
})
}
}

/**
* Check if a node can be unraveled.
*
* @param {Element} node
* Node.
* @param {boolean} inLink
* Whether the node is in a link.
* @returns {1 | 2 | 3}
* Info.
*/
function applicable(node, inLink) {
/** @type {1 | 2 | 3} */
let image = unknown
let index = -1

while (++index < node.children.length) {
const child = node.children[index]
if (node.tagName === 'p' && parent && typeof index === 'number') {
// First pass: check if the paragraph contains any non-image content
const hasNonImageContent = node.children.some((child) => {
if (child.type === 'text') {
return !whitespace(child.value)
} else {
return !isImage(child)
}
})

if (child.type === 'text' && whitespace(child.value)) {
// Whitespace is fine.
} else if (
(child.type === 'element' && child.tagName === 'img') ||
(child.type === 'mdxJsxFlowElement' && child.name === 'img')
) {
image = containsImage
} else if (!inLink && interactive(child)) {
// Cast as `interactive` is always `Element`.
const linkResult = applicable(/** @type {Element} */ (child), true)
// Second pass: add data attributes
node.children.forEach((child) => {
if (child.type === 'element' && child.tagName === 'a' && child.children.some(isImage)) {
child.properties = child.properties || {}
child.properties['data-wrapping-image'] = true
} else if (isImage(child) && hasNonImageContent) {
if (child.type === 'mdxJsxFlowElement') {
child.attributes = child.attributes || []
child.attributes.push({ type: 'mdxJsxAttribute', name: 'data-inline-image', value: true })
} else {
child.properties = child.properties || {}
child.properties['data-inline-image'] = true
}
}
})

if (linkResult === containsOther) {
return containsOther
// If the paragraph only contains images (and whitespace), remove it
if (!hasNonImageContent) {
parent.children.splice(index, 1, ...node.children)
return [SKIP, index]
}
}

if (linkResult === containsImage) {
image = containsImage
}
} else {
return containsOther
}
})
}

return image
}
Loading