remcohaszing / remark-mdx-images

A remark plugin for changing image sources to JavaScript imports using MDX

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Ability to add width and height to the image

collegewap opened this issue · comments

I am using this package along with mdx-bundler and Next Image. It would be great if width and height can be added to local images. This will help in avoiding cumulative layout shift

Thanks for opening this issue proposal! I think it’s a neat idea to support this.

Currently this plugin only supports markdown style images, which only support alt, src, and title. I think the best solution would be to support <img /> tags in addition to. markdown images.

Input:

<img alt="Nessie" src="./nessie.png" title="The Loch Ness monster" width="480" height="320" />

Output (simplified):

import __nessie__ from './nessie.png';

export default function MDXContent() {
  return <img alt="Nessie" src={__nessie__} title="The Loch Ness monster" width="480" height="320" />
}

Of course img can be a custom implementation using components

@collegewap would this solve your problem?

@remcohaszing I was about to create the similar issue but saw your comment. I'd very much like if you could also support img tag. In many cases there's a need to add custom styling or css classes which is not possible with image markdown ![]() syntax.

I just remembered I use a different solution to apply custom styling. I added some utility classes which can be applied on a preceding <span> element.

<span class="is-480x320"></span>

[Nessie](nessie.png 'The Loch Ness monster')
.is-480x320 {
  display: none;
}

.is-480x320 + img {
  width: 480px;
  height: 320px;
}

This is just a workaround. I still want to implement the proposed solution.

<span class="is-480x320"></span>

[Nessie](nessie.png 'The Loch Ness monster')
.is-480x320 {
  display: none;
}

.is-480x320 + img {
  width: 480px;
  height: 320px;
}

Wow! a neat trick w/o breaking md syntax.

Still not actively working on this, but I keep thinking about this issue.

This only applies to markdown (.md) files, right? Not MDX (.mdx) files.

When using MDX files, one could simply use the following instead:

import CustomImage from './custom-image';
import nessie from './nessie.png';

<CustomImage src={nessie} customProp="foo" />

There’s a difference between markdown and MDX on an AST level which really makes a difference of how this should work.

After some chatting with @wooorm he came up with the idea to use determine the image width and height from the file on disk, then insert those values as props.

I believe this is also a great solution to tackle the problem in the OP, which is to avoid cumulative layout shift. This also means the markdown content doesn’t have to change. However, this solution doesn’t align with the goal of this remark plugin, which is to make bundlers resolve images sources. This could be created as a separate plugin.

import CustomImage from './custom-image';
import nessie from './nessie.png';

<CustomImage src={nessie} customProp="foo" />

Issue with this approach is it's not portable i.e. images won't render on static markdown viewers like on Github, obsidian etc.
However <img alt="Nessie" src="./nessie.png" title="The Loch Ness monster" width="480" height="320" /> would render just fine on both markdown viewers and mdx tools like mdx bundler

Wrote a plugin to do what @remcohaszing suggested, passing the images' width and height as props (any feedback/improvements more than welcome!).

JavaScript version

import { visit } from 'unist-util-visit'
import { is } from 'unist-util-is'
import getImageSize from 'image-size'

const rehypeImageSizes = (options) => {
  return (tree) => {
    visit(tree, (node) => {
      if (
        !is(node, { type: 'element', tagName: 'img' }) ||
        !node.properties ||
        typeof node.properties.src !== 'string'
      ) {
        return
      }

      const imagePath = `${options?.root ?? ''}${node.properties.src}`
      const imageSize = getImageSize(imagePath)

      node.properties.width = imageSize.width
      node.properties.height = imageSize.height
    })
  }
}

export { rehypeImageSizes }

TypeScript version

import type { Plugin } from 'unified'
import { Root, Element } from 'hast'
import { visit } from 'unist-util-visit'
import { is } from 'unist-util-is'
import getImageSize from 'image-size'

type Options = Partial<{
  /** Images root directory. Used to build the path for each image `(path = root + image.src`). */
  root: string
}>

const rehypeImageSizes: Plugin<[Options?], Root> = (options) => {
  return (tree) => {
    visit(tree, (node) => {
      if (
        !is<Element>(node, { type: 'element', tagName: 'img' }) ||
        !node.properties ||
        typeof node.properties.src !== 'string'
      ) {
        return
      }

      const imagePath = `${options?.root ?? ''}${node.properties.src}`
      const imageSize = getImageSize(imagePath)

      node.properties.width = imageSize.width
      node.properties.height = imageSize.height
    })
  }
}

export { rehypeImageSizes }

And on the bundleMDX function use it as one of the plugins, setting the root folder for the images:

bundleMDX(mdxSource, {
    xdmOptions: (options) => ({
      ...options,
      rehypePlugins: [
        ...(options.rehypePlugins ?? []),
        [rehypeImageSizes, { root: `${process.cwd()}/public` }],
      ],
    }),
  })

On mdx files:

// Before
<Image 
  src="/images/image.jpg"
  alt="An image"
  width="500"
  height="250"
/>

// After
![An image](/images/image.jpg)

@AgustinBrst

That’s really cool!

I have some small suggestions:

  • unist-util-visit already accepts an optional unist test, so unist-util-is can be removed.
  • Typically unified/remark/rehype plugins use a default export. This makes them work with for example unified-engine. I like named exports, but I plan to migrate my plugins to default exports soon.
  • The transformer accepts a vfile as a second argument. You may want to use that instead of root.
  • Add tests to make sure this works with ![](path/to/img) and ![](./path/to/img)
import type { Plugin } from 'unified'
import { Root, Element } from 'hast'
import { visit } from 'unist-util-visit'
import getImageSize from 'image-size'

const rehypeImageSizes: Plugin<[], Root> = () => {
  return (tree, file) => {
    // XXX Not sure if the `Element` type annotation is even necessary.
    visit(tree, { type: 'element', tagName: 'img' }, (node: Element) => {
      if (typeof node?.properties.src !== 'string') {
        return
      }

      const imagePath = `${file.path}${node.properties.src}`
      const imageSize = getImageSize(imagePath)

      node.properties.width = imageSize.width
      node.properties.height = imageSize.height
    })
  }
}

export default rehypeImageSizes

Unfortunately this won’t work with remark-mdx-images, as it would have to run first. Luckily I probably need to turn this into a rehype plugin anyway to support <img /> tags in markdown.

Thanks for the suggestions @remcohaszing! 😄🙌

Why use rehype instead of remark, it seems like a good feature to add to remark-mdx-images

commented

Because rehype is for HTML. It’s the place where you deal with elements, attributes, etc. More: https://github.com/remarkjs/remark-rehype#what-is-this.

Maybe I didn't express myself clearly

I copied the remark-mdx-images to my local and made some changes

import { dirname, join } from 'path'
import { visit } from 'unist-util-visit'
import sizeOf from 'image-size'

const urlPattern = /^(https?:)?\//
const relativePathPattern = /\.\.?\//

const remarkMdxImages =
  ({ resolve = true } = {}) =>
  (ast, file) => {
    const imports = []
    const imported = new Map()

    visit(ast, 'image', (node, index, parent) => {
      let { alt = null, title, url } = node
      if (urlPattern.test(url)) {
        return
      }
      if (!relativePathPattern.test(url) && resolve) {
        url = `./${url}`
      }

      let name = imported.get(url)
      if (!name) {
        name = `__${imported.size}_${url.replace(/\W/g, '_')}__`

        imports.push({
          type: 'mdxjsEsm',
          value: '',
          data: {
            estree: {
              type: 'Program',
              sourceType: 'module',
              body: [
                {
                  type: 'ImportDeclaration',
                  source: { type: 'Literal', value: url, raw: JSON.stringify(url) },
                  specifiers: [
                    {
                      type: 'ImportDefaultSpecifier',
                      local: { type: 'Identifier', name },
                    },
                  ],
                },
              ],
            },
          },
        })
        imported.set(url, name)
      }

      const textElement = {
        type: 'mdxJsxTextElement',
        name: 'img',
        children: [],
        attributes: [
          { type: 'mdxJsxAttribute', name: 'alt', value: alt },
          {
            type: 'mdxJsxAttribute',
            name: 'src',
            value: {
              type: 'mdxJsxAttributeValueExpression',
              value: name,
              data: {
                estree: {
                  type: 'Program',
                  sourceType: 'module',
                  comments: [],
                  body: [
                    {
                      type: 'ExpressionStatement',
                      expression: { type: 'Identifier', name },
                    },
                  ],
                },
              },
            },
          },
        ],
      }

      if (title) {
        textElement.attributes.push({
          type: 'mdxJsxAttribute',
          name: 'title',
          value: title,
        })
      }

      const imagePath = join(dirname(file.path), url)
      const imageSize = sizeOf(imagePath)

      textElement.attributes.push(
        ...[
          {
            type: 'mdxJsxAttribute',
            name: 'width',
            value: imageSize.width,
          },
          {
            type: 'mdxJsxAttribute',
            name: 'height',
            value: imageSize.height,
          },
        ],
      )

      parent.children.splice(index, 1, textElement)
    })

    ast.children.unshift(...imports)
  }

export default remarkMdxImages
const components = {
  img: (props: any) => <img {...props} loading="lazy" />
}

<MDXProvider components={components}>
  // ...
</MDXProvider>
{
  test: /\.mdx/,
  exclude: /node_modules/,
  use: [
    'babel-loader',
    {
      loader: '@mdx-js/loader',
      options: {
        remarkPlugins: [
          remarkGfm,
          remarkMdxImages,
          remarkDirective,
          admonitionsPlugin,
        ],
        providerImportSource: '@mdx-js/react',
      },
    },
    {
      loader: getAbsPath('scripts/mdx-loader/index.cjs'),
    },
  ],
},

image

image

commented

There is no question in your comment? I don’t get it.

A little late to the party, but you may find @helmturner/recma-next-static-images helpful. I was running into the issue of images not working with the NextJS Image component, which requires either A) height & width or B) top-level static import. I'm actively developing it, and it's working well with my current project. I recently published it to npm. I apologize for the lack of a readme - that's coming up soon!

Quick notes: All local and remote images are cached at parse time in a folder of your choosing. As I'm typing this, I realize I need to handle local images differently because there will be duplication of local images (one in the original location, one in the cache directory).

Also, images are resolved from the file where they are referenced. For example, a markdown file in src/pages/blog/index.md would resolve ./image.png to src/pages/blog/image.png.

It's a bit more verbose than remark-mdx-images, so I'll be sure to borrow some patterns you've used here to see if I can clean it up a bit!

I created rehype-mdx-import-media as a replacement for remark-mdx-images. Since it’s a rehype plugin, it can run after other rehype plugins, meaning other any transforms are supported.