Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions e2e/fixtures/sample.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Sample Heading

This is a **generic** sample Markdown file used by the tests.

- one
- two
- [a link](https://example.com)

> Nothing real here — just fixture text.
37 changes: 37 additions & 0 deletions e2e/tools/markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import path from 'node:path';

const SAMPLE = path.join(__dirname, '../fixtures/sample.md');

test('opens a local .md file and renders it in the preview', async ({ page }) => {
await page.goto('/tools/markdown');
await page.waitForLoadState('networkidle').catch(() => {});

const input = page.locator('input[type="file"]');
await input.waitFor({ state: 'attached' });

// Loading a file switches to reading (Preview) mode and renders the doc.
await expect(async () => {
await input.setInputFiles(SAMPLE);
await expect(page.getByRole('heading', { name: 'Sample Heading' })).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 30_000 });

// The file name is shown.
await expect(page.getByText('sample.md')).toBeVisible();
});

test('toggles a full-screen reading view', async ({ page }) => {
await page.goto('/tools/markdown');
await page.waitForLoadState('networkidle').catch(() => {});

const expand = page.getByRole('button', { name: 'Full screen' });
await expect(async () => {
await expand.click();
// Native fullscreen may be denied in headless, but the CSS overlay still
// engages and the control flips to "Exit".
await expect(page.getByRole('button', { name: 'Exit' })).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 30_000 });

await page.getByRole('button', { name: 'Exit' }).click();
await expect(page.getByRole('button', { name: 'Full screen' })).toBeVisible();
});
180 changes: 131 additions & 49 deletions src/islands/dev/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,13 @@
import { useEffect, useState } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { useEffect, useRef, useState } from 'react';
import { Upload, Pencil, Eye, Columns, Maximize2, Minimize2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useExpand } from '@/hooks/useExpand';
import { renderMarkdown, isMarkdownFile, titleFromFileName } from '@/tools/dev/markdown.lib';
import type { Lang } from '@/i18n/config';

/**
* The ESM default export can be either a ready sanitizer or a factory that
* needs `window` (depends on bundler/runtime). Resolve it defensively so
* `.sanitize` is always callable in the browser.
*/
function resolvePurifier(): { sanitize: (html: string) => string } | null {
const dp = DOMPurify as unknown as {
sanitize?: (html: string) => string;
} & ((win: Window) => { sanitize: (html: string) => string });
// Already a ready sanitizer.
if (typeof dp.sanitize === 'function') return dp as { sanitize: (html: string) => string };
// Factory form — instantiate with the browser window.
if (typeof dp === 'function' && typeof window !== 'undefined') return dp(window);
return null;
}

const SAMPLE_EN = `# Hello, Markdown

Type on the **left**, see the preview on the **right**.
Type on the **left**, see the preview on the **right** — or open a \`.md\` file to read it.

- Lists
- [Links](https://example.com)
Expand All @@ -32,7 +18,7 @@ Type on the **left**, see the preview on the **right**.

const SAMPLE_ID = `# Halo, Markdown

Ketik di **kiri**, lihat pratinjau di **kanan**.
Ketik di **kiri**, lihat pratinjau di **kanan** — atau buka file \`.md\` untuk membacanya.

- Daftar
- [Tautan](https://example.com)
Expand All @@ -41,47 +27,143 @@ Ketik di **kiri**, lihat pratinjau di **kanan**.
> Semuanya dirender secara lokal — tidak ada yang diunggah.
`;

const TR: Record<Lang, { markdown: string; preview: string; sample: string }> = {
en: { markdown: 'Markdown', preview: 'Preview', sample: SAMPLE_EN },
id: { markdown: 'Markdown', preview: 'Pratinjau', sample: SAMPLE_ID },
type ViewMode = 'edit' | 'preview' | 'split';

const TR: Record<Lang, {
markdown: string; preview: string; sample: string;
open: string; edit: string; view: string; split: string; dropHint: string; wrongType: string;
expand: string; exit: string;
}> = {
en: {
markdown: 'Markdown', preview: 'Preview', sample: SAMPLE_EN,
open: 'Open .md file', edit: 'Edit', view: 'Preview', split: 'Split',
dropHint: 'Drop a Markdown file here to view it', wrongType: 'That doesn’t look like a Markdown file.',
expand: 'Full screen', exit: 'Exit',
},
id: {
markdown: 'Markdown', preview: 'Pratinjau', sample: SAMPLE_ID,
open: 'Buka file .md', edit: 'Edit', view: 'Pratinjau', split: 'Split',
dropHint: 'Jatuhkan file Markdown di sini untuk melihatnya', wrongType: 'Itu sepertinya bukan file Markdown.',
expand: 'Layar penuh', exit: 'Keluar',
},
};

export default function Markdown({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [input, setInput] = useState(t.sample);
const [html, setHtml] = useState('');
const [mode, setMode] = useState<ViewMode>('split');
const [fileName, setFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
const { ref: expandRef, expanded, enter, exit } = useExpand<HTMLDivElement>();

// marked + DOMPurify run browser-only (DOMPurify needs a DOM). Computing in
// an effect keeps SSR safe and avoids a hydration mismatch.
// marked + DOMPurify run browser-only; compute in an effect to stay SSR-safe.
useEffect(() => {
const purifier = resolvePurifier();
const raw = marked.parse(input, { async: false }) as string;
setHtml(purifier ? purifier.sanitize(raw) : '');
setHtml(renderMarkdown(input));
}, [input]);

async function loadFile(file: File) {
if (!isMarkdownFile(file)) {
setError(t.wrongType);
return;
}
setError('');
const text = await file.text();
setInput(text);
setFileName(file.name);
setMode('preview'); // land in reading mode — ideal on a phone
}

const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) void loadFile(file);
};

const modeBtn = (m: ViewMode, label: string, Icon: typeof Pencil) => (
<button
type="button"
aria-pressed={mode === m}
onClick={() => setMode(m)}
className={`inline-flex items-center gap-1.5 border-2 border-border px-3 py-1.5 text-xs font-bold uppercase tracking-wide ${
mode === m ? 'bg-accent text-accent-foreground shadow-brutal-sm' : 'hover:bg-muted'
}`}
>
<Icon className="h-3.5 w-3.5" aria-hidden />{label}
</button>
);

const showEditor = mode === 'edit' || mode === 'split';
const showPreview = mode === 'preview' || mode === 'split';
const paneHeight = expanded ? 'h-[calc(100vh-7rem)]' : 'h-[32rem]';

return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">
{t.markdown}
</span>
<textarea
value={input}
onChange={e => setInput(e.target.value)}
spellCheck={false}
className="h-[32rem] w-full resize-y border-2 border-border bg-muted p-3 font-mono text-sm outline-none focus:shadow-brutal"
/>
</label>

<div className="space-y-1.5">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">
{t.preview}
</span>
<div
className="markdown-preview h-[32rem] overflow-auto border-2 border-border bg-muted p-4"
dangerouslySetInnerHTML={{ __html: html }}
<div
ref={expandRef}
className={expanded ? 'fixed inset-0 z-[60] space-y-3 overflow-auto bg-background p-4' : 'space-y-3'}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
>
<div className="flex flex-wrap items-center gap-3">
<input
ref={fileRef}
type="file"
accept=".md,.markdown,.mdown,.mkd,.mdx,.txt,text/markdown"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) void loadFile(f); e.target.value = ''; }}
/>
<Button variant="secondary" onClick={() => fileRef.current?.click()}>
<Upload className="h-4 w-4" />{t.open}
</Button>
{fileName && <span className="truncate text-sm text-muted-foreground" title={titleFromFileName(fileName)}>{fileName}</span>}

<div className="ml-auto flex gap-1">
{modeBtn('edit', t.edit, Pencil)}
{modeBtn('preview', t.view, Eye)}
{modeBtn('split', t.split, Columns)}
<button
type="button"
onClick={() => (expanded ? exit() : enter())}
aria-label={expanded ? t.exit : t.expand}
className="ml-1 inline-flex items-center gap-1.5 border-2 border-border px-3 py-1.5 text-xs font-bold uppercase tracking-wide hover:bg-muted"
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
{expanded ? t.exit : t.expand}
</button>
</div>
</div>

{error && <p className="text-sm font-semibold text-red-600 dark:text-red-400">{error}</p>}

<div className={`grid grid-cols-1 gap-4 ${mode === 'split' ? 'md:grid-cols-2' : ''} ${dragOver ? 'rounded outline-2 outline-dashed outline-accent' : ''}`}>
{showEditor && (
<label className="block space-y-1.5">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.markdown}</span>
<textarea
value={input}
onChange={e => setInput(e.target.value)}
spellCheck={false}
className={`${paneHeight} w-full resize-y border-2 border-border bg-muted p-3 font-mono text-sm outline-none focus:shadow-brutal`}
/>
</label>
)}

{showPreview && (
<div className="space-y-1.5">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.preview}</span>
<div
className={`markdown-preview ${paneHeight} overflow-auto border-2 border-border bg-muted p-4`}
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
)}
</div>

<p className="text-xs text-muted-foreground">{t.dropHint}</p>
</div>
);
}
30 changes: 15 additions & 15 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1981,14 +1981,14 @@ const en: Record<string, ToolSeoContent> = {
],
},
'markdown': {
title: 'Free Markdown Preview Tool — Live Editor',
description: 'A free online Markdown preview tool with a live side-by-side editor. Type Markdown, see rendered HTML instantly — all in your browser, with nothing uploaded.',
intro: 'This free Markdown preview tool renders your Markdown to formatted HTML the moment you type, with the editor on the left and a live preview on the right. Everything runs on your device and the output is sanitized locally, so nothing you write is ever uploaded.',
title: 'Free Markdown Preview & Viewer — Open .md Files',
description: 'A free online Markdown viewer and live editor. Open a local .md file or type Markdown, see rendered HTML instantly, and read it full-screen — all in your browser, nothing uploaded.',
intro: 'This free Markdown tool renders your Markdown to formatted HTML the moment you type, with a live editor and preview. Open a local .md file to read it, switch to full-screen for a distraction-free view, and toggle between Edit, Preview and Split. Everything runs on your device and the output is sanitized locally, so nothing is ever uploaded — handy as a lightweight Markdown viewer on your phone.',
howTo: [
'Type or paste your Markdown into the editor on the left.',
'Watch the formatted preview update live on the right pane.',
'Use standard Markdown — headings, bold, lists, links and inline code all render.',
'Copy the rendered text straight from the preview when you\'re done.',
'Tap “Open .md file” (or drag a Markdown file onto the page) to load and read a local document — nothing is uploaded.',
'Or type/paste Markdown into the editor and watch the preview update live.',
'Use the Edit / Preview / Split toggle to focus on writing or reading.',
'Tap “Full screen” for a distraction-free reading view — great on mobile.',
],
faqs: [
{ q: 'Is my Markdown uploaded anywhere?', a: 'No. The Markdown is parsed and rendered entirely in your browser with JavaScript, and the HTML is sanitized locally before display. Your text never leaves your device.' },
Expand Down Expand Up @@ -5391,14 +5391,14 @@ const id: Record<string, ToolSeoContent> = {
],
},
'markdown': {
title: 'Tool Preview Markdown Gratis — Editor Langsung',
description: 'Tool preview Markdown online gratis dengan editor langsung berdampingan. Ketik Markdown, lihat HTML ter-render secara instan — semuanya di browser Anda, tanpa ada yang diunggah.',
intro: 'Tool preview Markdown gratis ini me-render Markdown Anda menjadi HTML terformat begitu Anda mengetik, dengan editor di kiri dan preview langsung di kanan. Semuanya berjalan di perangkat Anda dan keluarannya dibersihkan secara lokal, jadi apa pun yang Anda tulis tidak pernah diunggah.',
howTo: [
'Ketik atau tempel Markdown Anda ke dalam editor di sebelah kiri.',
'Perhatikan preview terformat diperbarui secara langsung di panel kanan.',
'Gunakan Markdown standar — heading, tebal, daftar, tautan, dan kode inline semuanya ter-render.',
'Salin teks ter-render langsung dari preview saat Anda selesai.',
title: 'Preview & Viewer Markdown Gratis — Buka File .md',
description: 'Viewer Markdown dan editor langsung online gratis. Buka file .md lokal atau ketik Markdown, lihat HTML ter-render instan, dan baca layar penuh — semuanya di browser Anda, tanpa unggah.',
intro: 'Tool Markdown gratis ini me-render Markdown Anda menjadi HTML terformat begitu Anda mengetik, dengan editor dan preview langsung. Buka file .md lokal untuk membacanya, beralih ke layar penuh untuk tampilan bebas gangguan, dan ganti antara Edit, Preview, dan Split. Semuanya berjalan di perangkat Anda dan keluarannya dibersihkan secara lokal, jadi tidak ada yang diunggah — praktis sebagai viewer Markdown ringan di ponsel Anda.',
howTo: [
'Tap “Buka file .md” (atau jatuhkan file Markdown ke halaman) untuk memuat dan membaca dokumen lokal — tidak ada yang diunggah.',
'Atau ketik/tempel Markdown ke editor dan perhatikan preview diperbarui langsung.',
'Gunakan toggle Edit / Preview / Split untuk fokus menulis atau membaca.',
'Tap “Layar penuh” untuk tampilan membaca bebas gangguan — bagus di ponsel.',
],
faqs: [
{ q: 'Apakah Markdown saya diunggah ke suatu tempat?', a: 'Tidak. Markdown diurai dan di-render sepenuhnya di browser Anda dengan JavaScript, dan HTML-nya dibersihkan secara lokal sebelum ditampilkan. Teks Anda tidak pernah meninggalkan perangkat Anda.' },
Expand Down
4 changes: 2 additions & 2 deletions src/registry/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,9 +1251,9 @@ export const tools: ToolDef[] = [
name: 'Markdown Preview',
category: 'Dev',
route: '/tools/markdown',
keywords: ['markdown', 'md', 'preview', 'render', 'html', 'readme'],
keywords: ['markdown', 'md', 'preview', 'render', 'html', 'readme', 'markdown viewer', 'open md file', 'view markdown', 'md viewer', 'markdown reader'],
icon: FileText,
summary: 'Live Markdown editor and preview',
summary: 'View, edit & preview Markdown — open .md files, full-screen',
load: () => import('@/islands/dev/Markdown'),
status: 'stable'
},
Expand Down
59 changes: 59 additions & 0 deletions src/tools/dev/markdown.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown, isMarkdownFile, titleFromFileName } from './markdown.lib';

describe('renderMarkdown', () => {
it('renders a heading', () => {
expect(renderMarkdown('# Hi')).toContain('<h1');
expect(renderMarkdown('# Hi')).toContain('Hi');
});

it('renders lists and emphasis', () => {
const html = renderMarkdown('- **bold**');
expect(html).toContain('<li');
expect(html).toContain('<strong>bold</strong>');
});

it('strips dangerous markup', () => {
const html = renderMarkdown('<script>alert(1)</script>\n\n# Safe');
expect(html).not.toContain('<script');
expect(html).toContain('Safe');
});

it('returns a string for empty input', () => {
expect(typeof renderMarkdown('')).toBe('string');
});
});

describe('isMarkdownFile', () => {
it.each([
['README.md', ''],
['notes.markdown', ''],
['a.MDOWN', ''],
['x.mkd', ''],
['doc.mdx', ''],
['plain.txt', ''],
['anything', 'text/markdown'],
['anything', 'text/x-markdown'],
])('accepts %s (type %s)', (name, type) => {
expect(isMarkdownFile({ name, type })).toBe(true);
});

it.each([
['photo.png', 'image/png'],
['data.json', 'application/json'],
['archive.zip', 'application/zip'],
])('rejects %s (type %s)', (name, type) => {
expect(isMarkdownFile({ name, type })).toBe(false);
});
});

describe('titleFromFileName', () => {
it.each([
['README.md', 'README'],
['My Notes.markdown', 'My Notes'],
['no-extension', 'no-extension'],
['.md', 'Untitled'],
])('%s → %s', (name, expected) => {
expect(titleFromFileName(name)).toBe(expected);
});
});
Loading
Loading