main
rb 93 lines 3.18 KB
Raw
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