PublishedDate: 11 April 2025

客製化 Prose 等 UI 元件

當看 Nuxt 官網的時候,為什麼跟我們產生的網站會不一樣,這是因為他們使用官方付費元件,這些在 NuxtContent 套件裡面都沒有,但是 NuxtContent 底層技術是透過 Nuxt MDC 程式來完成 Markdown 轉 Html,Nuxt MDC 支援客製化所有的UI元件,接下來我們要來修改內建的 UI 元件。

原理解釋

  1. Markdown

文章中的組成包含以下內容

  • 標題 Heading (h1 ~ h6)
  • 段落 Paragraph (p)
  • 超連結 Anchor Link (a)
  • 圖片 Image (img)
  • 列表 Unordered List (ul)
  • 順序列表 Ordered List (ol)
  • 格式化文字 Preformatted Text (pre)

在 Tailwind CSS 中,將它命名為 prose 需要安裝 @tailwindcss/typography 套件才有支援,prose-h1 prose-pre prose-img 等等,命名規則 prose-[tagName]

  1. NuxtContent

在 Nuxt MDC 每個 UI 元件都有一個專屬的元件,它命名規則為 Prose[TagName].vue 而這些UI元件,都可以用自己寫得替代掉。例如要替換掉 需要在 components/content 目錄下取一個相同的名字 components/content/ProseH2.vue

優化代碼顯示

增加拷貝與顯示檔名,從 github 拷貝 ProsePre.vue Nuxt MDC - ProsePre.vue 修改後如下。

app/components/content/ProsePre.vue
<!-- app/components/content/ProsePre.vue -->
<template>
<div class="relative">
  <!-- Copy 按鈕 -->
  <button
    @click="copyToClipboard"
    class="absolute top-2 right-2 bg-gray-800 text-white text-xs px-2 py-1 rounded hover:bg-gray-700 transition"
  >
    {{ copied ? "✔ Copied!" : "Copy" }}
  </button>
  <!-- 顯示檔名 -->
  <div
    v-if="$props.filename"
    class="bg-slate-200 font-mono text-sm border
           border-slate-300 py-2 px-3 rounded-t-md text-black"
  >
    {{ $props.filename }}
  </div>
  <pre
    :class="{
      [$props.class as string]: true,
      'mt-0 rounded-t-none': $props.filename,
    }"
  ><slot /></pre>
</div>
</template>

<script setup lang="ts">
defineProps({
  code: {
    type: String,
    default: '',
  },
  language: {
    type: String,
    default: null,
  },
  filename: {
    type: String,
    default: null,
  },
  highlights: {
    type: Array as () => number[],
    default: () => [],
  },
  meta: {
    type: String,
    default: null,
  },
  class: {
    type: String,
    default: null,
  },
});

const copied = ref(false);
const copyToClipboard = async () => {
  try {
    await navigator.clipboard.writeText(props.code);
    copied.value = true;
    setTimeout(() => (copied.value = false), 3000); // 3秒後恢復
  } catch (error) {
    console.error("複製失敗", error);
  }
};
</script>

<style>
pre code .line {
  display: block;
}
</style>

優化 blockquote

以下 markdown 代碼會顯示引號

> 這裡是顯示 blockquote 的原始碼,會有前後會有引號。

blockquote 的內容都會有引號,這裡透過自訂的 css 風格來取消引號,加入名稱為 "no-quotes" 的 css 風格

tailwind.config.js
module.exports = {
  "theme": { 
    "extend": {
      "typography": {
        "no-quotes": {
          css: {
            'blockquote p:first-of-type::before': { content: 'none' },
            'blockquote p:last-of-type::after': { content: 'none' },
          },
        },
      }
    }
  }
}

然後只要加入 風格 markdown 頁面就可以依照我們的風格來呈現畫面。

app/pages/[...slug
<script setup>
// app/pages/[...slug].vue
const route = useRoute();
const { data: article } = await useAsyncData(route.path, () => {
  return queryCollection('docs').path(route.path).first()
})
</script>
<template>
    <ContentRenderer :value="article" class="prose prose-no-quotes">
      <template #empty>
        <p>No content found.</p>
      </template>        
    </ContentRenderer>
</template>

結果顯示如下

blockquote 修改過後的結果,引號取消了!

ProseP.vue 原始碼

ProseP.vue 的原始碼

原始碼就只有三行,非常的簡單只是用 p tag 將 slot 包起來,暫時不修改 ProseP.vue 保留這些內容等待以後需要時再作修改。

components/prose/ProseP.vue
<template>
  <p><slot /></p>
</template>

ProseImg.vue 原始碼

components/prose/ProseImg.vue
<template>
  <component
    :is="ImageComponent"
    :src="refinedSrc"
    :alt="props.alt"
    :width="props.width"
    :height="props.height"
  />
</template>

<script setup lang="ts">
import { withTrailingSlash, withLeadingSlash, joinURL } from 'ufo'
import { useRuntimeConfig, computed } from '#imports'

import ImageComponent from '#build/mdc-image-component.mjs'

const props = defineProps({
  src: {
    type: String,
    default: ''
  },
  alt: {
    type: String,
    default: ''
  },
  width: {
    type: [String, Number],
    default: undefined
  },
  height: {
    type: [String, Number],
    default: undefined
  }
})

const refinedSrc = computed(() => {
  if (props.src?.startsWith('/') && !props.src.startsWith('//')) {
    const _base = withLeadingSlash(withTrailingSlash(useRuntimeConfig().app.baseURL))
    if (_base !== '/' && !props.src.startsWith(_base)) {
      return joinURL(_base, props.src)
    }
  }
  return props.src
})
</script>

備註:保留這些內容等待以後需要時再作修改

ProseH1.vue 原始碼

H1.vue
<template>
  <h1 :id="props.id">
    <a
      v-if="generate"
      :href="`#${props.id}`"
    >
      <slot />
    </a>
    <slot v-else />
  </h1>
</template>

<script setup lang="ts">
import { computed, useRuntimeConfig } from '#imports'

const props = defineProps<{ id?: string }>()

const { headings } = useRuntimeConfig().public.mdc
const generate = computed(() => props.id && ((typeof headings?.anchorLinks === 'boolean' && headings?.anchorLinks === true) || (typeof headings?.anchorLinks === 'object' && headings?.anchorLinks?.h1)))
</script>

備註:保留這些內容等待以後需要時再作修改

設定

nuxt.config.ts
// nuxt.config.ts
export default defineNuxtConfig({
  // 其他設定
  future: { compatibilityVersion: 4 },
  modules: ['@nuxt/content'],
  content: {
    build: {
      markdown: {
        mdc: true,
        highlight: {
          // Theme used in all color schemes.
          theme: 'github-dark',
        }
      }
    }
  }
  // 其他設定
})

錯誤方法

AI 給的錯誤方法,透過攔截 pre 來修改。

<template>
<div class="card max-w-7xl p-4 dark:bg-gray-900">
    <!-- 自訂 Markdown Slot -->
    <ContentRenderer :value="article" class="prose">
      <template #empty>
        <p>No content found.</p>
      </template>

      <!-- 攔截 <pre> 來加入 Copy 按鈕 -->
      <template #pre="{ children }">
        <CopyableCode :code="children[0].children[0].value" />
      </template>
      
    </ContentRenderer>
</div>
</template>

html tag 元件與 Prose 元件對照表

TagComponentSourceDescription
p<ProseP>ProseP.vueParagraph
h1<ProseH1>ProseH1.vueHeading 1
h2<ProseH2>ProseH2.vueHeading 2
h3<ProseH3>ProseH3.vueHeading 3
h4<ProseH4>ProseH4.vueHeading 4
h5<ProseH5>ProseH5.vueHeading 5
h6<ProseH6>ProseH6.vueHeading 6
ul<ProseUl>ProseUl.vueUnordered List
ol<ProseOl>ProseOl.vueOrdered List
li<ProseLi>ProseLi.vueList Item
blockquote<ProseBlockquote>ProseBlockquote.vueBlockquote
hr<ProseHr>ProseHr.vueHorizontal Rule
pre<ProsePre>ProsePre.vuePreformatted Text
code<ProseCode>ProseCode.vueCode Block
table<ProseTable>ProseTable.vueTable
thead<ProseThead>ProseThead.vueTable Head
tbody<ProseTbody>ProseTbody.vueTable Body
tr<ProseTr>ProseTr.vueTable Row
th<ProseTh>ProseTh.vueTable Header
td<ProseTd>ProseTd.vueTable Data
a<ProseA>ProseA.vueAnchor Link
img<ProseImg>ProseImg.vueImage
em<ProseEm>ProseEm.vueEmphasis
strong<ProseStrong>ProseStrong.vueStrong

參考資料 Reference

Nuxt
NuxtContent
Nuxt MDC

版本備註

nuxt v3.15.4
@nuxt/content v3.3.0
@nuxtjs/tailwindcss v6.13.1