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
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Binary file added .github/assets/og-markdown-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ A developer's toolbelt: fast, privacy-first browser tools. Everything runs clien
<td width="50%">
<a href="https://tools.codercops.com/regex-tester"><img src="./.github/assets/og-regex-tester.png" alt="Regex Tester"></a>
</td>
<td width="50%"></td>
<td width="50%">
<a href="https://tools.codercops.com/markdown-preview"><img src="./.github/assets/og-markdown-preview.png" alt="Markdown Live Preview"></a>
</td>
</tr>
</table>

Expand All @@ -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

Expand Down
18 changes: 18 additions & 0 deletions app/(tools)/markdown-preview/MarkdownPreviewClient.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MarkdownPreviewClient />);
fireEvent.change(screen.getByLabelText("Markdown input"), {
target: { value: '<script>alert("xss")</script>\n\n<img src="x" onerror="alert(1)">\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();
});
});
84 changes: 84 additions & 0 deletions app/(tools)/markdown-preview/MarkdownPreviewClient.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement>(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 <section className="mx-auto max-w-7xl w-full px-4 sm:px-6 pb-10 space-y-3">
<div className="flex flex-wrap items-center gap-2">
{[['Bold','**','**'],['Italic','*','*'],['Code','`','`'],['Link','[','](https://example.com)'],['H2','## ','']].map(([label, before, after]) => <button key={label} type="button" className="btn" onClick={() => insert(before, after)}>{label}</button>)}
<button type="button" className="btn" onClick={insertImage}>Image</button>
<button type="button" className="btn" onClick={insertTable}>Table</button>
<button type="button" className="btn" onClick={() => insert("- [ ] ", "", "task")}>Task</button>
<button type="button" className="btn ml-auto" onClick={() => setDarkPreview((v) => !v)}>{darkPreview ? "Light preview" : "Dark preview"}</button>
<CopyButton value={html} label="Copy HTML" successMessage="Sanitized HTML copied" disabled={!markdown} />
<CopyButton value={markdown} label="Copy Markdown" disabled={!markdown} />
<button type="button" className="btn" onClick={download} disabled={!markdown}>Download .md</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="card overflow-hidden"><div className="px-3 py-2 border-b border-[var(--hairline)] bg-[var(--bg-raise)] font-mono text-[10.5px] tracking-[0.22em] uppercase text-[var(--fg-dim)]">Markdown</div><textarea ref={textareaRef} aria-label="Markdown input" value={markdown} onChange={(e) => setMarkdown(e.target.value)} spellCheck={false} className="editor-input p-4" style={{ minHeight: 520 }} /></div>
<div className="card overflow-hidden" style={{ background: darkPreview ? "#17191f" : "#ffffff", color: darkPreview ? "#f4f4f5" : "#17191f" }}><div className="px-3 py-2 border-b border-black/10 font-mono text-[10.5px] tracking-[0.22em] uppercase opacity-60">Preview</div><article className="markdown-preview p-5 min-h-[520px] overflow-auto" dangerouslySetInnerHTML={{ __html: html }} /></div>
</div>
<div className="card px-4 py-3 flex flex-wrap gap-x-6 gap-y-2 font-mono text-[11px] text-[var(--fg-dim)]"><span>{words} words</span><span>{chars} chars</span><span>{lines} lines</span><span>{readingMinutes} min read</span><span className="ml-auto">HTML is sanitized before rendering and export</span></div>
</section>;
}
11 changes: 11 additions & 0 deletions app/(tools)/markdown-preview/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getTool } from "@/lib/tools";
import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";

const tool = getTool("markdown-preview")!;
export const alt = tool.ogTitle;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;

export default function OpenGraphImage() {
return renderToolOgImage(tool);
}
12 changes: 12 additions & 0 deletions app/(tools)/markdown-preview/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { ToolPageLayout } from "@/components/shared/ToolPageLayout";
import { generateToolMetadata } from "@/lib/toolMetadata";
import { getTool } from "@/lib/tools";
import { MarkdownPreviewClient } from "./MarkdownPreviewClient";

const tool = getTool("markdown-preview")!;
export const metadata: Metadata = generateToolMetadata(tool);

export default function MarkdownPreviewPage() {
return <ToolPageLayout tool={tool}><MarkdownPreviewClient /></ToolPageLayout>;
}
13 changes: 13 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,19 @@ kbd,
font-feature-settings: "calt", "liga" 0;
}

/* Markdown preview typography is intentionally scoped to the rendered document. */
.markdown-preview { font-family: var(--font-ibm-plex-sans), sans-serif; line-height: 1.7; }
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3 { font-weight: 700; line-height: 1.25; margin: 0 0 .7em; }
.markdown-preview h1 { font-size: 2em; }.markdown-preview h2 { font-size: 1.5em; }.markdown-preview h3 { font-size: 1.25em; }
.markdown-preview p, .markdown-preview ul, .markdown-preview ol, .markdown-preview blockquote, .markdown-preview pre, .markdown-preview table { margin: 0 0 1em; }
.markdown-preview ul, .markdown-preview ol { padding-left: 1.5em; }.markdown-preview li + li { margin-top: .2em; }
.markdown-preview a { color: #087f8c; text-decoration: underline; }.markdown-preview blockquote { border-left: 3px solid currentColor; opacity: .75; padding-left: 1em; }
.markdown-preview code { background: rgba(127,127,127,.16); border-radius: 4px; padding: .15em .35em; font-family: var(--font-jetbrains), monospace; font-size: .88em; }
.markdown-preview pre { background: rgba(127,127,127,.14); border-radius: 8px; padding: 1em; overflow-x: auto; }.markdown-preview pre code { background: transparent; padding: 0; }
.markdown-preview pre code[class*="language-"] { color: inherit; }.markdown-preview .token.comment, .markdown-preview .token.prolog, .markdown-preview .token.doctype { color: #7f8c98; }.markdown-preview .token.keyword, .markdown-preview .token.boolean { color: #9b72ff; }.markdown-preview .token.string, .markdown-preview .token.char, .markdown-preview .token.attr-value { color: #159570; }.markdown-preview .token.number, .markdown-preview .token.function { color: #b86b00; }.markdown-preview .token.operator, .markdown-preview .token.punctuation { color: #687080; }
.markdown-preview table { border-collapse: collapse; width: 100%; }.markdown-preview th, .markdown-preview td { border: 1px solid rgba(127,127,127,.35); padding: .45em .7em; text-align: left; }.markdown-preview th { background: rgba(127,127,127,.12); }
.markdown-preview img { max-width: 100%; height: auto; }.markdown-preview input[type="checkbox"] { margin-right: .5em; }

textarea.editor,
.editor-input {
width: 100%;
Expand Down
16 changes: 16 additions & 0 deletions lib/__tests__/markdown-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getMarkdownStats, insertMarkdown, renderMarkdown } from "@/lib/markdown-preview";

describe("markdown preview", () => {
it("renders GFM and highlights fenced code", () => {
const html = renderMarkdown("- [x] done\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n```ts\nconst value = 1;\n```");
expect(html).toContain("type=\"checkbox\"");
expect(html).toContain("<table>");
expect(html).toContain("language-ts");
expect(html).toContain("token");
});

it("calculates writing stats", () => expect(getMarkdownStats("one two\nthree")).toEqual({ words: 3, chars: 13, lines: 2, readingMinutes: 1 }));

it("inserts formatting around the selection", () => expect(insertMarkdown("hello", 0, 5, "**")).toEqual({ value: "**hello**", selectionStart: 2, selectionEnd: 7 }));
});
36 changes: 36 additions & 0 deletions lib/markdown-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { marked } from "marked";
import Prism from "prismjs";
import "prismjs/components/prism-markup";
import "prismjs/components/prism-css";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-json";
import "prismjs/components/prism-bash";

export interface MarkdownStats { words: number; chars: number; lines: number; readingMinutes: number; }

export function renderMarkdown(markdown: string): string {
return marked.parse(markdown) as string;
}

export function getMarkdownStats(markdown: string): MarkdownStats {
const words = markdown.trim() ? markdown.trim().split(/\s+/).length : 0;
return { words, chars: markdown.length, lines: markdown ? markdown.split("\n").length : 0, readingMinutes: Math.max(1, Math.ceil(words / 200)) };
}

export function insertMarkdown(markdown: string, start: number, end: number, before: string, after = before, placeholder = "text") {
const selected = markdown.slice(start, end) || placeholder;
return { value: markdown.slice(0, start) + before + selected + after + markdown.slice(end), selectionStart: start + before.length, selectionEnd: start + before.length + selected.length };
}

function safeLanguage(lang: string | undefined) {
return (lang ?? "").toLowerCase().replace(/[^\w-]/g, "");
}

marked.use({ renderer: { code({ text, lang }) {
const language = safeLanguage(lang);
const grammar = language && Prism.languages[language];
const highlighted = grammar ? Prism.highlight(text, grammar, language) : Prism.util.encode(text);
return `<pre class="language-${language || "text"}"><code>${highlighted}</code></pre>`;
} } });
marked.setOptions({ gfm: true, breaks: true });
28 changes: 28 additions & 0 deletions lib/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,34 @@ export const TOOLS: Tool[] = [
},
sitemapPriority: 0.9,
},
{
slug: "markdown-preview",
path: "/markdown-preview",
title: "Markdown Live Preview",
tagline: "Markdown",
cardDescription: "Write Markdown and see sanitized, GitHub-flavored HTML rendered live. Copy HTML or download the source without leaving your browser.",
features: ["GFM", "Live preview", "Sanitized HTML", "Stats", "Export"],
accentVar: "--green",
accentHex: "#3FDD6B",
metaTitle: "Markdown Live Preview - GFM Editor & HTML Export",
metaDescription: "Write GitHub-flavored Markdown and preview sanitized HTML live. Copy HTML, download Markdown, and track writing stats in your browser.",
ogTitle: "Markdown Live Preview - CODERCOPS Tools",
ogDescription: "A private, client-side Markdown editor with GFM preview, sanitized HTML export, and writing stats.",
heroBreadcrumb: "Write · Preview · Export",
heroHeading: "Markdown Live Preview",
heroDim: "Live",
heroDescription: "Write GitHub-flavored Markdown on the left and preview sanitized HTML on the right — privately, in your browser.",
jsonLdName: "Markdown Live Preview",
jsonLdDescription: "A browser-based GitHub-flavored Markdown editor with sanitized live HTML preview, writing statistics, and Markdown or HTML export.",
applicationCategory: "DeveloperApplication",
faqs: [
{ q: "Is my Markdown uploaded?", a: "No. Parsing, sanitizing, previewing, and exporting all happen locally in your browser. Your document never leaves this tab." },
{ q: "Is the preview safe for untrusted Markdown?", a: "Yes. The generated HTML is sanitized with DOMPurify before it is rendered or copied, so unsafe markup and scripts are removed." },
{ q: "Which Markdown features are supported?", a: "The preview supports GitHub-flavored Markdown including tables, task lists, strikethrough, autolinks, headings, links, images, and fenced code blocks." },
],
cta: { headline: "Need a content editor in your product?", sub: "CODERCOPS builds polished developer tools, content workflows, and full-stack applications for startups and businesses.", primaryLabel: "View our work", primaryHref: CTA_PROJECTS, secondaryLabel: "Get a free quote", secondaryHref: CTA_CONTACT },
sitemapPriority: 0.9,
},
];

export function getTool(slug: string): Tool | undefined {
Expand Down
Loading
Loading