From 2ed3c6955cbc68860b0e1b409159907043606e15 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:48:03 +0700 Subject: [PATCH] feat(markdown): open local .md files + full-screen reading view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the Markdown Preview into a proper viewer: an 'Open .md file' button and drag-and-drop load a local Markdown document (client-side, via file.text() — nothing uploaded), landing in reading mode. Add an Edit / Preview / Split toggle and a full-screen expand (reusing the shared useExpand hook: native Fullscreen API + iOS CSS-overlay fallback) so it works as a lightweight Markdown reader on a phone. Rendering (marked + DOMPurify) moves into a pure, unit-tested markdown.lib alongside isMarkdownFile / titleFromFileName. Happy-path E2E (open file → render, toggle full-screen) with a generic fixture; EN + ID SEO refreshed for the viewer capabilities. --- e2e/fixtures/sample.md | 9 ++ e2e/tools/markdown.spec.ts | 37 ++++++ src/islands/dev/Markdown.tsx | 180 +++++++++++++++++++++-------- src/registry/tool-seo.ts | 30 ++--- src/registry/tools.ts | 4 +- src/tools/dev/markdown.lib.test.ts | 59 ++++++++++ src/tools/dev/markdown.lib.ts | 44 +++++++ 7 files changed, 297 insertions(+), 66 deletions(-) create mode 100644 e2e/fixtures/sample.md create mode 100644 e2e/tools/markdown.spec.ts create mode 100644 src/tools/dev/markdown.lib.test.ts create mode 100644 src/tools/dev/markdown.lib.ts diff --git a/e2e/fixtures/sample.md b/e2e/fixtures/sample.md new file mode 100644 index 00000000..e0f0cae9 --- /dev/null +++ b/e2e/fixtures/sample.md @@ -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. diff --git a/e2e/tools/markdown.spec.ts b/e2e/tools/markdown.spec.ts new file mode 100644 index 00000000..baa1ace7 --- /dev/null +++ b/e2e/tools/markdown.spec.ts @@ -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(); +}); diff --git a/src/islands/dev/Markdown.tsx b/src/islands/dev/Markdown.tsx index 7cfd971f..a2a7f336 100644 --- a/src/islands/dev/Markdown.tsx +++ b/src/islands/dev/Markdown.tsx @@ -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) @@ -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) @@ -41,47 +27,143 @@ Ketik di **kiri**, lihat pratinjau di **kanan**. > Semuanya dirender secara lokal — tidak ada yang diunggah. `; -const TR: Record = { - en: { markdown: 'Markdown', preview: 'Preview', sample: SAMPLE_EN }, - id: { markdown: 'Markdown', preview: 'Pratinjau', sample: SAMPLE_ID }, +type ViewMode = 'edit' | 'preview' | 'split'; + +const TR: Record = { + 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('split'); + const [fileName, setFileName] = useState(''); + const [dragOver, setDragOver] = useState(false); + const [error, setError] = useState(''); + const fileRef = useRef(null); + const { ref: expandRef, expanded, enter, exit } = useExpand(); - // 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) => ( + + ); + + const showEditor = mode === 'edit' || mode === 'split'; + const showPreview = mode === 'preview' || mode === 'split'; + const paneHeight = expanded ? 'h-[calc(100vh-7rem)]' : 'h-[32rem]'; + return ( -
-