-
Notifications
You must be signed in to change notification settings - Fork 3
Support YAML front matter in Markdown uploads #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7a2019c
99ae717
fad6217
0089373
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| web: env RUBY_DEBUG_OPEN=true bin/rails server | ||
| js: yarn build --watch | ||
| css: yarn build:css --watch | ||
| css: yarn build:css --watch=always | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,10 @@ class MarkdownDocument | |
| autolink: true, tasklist: true, footnotes: true | ||
| }.freeze | ||
|
|
||
| # A leading `---` block of YAML, terminated by another `---` line. Common to | ||
| # every static-site generator's Markdown dialect (Jekyll, Hugo, Astro, ...). | ||
| FRONT_MATTER_PATTERN = /\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n?(.*)\z/m | ||
|
|
||
| # 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 | ||
|
|
@@ -67,7 +71,7 @@ class MarkdownDocument | |
| CSS | ||
|
|
||
| def initialize(markdown, filename: nil) | ||
| @markdown = markdown.to_s | ||
| @front_matter, @markdown = self.class.split_front_matter(markdown.to_s) | ||
| @filename = filename.to_s | ||
| end | ||
|
|
||
|
|
@@ -81,7 +85,7 @@ def to_html | |
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>#{CGI.escapeHTML(document_title)}</title> | ||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||
| #{description_meta_tag}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed. The resulting HTML remains valid, so I consider this cosmetic/non-blocking, but constructing the |
||
| <link rel="stylesheet" href="#{FONTS_HREF}"> | ||
| <style> | ||
| #{BRAND_CSS}#{HIGHLIGHT_CSS} | ||
|
|
@@ -141,9 +145,35 @@ def mermaid_script | |
| %(</script>) | ||
| end | ||
|
|
||
| # The document's own H1 names it; fall back to the filename (sans extension) | ||
| # so the paste still gets a meaningful <title> -- and title. | ||
| # Front matter's `title` wins if present; then the document's own H1; then | ||
| # the filename (sans extension), so the paste still gets a meaningful | ||
| # <title>. | ||
| def document_title | ||
| @heading.presence || File.basename(@filename, ".*").presence || "Untitled" | ||
| @front_matter["title"].presence || @heading.presence || | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: |
||
| File.basename(@filename, ".*").presence || "Untitled" | ||
| end | ||
|
|
||
| def description_meta_tag | ||
| description = @front_matter["description"].presence | ||
| return "" unless description | ||
|
|
||
| %(<meta name="description" content="#{CGI.escapeHTML(description.to_s)}">\n) | ||
| end | ||
|
|
||
| class << self | ||
| # Splits a leading YAML front-matter block off the Markdown body. Returns | ||
| # [{}, raw] unchanged if there's no front matter, the YAML doesn't parse, | ||
| # or it doesn't parse to a Hash -- front matter is metadata, never load-bearing | ||
| # for rendering, so any trouble here just means the whole file is treated | ||
| # as plain Markdown body. | ||
| def split_front_matter(raw) | ||
| match = FRONT_MATTER_PATTERN.match(raw) | ||
| return [ {}, raw ] unless match | ||
|
|
||
| data = YAML.safe_load(match[1], permitted_classes: [ Date, Time ]) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. High-severity resilience issue: deeply nested valid YAML can raise |
||
| data.is_a?(Hash) ? [ data, match[2] ] : [ {}, raw ] | ||
|
Comment on lines
+170
to
+174
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed locally, with one additional regex edge case: |
||
| rescue Psych::Exception | ||
| [ {}, raw ] | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,4 +56,62 @@ class MarkdownDocumentTest < ActiveSupport::TestCase | |
|
|
||
| assert_not_includes html, "alert('xss')" | ||
| end | ||
|
|
||
| test "strips yaml front matter and uses it for title and description" do | ||
| html = MarkdownDocument.new(<<~MD, filename: "x.md").to_html | ||
| --- | ||
| title: "My Document Title" | ||
| author: "Jane Doe" | ||
| date: 2026-07-13 | ||
| tags: [markdown, tutorial, yaml] | ||
| draft: false | ||
| description: "A test document" | ||
| --- | ||
|
|
||
| # Document Heading Starts Here | ||
| This is the regular Markdown body text. | ||
| MD | ||
|
|
||
| assert_includes html, "<title>My Document Title</title>" | ||
| assert_includes html, '<meta name="description" content="A test document">' | ||
| assert_not_includes html, "title: \"My Document Title\"" | ||
| assert_includes html, ">Document Heading Starts Here</h1>" | ||
| end | ||
|
|
||
| test "front matter title overrides an h1" do | ||
| html = MarkdownDocument.new(<<~MD, filename: "x.md").to_html | ||
| --- | ||
| title: From Front Matter | ||
| --- | ||
| # From Heading | ||
| MD | ||
|
|
||
| assert_includes html, "<title>From Front Matter</title>" | ||
| end | ||
|
|
||
| test "falls back to h1, then filename, when front matter has no title" do | ||
| assert_includes MarkdownDocument.new("---\nauthor: Jane\n---\n# Heading", filename: "x.md").to_html, | ||
| "<title>Heading</title>" | ||
| assert_includes MarkdownDocument.new("---\nauthor: Jane\n---\nbody", filename: "my-notes.md").to_html, | ||
| "<title>my-notes</title>" | ||
| end | ||
|
|
||
| test "omits the description tag when front matter has none" do | ||
| html = MarkdownDocument.new("# Plain\n\ntext", filename: "x.md").to_html | ||
| assert_not_includes html, 'name="description"' | ||
| end | ||
|
|
||
| test "treats malformed or non-mapping front matter as plain body" do | ||
| malformed = MarkdownDocument.new("---\n[1, 2\n---\nbody", filename: "x.md").to_html | ||
| assert_includes malformed, "[1, 2" | ||
|
|
||
| sequence = MarkdownDocument.new("---\n- a\n- b\n---\nbody", filename: "x.md").to_html | ||
| assert_includes sequence, "<li>a</li>" | ||
| assert_includes sequence, "<li>b</li>" | ||
| end | ||
|
|
||
| test "does not treat a body horizontal rule as front matter" do | ||
| html = MarkdownDocument.new("no leading marker\n\n---\n\nafter the rule", filename: "x.md").to_html | ||
| assert_includes html, "<hr" | ||
| end | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please extend the boundary coverage before merging: non-string/implicitly typed |
||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I verified this is functionally correct: with the pinned
misetoolchain,yarn build:css --watch=alwaysremained alive after stdin was closed. It is unrelated to YAML front matter, though, so consider moving it to a separate PR to keep review and rollback scope focused. Non-blocking.