Support YAML front matter in Markdown uploads#19
Conversation
Tailwind's --watch stops as soon as stdin closes, which Foreman does immediately for spawned processes, so `mise run dev` exited the whole process group right after boot. --watch=always keeps it running regardless of stdin.
Parse a leading --- delimited YAML block off Markdown uploads before rendering. `title` overrides the H1/filename fallback for the page <title>, and `description` renders as a meta description tag. Malformed or non-mapping front matter (or none at all) falls back to treating the whole file as plain Markdown body, so nothing breaks rendering.
Psych::SyntaxError and Psych::DisallowedClass aren't the only failure modes -- YAML aliases (&anchor/*alias) raise Psych::BadAlias or Psych::AliasesNotEnabled, which weren't rescued and would break rendering instead of falling back to plain Markdown body. Co-authored-by: Copilot code review
Redundant with the broadened Psych::Exception rescue itself; not worth the upkeep.
There was a problem hiding this comment.
Pull request overview
Adds support for optional YAML front matter in Markdown uploads by extracting metadata (title/description) before rendering, while keeping rendering resilient when front matter is missing or invalid. Also adjusts the local dev Procfile so Tailwind CSS watching doesn’t exit immediately under Foreman.
Changes:
- Parse and strip leading
----delimited YAML front matter inMarkdownDocument, usingtitlefor the HTML<title>anddescriptionfor a<meta name="description">tag. - Extend test coverage for front matter parsing behavior and fallback scenarios.
- Update
Procfile.devCSS watcher to use--watch=alwayssomise run devstays up reliably.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| app/models/markdown_document.rb | Implements front matter splitting + uses metadata for <title> and meta description. |
| test/models/markdown_document_test.rb | Adds tests validating front matter extraction, fallback behavior, and HR non-detection. |
| Procfile.dev | Keeps Tailwind CSS watch process alive under Foreman via --watch=always. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| match = FRONT_MATTER_PATTERN.match(raw) | ||
| return [ {}, raw ] unless match | ||
|
|
||
| data = YAML.safe_load(match[1], permitted_classes: [ Date, Time ]) | ||
| data.is_a?(Hash) ? [ data, match[2] ] : [ {}, raw ] |
There was a problem hiding this comment.
Confirmed locally, with one additional regex edge case: ---\n---\nbody does not match FRONT_MATTER_PATTERN at all because the pattern requires a newline between the captured YAML and the closing delimiter. ---\n\n---\nbody does match, but safe_load returns nil and the code falls back to the raw body. Both forms therefore render the delimiters (including an <hr>), so the fix needs to make the pattern accept an adjacent closing delimiter as well as treating nil as an empty mapping.
| <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> |
There was a problem hiding this comment.
Confirmed. The resulting HTML remains valid, so I consider this cosmetic/non-blocking, but constructing the <head> as consistently indented lines would remove the formatting glitch.
AliOsm
left a comment
There was a problem hiding this comment.
Detailed review findings are inline. I found two failure modes beyond the existing automated comments; both were reproduced locally with the repository's pinned mise toolchain.
| # <title>. | ||
| def document_title | ||
| @heading.presence || File.basename(@filename, ".*").presence || "Untitled" | ||
| @front_matter["title"].presence || @heading.presence || |
There was a problem hiding this comment.
Blocking: @front_matter["title"] is not guaranteed to be a String. YAML.safe_load can return an Integer, TrueClass, Date, Array, or Hash here, and CGI.escapeHTML(document_title) then raises TypeError. I reproduced this with title: 2026-07-13 (a Date, because dates are explicitly permitted), title: 1984, title: true, a list, and a mapping. This makes an otherwise valid Markdown upload fail before model validation. Please validate the metadata schema or deliberately normalize supported scalar values before returning the title, and add regression tests for implicit YAML types.
| match = FRONT_MATTER_PATTERN.match(raw) | ||
| return [ {}, raw ] unless match | ||
|
|
||
| data = YAML.safe_load(match[1], permitted_classes: [ Date, Time ]) |
There was a problem hiding this comment.
High-severity resilience issue: deeply nested valid YAML can raise SystemStackError, which is not covered by the Psych::Exception rescue below. I reproduced SystemStackError: stack level too deep with a 4,021-byte front-matter document containing a 2,000-level nested flow sequence. That escapes the promised plain-Markdown fallback on public creation paths. Please enforce a safe nesting limit/parser strategy, or narrowly handle SystemStackError around this parse operation and fall back.
| 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 |
There was a problem hiding this comment.
Please extend the boundary coverage before merging: non-string/implicitly typed title values, empty ---\n--- front matter, deeply nested YAML, aliases, and CRLF input. In particular, the alias case motivated broadening the rescue to Psych::Exception, but its regression test was removed in the final commit; keeping that test would protect the behavior from a later narrowing of the rescue.
| web: env RUBY_DEBUG_OPEN=true bin/rails server | ||
| js: yarn build --watch | ||
| css: yarn build:css --watch | ||
| css: yarn build:css --watch=always |
There was a problem hiding this comment.
I verified this is functionally correct: with the pinned mise toolchain, yarn build:css --watch=always remained 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.
Review summaryVerdict: request changes before merge. The implementation is readable and the happy path is well covered, but the inline review documents two untested rendering failures:
I also confirmed the existing empty-front-matter issue and added the regex-specific detail to that thread. The meta-description indentation issue is valid but cosmetic. Local validationAll checks below were run through the repository's pinned
The hosted GitHub Actions run is not green yet: it concluded Before merging, I recommend fixing the typed-title, parser-depth, and empty-front-matter cases; restoring the YAML-alias regression test; and obtaining a real CI run. The Procfile change is correct, but would be cleaner as a separate PR because it is unrelated to front-matter support. |
Summary
----delimited YAML front matter block off Markdown uploads before rendering (MarkdownDocument).titleoverrides the H1/filename fallback for the page<title>;descriptionrenders as a<meta name="description">tag.Procfile.dev'scssprocess used Tailwind's--watch, which stops as soon as stdin closes — which Foreman does immediately for spawned processes — killing the wholemise run devprocess group right after boot. Switched to--watch=always.Before
After
tested md syntax:
Test plan
bin/rails test test/models/markdown_document_test.rb— all passbin/rails test test/models/paste_test.rb— all pass (no regression in the ingest path)rails runnerand confirmed<title>/<meta description>are set correctly and the YAML block doesn't leak into the bodymise run devkeeps all three Foreman processes (web/js/css) alive after the Procfile fix🤖 Generated with Claude Code