Markdown
Markdownは、Markdownコンポーネント内のHTML要素に対し、一貫したデザインを適用するためのスタイルのセットです。
markdown/default is coming soon.
<script lang="ts">
import sampleMarkdown from '$data/markdowns/samples/markdown/samples.md?raw';
import Markdown from '$lib/components/ui/modules/Markdown.svelte';
let markdownText = $derived(sampleMarkdown.trim());
</script>
<Markdown text={markdownText} class="flex flex-col gap-4 px-16 py-32" />
プロパティ
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> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
'\'': ''',
};
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.
<script lang="ts">
import sampleMarkdown from '$data/markdowns/samples/markdown/samples.md?raw';
import Markdown from '$lib/components/ui/modules/Markdown.svelte';
let markdownText = $derived(sampleMarkdown.trim());
</script>
<Markdown text={markdownText} class="flex flex-col gap-4 px-16 py-32" />
サンプル
Default
Markdown の基本的な表示サンプルです。
markdown/default is coming soon.
<script lang="ts">
import sampleMarkdown from '$data/markdowns/samples/markdown/samples.md?raw';
import Markdown from '$lib/components/ui/modules/Markdown.svelte';
let markdownText = $derived(sampleMarkdown.trim());
</script>
<Markdown text={markdownText} class="flex flex-col gap-4 px-16 py-32" />