Skip to content

Commit 362f38a

Browse files
committed
Merge PR CorentinTh#1753: added markdown preview feature
2 parents e2cc133 + f44086a commit 362f38a

7 files changed

Lines changed: 133 additions & 4 deletions

File tree

components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ declare module 'vue' {
136136
LoremIpsumGenerator: typeof import('./src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue')['default']
137137
MacAddressGenerator: typeof import('./src/tools/mac-address-generator/mac-address-generator.vue')['default']
138138
MacAddressLookup: typeof import('./src/tools/mac-address-lookup/mac-address-lookup.vue')['default']
139+
MarkdownPreview: typeof import('./src/tools/markdown-preview/markdown-preview.vue')['default']
139140
MarkdownToHtml: typeof import('./src/tools/markdown-to-html/markdown-to-html.vue')['default']
140141
MathEvaluator: typeof import('./src/tools/math-evaluator/math-evaluator.vue')['default']
141142
MenuBar: typeof import('./src/tools/html-wysiwyg-editor/editor/menu-bar.vue')['default']

src/tools/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { tool as base64FileConverter } from './base64-file-converter';
22
import { tool as base64StringConverter } from './base64-string-converter';
33
import { tool as basicAuthGenerator } from './basic-auth-generator';
4-
import { tool as latexToUnicode } from './latex-to-unicode';
5-
import { tool as textToStyledLetters } from './text-to-styled-letters';
4+
import { tool as markdownPreview } from './markdown-preview';
65
import { tool as emailNormalizer } from './email-normalizer';
76

87
import { tool as asciiTextDrawer } from './ascii-text-drawer';
@@ -118,7 +117,7 @@ export const toolsByCategory: ToolCategory[] = [
118117
xmlToJson,
119118
jsonToXml,
120119
markdownToHtml,
121-
textToStyledLetters,
120+
markdownPreview,
122121
],
123122
},
124123
{
@@ -171,7 +170,7 @@ export const toolsByCategory: ToolCategory[] = [
171170
},
172171
{
173172
name: 'Math',
174-
components: [mathEvaluator, etaCalculator, percentageCalculator, latexToUnicode],
173+
components: [mathEvaluator, etaCalculator, percentageCalculator],
175174
},
176175
{
177176
name: 'Measurement',
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Markdown } from '@vicons/tabler';
2+
import { defineTool } from '../tool';
3+
4+
export const tool = defineTool({
5+
name: 'Markdown preview',
6+
path: '/markdown-preview',
7+
description: 'Live preview of Markdown as you type',
8+
keywords: ['markdown', 'preview'],
9+
component: () => import('./markdown-preview.vue'),
10+
icon: Markdown,
11+
createdAt: new Date('2026-03-17'),
12+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.describe('Tool - Markdown preview', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/markdown-preview');
6+
});
7+
8+
test('Has correct title', async ({ page }) => {
9+
await expect(page).toHaveTitle('Markdown preview - IT Tools');
10+
});
11+
12+
test('renders markdown in preview as user types', async ({ page }) => {
13+
await page.getByTestId('markdown-input').fill('# Hello World\n\n**Bold** and *italic*');
14+
const preview = page.getByTestId('markdown-preview');
15+
await expect(preview.locator('h1')).toHaveText('Hello World');
16+
await expect(preview.locator('strong')).toHaveText('Bold');
17+
await expect(preview.locator('em')).toHaveText('italic');
18+
});
19+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { renderMarkdown } from './markdown-preview.service';
3+
4+
describe('markdown-preview', () => {
5+
it('renders markdown to html', () => {
6+
expect(renderMarkdown('# Hello')).toContain('<h1>Hello</h1>');
7+
expect(renderMarkdown('**bold**')).toContain('<strong>bold</strong>');
8+
expect(renderMarkdown('*italic*')).toContain('<em>italic</em>');
9+
});
10+
11+
it('renders empty string as empty', () => {
12+
expect(renderMarkdown('')).toBe('');
13+
});
14+
15+
it('sanitizes script tags', () => {
16+
const result = renderMarkdown('<script>alert("xss")</script>');
17+
expect(result).not.toContain('<script>');
18+
});
19+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import markdownit from 'markdown-it';
2+
import DomPurify from 'dompurify';
3+
4+
const md = markdownit();
5+
6+
export function renderMarkdown(markdown: string): string {
7+
const html = md.render(markdown);
8+
return DomPurify.sanitize(html, { ADD_ATTR: ['target'] });
9+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<script setup lang="ts">
2+
import { useStorage } from '@vueuse/core';
3+
import { renderMarkdown } from './markdown-preview.service';
4+
5+
const inputMarkdown = useStorage('markdown-preview:input', '');
6+
const previewHtml = computed(() => renderMarkdown(inputMarkdown.value));
7+
</script>
8+
9+
<template>
10+
<div class="markdown-preview-tool">
11+
<c-input-text
12+
v-model:value="inputMarkdown"
13+
14+
placeholder="Your Markdown content..."
15+
rows="8"
16+
17+
label="Your Markdown to preview:"
18+
autocomplete="off"
19+
autocorrect="off"
20+
autocapitalize="off"
21+
spellcheck="false"
22+
raw-text autofocus multiline monospace
23+
test-id="markdown-input"
24+
/>
25+
26+
<n-divider />
27+
28+
<n-form-item label="Rendered preview:">
29+
<c-card>
30+
<div
31+
class="markdown-preview"
32+
data-test-id="markdown-preview"
33+
v-html="previewHtml"
34+
/>
35+
</c-card>
36+
</n-form-item>
37+
</div>
38+
</template>
39+
40+
<style lang="less" scoped>
41+
.markdown-preview-tool {
42+
flex: 0 0 100%;
43+
}
44+
45+
.markdown-preview {
46+
overflow: auto;
47+
width: 100%;
48+
min-height: 200px;
49+
max-height: 600px;
50+
box-sizing: border-box;
51+
scrollbar-width: none;
52+
-ms-overflow-style: none;
53+
54+
&::-webkit-scrollbar {
55+
display: none;
56+
}
57+
58+
:deep(h1) { font-size: 1.5em; }
59+
:deep(h2) { font-size: 1.3em; }
60+
:deep(h3) { font-size: 1.1em; }
61+
:deep(h1), :deep(h2), :deep(h3) { margin: 0.5em 0; line-height: 1.3; }
62+
:deep(p), :deep(ul), :deep(ol) { margin: 0.5em 0; line-height: 1.6; }
63+
:deep(ul), :deep(ol) { padding-left: 1.5em; }
64+
:deep(code) { background: var(--n-color-modal); padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }
65+
:deep(pre) { background: var(--n-color-modal); padding: 12px; border-radius: 6px; overflow-x: auto; margin: 0.5em 0; }
66+
:deep(pre code) { background: none; padding: 0; font-size: 0.9em; }
67+
:deep(a) { color: var(--n-color-primary); text-decoration: none; }
68+
:deep(a:hover) { text-decoration: underline; }
69+
}
70+
</style>

0 commit comments

Comments
 (0)