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.
- checksums.yaml +4 -4
- data/README.md +59 -43
- data/bin/otto +1 -0
- data/lib/ottogen/collection.rb +35 -0
- data/lib/ottogen/collection_item.rb +61 -0
- data/lib/ottogen/config.rb +126 -0
- data/lib/ottogen/front_matter.rb +30 -0
- data/lib/ottogen/layout.rb +65 -0
- data/lib/ottogen/otto.rb +36 -15
- data/lib/ottogen/ottogen.rb +168 -28
- data/lib/ottogen/page.rb +58 -0
- data/lib/ottogen/permalink.rb +35 -0
- data/lib/ottogen/post.rb +108 -0
- data/lib/ottogen/scaffold.rb +50 -0
- data/spec/ottogen/collection_spec.rb +61 -0
- data/spec/ottogen/config_spec.rb +249 -0
- data/spec/ottogen/layout_spec.rb +150 -0
- data/spec/ottogen/ottogen_spec.rb +430 -5
- data/spec/ottogen/page_spec.rb +101 -0
- data/spec/ottogen/permalink_spec.rb +49 -0
- data/spec/ottogen/post_spec.rb +172 -0
- data/spec/spec_helper.rb +51 -0
- metadata +176 -57
|
@@ -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
|