Code Block Component: A Simple but Neat Solution

Update for Nuxt Content v3

So I upgraded to Nuxt Content v3 and a few things changed. The component now needs to be called ProsePre.vue instead of ProseCode.vue - that's because v3 changed how it maps code blocks. Triple backticks now map to ProsePre, while ProseCode is just for inline code.

The other big thing is that the slot no longer gives you <pre><code>...</code></pre> - it just gives you <code>...</code>. So we need to wrap the slot in a <pre> ourselves, and the styles change from :slotted(pre) to just styling the pre directly and using :deep() for the code inside.

Updated code is at the bottom of this post.


Just wanted to share a quick look at my solution for a Code Block in Nuxt Content. It's pretty straightforward - it displays code blocks with some nice features like syntax highlighting and a copy button. Most of the heavy lifting is already done by Shiki.

What's cool about Nuxt Content is that we simple can replace the Prose Components with our own. We just have to create a component in components/content to replace it.

What Features where important for me?

Aside from syntax highlighting I wanted to have:

  • a Top Bar that displays the Filepath and a File Icon. This Top Bar should be optional to use.
  • a Copy Button
  • Line Numbers

What's Cool About It?

The component handles different programming languages and shows the right icon for each one. Vue, JavaScript, TypeScript - you name it, it'll display it properly with the correct VSCode-style icon. If it doesn't recognize the language, no worries - it'll just use a default document icon.

The copy functionality is handled by VueUse useClipboard.

components/content/ProsePre.vue
function getLanguageIcon(language: string | null) {
  const iconMap: Record<string, string> = {
    vue: 'vscode-icons:file-type-vue',
    js: 'vscode-icons:file-type-js',
    ts: 'vscode-icons:file-type-typescript',
    html: 'vscode-icons:file-type-html',
    css: 'vscode-icons:file-type-css',
    json: 'vscode-icons:file-type-json',
    md: 'vscode-icons:file-type-markdown',
  }
  return language ? iconMap[language] || 'heroicons:document' : 'heroicons:document'
}

That's It!

Not much else to say - it's a simple component that does its job well. If you want to use it, just drop it into your Nuxt project and you're good to go.

Feel free to grab the code and modify it for your own needs. These kinds of components make documentation and code sharing just a bit nicer, and that's always a good thing.

The Code

nuxt.config.ts
content: {
    build: {
      markdown: {
        highlight: {
          theme: 'catppuccin-mocha',
          langs: ['javascript', 'js', 'typescript', 'ts', 'json', 'bash', 'shell', 'yaml', 'markdown', 'css', 'scss', 'html', 'vue'],
        },
      },
    },
  },
components/content/ProsePre.vue
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'

const props = withDefaults(
  defineProps<{
    code?: string
    language?: string | null
    filename?: string | null
    highlights?: Array<number>
    meta?: string | null
    class?: string | null
  }>(),
  { code: '', language: null, filename: null, highlights: () => [], meta: null, class: null },
)

const { copy, copied } = useClipboard()

function getLanguageIcon(language: string | null) {
  const iconMap: Record<string, string> = {
    vue: 'vscode-icons:file-type-vue',
    js: 'vscode-icons:file-type-js',
    ts: 'vscode-icons:file-type-typescript',
    html: 'vscode-icons:file-type-html',
    css: 'vscode-icons:file-type-css',
    json: 'vscode-icons:file-type-json',
    md: 'vscode-icons:file-type-markdown',
  }
  return language ? iconMap[language] || 'heroicons:document' : 'heroicons:document'
}
</script>

<template>
  <div class="group relative my-4 max-w-full overflow-hidden rounded-lg bg-octogrey-700">
    <div v-if="filename" class="border-primary-500 flex items-center justify-between border-b bg-octogrey-700 px-4 py-2">
      <div class="flex min-w-0 items-center gap-2 text-sm text-gray-400">
        <Icon :name="getLanguageIcon(language)" class="size-5 shrink-0" />
        <span class="truncate">{{ filename }}</span>
      </div>
      <button
        class="flex items-center justify-center p-1 text-gray-400 transition-colors hover:text-gray-500"
        :class="{ 'text-green-500 hover:text-green-500': copied }"
        @click="copy(code)"
      >
        <Icon :name="copied ? 'heroicons:check' : 'heroicons:clipboard'" class="size-4.5" />
      </button>
    </div>
    <button
      v-else
      class="absolute right-3 top-3 z-10 flex items-center justify-center rounded-md  bg-octogrey-700 p-1.5 text-gray-400 opacity-0 transition-all hover:text-gray-500 group-hover:opacity-100"
      :class="{ 'text-green-500 hover:text-green-500': copied }"
      @click="copy(code)"
    >
      <Icon :name="copied ? 'heroicons:check' : 'heroicons:clipboard'" class="size-4.5" />
    </button>
    <pre :class="$props.class"><slot /></pre>
  </div>
</template>

<style scoped>
pre {
  margin: 0;
  padding: 1rem;
  background: transparent;
  overflow-x: auto;
  line-height: 1.5;
  counter-reset: lines;
}

:deep(code) {
  color: #a9b1d6;
  width: 100%;
  display: flex;
  flex-direction: column;
}

:deep(code .line) {
  display: inline-table;
  min-height: 1.25rem;
  padding-right: 1rem;
}

:deep(code .line::before) {
  counter-increment: lines;
  content: counter(lines);
  width: 1rem;
  margin-right: 1.5rem;
  display: inline-block;
  text-align: right;
  color: #565f89;
}

:deep(code .highlight) {
  background: #1e2030;
  display: block;
  margin-right: -1rem;
  margin-left: -1rem;
  padding-right: 1rem;
  padding-left: 0.75rem;
  border-left: 0.25rem solid #7aa2f7;
}
</style>
nuxt.config.ts
content: {
    highlight: {
      theme: 'catppuccin-mocha',
      langs: ['javascript', 'js', 'typescript', 'ts', 'json', 'bash', 'shell', 'yaml', 'markdown', 'css', 'scss', 'html', 'vue'],
    },
  },
components/content/ProseCode.vue
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'

const props = withDefaults(
  defineProps<{
    code?: string
    language?: string | null
    filename?: string | null
    highlights?: Array<number>
  }>(),
  { code: '', language: null, filename: null, highlights: [] },
)

const { copy, copied } = useClipboard()

function getLanguageIcon(language: string | null) {
  const iconMap: Record<string, string> = {
    vue: 'vscode-icons:file-type-vue',
    js: 'vscode-icons:file-type-js',
    ts: 'vscode-icons:file-type-typescript',
    html: 'vscode-icons:file-type-html',
    css: 'vscode-icons:file-type-css',
    json: 'vscode-icons:file-type-json',
    md: 'vscode-icons:file-type-markdown',
  }
  return language ? iconMap[language] || 'heroicons:document' : 'heroicons:document'
}
</script>

<template>
  <div class="group relative my-4 overflow-hidden rounded-lg bg-octogrey-700">
    <div v-if="filename" class="flex items-center justify-between border-b border-primary-500 bg-octogrey-700 px-4 py-2">
      <div class="flex items-center gap-2 text-sm text-gray-400">
        <Icon :name="getLanguageIcon(language)" class="size-5" />
        <span>{{ filename }}</span>
      </div>
      <button
        class="flex items-center justify-center p-1 text-gray-400 transition-colors hover:text-gray-500"
        :class="{ 'text-green-500 hover:text-green-500': copied }"
        @click="copy(code)"
      >
        <Icon :name="copied ? 'heroicons:check' : 'heroicons:clipboard'" class="size-4.5" />
      </button>
    </div>
    <button
      v-else
      class="absolute right-3 top-3 z-10 flex items-center justify-center rounded-md  bg-octogrey-700 p-1.5 text-gray-400 opacity-0 transition-all hover:text-gray-500 group-hover:opacity-100"
      :class="{ 'text-green-500 hover:text-green-500': copied }"
      @click="copy(code)"
    >
      <Icon :name="copied ? 'heroicons:check' : 'heroicons:clipboard'" class="size-4.5" />
    </button>
    <slot />
  </div>
</template>

<style scoped>
:slotted(pre) {
  margin: 0;
  padding: 1rem;
  background: transparent;
  overflow-x: auto;
  line-height: 1.5;
  counter-reset: lines;
}

:slotted(pre code) {
  color: #a9b1d6;
  width: 100%;
  display: flex;
  flex-direction: column;
}

:slotted(pre code .line) {
  display: inline-table;
  min-height: 1.25rem;
  padding-right: 1rem;
}

:slotted(pre code .line::before) {
  counter-increment: lines;
  content: counter(lines);
  width: 1rem;
  margin-right: 1.5rem;
  display: inline-block;
  text-align: right;
  color: #565f89;
}

:slotted(pre code .highlight) {
  background: #1e2030;
  display: block;
  margin-right: -1rem;
  margin-left: -1rem;
  padding-right: 1rem;
  padding-left: 0.75rem;
  border-left: 0.25rem solid #7aa2f7;
}
</style>