remcohaszing / remark-mdx-images

A remark plugin for changing image sources to JavaScript imports using MDX
MIT License
26 stars 6 forks source link

Ability to add width and height to the image #3

Closed collegewap closed 7 months ago

collegewap commented 3 years ago

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

remcohaszing commented 3 years ago

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?

GorvGoyl commented 3 years ago

@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.

GorvGoyl commented 3 years ago

@collegewap I found one remark plugin for that https://github.com/arobase-che/remark-attr

remcohaszing commented 3 years ago

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.

GorvGoyl commented 3 years ago
<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.

remcohaszing commented 3 years ago

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.

remcohaszing commented 3 years ago

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.

GorvGoyl commented 3 years ago
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

tino-brst commented 3 years ago

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)
remcohaszing commented 3 years ago

@AgustinBrst

That’s really cool!

I have some small suggestions:

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.

tino-brst commented 3 years ago

Thanks for the suggestions @remcohaszing! 😄🙌

keenwon commented 2 years ago

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

wooorm commented 2 years ago

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.

keenwon commented 2 years ago

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
wooorm commented 2 years ago

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

helmturner commented 1 year ago

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!

remcohaszing commented 7 months ago

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.