From 6213afdbed9295c341cbe0bf88145aa70187a109 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 11:01:24 +0000 Subject: [PATCH] Render Markdown uploads into branded HTML pages Accept .md/.markdown uploads (web and API) and render them server-side into self-contained, brand-styled HTML pages, so the stored paste is still HTML and every existing endpoint (raw, render, live origin, /markdown read-back, source view, title extraction) keeps working. - MarkdownDocument: GitHub-flavored Markdown via commonmarker (tables, task lists, strikethrough, autolinks, footnotes), code highlighted with Rouge (github.dark, no runtime fetch), and mermaid fences left as
 upgraded by a pinned CDN module injected only when a diagram is
  present. Raw HTML in the Markdown is dropped; the plain HTML upload path
  remains for arbitrary markup.
- Paste: detect Markdown by filename, render at ingest via render_content
  (shared by from_upload and republish), and widen filename validation.
- API: also detect a text/markdown body and default its filename to .md.
- Web: dropzone and file input accept .md/.markdown; constraints copy updated
  (en + ar).
- llms.txt: document the Markdown upload path; keep the mark-it-down guide as
  the no-server alternative.

Co-Authored-By: Claude Opus 4.8 
Claude-Session: https://claude.ai/code/session_01K8nFQAeyYDYaNFUjtzaEvg
---
 Gemfile                                       |   3 +
 Gemfile.lock                                  |  13 ++
 app/controllers/api/pastes_controller.rb      |  13 +-
 .../controllers/dropzone_controller.js        |   2 +-
 app/models/markdown_document.rb               | 149 ++++++++++++++++++
 app/models/paste.rb                           |  28 +++-
 app/views/pastes/new.html.erb                 |   4 +-
 config/locales/ar.yml                         |   6 +-
 config/locales/en.yml                         |   6 +-
 public/llms.txt                               |  24 ++-
 .../controllers/api/pastes_controller_test.rb |  28 ++++
 test/controllers/pastes_controller_test.rb    |  16 +-
 test/fixtures/files/sample.md                 |  14 ++
 test/models/markdown_document_test.rb         |  59 +++++++
 test/models/paste_test.rb                     |  43 +++++
 15 files changed, 391 insertions(+), 17 deletions(-)
 create mode 100644 app/models/markdown_document.rb
 create mode 100644 test/fixtures/files/sample.md
 create mode 100644 test/models/markdown_document_test.rb

diff --git a/Gemfile b/Gemfile
index 3edaeb1..94679ee 100644
--- a/Gemfile
+++ b/Gemfile
@@ -65,6 +65,9 @@ gem "rouge", "~> 5.0"
 # Converts a paste's HTML into Markdown for the /p//markdown endpoint.
 gem "reverse_markdown", "~> 3.0"
 
+# GitHub-flavored Markdown -> HTML for markdown uploads (cmark-gfm/comrak).
+gem "commonmarker", "~> 2.0"
+
 gem "meta-tags", "~> 2.23"
 
 # Trust Cloudflare's IP ranges so remote_ip (and the per-IP rate limits) see
diff --git a/Gemfile.lock b/Gemfile.lock
index e4b45ad..d77eb0d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -104,6 +104,12 @@ GEM
       activesupport (>= 7.2.0, < 8.2.0)
       railties (>= 7.2.0, < 8.2.0)
       zeitwerk (>= 2.5.0)
+    commonmarker (2.8.2-aarch64-linux)
+    commonmarker (2.8.2-aarch64-linux-musl)
+    commonmarker (2.8.2-arm-linux)
+    commonmarker (2.8.2-arm64-darwin)
+    commonmarker (2.8.2-x86_64-linux)
+    commonmarker (2.8.2-x86_64-linux-musl)
     concurrent-ruby (1.3.7)
     connection_pool (3.0.2)
     crass (1.0.7)
@@ -383,6 +389,7 @@ DEPENDENCIES
   bundler-audit
   capybara
   cloudflare-rails (~> 7.0)
+  commonmarker (~> 2.0)
   cssbundling-rails
   debug
   jsbundling-rails
@@ -429,6 +436,12 @@ CHECKSUMS
   bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
   capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
   cloudflare-rails (7.0.0) sha256=9049b8305eab120e2d4ed9d04b42ce7030da5297fb62b0fa718b0a9e2d9af56c
+  commonmarker (2.8.2-aarch64-linux) sha256=715a5df28e5c1ec7b520e6441466062e9717b82193d2a5bd11a9c94f2f1e15f0
+  commonmarker (2.8.2-aarch64-linux-musl) sha256=c7d587bd7978416978e84d0de7488906b8f29b3f67ce8424942f0ca9aa6899db
+  commonmarker (2.8.2-arm-linux) sha256=da9da5c08d5133ad429ece13f2e93d3a30e1b23621ffac9040e185d7ea29bde6
+  commonmarker (2.8.2-arm64-darwin) sha256=cd7364f1fb9735d60218e4af0a4afbbeedbc465eb81cccca56f29a4efe2b5013
+  commonmarker (2.8.2-x86_64-linux) sha256=42cfb6532b15dd5821ce99e47397923255f32dcfc81024ef4c17e01e66ac7d75
+  commonmarker (2.8.2-x86_64-linux-musl) sha256=66568dce1c6f265e85d57ea7f749a148446527c7dd599c6ded4cdc819997c43e
   concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
   connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
   crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
diff --git a/app/controllers/api/pastes_controller.rb b/app/controllers/api/pastes_controller.rb
index 4e97075..91122b0 100644
--- a/app/controllers/api/pastes_controller.rb
+++ b/app/controllers/api/pastes_controller.rb
@@ -22,7 +22,8 @@ class PastesController < ActionController::API
     end
 
     def create
-      paste = Paste.new(content: submitted_content, original_filename: submitted_filename || "untitled.html")
+      filename = submitted_filename || default_filename
+      paste = Paste.new(content: Paste.render_content(submitted_content, filename), original_filename: filename)
 
       if paste.save
         render json: payload(paste).merge(update_token: paste.update_token), status: :created
@@ -55,6 +56,16 @@ def submitted_filename
         upload ? upload.original_filename : params[:filename].presence
       end
 
+      # A raw body sent as text/markdown gets a .md default so it's rendered
+      # (multipart uploads carry their own filename via `submitted_filename`).
+      def default_filename
+        markdown_request? ? "untitled.md" : "untitled.html"
+      end
+
+      def markdown_request?
+        request.media_type.to_s.match?(%r{\Atext/(x-)?markdown\z})
+      end
+
       def raw_content
         request.raw_post.to_s.byteslice(0, Paste::MAX_CONTENT_BYTES + 1).force_encoding(Encoding::UTF_8).scrub
       end
diff --git a/app/javascript/controllers/dropzone_controller.js b/app/javascript/controllers/dropzone_controller.js
index 8a11b95..a7a01b7 100644
--- a/app/javascript/controllers/dropzone_controller.js
+++ b/app/javascript/controllers/dropzone_controller.js
@@ -101,7 +101,7 @@ export default class extends Controller {
   }
 
   problemWith(file) {
-    if (!/\.html?$/i.test(file.name)) return this.notHtmlFileValue
+    if (!/\.(html?|md|markdown)$/i.test(file.name)) return this.notHtmlFileValue
     if (file.size === 0) return this.emptyValue
     if (file.size > MAX_BYTES) return this.tooLargeValue
     return null
diff --git a/app/models/markdown_document.rb b/app/models/markdown_document.rb
new file mode 100644
index 0000000..18c586e
--- /dev/null
+++ b/app/models/markdown_document.rb
@@ -0,0 +1,149 @@
+# Renders GitHub-flavored Markdown into a self-contained HTML page dressed in
+# the pastehtml.dev brand -- the server-side counterpart to the client-side
+# mark-it-down guide. Because we render here (not in the browser), the stored
+# paste is real HTML: /raw, /render, the live origin, /markdown read-back, the
+# source view, and  extraction all keep working unchanged.
+#
+# Code fences are highlighted with Rouge (the same highlighter the source view
+# uses) so nothing is fetched at view time. Mermaid is the exception: its
+# library is ~2.8 MB -- far past the 2 MB paste limit -- so it can never be
+# inlined. Mermaid fences are left as <pre class="mermaid"> and upgraded in the
+# browser by a pinned CDN module, injected ONLY when the document actually
+# contains a diagram.
+class MarkdownDocument
+  # GitHub's flavor: tables, ~~strikethrough~~, the raw-HTML tag filter,
+  # autolinks, task lists, and footnotes.
+  EXTENSIONS = {
+    table: true, strikethrough: true, tagfilter: true,
+    autolink: true, tasklist: true, footnotes: true
+  }.freeze
+
+  # Pinned so a stored paste renders identically forever. The ESM build boots
+  # itself via startOnLoad and upgrades every <pre class="mermaid">.
+  MERMAID_MODULE = "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs".freeze
+
+  # The brand fonts the .md-body styles reference (Bangers for headings, Inter
+  # for body, IBM Plex Mono for code, Lalezar/IBM Plex Sans Arabic for RTL).
+  FONTS_HREF = "https://fonts.googleapis.com/css2?family=Bangers&family=Inter:wght@400;600;700&family=IBM+Plex+Mono:wght@400;500&family=Lalezar&family=IBM+Plex+Sans+Arabic:wght@400;600;700&display=swap".freeze
+
+  FORMATTER = Rouge::Formatters::HTML.new
+
+  # github.dark tokens read cleanly on the brand's ink code background; the
+  # classed spans Rouge emits pair with this stylesheet, rendered once at boot.
+  HIGHLIGHT_CSS = Rouge::Theme.find("github.dark").render(scope: ".md-body .highlight").freeze
+
+  # The brand chrome, lifted verbatim from the mark-it-down template so a
+  # server-rendered page is visually identical to one built by the guide.
+  BRAND_CSS = <<~CSS.freeze
+    :root { --ink:#18120e; --paper:#fff8ec; --red:#e62429; --yellow:#ffd400; --blue:#0072ce; }
+    * { margin:0; box-sizing:border-box; }
+    body { background-color:var(--paper);
+      background-image:radial-gradient(circle, rgb(24 18 14 / 0.07) 1px, transparent 1.4px);
+      background-size:12px 12px; color:var(--ink);
+      font-family:Inter,"IBM Plex Sans Arabic",system-ui,sans-serif; -webkit-font-smoothing:antialiased; }
+    [dir="rtl"] * { letter-spacing:normal; }
+    .md-body { max-width: 50rem; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; color: var(--ink); line-height: 1.65; }
+    .md-body > :first-child { margin-top: 0; }
+    .md-body h1, .md-body h2, .md-body h3, .md-body h4 { font-family: Bangers, Lalezar, Impact, sans-serif; letter-spacing: 0.02em; line-height: 1.2; margin: 1.8rem 0 0.7rem; }
+    .md-body h1 { font-size: clamp(2.2rem,6vw,3rem); text-shadow: 2px 2px 0 var(--red); }
+    .md-body h2 { font-size: 1.8rem; border-bottom: 3px solid var(--ink); padding-bottom: 0.2rem; }
+    .md-body h3 { font-size: 1.4rem; } .md-body h4 { font-size: 1.15rem; }
+    .md-body p { margin: 0.85rem 0; }
+    .md-body a { color: var(--blue); text-decoration: underline; text-underline-offset: 2px; }
+    .md-body ul, .md-body ol { margin: 0.85rem 0; padding-inline-start: 1.7rem; }
+    .md-body li { margin: 0.3rem 0; }
+    .md-body code { font-family: "IBM Plex Mono", "IBM Plex Sans Arabic", monospace; font-size: 0.85em; background: rgb(24 18 14 / 0.08); border-radius: 4px; padding: 0.08rem 0.34rem; unicode-bidi: isolate; }
+    .md-body pre { direction: ltr; text-align: left; background: var(--ink); border: 3px solid var(--ink); border-radius: 12px; box-shadow: 5px 5px 0 0 var(--red); padding: 1rem 1.1rem; overflow-x: auto; margin: 1.1rem 0; }
+    .md-body pre code { background: none; padding: 0; font-size: 0.85rem; line-height: 1.55; }
+    .md-body blockquote { border-inline-start: 6px solid var(--yellow); background: #fff7d6; margin: 1.1rem 0; padding: 0.6rem 1rem; border-radius: 8px; border-start-start-radius: 0; border-end-start-radius: 0; }
+    .md-body blockquote > :first-child { margin-top: 0; } .md-body blockquote > :last-child { margin-bottom: 0; }
+    .md-body table { border-collapse: collapse; margin: 1.1rem 0; width: 100%; display: block; overflow-x: auto; }
+    .md-body th, .md-body td { border: 2px solid var(--ink); padding: 0.45rem 0.7rem; text-align: start; }
+    .md-body th { background: var(--yellow); font-family: "IBM Plex Mono", "IBM Plex Sans Arabic", monospace; font-size: 0.9rem; }
+    .md-body img { max-width: 100%; height: auto; border: 3px solid var(--ink); border-radius: 8px; }
+    .md-body hr { border: none; border-top: 3px dashed rgb(24 18 14 / 0.3); margin: 1.8rem 0; }
+    .md-body input[type="checkbox"] { width: 1rem; height: 1rem; vertical-align: middle; margin-inline-end: 0.3rem; }
+    .md-body pre.mermaid { background: none; border: none; box-shadow: none; padding: 0; text-align: center; }
+  CSS
+
+  def initialize(markdown, filename: nil)
+    @markdown = markdown.to_s
+    @filename = filename.to_s
+  end
+
+  # The full HTML document. Safe to store and serve verbatim as a paste.
+  def to_html
+    body = render_body
+    <<~HTML
+      <!DOCTYPE html>
+      <html lang="en">
+      <head>
+      <meta charset="utf-8">
+      <meta name="viewport" content="width=device-width, initial-scale=1">
+      <title>#{CGI.escapeHTML(document_title)}
+      
+      
+      
+      
+      
+      
+ #{body} +
+ #{mermaid_script} + + + HTML + end + + private + # Convert the Markdown, then post-process every code fence: Mermaid fences + # become diagram containers, all others are highlighted with Rouge. Raw HTML + # in the source is dropped (unsafe: false) -- the plain HTML upload path + # already exists for anyone who wants arbitrary markup. + def render_body + html = Commonmarker.to_html(@markdown, + options: { extension: EXTENSIONS, render: { unsafe: false } }, + plugins: { syntax_highlighter: nil }) + + fragment = Nokogiri::HTML5.fragment(html) + fragment.css("pre").each { |pre| transform_code_block(pre) } + @heading = fragment.at_css("h1")&.text&.strip + fragment.to_html + end + + def transform_code_block(pre) + code = pre.at_css("code") + return unless code + + language = pre["lang"].to_s + source = code.text + pre.remove_attribute("lang") + + if language == "mermaid" + @has_mermaid = true + pre["class"] = "mermaid" + pre.content = source # replaces ; Mermaid reads the element's text + else + lexer = Rouge::Lexer.find(language) || Rouge::Lexers::PlainText + code["class"] = "highlight" + code.inner_html = FORMATTER.format(lexer.lex(source)) + end + end + + def mermaid_script + return "" unless @has_mermaid + + %() + end + + # The document's own H1 names it; fall back to the filename (sans extension) + # so the paste still gets a meaningful -- and title. + def document_title + @heading.presence || File.basename(@filename, ".*").presence || "Untitled" + end +end diff --git a/app/models/paste.rb b/app/models/paste.rb index 9a695d7..dd41fb1 100644 --- a/app/models/paste.rb +++ b/app/models/paste.rb @@ -5,6 +5,9 @@ class Paste < ApplicationRecord TOKEN_ALPHABET = [ *"a".."z", *"0".."9" ].freeze MAX_CONTENT_BYTES = 2.megabytes HTML_EXTENSION = /\A\.html?\z/i + # Markdown uploads are rendered to a branded HTML page at ingest, so the + # stored paste is still HTML -- only the accepted filename widens. + MARKDOWN_EXTENSION = /\A\.(md|markdown)\z/i TITLE_TAG = %r{<title[^>]*>(.*?)}im MAX_TITLE_LENGTH = 120 @@ -15,7 +18,7 @@ class Paste < ApplicationRecord validates :content, presence: true validates :original_filename, presence: true - validate :original_filename_must_be_html + validate :original_filename_must_be_supported validate :content_must_fit_size_limit before_create :assign_token, :assign_update_token @@ -27,13 +30,26 @@ class Paste < ApplicationRecord class << self def from_upload(upload) - new(content: read_upload(upload), original_filename: upload.original_filename) + filename = upload.original_filename + new(content: render_content(read_upload(upload), filename), original_filename: filename) end def read_upload(upload) upload.read(MAX_CONTENT_BYTES + 1).to_s.force_encoding(Encoding::UTF_8).scrub end + # Markdown ingests are rendered to a branded HTML page; every other upload + # is stored as-is. Keyed on the filename so both create and republish agree. + def render_content(content, original_filename) + return content unless markdown_filename?(original_filename) + + MarkdownDocument.new(content, filename: original_filename).to_html + end + + def markdown_filename?(filename) + File.extname(filename.to_s).match?(MARKDOWN_EXTENSION) + end + def digest_update_token(token) OpenSSL::Digest::SHA256.hexdigest(token) end @@ -53,8 +69,8 @@ def updatable_with?(candidate) end def republish(content:, original_filename: nil) - self.content = content self.original_filename = original_filename if original_filename.present? + self.content = self.class.render_content(content, self.original_filename) save end @@ -94,9 +110,11 @@ def extract_title end end - def original_filename_must_be_html + def original_filename_must_be_supported return if original_filename.blank? - return if File.extname(original_filename).match?(HTML_EXTENSION) + + extension = File.extname(original_filename) + return if extension.match?(HTML_EXTENSION) || extension.match?(MARKDOWN_EXTENSION) errors.add(:original_filename, :not_html) end diff --git a/app/views/pastes/new.html.erb b/app/views/pastes/new.html.erb index b29014d..75ead06 100644 --- a/app/views/pastes/new.html.erb +++ b/app/views/pastes/new.html.erb @@ -61,7 +61,7 @@ browse: tag.span(t("home.dropzone.browse"), class: "font-semibold text-hero-blue underline decoration-2 underline-offset-4"), paste: tag.kbd("⌘V", class: "rounded border border-ink/30 bg-paper px-1 font-mono text-xs", dir: "ltr")) %> <%= t("home.dropzone.constraints_html", - html: tag.bdi(".html", dir: "ltr"), htm: tag.bdi(".htm", dir: "ltr"), size: tag.bdi("2 MB", dir: "ltr")) %> + html: tag.bdi(".html", dir: "ltr"), htm: tag.bdi(".htm", dir: "ltr"), md: tag.bdi(".md", dir: "ltr"), size: tag.bdi("2 MB", dir: "ltr")) %> @@ -77,7 +77,7 @@

<%= flash[:alert] %>

- <%= form.file_field :file, accept: ".html,.htm,text/html", class: "sr-only", tabindex: -1, + <%= form.file_field :file, accept: ".html,.htm,.md,.markdown,text/html,text/markdown", class: "sr-only", tabindex: -1, aria: { label: t("home.dropzone.choose_file") }, data: { dropzone_target: "input", action: "change->dropzone#fileSelected" } %> <% end %> diff --git a/config/locales/ar.yml b/config/locales/ar.yml index 2011422..dfc643d 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -22,7 +22,7 @@ ar: heading: أفلِت ملف HTML هنا! browse: تصفّح ملفاتك browse_html: أو %{browse} · أو الصق HTML مباشرةً (%{paste}) - constraints_html: "%{html} أو %{htm} · حتى %{size}" + constraints_html: "%{html} أو %{htm} أو %{md} · حتى %{size}" publishing: جارٍ نشر صفحتك… publishing_hint: لن يستغرق الأمر سوى لحظة. choose_file: اختر ملف HTML @@ -76,7 +76,7 @@ ar: iframe_title: معاينة %{filename} isolation_html: 'تعمل كل صفحة في بيئة معزولة خاصة بها: تعمل بداخلها النصوص البرمجية والتخزين المحلي بشكل كامل، لكنها لا تستطيع الوصول إلى هذا الموقع أو إلى الصفحات الأخرى مطلقًا. اضغط %{fullscreen_key} لملء الشاشة، أو %{exit_key} للخروج.' dropzone_errors: - not_html_file: لا يبدو هذا ملف HTML. اختر ملف .html أو .htm. + not_html_file: لا يبدو هذا ملف HTML أو Markdown. اختر ملف .html أو .htm أو .md. empty: هذا الملف فارغ. too_large: حجم هذا الملف أكبر من 2 MB. not_html_clipboard: لا يبدو محتوى الحافظة بصيغة HTML. يجب أن يبدأ بوسم. @@ -123,7 +123,7 @@ ar: paste: attributes: original_filename: - not_html: يجب أن يكون ملف .html أو .htm + not_html: يجب أن يكون ملف .html أو .htm أو .md أو .markdown content: blank: لا يمكن أن يكون فارغًا too_large: كبير جدًا (الحد الأقصى 2 MB) diff --git a/config/locales/en.yml b/config/locales/en.yml index 257ad1e..74570e9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -23,7 +23,7 @@ en: heading: "Drop your HTML file here!" browse: "browse your files" browse_html: "or %{browse} · or just paste HTML (%{paste})" - constraints_html: "%{html} or %{htm} · up to %{size}" + constraints_html: "%{html}, %{htm} or %{md} · up to %{size}" publishing: "Publishing your page…" publishing_hint: "This only takes a moment." choose_file: "Choose an HTML file" @@ -79,7 +79,7 @@ en: isolation_html: "Every page runs on its own isolated origin: scripts and local storage work, but they can never touch this site or other pastes. Press %{fullscreen_key} for fullscreen, %{exit_key} to leave." dropzone_errors: - not_html_file: "That doesn't look like an HTML file. Choose a .html or .htm file." + not_html_file: "That doesn't look like an HTML or Markdown file. Choose a .html, .htm, or .md file." empty: "That file is empty." too_large: "That file is larger than 2 MB." not_html_clipboard: "The clipboard doesn't look like HTML. It should start with a tag." @@ -130,7 +130,7 @@ en: paste: attributes: original_filename: - not_html: "must be an .html or .htm file" + not_html: "must be an .html, .htm, .md, or .markdown file" content: blank: "can't be blank" too_large: "is too large (maximum is 2 MB)" diff --git a/public/llms.txt b/public/llms.txt index 4885588..9d69206 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -42,6 +42,26 @@ Response 201: 422 with { "errors": [...] } when the content is invalid. +## Publish Markdown + +Upload Markdown and the server renders it into a styled, self-contained HTML +page in the pastehtml.dev look -- GitHub-flavored (tables, task lists, +strikethrough, footnotes, autolinks) with fenced `mermaid` blocks rendered as +diagrams. Markdown is detected by a .md/.markdown filename or a text/markdown +content type: + + # Multipart file upload + curl -F "file=@notes.md" https://pastehtml.dev/api/pastes + + # Raw Markdown body + curl --data-binary @notes.md -H "Content-Type: text/markdown" \ + https://pastehtml.dev/api/pastes + +The stored paste IS HTML, so the response is exactly the same shape as an HTML +publish and every URL (live_url, url, raw_url, render_url, markdown_url) works +as usual. Raw HTML embedded in the Markdown is dropped -- use the plain HTML +upload above if you need arbitrary markup. + ## Update Only the holder of a paste's update_token can change it: @@ -77,7 +97,9 @@ just share the guide link with the user. https://lock-it-up.pastehtml.dev/ (source: https://pastehtml.dev/p/lock-it-up/raw) - Publish a Markdown (.md) file as a rendered HTML page -- the user hands you - Markdown and wants a styled, self-contained page: + Markdown and wants a styled, self-contained page. Simplest path is to just + upload the .md (see "Publish Markdown" above); this guide is the no-server + alternative that builds the page in the browser and adds a live preview: https://mark-it-down.pastehtml.dev/ (source: https://pastehtml.dev/p/mark-it-down/raw) ## Rules diff --git a/test/controllers/api/pastes_controller_test.rb b/test/controllers/api/pastes_controller_test.rb index 1e1a4a7..03b6096 100644 --- a/test/controllers/api/pastes_controller_test.rb +++ b/test/controllers/api/pastes_controller_test.rb @@ -34,6 +34,34 @@ class PastesControllerTest < ActionDispatch::IntegrationTest assert_equal "plan.html", paste.original_filename end + test "renders a raw markdown body sent as text/markdown" do + assert_difference "Paste.count" do + post api_pastes_url, params: "# Hello\n\nBody **text**.", + headers: { "Content-Type" => "text/markdown" } + end + + assert_response :created + body = response.parsed_body + assert_equal "Hello", body["title"] + + paste = Paste.find_by!(token: body["token"]) + assert_equal "untitled.md", paste.original_filename + assert_includes paste.content, 'class="md-body"' + assert_includes paste.content, "text" + end + + test "renders a multipart markdown upload by its .md filename" do + post api_pastes_url, params: { file: fixture_file_upload("sample.md", "text/markdown") } + + assert_response :created + body = response.parsed_body + assert_equal "Sample Doc", body["title"] + + paste = Paste.find_by!(token: body["token"]) + assert_equal "sample.md", paste.original_filename + assert_includes paste.content, '
'
+    end
+
     test "defaults the filename for raw bodies" do
       post api_pastes_url, params: "

Hi

", headers: { "Content-Type" => "text/html" } diff --git a/test/controllers/pastes_controller_test.rb b/test/controllers/pastes_controller_test.rb index 3149498..71cec1d 100644 --- a/test/controllers/pastes_controller_test.rb +++ b/test/controllers/pastes_controller_test.rb @@ -56,7 +56,21 @@ class PastesControllerTest < ActionDispatch::IntegrationTest end assert_redirected_to root_url - assert_match(/must be an .html or .htm file/, flash[:alert]) + assert_match(/must be an .html/, flash[:alert]) + end + + test "creating a paste from a markdown file renders and redirects to its page" do + assert_difference "Paste.count" do + post pastes_url, params: { file: fixture_file_upload("sample.md", "text/markdown") } + end + + token = @response.redirect_url[%r{/p/([a-z0-9]+)\z}, 1] + paste = Paste.find_by!(token:) + assert_equal "sample.md", paste.original_filename + assert_includes paste.content, 'class="md-body"' + assert_includes paste.content, ">Sample Doc" + assert_includes paste.content, '
'
+    assert_equal "Sample Doc", paste.title
   end
 
   test "show displays the share link, preview and source" do
diff --git a/test/fixtures/files/sample.md b/test/fixtures/files/sample.md
new file mode 100644
index 0000000..74282f2
--- /dev/null
+++ b/test/fixtures/files/sample.md
@@ -0,0 +1,14 @@
+# Sample Doc
+
+Some **bold** text and a [link](https://example.com).
+
+- [ ] a todo
+- [x] a done item
+
+```ruby
+def hi = puts("hi")
+```
+
+```mermaid
+graph TD; A-->B;
+```
diff --git a/test/models/markdown_document_test.rb b/test/models/markdown_document_test.rb
new file mode 100644
index 0000000..929c376
--- /dev/null
+++ b/test/models/markdown_document_test.rb
@@ -0,0 +1,59 @@
+require "test_helper"
+
+class MarkdownDocumentTest < ActiveSupport::TestCase
+  test "renders github-flavored markdown into the branded page" do
+    html = MarkdownDocument.new(<<~MD, filename: "doc.md").to_html
+      # Heading
+
+      Text with **bold**, ~~strike~~, and a [link](https://example.com).
+
+      | a | b |
+      |---|---|
+      | 1 | 2 |
+
+      - [ ] todo
+      - [x] done
+    MD
+
+    assert_includes html, ""
+    assert_includes html, 'class="md-body"'
+    assert_includes html, ">Heading"
+    assert_includes html, "bold"
+    assert_includes html, "strike"
+    assert_includes html, "a"
+    assert_includes html, 'type="checkbox"'
+  end
+
+  test "titles the page from the first h1, else the filename" do
+    assert_includes MarkdownDocument.new("# Real Title\n\nbody", filename: "x.md").to_html,
+      "Real Title"
+    assert_includes MarkdownDocument.new("just body, no heading", filename: "my-notes.md").to_html,
+      "my-notes"
+  end
+
+  test "highlights code fences with rouge, not a runtime fetch" do
+    html = MarkdownDocument.new("```ruby\ndef hi; end\n```", filename: "x.md").to_html
+
+    assert_includes html, 'class="highlight"'
+    assert_includes html, "B;\n```", filename: "x.md").to_html
+    assert_includes with_diagram, '
'
+    assert_includes with_diagram, "graph TD; A-->B;"
+    assert_includes with_diagram, "mermaid@11.4.1"
+    assert_not_includes with_diagram, "alert('xss')", filename: "x.md").to_html
+
+    assert_not_includes html, "alert('xss')"
+  end
+end
diff --git a/test/models/paste_test.rb b/test/models/paste_test.rb
index 11dea01..6069e88 100644
--- a/test/models/paste_test.rb
+++ b/test/models/paste_test.rb
@@ -146,6 +146,49 @@ class PasteTest < ActiveSupport::TestCase
     assert_nothing_raised { paste.to_markdown }
   end
 
+  test "accepts .md and .markdown filenames" do
+    assert Paste.new(content: "

Hi

", original_filename: "notes.md").valid? + assert Paste.new(content: "

Hi

", original_filename: "notes.markdown").valid? + end + + test "from_upload renders a markdown upload into a branded html page" do + upload = Rack::Test::UploadedFile.new( + StringIO.new("# Title\n\nHello **world**"), "text/markdown", original_filename: "notes.md" + ) + + paste = Paste.from_upload(upload) + + assert_equal "notes.md", paste.original_filename + assert_includes paste.content, 'class="md-body"' + assert_includes paste.content, "world" + assert paste.valid? + end + + test "extracts the title from a rendered markdown upload" do + upload = Rack::Test::UploadedFile.new( + StringIO.new("# The Heading\n\nbody"), "text/markdown", original_filename: "notes.md" + ) + + paste = Paste.from_upload(upload) + paste.save! + + assert_equal "The Heading", paste.title + end + + test "render_content leaves html uploads untouched" do + assert_equal "

Hi

", Paste.render_content("

Hi

", "page.html") + end + + test "republish re-renders when the paste originated from markdown" do + paste = Paste.create!(content: Paste.render_content("# One", "doc.md"), original_filename: "doc.md") + assert_includes paste.content, ">One" + + paste.republish(content: "# Two") + + assert_includes paste.reload.content, ">Two" + assert_includes paste.content, 'class="md-body"' + end + test "from_upload scrubs invalid utf-8 bytes" do upload = Rack::Test::UploadedFile.new( StringIO.new("

caf\xE9

".b), "text/html", original_filename: "cafe.html"