pngwn / MDsveX

A markdown preprocessor for Svelte.

Home Page:https://mdsvex.pngwn.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Copy button on code blocks

sswatson opened this issue · comments

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.

commented

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

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.

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';",
  }
}
commented

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.

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.

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.

+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?

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.

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.

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?

commented

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>

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
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:

// 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:

  <!-- https://icones.js.org/collection/all?s=octicon:copy-16 -->
  <symbol id="copy-icon" fill="currentColor">
    <path
      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"
    />
    <path
      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"
    />
  </symbol>

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.

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)
    ]
  })
}