pngwn / MDsveX

A markdown preprocessor for Svelte.
https://mdsvex.pngwn.io
MIT License
2.36k stars 103 forks source link

Copy button on code blocks #385

Open sswatson opened 2 years ago

sswatson commented 2 years ago

I'd like to customize the code blocks, for example by adding a copy button and allowing them to appear in a numbered list. I'm able to achieve this with a custom Svelte component, but it sort of defeats the purpose if one can't write one's content in Markdown. I'm wondering what might be the most sensible/feasible way to do this kind of customization.

babichjacob commented 2 years ago

(Disclaimer: I didn't test this)

I think you could write an mdsvex layout that replaces the pre tag with a custom component. You can put a button that pulls the innerText (I think) of the slot the custom component is given and uses the clipboard API to copy it there.

sswatson commented 2 years ago

Thanks for the reply. From what I can tell by experimenting, it appears that the custom components mechanism doesn't work for pre tags. I suppose another option might be to use a custom code_highlight function. I'll keep tinkering, and if I come up with a good solution, I'll post it here.

sswatson commented 2 years ago

Just a follow-up: I did solve this problem, but it required forking mdsvex.

In short, if you change the highlight function to return a string that starts with <Code... in your mdsvex.config.js, you can use the imports highlighter option I added in the fork to make your custom Code component available. For example, my mdsvex.config.js looks like this (yes, the highlighter function is ugly):

import Prism from 'prismjs';
import 'prismjs/components/prism-python.js';

import { escapeSvelte } from '@sswatson/mdsvex';

function highlighter(code, lang) {
  return `<Code code="${
    escapeSvelte(code.replace(/\"/g, "&#34;"))
    }" highlighted="${
    escapeSvelte(Prism.highlight(
      code.trim(), 
      Prism.languages[lang], 
      lang
    ).replace(/\"/g, "&#34;"))
  }" lang="${lang}"
  />`;
}

const config = {
  layout: './layout.svelte',
  highlight: {
    highlighter,
    imports: "import Code from '$lib/Code.svelte';",
  }
}
pngwn commented 2 years ago

It is definitely not necessary to fork the library to achieve this; it can be done in a plugin. mdsvex could probably make this easier though.

sswatson commented 2 years ago

I thought about that, but I didn't have the confidence that I could inject the extra import in a robust way. My reasoning was that you have to "find your place" if you do it through a plugin, whereas it's really easy to see where the extra import statement goes in the mdsvex source code. Given that I'm not an expert on these kinds of tools, though, I can easily believe that the plugin route is easier.

kresnasatya commented 2 years ago

It is definitely not necessary to fork the library to achieve this; it can be done in a plugin. mdsvex could probably make this easier though.

Hi @pngwn, could you please give us example how to achieve this by use mdsvex? Thank you.

Masstronaut commented 2 years ago

+1 would also like to see this. I've tried using a custom mdsvex layout file to provide a custom component to replace <pre> elements, but <pre> seems to be an exception to the feature as the replacement doesn't occur. Changing the layout to export the component as code replaces the code elements for inline code, but not for code blocks. Neither of these is really a workable solution.

Furthermore, for this case particularly - replacing the original pre elements with a custom one would break prism styling if the same attributes aren't added to the <pre> in my custom component.

Is there a path forward that I'm not seeing?

woss commented 2 years ago

It is definitely not necessary to fork the library to achieve this; it can be done in a plugin. mdsvex could probably make this easier though.

introducing the plugin system or a hook for a highlight would be really useful, we could write anything then! Copy button, executable snippet ...

@sswatson approach seems really good and clean. We can us it to wrap all our logic in a single Svelte component

EDIT:

so i spent 8 hours figuring out how to do this WITHOUT forking and there is no way! even if i use the mdsvex on the fly ( not config file) and the compile i get stuck with the import errors. It would also help us a lot the know EXACTLY the order of the transformations especially when the highlighter kicks in.

Cahllagerfeld commented 2 years ago

Hey all, I wanted to share the MVP I came up with for adding a copy button to code fences: See this PR on the Gitpod-Website. The approach I took, was using this Gist for injecting an import to all .md-files. This custom code-fence component then gets called from the custom highlighter function.

I can't say anything on how resilient this solution is just yet, but from the first tests and the Netlify-Preview-deployments, it seems to work.

artemkovalyov commented 1 year ago

Having this sorted in a neat way would be a killer feature. If Prism is kept as a default highlighter, which is a reasonable idea, why not exposing its API?

a96lex commented 1 year ago

I did my own workaround without checking the issues first :)

I am sharing in case it helps someone in the future.

I created a component to handle this for my blog, which I am building now. It is basically a javascript file that selects all pre elements with class starting with language- and adds a click event listener in which I copy the pre.innerText to the clipboard. THe full file has some styling and tooltip to display when the code has been copied.

CopyCodeInjector (summarized)

<script lang="ts">
    import { onMount } from "svelte";

    onMount(() => {
        // will add a children to any <pre> element with class language-*
        let pres: HTMLCollection = document.getElementsByTagName("pre");
        for (let _ of pres) {
            const pre = _ as HTMLPreElement;
            if (![...pre.classList].some((el) => el.startsWith("language-"))) {
                continue;
            }
            const text = pre.innerText;
            let copyButton = document.createElement("button");
            copyButton.addEventListener(
                "click",
                () => (navigator.clipboard.writeText(text))
            );
            copyButton.className = "copy";
            copyButton.innerText = "Copy";
            pre.appendChild(copyButton);
        }
    });
</script>

<slot />

To use this, I basically need to wrap the content extracted from the markdown files like so:

+page.ts

import { error } from "@sveltejs/kit";

export const load = async ({ params }: { params: { slug: string } }) => {
    try {   
        const post = await import(`../../../lib/blogEntries/${params.slug}.md`);

        return {
            PostContent: post.default,
            meta: { ...post.metadata, slug: params.slug }
        };
    } catch (err) {
        console.log(err);
        throw error(404);
    }

};

+page.svelte (summarized)

<script lang="ts">
    import CopyCodeInjector from "$lib/components/CopyCodeInjector.svelte";
    export let data;
    const Content = data.PostContent; // this is post.default
</script>

<CopyCodeInjector>
    <Content /> 
</CopyCodeInjector>
janosh commented 1 year ago

Lowest effort solution I've found is to use afterNavigate in your base +layout.svelte:

import { CopyButton } from 'svelte-zoo' // npm install svelte-zoo
import { afterNavigate } from '$app/navigation' // assumes you use SvelteKit

afterNavigate(() => {
  for (const node of document.querySelectorAll('pre > code')) {
    new CopyButton({ // use whatever Svelte component you like here
      target: node,
      props: {
        content: node.textContent ?? '',
        style: 'position: absolute; top: 1ex; right: 1ex;', // requires <pre> to have position: relative;
      },
    })
  })

Looks like this:

Screenshot 2023-05-27 at 19 21 05

Button styles > ```css > button { > color: white; > cursor: pointer; > border: none; > border-radius: 3pt; > background-color: teal; > padding: 2pt 4pt; > font-size: 12pt; > line-height: initial; > transition: background-color 0.2s; > } > ```

Of course, if you feel like a caveman, you can also do this imperatively:

afterNavigate(() => {
  for (const node of document.querySelectorAll('pre > code')) {
    const button = document.createElement('button')
    button.textContent = 'Copy'
    button.className = 'copy-button'

    button.onclick = () => navigator.clipboard.writeText(node.textContent ?? '')

    node.parentNode?.prepend(button)
  }
})
Just for lols > If you feel like a sophisticated caveman, you can even insert a pretty SVG: > > ```ts > // anywhere after `const button = document.createElement('button')` > > const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') > svg.setAttribute('width', '16') > svg.setAttribute('height', '16') > svg.setAttribute('viewBox', '0 0 16 16') > > const use = document.createElementNS('http://www.w3.org/2000/svg', 'use') > use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#copy-icon') > > svg.appendChild(use) > button.prepend(svg) > ``` > > and then put e.g. this in your `app.html`: > > ```html > > > d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" > /> > d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" > /> > > ```

This completely circumvents MSveX of course. I tried writing a rehype plugin first as @pngwn suggested, then a remark plugin, then a combo of both which kind of worked but was ridiculously more complex than this. This also has the advantage that it applies to both code blocks in markdown as well those in Svelte components that are not processed by MSveX.

joshnuss commented 8 months ago

If you use Shiki, you can use this transformer: https://github.com/joshnuss/shiki-transformer-copy-button

The highlighter function would look something like this:

import { codeToHtml } from 'shiki/bundle/full'
import { addCopyButton } from 'shiki-transformer-copy-button'

export async function highlighter(code, lang) {
  return await codeToHtml(code, {
    lang,
    transformers: [
      addCopyButton(code)
    ]
  })
}