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