diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b506329..077b492 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -24,6 +24,7 @@ body: - Cron Expression Explainer - CSV/TSV Formatter & Validator - Regex Tester + - Markdown Live Preview - Shell (theme, command palette, header) - Other / not sure validations: diff --git a/.github/assets/og-markdown-preview.png b/.github/assets/og-markdown-preview.png new file mode 100644 index 0000000..aaeaff1 Binary files /dev/null and b/.github/assets/og-markdown-preview.png differ diff --git a/README.md b/README.md index ccc8a67..0b5d71d 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ A developer's toolbelt: fast, privacy-first browser tools. Everything runs clien Regex Tester - + + Markdown Live Preview + @@ -110,6 +112,7 @@ A developer's toolbelt: fast, privacy-first browser tools. Everything runs clien - **[Cron expression explainer](https://tools.codercops.com/cron-explainer).** Paste a crontab line and get a plain-English description, a field-by-field breakdown and the next run times in UTC or your local time. - **[CSV/TSV formatter & validator](https://tools.codercops.com/csv-formatter).** Parse, validate, and pretty-print CSV/TSV. Auto-detect delimiters, convert to JSON, TypeScript, YAML, SQL, and fix common formatting issues. - **[Regex tester](https://tools.codercops.com/regex-tester).** Test a JavaScript regular expression against sample text with highlighted matches, capture groups, named groups and a replace preview. +- **[Markdown live preview](https://tools.codercops.com/markdown-preview).** Write GitHub-flavored Markdown with a sanitized live HTML preview, syntax-highlighted code, stats, and export tools. ## Why it exists diff --git a/app/(tools)/markdown-preview/MarkdownPreviewClient.test.tsx b/app/(tools)/markdown-preview/MarkdownPreviewClient.test.tsx new file mode 100644 index 0000000..bb42a12 --- /dev/null +++ b/app/(tools)/markdown-preview/MarkdownPreviewClient.test.tsx @@ -0,0 +1,18 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MarkdownPreviewClient } from "./MarkdownPreviewClient"; + +describe("MarkdownPreviewClient", () => { + it("sanitizes scripts, event handlers, and javascript links", async () => { + render(); + fireEvent.change(screen.getByLabelText("Markdown input"), { + target: { value: '\n\n\n\n[javascript](javascript:alert(1))' }, + }); + + await screen.findByText("Preview"); + const preview = document.querySelector(".markdown-preview"); + expect(preview?.querySelector("script")).toBeNull(); + expect(preview?.querySelector("[onerror]")).toBeNull(); + expect(preview?.querySelector('a[href^="javascript:"]')).toBeNull(); + }); +}); diff --git a/app/(tools)/markdown-preview/MarkdownPreviewClient.tsx b/app/(tools)/markdown-preview/MarkdownPreviewClient.tsx new file mode 100644 index 0000000..b8501bd --- /dev/null +++ b/app/(tools)/markdown-preview/MarkdownPreviewClient.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useMemo, useRef, useState, useSyncExternalStore } from "react"; +import DOMPurify from "dompurify"; +import { CopyButton } from "@/components/shared/CopyButton"; +import { getMarkdownStats, insertMarkdown, renderMarkdown } from "@/lib/markdown-preview"; + +const starter = `# Hello, Markdown!\n\nWrite on the left and see **GitHub-flavored Markdown** rendered here.\n\n- [x] Live preview\n- [ ] Copy sanitized HTML\n\n| Feature | Status |\n| --- | --- |\n| Tables | Ready |\n| Code | \`const answer = 42\` |\n\n~~~ts\nconst greeting = "Hello, world!";\n~~~`; +const subscribeToMountedState = () => () => {}; + +function sanitizePreviewHtml(markdown: string) { + const clean = DOMPurify.sanitize(renderMarkdown(markdown), { + FORBID_TAGS: ["script"], + FORBID_ATTR: ["onerror", "onload", "onclick"], + }); + const template = document.createElement("template"); + template.innerHTML = clean; + template.content.querySelectorAll("script").forEach((element) => element.remove()); + template.content.querySelectorAll("*").forEach((element) => { + for (const attribute of Array.from(element.attributes)) { + const name = attribute.name.toLowerCase(); + if (name.startsWith("on") || ((name === "href" || name === "src") && /^\s*javascript:/i.test(attribute.value))) { + element.removeAttribute(attribute.name); + } + } + }); + return template.innerHTML; +} + +export function MarkdownPreviewClient() { + const [markdown, setMarkdown] = useState(starter); + const [darkPreview, setDarkPreview] = useState(false); + const mounted = useSyncExternalStore(subscribeToMountedState, () => true, () => false); + const textareaRef = useRef(null); + const html = useMemo(() => { + return mounted ? sanitizePreviewHtml(markdown) : ""; + }, [markdown, mounted]); + const { words, chars, lines, readingMinutes } = getMarkdownStats(markdown); + + function insert(before: string, after = before, placeholder = "text") { + const input = textareaRef.current; + if (!input) return; + const start = input.selectionStart; + const end = input.selectionEnd; + const result = insertMarkdown(markdown, start, end, before, after, placeholder); + setMarkdown(result.value); + requestAnimationFrame(() => { input.focus(); input.setSelectionRange(result.selectionStart, result.selectionEnd); }); + } + + function download() { + const url = URL.createObjectURL(new Blob([markdown], { type: "text/markdown" })); + const link = document.createElement("a"); link.href = url; link.download = "document.md"; link.click(); URL.revokeObjectURL(url); + } + + function insertImage() { + insert("![alt text](", ")", "https://example.com/image.png"); + } + + function insertTable() { + const input = textareaRef.current; + const position = input?.selectionStart ?? markdown.length; + const table = "| Header | Header |\n| --- | --- |\n| Cell | Cell |\n"; + setMarkdown(markdown.slice(0, position) + table + markdown.slice(position)); + requestAnimationFrame(() => input?.focus()); + } + + return
+
+ {[['Bold','**','**'],['Italic','*','*'],['Code','`','`'],['Link','[','](https://example.com)'],['H2','## ','']].map(([label, before, after]) => )} + + + + + + + +
+
+
Markdown