ottogen 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Ottogen::Config do
4
+ describe '.load' do
5
+ it 'reads top-level keys from config.yml' do
6
+ in_tmp_dir do
7
+ File.write('config.yml', <<~YAML)
8
+ title: My Site
9
+ description: An Otto site
10
+ url: https://example.com
11
+ baseurl: /blog
12
+ YAML
13
+
14
+ config = described_class.load
15
+
16
+ expect(config['title']).to eq('My Site')
17
+ expect(config['description']).to eq('An Otto site')
18
+ expect(config['url']).to eq('https://example.com')
19
+ expect(config['baseurl']).to eq('/blog')
20
+ end
21
+ end
22
+
23
+ it 'exposes arbitrary custom keys' do
24
+ in_tmp_dir do
25
+ File.write('config.yml', <<~YAML)
26
+ author: Ada Lovelace
27
+ twitter: ada
28
+ YAML
29
+
30
+ config = described_class.load
31
+
32
+ expect(config['author']).to eq('Ada Lovelace')
33
+ expect(config['twitter']).to eq('ada')
34
+ end
35
+ end
36
+
37
+ it 'raises Ottogen::Config::Error when config.yml is missing' do
38
+ in_tmp_dir do
39
+ expect { described_class.load }.to raise_error(Ottogen::Config::Error)
40
+ end
41
+ end
42
+
43
+ it 'raises Ottogen::Config::Error when config.yml is malformed YAML' do
44
+ in_tmp_dir do
45
+ File.write('config.yml', "title: 'unclosed\n")
46
+
47
+ expect { described_class.load }.to raise_error(Ottogen::Config::Error)
48
+ end
49
+ end
50
+
51
+ it 'treats an empty config.yml as an empty config' do
52
+ in_tmp_dir do
53
+ File.write('config.yml', '')
54
+
55
+ config = described_class.load
56
+
57
+ expect(config['title']).to be_nil
58
+ expect(config.asciidoctor_attributes).to eq({})
59
+ end
60
+ end
61
+ end
62
+
63
+ describe '#asciidoctor_attributes' do
64
+ it 'prefixes every key with site_' do
65
+ in_tmp_dir do
66
+ File.write('config.yml', <<~YAML)
67
+ title: Otto
68
+ author: Ada
69
+ YAML
70
+
71
+ attrs = described_class.load.asciidoctor_attributes
72
+
73
+ expect(attrs).to eq('site_title' => 'Otto', 'site_author' => 'Ada')
74
+ end
75
+ end
76
+ end
77
+
78
+ describe '#data' do
79
+ it 'reads .yml files from _data/ as data.<name>' do
80
+ in_tmp_dir do
81
+ File.write('config.yml', "title: T\n")
82
+ FileUtils.mkdir_p('_data')
83
+ File.write('_data/nav.yml', "- title: Home\n url: /\n- title: About\n url: /about\n")
84
+
85
+ config = described_class.load
86
+
87
+ expect(config.data.nav).to eq([{ 'title' => 'Home', 'url' => '/' },
88
+ { 'title' => 'About', 'url' => '/about' }])
89
+ end
90
+ end
91
+
92
+ it 'reads .json files from _data/ as data.<name>' do
93
+ in_tmp_dir do
94
+ File.write('config.yml', "title: T\n")
95
+ FileUtils.mkdir_p('_data')
96
+ File.write('_data/items.json', '[{"name":"one"},{"name":"two"}]')
97
+
98
+ config = described_class.load
99
+
100
+ expect(config.data.items).to eq([{ 'name' => 'one' }, { 'name' => 'two' }])
101
+ end
102
+ end
103
+
104
+ it 'supports both .yml and .yaml extensions' do
105
+ in_tmp_dir do
106
+ File.write('config.yml', "title: T\n")
107
+ FileUtils.mkdir_p('_data')
108
+ File.write('_data/short.yml', "key: yml\n")
109
+ File.write('_data/long.yaml', "key: yaml\n")
110
+
111
+ config = described_class.load
112
+
113
+ expect(config.data.short).to eq('key' => 'yml')
114
+ expect(config.data.long).to eq('key' => 'yaml')
115
+ end
116
+ end
117
+
118
+ it 'returns empty data when _data/ is missing or empty' do
119
+ in_tmp_dir do
120
+ File.write('config.yml', "title: T\n")
121
+
122
+ config = described_class.load
123
+
124
+ expect(config.data['nav']).to be_nil
125
+ end
126
+ end
127
+
128
+ it 'raises Config::Error for a malformed data file (with file path in message)' do
129
+ in_tmp_dir do
130
+ File.write('config.yml', "title: T\n")
131
+ FileUtils.mkdir_p('_data')
132
+ File.write('_data/bad.yml', "title: 'unclosed\n")
133
+
134
+ expect { described_class.load }
135
+ .to raise_error(Ottogen::Config::Error, %r{_data/bad\.yml})
136
+ end
137
+ end
138
+
139
+ it 'exposes entries via both [] and method_missing' do
140
+ in_tmp_dir do
141
+ File.write('config.yml', "title: T\n")
142
+ FileUtils.mkdir_p('_data')
143
+ File.write('_data/nav.yml', "- title: Home\n")
144
+
145
+ data = described_class.load.data
146
+
147
+ expect(data['nav']).to eq([{ 'title' => 'Home' }])
148
+ expect(data.nav).to eq([{ 'title' => 'Home' }])
149
+ end
150
+ end
151
+ end
152
+
153
+ describe '#posts' do
154
+ it 'exposes site.posts sorted by date descending' do
155
+ in_tmp_dir do
156
+ File.write('config.yml', "title: T\n")
157
+ FileUtils.mkdir_p('_posts')
158
+ File.write('_posts/2026-01-15-old.adoc', "= Old\n")
159
+ File.write('_posts/2026-02-15-new.adoc', "= New\n")
160
+ File.write('_posts/2026-01-30-mid.adoc', "= Mid\n")
161
+
162
+ slugs = described_class.load.posts.map(&:slug)
163
+
164
+ expect(slugs).to eq(%w[new mid old])
165
+ end
166
+ end
167
+
168
+ it 'excludes drafts by default' do
169
+ in_tmp_dir do
170
+ File.write('config.yml', "title: T\n")
171
+ FileUtils.mkdir_p('_posts')
172
+ FileUtils.mkdir_p('_drafts')
173
+ File.write('_posts/2026-01-15-real.adoc', "= R\n")
174
+ File.write('_drafts/wip.adoc', "= D\n")
175
+
176
+ slugs = described_class.load.posts.map(&:slug)
177
+
178
+ expect(slugs).to eq(%w[real])
179
+ end
180
+ end
181
+
182
+ it 'includes drafts when loaded with drafts: true' do
183
+ in_tmp_dir do
184
+ File.write('config.yml', "title: T\n")
185
+ FileUtils.mkdir_p('_posts')
186
+ FileUtils.mkdir_p('_drafts')
187
+ File.write('_posts/2020-01-01-real.adoc', "= R\n")
188
+ File.write('_drafts/wip.adoc', "= D\n")
189
+
190
+ slugs = described_class.load(drafts: true).posts.map(&:slug)
191
+
192
+ expect(slugs).to contain_exactly('real', 'wip')
193
+ end
194
+ end
195
+ end
196
+
197
+ describe '#tags' do
198
+ it 'is a hash of tag string to posts that have that tag' do
199
+ in_tmp_dir do
200
+ File.write('config.yml', "title: T\n")
201
+ FileUtils.mkdir_p('_posts')
202
+ File.write('_posts/2026-01-15-a.adoc', "---\ntags: [ruby, cli]\n---\nA\n")
203
+ File.write('_posts/2026-02-15-b.adoc', "---\ntags: [ruby]\n---\nB\n")
204
+
205
+ tags = described_class.load.tags
206
+
207
+ expect(tags.keys).to contain_exactly('ruby', 'cli')
208
+ expect(tags['ruby'].map(&:slug)).to contain_exactly('a', 'b')
209
+ expect(tags['cli'].map(&:slug)).to eq(['a'])
210
+ end
211
+ end
212
+ end
213
+
214
+ describe '#categories' do
215
+ it 'is a hash of category string to posts in that category' do
216
+ in_tmp_dir do
217
+ File.write('config.yml', "title: T\n")
218
+ FileUtils.mkdir_p('_posts')
219
+ File.write('_posts/2026-01-15-a.adoc', "---\ncategories: [dev]\n---\nA\n")
220
+ File.write('_posts/2026-02-15-b.adoc', "---\ncategories: [dev, ruby]\n---\nB\n")
221
+
222
+ cats = described_class.load.categories
223
+
224
+ expect(cats['dev'].map(&:slug)).to contain_exactly('a', 'b')
225
+ expect(cats['ruby'].map(&:slug)).to eq(['b'])
226
+ end
227
+ end
228
+ end
229
+
230
+ describe '#collections' do
231
+ it 'exposes site.<collection_name> as the items array' do
232
+ in_tmp_dir do
233
+ File.write('config.yml', <<~YAML)
234
+ title: T
235
+ collections:
236
+ recipes:
237
+ output: true
238
+ YAML
239
+ FileUtils.mkdir_p('_recipes')
240
+ File.write('_recipes/pizza.adoc', "= Pizza\n")
241
+ File.write('_recipes/bread.adoc', "= Bread\n")
242
+
243
+ config = described_class.load
244
+
245
+ expect(config.recipes.map(&:slug)).to contain_exactly('pizza', 'bread')
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Ottogen::Layout do
4
+ describe '.find' do
5
+ it 'loads a layout from _layouts/<name>.html.erb' do
6
+ in_tmp_dir do
7
+ FileUtils.mkdir_p('_layouts')
8
+ File.write('_layouts/default.html.erb', '<html><%= content %></html>')
9
+
10
+ layout = described_class.find('default')
11
+
12
+ expect(layout.name).to eq('default')
13
+ end
14
+ end
15
+
16
+ it 'raises Layout::Error when the layout file is missing' do
17
+ in_tmp_dir do
18
+ FileUtils.mkdir_p('_layouts')
19
+
20
+ expect { described_class.find('missing') }.to raise_error(Ottogen::Layout::Error)
21
+ end
22
+ end
23
+
24
+ it 'parses YAML front matter on layout files' do
25
+ in_tmp_dir do
26
+ FileUtils.mkdir_p('_layouts')
27
+ File.write('_layouts/post.html.erb', "---\nlayout: default\n---\n<article><%= content %></article>")
28
+
29
+ layout = described_class.find('post')
30
+
31
+ expect(layout.front_matter).to eq('layout' => 'default')
32
+ end
33
+ end
34
+ end
35
+
36
+ describe '#render' do
37
+ let(:site) { Ottogen::Config.new('title' => 'My Site') }
38
+ let(:page) { Ottogen::Page.new(front_matter: { 'title' => 'My Page' }, body: '') }
39
+
40
+ it 'substitutes <%= content %> with the given content' do
41
+ in_tmp_dir do
42
+ FileUtils.mkdir_p('_layouts')
43
+ File.write('_layouts/default.html.erb', '<html><body><%= content %></body></html>')
44
+
45
+ layout = described_class.find('default')
46
+ result = layout.render(content: '<p>Hi</p>', site: site, page: page)
47
+
48
+ expect(result).to eq('<html><body><p>Hi</p></body></html>')
49
+ end
50
+ end
51
+
52
+ it 'exposes site.<key> from the config' do
53
+ in_tmp_dir do
54
+ FileUtils.mkdir_p('_layouts')
55
+ File.write('_layouts/default.html.erb', '<title><%= site.title %></title>')
56
+
57
+ result = described_class.find('default').render(content: '', site: site, page: page)
58
+
59
+ expect(result).to eq('<title>My Site</title>')
60
+ end
61
+ end
62
+
63
+ it 'exposes page.<key> from page front matter' do
64
+ in_tmp_dir do
65
+ FileUtils.mkdir_p('_layouts')
66
+ File.write('_layouts/default.html.erb', '<h1><%= page.title %></h1>')
67
+
68
+ result = described_class.find('default').render(content: '', site: site, page: page)
69
+
70
+ expect(result).to eq('<h1>My Page</h1>')
71
+ end
72
+ end
73
+
74
+ it 'chains parent layouts via front matter layout: key' do
75
+ in_tmp_dir do
76
+ FileUtils.mkdir_p('_layouts')
77
+ File.write('_layouts/default.html.erb', '<html><body><%= content %></body></html>')
78
+ File.write('_layouts/post.html.erb', "---\nlayout: default\n---\n<article><%= content %></article>")
79
+
80
+ result = described_class.find('post').render(content: '<p>Hi</p>', site: site, page: page)
81
+
82
+ expect(result).to eq('<html><body><article><p>Hi</p></article></body></html>')
83
+ end
84
+ end
85
+
86
+ it 'embeds an _includes/<name> partial via partial(...)' do
87
+ in_tmp_dir do
88
+ FileUtils.mkdir_p('_layouts')
89
+ FileUtils.mkdir_p('_includes')
90
+ File.write('_includes/header.html', '<header>Top</header>')
91
+ File.write('_layouts/default.html.erb', "<%= partial 'header.html' %><%= content %>")
92
+
93
+ result = described_class.find('default').render(content: '<p>Hi</p>', site: site, page: page)
94
+
95
+ expect(result).to eq('<header>Top</header><p>Hi</p>')
96
+ end
97
+ end
98
+
99
+ it 'allows partials to reference site.<key>' do
100
+ in_tmp_dir do
101
+ FileUtils.mkdir_p('_layouts')
102
+ FileUtils.mkdir_p('_includes')
103
+ File.write('_includes/header.html', '<title><%= site.title %></title>')
104
+ File.write('_layouts/default.html.erb', "<%= partial 'header.html' %>")
105
+
106
+ result = described_class.find('default').render(content: '', site: site, page: page)
107
+
108
+ expect(result).to eq('<title>My Site</title>')
109
+ end
110
+ end
111
+
112
+ it 'allows partials to reference page.<key>' do
113
+ in_tmp_dir do
114
+ FileUtils.mkdir_p('_layouts')
115
+ FileUtils.mkdir_p('_includes')
116
+ File.write('_includes/header.html', '<h1><%= page.title %></h1>')
117
+ File.write('_layouts/default.html.erb', "<%= partial 'header.html' %>")
118
+
119
+ result = described_class.find('default').render(content: '', site: site, page: page)
120
+
121
+ expect(result).to eq('<h1>My Page</h1>')
122
+ end
123
+ end
124
+
125
+ it 'allows partials to include other partials' do
126
+ in_tmp_dir do
127
+ FileUtils.mkdir_p('_layouts')
128
+ FileUtils.mkdir_p('_includes')
129
+ File.write('_includes/inner.html', '<span>inner</span>')
130
+ File.write('_includes/outer.html', "<div><%= partial 'inner.html' %></div>")
131
+ File.write('_layouts/default.html.erb', "<%= partial 'outer.html' %>")
132
+
133
+ result = described_class.find('default').render(content: '', site: site, page: page)
134
+
135
+ expect(result).to eq('<div><span>inner</span></div>')
136
+ end
137
+ end
138
+
139
+ it 'raises Layout::Error when an include is missing' do
140
+ in_tmp_dir do
141
+ FileUtils.mkdir_p('_layouts')
142
+ File.write('_layouts/default.html.erb', "<%= partial 'missing.html' %>")
143
+
144
+ expect do
145
+ described_class.find('default').render(content: '', site: site, page: page)
146
+ end.to raise_error(Ottogen::Layout::Error)
147
+ end
148
+ end
149
+ end
150
+ end