| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Reads the site changelog from per-version Markdown files in |
| 4 | # `config/changelog/` and exposes them as ordered entries for the /changelog |
| 5 | # pages. Each file is `v<MAJOR>.<MINOR>.<PATCH>.md` with a small YAML |
| 6 | # frontmatter block: |
| 7 | # |
| 8 | # --- |
| 9 | # date: 2026-07-05 |
| 10 | # title: First stable release |
| 11 | # headline: One-line summary shown in the index. |
| 12 | # --- |
| 13 | # Markdown body... |
| 14 | # |
| 15 | # A release adds one file; nothing else needs to change. Entries are sorted |
| 16 | # newest-first by semantic version, so the index and "latest" always reflect |
| 17 | # the highest version present rather than file mtime or date typos. |
| 18 | class Changelog |
| 19 | CHANGELOG_DIR = Rails.root.join("config/changelog") |
| 20 | FILENAME_RE = /\Av(\d+)\.(\d+)\.(\d+)\.md\z/ |
| 21 | |
| 22 | # One changelog release. `version` is the bare "1.0.0"; `tag` is "v1.0.0" |
| 23 | # (the URL slug and display form). `body_html` is sanitized by MarkdownRenderer. |
| 24 | Entry = Struct.new(:version, :date, :title, :headline, :body_html, keyword_init: true) do |
| 25 | def tag = "v#{version}" |
| 26 | |
| 27 | # [major, minor, patch] for sorting; comparison is numeric, not string, so |
| 28 | # 1.10.0 correctly sorts after 1.9.0. |
| 29 | def sort_key = version.split(".").map(&:to_i) |
| 30 | end |
| 31 | |
| 32 | # All entries, newest first. Malformed files are skipped rather than raising, |
| 33 | # so one bad file can never take down the whole changelog page. |
| 34 | def self.all |
| 35 | return [] unless CHANGELOG_DIR.directory? |
| 36 | |
| 37 | CHANGELOG_DIR.children.filter_map { |path| load_entry(path) } |
| 38 | .sort_by { |entry| entry.sort_key } |
| 39 | .reverse |
| 40 | end |
| 41 | |
| 42 | # The entry for a tag like "v1.0.0", or nil if there is none. |
| 43 | def self.find(tag) |
| 44 | version = tag.to_s.delete_prefix("v") |
| 45 | all.find { |entry| entry.version == version } |
| 46 | end |
| 47 | |
| 48 | # The highest-versioned entry, or nil when there are none. |
| 49 | def self.latest = all.first |
| 50 | |
| 51 | def self.load_entry(path) |
| 52 | match = path.basename.to_s.match(FILENAME_RE) |
| 53 | return nil unless match |
| 54 | |
| 55 | raw = path.read |
| 56 | front, body = split_frontmatter(raw) |
| 57 | return nil if front.nil? |
| 58 | |
| 59 | Entry.new( |
| 60 | version: "#{match[1]}.#{match[2]}.#{match[3]}", |
| 61 | date: parse_date(front["date"]), |
| 62 | title: front["title"].to_s.strip, |
| 63 | headline: front["headline"].to_s.strip, |
| 64 | # MarkdownRenderer is the trust boundary: it sanitizes to a tag allow-list, |
| 65 | # so the result is safe to render as HTML (same contract the repository |
| 66 | # README rendering relies on). |
| 67 | body_html: MarkdownRenderer.render(body).html_safe |
| 68 | ) |
| 69 | rescue StandardError => e |
| 70 | Rails.logger.warn("Changelog: skipping #{path.basename} (#{e.class}: #{e.message})") |
| 71 | nil |
| 72 | end |
| 73 | private_class_method :load_entry |
| 74 | |
| 75 | # Split "---\n<yaml>\n---\n<body>" into [Hash, body]. Returns [nil, raw] when |
| 76 | # there is no frontmatter block, which marks the file as malformed. |
| 77 | def self.split_frontmatter(raw) |
| 78 | match = raw.match(/\A---\s*\n(.*?\n)---\s*\n(.*)\z/m) |
| 79 | return [ nil, raw ] unless match |
| 80 | |
| 81 | front = YAML.safe_load(match[1], permitted_classes: [ Date ]) || {} |
| 82 | [ front, match[2] ] |
| 83 | end |
| 84 | private_class_method :split_frontmatter |
| 85 | |
| 86 | def self.parse_date(value) |
| 87 | return value if value.is_a?(Date) |
| 88 | Date.parse(value.to_s) |
| 89 | rescue ArgumentError |
| 90 | nil |
| 91 | end |
| 92 | private_class_method :parse_date |
| 93 | end |