Markdown

Markdownは、Markdownコンポーネント内のHTML要素に対し、一貫したデザインを適用するためのスタイルのセットです。

markdown/default is coming soon.

プロパティ

Markdownは、以下のプロパティをサポートしています。

名前 デフォルト値 説明
text string マークダウンテキストです。

インストールの手順

以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。

modules/Markdown.svelte
        <!--
@component Markdownを表示するコンポーネント

## 機能
- MarkDownとしてテキストなどを表示します

## Usage
```svelte
  <Markdown text={markdownText} />
```
-->

<script module lang="ts">
  import type { ClassValue } from 'svelte/elements';

  export interface MarkdownProps {
    // Markdown 文字列
    text: string;
    class?: ClassValue;
  }
</script>

<script lang="ts">
  import Checkbox from '$lib/components/ui/atoms/Checkbox.svelte';
  import { ExternalLink } from '@lucide/svelte';
  import { Marked, type RendererObject, type Tokens } from 'marked';
  import { mount, unmount } from 'svelte';

  type MountInfo = {
    selector: string;
    component: any;
    getProps: (el: HTMLElement) => Record<string, unknown>;
  };

  let { text, class: className }: MarkdownProps = $props();
  let markdownElement: HTMLElement | undefined;

  const HTML_ESCAPE_MAP: Record<string, string> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    '\'': '&#39;',
  };

  const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char] ?? char);

  const renderer: RendererObject = {
    // リンク
    link(token) {
      return renderLink(token);
    },

    // カスタムコードブロック
    code(token) {
      return renderCodeBlock(token);
    },

    // カスタムチェックボックス
    listitem(item) {
      return renderListItem(item);
    },
  };

  // markedの設定
  const marked = new Marked({
    breaks: true,
    renderer,
  });

  const html = $derived(marked.parse(text.trim()));

  $effect(() => {
    html;

    if (!markdownElement) {
      return;
    }
    const root_element = markdownElement;

    // マウントするコンポーネントの情報を定義
    const mount_infos: MountInfo[] = [
      {
        selector: '[data-checkbox]',
        component: Checkbox,
        getProps: (el) => ({
          checked: el.dataset.checked === 'true',
        }),
      },
      {
        selector: '[data-external-link-icon]',
        component: ExternalLink,
        getProps: () => ({
          class: 'inline-flex shrink-0 size-4',
        }),
      },
    ];

    const mounted_components: Record<string, unknown>[] = [];

    // markdownElement内の指定されたセレクタにマッチする要素を検索し、コンポーネントをマウント
    mount_infos.forEach(({ selector, component, getProps }) => {
      const elements = root_element.querySelectorAll<HTMLElement>(selector);
      elements.forEach((el) => {
        const mounted_component = mount(component, {
          target: el,
          props: getProps(el),
        });

        mounted_components.push(mounted_component);
      });
    });

    return () => {
      mounted_components.forEach((component) => {
        unmount(component);
      });
    };
  });

  /**
   * リンクのトークンをHTML文字列にレンダリング
   */
  function renderLink({ href, title, text }: Tokens.Link): string {
    const is_external_link = /^(https?:)?\/\//i.test(href);
    const title_attr = title ? ` title="${title}"` : '';
    const target_attr = is_external_link ? ' target="_blank" rel="noopener noreferrer"' : '';
    const icon = is_external_link ? `<span class="ml-1" data-external-link-icon></span>` : '';
    return `<a href="${href}"${title_attr}${target_attr}>${text}${icon}</a>`;
  }

  /**
   * コードブロックをHTML文字列にレンダリング
   */
  function renderCodeBlock({ text, lang, escaped }: Tokens.Code): string {
    const label = lang ? `<div>${lang}</div>` : '';
    const code_text = escaped ? text : escapeHtml(text);
    return `<pre>${label}<code>${code_text}</code></pre>`;
  }

  /**
   * リストアイテムをHTML文字列にレンダリング(チェックボックスのみ)
   */
  function renderListItem(item: Tokens.ListItem): string | false {
    // チェックボックスではないリストはここでリターンする
    if (!item.task) {
      return false;
    }

    const checked = item.checked ? 'true' : 'false';
    return `
      <li>
        <span>
          <span data-checkbox data-checked="${checked}"></span>${item.text}
        </span>
      </li>
    `;
  }
</script>

<div class={['markdown-content', className]} bind:this={markdownElement}>
  {@html html}
</div>

<style>
  @reference "../../../../app.css";

  .markdown-content :global {
    font-family: 'Inter', 'Noto sans jp', sans-serif;
    -webkit-font-smoothing: antialiased;

    h1 {
      @apply py-6 text-4xl font-bold leading-tight text-base-foreground-default;
    }

    h2 {
      @apply pt-6 pb-2 text-2xl font-semibold leading-tight text-base-foreground-default border-b-2 border-base-stroke-default;
    }

    h3 {
      @apply inline-flex pt-4 pb-2 text-xl font-semibold leading-tight text-base-foreground-default;
    }

    h4 {
      @apply px-2 py-4 text-lg font-semibold leading-tight text-base-foreground-default;
    }

    p {
      @apply text-base font-normal leading-normal text-base-foreground-default;
    }

    strong {
      @apply font-bold;
    }

    em {
      @apply italic;
    }

    del {
      @apply line-through;
    }

    blockquote {
      @apply my-2 flex items-center self-stretch border-l-2 border-base-stroke-default pl-6;
    }

    ul {
      @apply ml-6 flex list-disc flex-col items-start self-stretch py-2 pl-6;
    }

    ol {
      @apply ml-6 list-decimal py-2 pl-6;
    }

    li {
      @apply leading-normal;
    }

    a {
      @apply text-base-foreground-link duration-200 ease-in-out underline underline-offset-4;
      transition-property: text-decoration, opacity;
      &:hover {
        @apply opacity-90 decoration-transparent;
      }
    }

    li:has(> span > [data-checkbox]) {
      @apply relative list-none;

      &::before {
        content: '•';
        @apply absolute text-base-foreground-default;
        left: -0.9rem;
      }

      > span {
        @apply flex items-start gap-2;
      }

      > span > [data-checkbox] {
        @apply mt-1 shrink-0;
      }
    }

    code:not(pre code) {
      @apply inline-flex items-center gap-2 rounded-md bg-base-container-accent p-1 text-base font-normal leading-normal text-base-foreground-default font-mono;
    }

    pre {
      @apply flex flex-col rounded-lg bg-zinc-900;

      div {
        @apply px-4 py-2 font-mono leading-normal text-base-foreground-on-fill-bright text-xs;
      }

      code {
        @apply block overflow-x-auto whitespace-pre bg-transparent p-4 text-base font-normal leading-normal text-base-foreground-on-fill-bright font-mono;
      }
    }

    table {
      @apply my-2 border border-base-stroke-default;
    }

    thead {
      @apply border-b border-base-stroke-default;
    }

    tr {
      @apply border-b border-base-stroke-default;
    }

    tbody tr:nth-child(even) {
      @apply bg-base-container-muted/40;
    }

    th {
      @apply w-36 border-r border-base-stroke-default px-4 py-2;
    }

    th:last-child {
      @apply border-r-0;
    }

    td {
      @apply border-r border-base-stroke-default px-4 py-2;
    }

    td:last-child {
      @apply border-r-0;
    }

    img {
      @apply max-h-56.25 object-contain;
    }
  }
</style>

      

使い方

markdown/default is coming soon.


サンプル

Default

Markdown の基本的な表示サンプルです。

markdown/default is coming soon.