Skip to content

Commit 07c6695

Browse files
committed
Style inline images properly
1 parent 37eaf44 commit 07c6695

File tree

2 files changed

+69
-62
lines changed

2 files changed

+69
-62
lines changed
Lines changed: 38 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
/**
2-
* @import {Element, Root} from 'hast'
2+
* @import {Root} from 'hast'
33
*/
44

5-
/**
6-
* This is a private fork of https://github.com/rehypejs/rehype-unwrap-images to work around a Nextra bug.
7-
* See https://github.com/rehypejs/rehype-unwrap-images/pull/1 for why this won't be merged upstream.
8-
*/
9-
10-
import { interactive } from 'hast-util-interactive'
115
import { whitespace } from 'hast-util-whitespace'
126
import { SKIP, visit } from 'unist-util-visit'
137

14-
const unknown = 1
15-
const containsImage = 2
16-
const containsOther = 3
8+
const isImage = (node) =>
9+
(node.type === 'element' && node.tagName === 'img') || (node.type === 'mdxJsxFlowElement' && node.name === 'img')
1710

1811
/**
19-
* Remove the wrapping paragraph for images.
12+
* This plugin does two things:
13+
* 1. Removes the `p` tag when it only contains images (and whitespace)
14+
* 2. Adds data attributes to help with image rendering:
15+
* - `data-wrapping-image` to links that contain an image
16+
* - `data-inline-image` to images that are in a paragraph with other content (text, links, etc.)
2017
*
2118
* @returns
2219
* Transform.
@@ -32,54 +29,38 @@ export default function rehypeUnwrapImages() {
3229
*/
3330
return function (tree) {
3431
visit(tree, 'element', function (node, index, parent) {
35-
if (node.tagName === 'p' && parent && typeof index === 'number' && applicable(node, false) === containsImage) {
36-
parent.children.splice(index, 1, ...node.children)
37-
return [SKIP, index]
38-
}
39-
})
40-
}
41-
}
42-
43-
/**
44-
* Check if a node can be unraveled.
45-
*
46-
* @param {Element} node
47-
* Node.
48-
* @param {boolean} inLink
49-
* Whether the node is in a link.
50-
* @returns {1 | 2 | 3}
51-
* Info.
52-
*/
53-
function applicable(node, inLink) {
54-
/** @type {1 | 2 | 3} */
55-
let image = unknown
56-
let index = -1
57-
58-
while (++index < node.children.length) {
59-
const child = node.children[index]
32+
if (node.tagName === 'p' && parent && typeof index === 'number') {
33+
// First pass: check if the paragraph contains any non-image content
34+
const hasNonImageContent = node.children.some((child) => {
35+
if (child.type === 'text') {
36+
return !whitespace(child.value)
37+
} else {
38+
return !isImage(child)
39+
}
40+
})
6041

61-
if (child.type === 'text' && whitespace(child.value)) {
62-
// Whitespace is fine.
63-
} else if (
64-
(child.type === 'element' && child.tagName === 'img') ||
65-
(child.type === 'mdxJsxFlowElement' && child.name === 'img')
66-
) {
67-
image = containsImage
68-
} else if (!inLink && interactive(child)) {
69-
// Cast as `interactive` is always `Element`.
70-
const linkResult = applicable(/** @type {Element} */ (child), true)
42+
// Second pass: add data attributes
43+
node.children.forEach((child) => {
44+
if (child.type === 'element' && child.tagName === 'a' && child.children.some(isImage)) {
45+
child.properties = child.properties || {}
46+
child.properties['data-wrapping-image'] = true
47+
} else if (isImage(child) && hasNonImageContent) {
48+
if (child.type === 'mdxJsxFlowElement') {
49+
child.attributes = child.attributes || []
50+
child.attributes.push({ type: 'mdxJsxAttribute', name: 'data-inline-image', value: true })
51+
} else {
52+
child.properties = child.properties || {}
53+
child.properties['data-inline-image'] = true
54+
}
55+
}
56+
})
7157

72-
if (linkResult === containsOther) {
73-
return containsOther
58+
// If the paragraph only contains images (and whitespace), remove it
59+
if (!hasNonImageContent) {
60+
parent.children.splice(index, 1, ...node.children)
61+
return [SKIP, index]
62+
}
7463
}
75-
76-
if (linkResult === containsImage) {
77-
image = containsImage
78-
}
79-
} else {
80-
return containsOther
81-
}
64+
})
8265
}
83-
84-
return image
8566
}

website/src/layout/Layout.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import merge from 'lodash/merge'
2+
import NextImage from 'next/image'
23
import { NextSeo, type NextSeoProps } from 'next-seo'
34
import type { NextraThemeLayoutProps } from 'nextra'
45
import { useFSRoute, useRouter } from 'nextra/hooks'
56
import { MDXProvider } from 'nextra/mdx'
67
import { normalizePages } from 'nextra/normalize-pages'
7-
import { type ReactNode, useEffect, useState } from 'react'
8+
import { type ComponentProps, createContext, type ReactNode, useContext, useEffect, useState } from 'react'
89

910
import type { WithOptional } from '@edgeandnode/common'
1011
import {
1112
ButtonOrLink,
13+
classNames,
1214
ExperimentalAppLauncher,
1315
ExperimentalButton,
1416
ExperimentalCodeInline,
1517
ExperimentalLink,
18+
type ExperimentalLinkProps,
1619
ExperimentalLocaleSwitcher,
1720
ExperimentalNavLink,
1821
type NestedStrings,
@@ -44,6 +47,7 @@ import {
4447
DocSearch,
4548
Heading,
4649
Image,
50+
type ImageProps,
4751
NavigationGroup,
4852
NavigationItem,
4953
Table,
@@ -521,18 +525,17 @@ export default function Layout({ pageOpts, children }: NextraThemeLayoutProps<Fr
521525
<div>
522526
<MDXProvider
523527
components={{
524-
// TODO: Get rid of the `as any`s
525-
a: ExperimentalLink as any,
526-
Link: ExperimentalLink,
528+
a: LinkWrapper,
527529
blockquote: Callout,
530+
// TODO: Get rid of the `as any`
528531
code: ExperimentalCodeInline as any,
529532
h1: Heading.H1,
530533
h2: Heading.H2,
531534
h3: Heading.H3,
532535
h4: Heading.H4,
533536
h5: Heading.H5,
534537
h6: Heading.H6,
535-
img: Image,
538+
img: ImageWrapper,
536539
// TODO: Fix "[Shiki] X instances have been created. Shiki is supposed to be used as a singleton" warnings
537540
pre: CodeBlock,
538541
// TODO: Build and use `ExperimentalTable`
@@ -605,3 +608,26 @@ export default function Layout({ pageOpts, children }: NextraThemeLayoutProps<Fr
605608
</LayoutContext.Provider>
606609
)
607610
}
611+
612+
const LinkContext = createContext<{} | null>(null)
613+
614+
function LinkWrapper({ children, ...props }: ComponentProps<'a'>) {
615+
if ('data-wrapping-image' in props) {
616+
return (
617+
<LinkContext.Provider value={{}}>
618+
<a {...props}>{children}</a>
619+
</LinkContext.Provider>
620+
)
621+
}
622+
return <ExperimentalLink {...(props as ExperimentalLinkProps.ExternalLinkProps)}>{children}</ExperimentalLink>
623+
}
624+
625+
function ImageWrapper({ src: passedSrc, className, ...props }: ImageProps) {
626+
const linkContext = useContext(LinkContext)
627+
if (linkContext || 'data-inline-image' in props) {
628+
const src =
629+
typeof passedSrc === 'object' ? ('default' in passedSrc ? passedSrc.default.src : passedSrc.src) : passedSrc
630+
return <img src={src} alt="" className={classNames(['inline-block', className])} {...props} />
631+
}
632+
return <Image src={passedSrc} className={className} {...props} />
633+
}

0 commit comments

Comments
 (0)