Just Render the Markdown, Please

Ready.

About

Just Render the Markdown, Please is a minimal offline Markdown viewer and HTML renderer.

Generated and human-reviewed by Al Sweigart. This app works offline and you own it forever. No ads. No registration. No subscriptions. No trackers. Just right-click and save the .html page.

Nothing is uploaded and all data stays on your computer.

Raw HTML

Raw HTML is disabled by default because embedded HTML can change the document in unexpected ways. When enabled, this app still removes scripts, event-handler attributes, forms, embedded frames, and unsafe URLs before preview or export. Only enable raw HTML for Markdown you trust.

\n\n\n\n${safeTitle}\n\n\n\n${bodyHtml}\n\n\n`; } function previewDocument(bodyHtml) { // Same standalone document, same CSS, same CSP. Preview and download are // intentionally WYSIWYG except for the browser's viewport size. return standaloneDocument(bodyHtml, APP.name); } function showInitialBlankPreview() { // Use the selected document theme even before the first render so theme // selection is immediately visible and preview/export remain WYSIWYG. elements.previewFrame.srcdoc = standaloneDocument("", APP.name); elements.downloadHtmlButton.disabled = true; } function acceptRender(bodyHtml) { state.lastGoodBodyHtml = bodyHtml; state.lastGoodStandaloneHtml = standaloneDocument(bodyHtml, sanitizedFilename(elements.nameInput.value)); state.hasGoodRender = true; elements.previewFrame.srcdoc = previewDocument(bodyHtml); elements.downloadHtmlButton.disabled = false; setStatus(t("renderedHtml")); clearError(); } function rejectRender(error) { setStatus(t("invalid"), true); showError(error?.message || String(error)); // Deliberately do not clear lastGood...; preview/export remain on the last // successful render. Before the first success, the initial blank page stays. elements.downloadHtmlButton.disabled = !state.hasGoodRender; } async function renderSource() { const generation = ++state.renderGeneration; const source = elements.sourceInput.value; clearError(); if (!source.trim()) { setStatus(t("empty")); if (!state.hasGoodRender) showInitialBlankPreview(); return; } setStatus(t("rendering")); try { const parsed = renderMarkdown(source, elements.rawHtmlCheckbox.checked); const sanitized = sanitizeRenderedHtml(parsed); if (generation !== state.renderGeneration) return; acceptRender(sanitized); } catch (error) { if (generation !== state.renderGeneration) return; rejectRender(error); } } function scheduleRender(delay = APP.renderDebounceMs) { clearTimeout(state.renderTimer); state.renderTimer = setTimeout(renderSource, delay); } function sanitizedFilename(name) { const cleaned = String(name || "") .replace(/[\\/:*?"<>|]/g, "") .replace(/[\u0000-\u001f\u007f]/g, "") .replace(/\s+/g, " ") .trim(); return cleaned || APP.defaultFilename; } function triggerDownload(blob, filename) { const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); } function downloadMarkdown() { triggerDownload(new Blob([elements.sourceInput.value], { type: "text/markdown;charset=utf-8" }), `${sanitizedFilename(elements.nameInput.value)}.md`); } function downloadHtml() { if (!state.hasGoodRender) return; // Regenerate wrapper title from current Name field without changing the // last-good BODY. This lets filename/title metadata change independently. const html = standaloneDocument(state.lastGoodBodyHtml, sanitizedFilename(elements.nameInput.value)); triggerDownload(new Blob([html], { type: "text/html;charset=utf-8" }), `${sanitizedFilename(elements.nameInput.value)}.html`); } async function openFile(file) { if (!file) return; try { elements.sourceInput.value = (await file.text()).replace(/\r\n?/g, "\n"); updateEditorDecorations(); if (!elements.nameInput.value.trim()) elements.nameInput.value = sanitizedFilename(file.name.replace(/\.[^.]+$/, "")); scheduleRender(0); } catch (error) { setStatus(t("openFailed"), true); showError(error?.message || String(error)); } } async function shareApp() { const text = `${APP.name} — ${APP.description} ${APP.shareUrl}`; try { await navigator.clipboard.writeText(text); } catch { const area = document.createElement("textarea"); area.value = text; document.body.append(area); area.select(); document.execCommand("copy"); area.remove(); } showToast(t("copied")); } function showToast(message) { elements.toast.textContent = message; elements.toast.classList.add("show"); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => elements.toast.classList.remove("show"), 1600); } function rebuildDocumentPresentation() { if (state.hasGoodRender) { elements.previewFrame.srcdoc = previewDocument(state.lastGoodBodyHtml); state.lastGoodStandaloneHtml = standaloneDocument( state.lastGoodBodyHtml, sanitizedFilename(elements.nameInput.value) ); } else { showInitialBlankPreview(); } } function runSelfTests() { console.assert(Object.keys(TRANSLATIONS).length === 40, "Expected 40 languages."); console.assert(EXAMPLES.every(example => example.source.endsWith("\n")), "Every example must end with a newline."); console.assert(elements.rawHtmlCheckbox.checked === false, "Raw HTML must start disabled."); console.assert(elements.downloadMdButton.disabled === false, "MD source download must always be enabled."); console.assert(!elements.previewFrame.sandbox.contains("allow-scripts"), "Preview must never allow scripts."); console.assert(Object.keys(DOCUMENT_THEMES).length === 12, "Expected 12 built-in document themes."); console.assert(Object.values(DOCUMENT_THEMES).every(theme => theme.label && theme.light && theme.dark && theme.layout), "Every document theme needs light and dark palettes plus layout settings."); const savedMode = state.documentMode; state.documentMode = "auto"; console.assert(currentDocumentThemeCss().includes("prefers-color-scheme: dark"), "Auto mode must follow browser color preference."); state.documentMode = "dark"; console.assert(!currentDocumentThemeCss().includes("prefers-color-scheme"), "Forced dark mode must not depend on browser preference."); state.documentMode = "light"; console.assert(!currentDocumentThemeCss().includes("prefers-color-scheme"), "Forced light mode must not depend on browser preference."); state.documentMode = savedMode; console.assert(sanitizeRenderedHtml('alert(1)

ok

').includes('

ok

'), "Sanitizer must remove script/event handlers."); console.assert(typeof window.hljs?.listLanguages === "function" && window.hljs.listLanguages().length >= 36, "Expected the embedded highlight.js common bundle."); console.assert(Object.values(DOCUMENT_THEMES).every(theme => SYNTAX_PALETTES[theme.syntax]), "Every document theme must name an existing syntax palette."); console.assert(Object.values(SYNTAX_PALETTES).every(palette => palette.light && palette.dark), "Every syntax palette needs light and dark token colors."); const highlightedFence = renderMarkdown("```python\nimport os\n```\n", false); console.assert(highlightedFence.includes("hljs-keyword"), "Known fence languages must be highlighted."); console.assert(sanitizeRenderedHtml(highlightedFence).includes("hljs-keyword"), "Sanitizing must preserve highlight markup."); console.assert(renderMarkdown("```py\nimport os\n```\n", false).includes("hljs-keyword"), "Language aliases must be highlighted."); console.assert(!renderMarkdown("```notalanguage\nimport os\n```\n", false).includes("hljs-"), "Unknown fence languages must fall back to plain code."); console.assert(renderMarkdown("```notalanguage\nx\n```\n", false).includes('class="language-notalanguage"'), "Unknown fence languages must keep their language- class."); console.assert(currentDocumentThemeCss().includes("--doc-hl-keyword"), "Document CSS must carry syntax palette variables."); } function installEvents() { elements.sourceInput.addEventListener("input", () => { updateEditorDecorations(); scheduleRender(); }); elements.sourceInput.addEventListener("scroll", () => { elements.lineNumbers.scrollTop = elements.sourceInput.scrollTop; elements.sourceHighlight.scrollTop = elements.sourceInput.scrollTop; elements.sourceHighlight.scrollLeft = elements.sourceInput.scrollLeft; }); elements.sourceInput.addEventListener("keydown", event => { if (event.key !== "Tab") return; event.preventDefault(); const start = elements.sourceInput.selectionStart; const end = elements.sourceInput.selectionEnd; elements.sourceInput.setRangeText(" ", start, end, "end"); updateEditorDecorations(); scheduleRender(); }); elements.openButton.addEventListener("click", () => elements.fileInput.click()); elements.fileInput.addEventListener("change", event => { openFile(event.target.files?.[0]); event.target.value = ""; }); elements.exampleSelect.addEventListener("change", () => { const example = EXAMPLES.find(item => item.id === elements.exampleSelect.value); if (!example) return; elements.sourceInput.value = example.source.endsWith("\n") ? example.source : `${example.source}\n`; elements.exampleSelect.value = ""; if (!elements.nameInput.value.trim()) elements.nameInput.value = sanitizedFilename(example.label.toLowerCase().replace(/\s+/g, "-")); elements.sourceInput.scrollTop = 0; elements.sourceInput.selectionStart = elements.sourceInput.selectionEnd = 0; updateEditorDecorations(); scheduleRender(0); elements.sourceInput.focus(); }); elements.themeSelect.addEventListener("change", () => { state.theme = elements.themeSelect.value; rebuildDocumentPresentation(); }); elements.documentModeSelect.addEventListener("change", () => { state.documentMode = elements.documentModeSelect.value; rebuildDocumentPresentation(); }); elements.rawHtmlCheckbox.addEventListener("change", () => scheduleRender(0)); elements.rawHtmlHelpButton.addEventListener("click", () => elements.rawHtmlDialog.showModal()); elements.closeRawHtmlButton.addEventListener("click", () => elements.rawHtmlDialog.close()); elements.rawHtmlDialog.addEventListener("click", event => { if (event.target === elements.rawHtmlDialog) elements.rawHtmlDialog.close(); }); elements.downloadMdButton.addEventListener("click", downloadMarkdown); elements.downloadHtmlButton.addEventListener("click", downloadHtml); elements.shareButton.addEventListener("click", shareApp); elements.aboutButton.addEventListener("click", () => elements.aboutDialog.showModal()); elements.closeAboutButton.addEventListener("click", () => elements.aboutDialog.close()); elements.aboutDialog.addEventListener("click", event => { if (event.target === elements.aboutDialog) elements.aboutDialog.close(); }); elements.languageSelect.addEventListener("change", () => { state.language = elements.languageSelect.value; applyTranslations(); // Rebuild preview/export wrapper language metadata without changing the body. if (state.hasGoodRender) elements.previewFrame.srcdoc = previewDocument(state.lastGoodBodyHtml); }); window.addEventListener("dragover", event => event.preventDefault()); window.addEventListener("drop", event => { event.preventDefault(); openFile(event.dataTransfer?.files?.[0]); }); } function initialize() { populateLanguages(); populateExamples(); elements.themeSelect.value = state.theme; elements.documentModeSelect.value = state.documentMode; applyTranslations(); installEvents(); updateEditorDecorations(); showInitialBlankPreview(); setStatus(t("ready")); $("#versionText").textContent = `v${APP.version}`; runSelfTests(); } initialize();