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.
@@ -1,9 +1,434 @@
1
- require 'ottogen/ottogen'
1
+ # frozen_string_literal: true
2
2
 
3
- module Ottogen
4
- describe Ottogen do
5
- it 'works' do
6
- expect(1).to eq(1)
3
+ RSpec.describe Ottogen::Ottogen do
4
+ it 'is loaded' do
5
+ expect(defined?(Ottogen::Ottogen)).to eq('constant')
6
+ end
7
+
8
+ describe '.build' do
9
+ it 'passes site_title as an Asciidoctor attribute, resolvable in pages' do
10
+ in_otto_project(config: "title: My Otto Site\n") do
11
+ File.write('pages/index.adoc', "= Index\n\nMy site is {site_title}.\n")
12
+
13
+ capture_stdout { described_class.build }
14
+
15
+ expect(File.read('_build/index.html')).to include('My site is My Otto Site.')
16
+ end
17
+ end
18
+
19
+ it 'passes custom config keys as site_<key> attributes' do
20
+ in_otto_project(config: "title: T\nauthor: Ada Lovelace\n") do
21
+ File.write('pages/index.adoc', "= Index\n\nWritten by {site_author}.\n")
22
+
23
+ capture_stdout { described_class.build }
24
+
25
+ expect(File.read('_build/index.html')).to include('Written by Ada Lovelace.')
26
+ end
27
+ end
28
+
29
+ it 'still succeeds when config.yml has only a title' do
30
+ in_otto_project(config: "title: Minimal\n") do
31
+ File.write('pages/index.adoc', "= Index\n\nHello.\n")
32
+
33
+ capture_stdout { described_class.build }
34
+
35
+ expect(File.exist?('_build/index.html')).to be true
36
+ end
37
+ end
38
+
39
+ it 'resolves {page_title} from front matter in rendered HTML' do
40
+ in_otto_project do
41
+ File.write('pages/index.adoc', <<~ADOC)
42
+ ---
43
+ title: My Page
44
+ ---
45
+ = Index
46
+
47
+ Welcome to {page_title}.
48
+ ADOC
49
+
50
+ capture_stdout { described_class.build }
51
+
52
+ expect(File.read('_build/index.html')).to include('Welcome to My Page.')
53
+ end
54
+ end
55
+
56
+ it 'resolves arbitrary front matter keys in rendered HTML' do
57
+ in_otto_project do
58
+ File.write('pages/index.adoc', <<~ADOC)
59
+ ---
60
+ author: Ada Lovelace
61
+ ---
62
+ = Index
63
+
64
+ Written by {page_author}.
65
+ ADOC
66
+
67
+ capture_stdout { described_class.build }
68
+
69
+ expect(File.read('_build/index.html')).to include('Written by Ada Lovelace.')
70
+ end
71
+ end
72
+
73
+ it 'does not include front matter delimiters in rendered HTML' do
74
+ in_otto_project do
75
+ File.write('pages/index.adoc', <<~ADOC)
76
+ ---
77
+ title: My Page
78
+ ---
79
+ = Index
80
+
81
+ Body.
82
+ ADOC
83
+
84
+ capture_stdout { described_class.build }
85
+
86
+ html = File.read('_build/index.html')
87
+ expect(html).not_to include('---')
88
+ expect(html).not_to include('title: My Page')
89
+ end
90
+ end
91
+
92
+ it 'still builds pages without front matter' do
93
+ in_otto_project do
94
+ File.write('pages/index.adoc', "= Plain\n\nNo front matter here.\n")
95
+
96
+ capture_stdout { described_class.build }
97
+
98
+ expect(File.read('_build/index.html')).to include('No front matter here.')
99
+ end
100
+ end
101
+
102
+ it 'wraps a page in its declared layout' do
103
+ in_otto_project do
104
+ FileUtils.mkdir_p('_layouts')
105
+ File.write('_layouts/default.html.erb', '<html><body><%= content %></body></html>')
106
+ File.write('pages/index.adoc', "---\nlayout: default\n---\n= Hi\n\nHello.\n")
107
+
108
+ capture_stdout { described_class.build }
109
+
110
+ html = File.read('_build/index.html')
111
+ expect(html).to start_with('<html><body>')
112
+ expect(html).to include('Hello.')
113
+ expect(html).to end_with('</body></html>')
114
+ end
115
+ end
116
+
117
+ it 'renders collection items when output: true' do
118
+ in_otto_project(config: <<~YAML) do
119
+ title: T
120
+ collections:
121
+ recipes:
122
+ output: true
123
+ YAML
124
+ FileUtils.mkdir_p('_recipes')
125
+ File.write('_recipes/pizza.adoc', "= Pizza\n\nDough.\n")
126
+
127
+ capture_stdout { described_class.build }
128
+
129
+ expect(File.exist?('_build/recipes/pizza.html')).to be true
130
+ expect(File.read('_build/recipes/pizza.html')).to include('Dough.')
131
+ end
132
+ end
133
+
134
+ it 'does not render collection items when output: false' do
135
+ in_otto_project(config: <<~YAML) do
136
+ title: T
137
+ collections:
138
+ books:
139
+ output: false
140
+ YAML
141
+ FileUtils.mkdir_p('_books')
142
+ File.write('_books/midnight.adoc', "= Midnight\n")
143
+
144
+ capture_stdout { described_class.build }
145
+
146
+ expect(File.exist?('_build/books/midnight.html')).to be false
147
+ end
148
+ end
149
+
150
+ it 'excludes drafts by default' do
151
+ in_otto_project do
152
+ FileUtils.mkdir_p('_drafts')
153
+ File.write('_drafts/wip.adoc', "= WIP\n\nDraft body.\n")
154
+
155
+ capture_stdout { described_class.build }
156
+
157
+ expect(File.exist?('_build/wip.html')).to be false
158
+ end
159
+ end
160
+
161
+ it 'renders drafts when build is called with drafts: true' do
162
+ in_otto_project do
163
+ FileUtils.mkdir_p('_drafts')
164
+ File.write('_drafts/wip.adoc', "= WIP\n\nDraft body.\n")
165
+
166
+ capture_stdout { described_class.build(drafts: true) }
167
+
168
+ expect(File.exist?('_build/wip.html')).to be true
169
+ expect(File.read('_build/wip.html')).to include('Draft body.')
170
+ end
171
+ end
172
+
173
+ it 'honors a per-document permalink: in front matter' do
174
+ in_otto_project do
175
+ FileUtils.mkdir_p('_posts')
176
+ File.write('_posts/2026-01-15-hello.adoc', "---\npermalink: /custom/path.html\n---\nBody.\n")
177
+
178
+ capture_stdout { described_class.build }
179
+
180
+ expect(File.exist?('_build/custom/path.html')).to be true
181
+ expect(File.exist?('_build/hello.html')).to be false
182
+ end
183
+ end
184
+
185
+ it 'applies a global permalink: from config.yml to posts with date tokens' do
186
+ in_otto_project(config: "title: T\npermalink: /:year/:month/:day/:slug/\n") do
187
+ FileUtils.mkdir_p('_posts')
188
+ File.write('_posts/2026-01-15-hello.adoc', "= Hello\n\nBody.\n")
189
+
190
+ capture_stdout { described_class.build }
191
+
192
+ expect(File.exist?('_build/2026/01/15/hello/index.html')).to be true
193
+ expect(File.read('_build/2026/01/15/hello/index.html')).to include('Body.')
194
+ end
195
+ end
196
+
197
+ it 'does not apply the global permalink to pages' do
198
+ in_otto_project(config: "title: T\npermalink: /:year/:month/:day/:slug/\n") do
199
+ File.write('pages/about.adoc', "= About\n\nAbout body.\n")
200
+
201
+ capture_stdout { described_class.build }
202
+
203
+ expect(File.exist?('_build/about.html')).to be true
204
+ end
205
+ end
206
+
207
+ it 'renders posts to _build/<slug>.html' do
208
+ in_otto_project do
209
+ FileUtils.mkdir_p('_posts')
210
+ File.write('_posts/2026-01-15-hello-world.adoc', "= Hello\n\nBody.\n")
211
+
212
+ capture_stdout { described_class.build }
213
+
214
+ expect(File.exist?('_build/hello-world.html')).to be true
215
+ expect(File.read('_build/hello-world.html')).to include('Body.')
216
+ end
217
+ end
218
+
219
+ it 'exposes site.posts to layouts' do
220
+ in_otto_project do
221
+ FileUtils.mkdir_p('_layouts')
222
+ FileUtils.mkdir_p('_posts')
223
+ File.write('_posts/2026-01-15-only-post.adoc', "= Hi\n\nBody.\n")
224
+ File.write('_layouts/default.html.erb', '<nav><%= site.posts.first.title %></nav><%= content %>')
225
+ File.write('pages/index.adoc', "---\nlayout: default\n---\nIndex.\n")
226
+
227
+ capture_stdout { described_class.build }
228
+
229
+ html = File.read('_build/index.html')
230
+ expect(html).to include('<nav>Only Post</nav>')
231
+ end
232
+ end
233
+
234
+ it 'exposes site.data.<file> in layouts' do
235
+ in_otto_project do
236
+ FileUtils.mkdir_p('_layouts')
237
+ FileUtils.mkdir_p('_data')
238
+ File.write('_data/nav.yml', "- title: Home\n url: /\n")
239
+ File.write('_layouts/default.html.erb', "<%= site.data.nav.first['title'] %>|<%= content %>")
240
+ File.write('pages/index.adoc', "---\nlayout: default\n---\nBody.\n")
241
+
242
+ capture_stdout { described_class.build }
243
+
244
+ html = File.read('_build/index.html')
245
+ expect(html).to include('Home|')
246
+ expect(html).to include('Body.')
247
+ end
248
+ end
249
+
250
+ it 'embeds an include declared in the page layout' do
251
+ in_otto_project do
252
+ FileUtils.mkdir_p('_layouts')
253
+ FileUtils.mkdir_p('_includes')
254
+ File.write('_includes/header.html', '<header>Site Header</header>')
255
+ File.write('_layouts/default.html.erb', "<html><%= partial 'header.html' %><body><%= content %></body></html>")
256
+ File.write('pages/index.adoc', "---\nlayout: default\n---\n= Hi\n\nHello.\n")
257
+
258
+ capture_stdout { described_class.build }
259
+
260
+ html = File.read('_build/index.html')
261
+ expect(html).to include('<header>Site Header</header>')
262
+ expect(html).to include('Hello.')
263
+ end
264
+ end
265
+
266
+ it 'chains layouts (post -> default)' do
267
+ in_otto_project do
268
+ FileUtils.mkdir_p('_layouts')
269
+ File.write('_layouts/default.html.erb', '<html><body><%= content %></body></html>')
270
+ File.write('_layouts/post.html.erb', "---\nlayout: default\n---\n<article><%= content %></article>")
271
+ File.write('pages/index.adoc', "---\nlayout: post\n---\n= Hi\n\nHello.\n")
272
+
273
+ capture_stdout { described_class.build }
274
+
275
+ html = File.read('_build/index.html')
276
+ expect(html).to include('<html><body><article>')
277
+ expect(html).to include('Hello.')
278
+ expect(html).to include('</article></body></html>')
279
+ end
280
+ end
281
+
282
+ it 'still emits standalone HTML for pages without a layout' do
283
+ in_otto_project do
284
+ File.write('pages/index.adoc', "= Hi\n\nNo layout here.\n")
285
+
286
+ capture_stdout { described_class.build }
287
+
288
+ html = File.read('_build/index.html')
289
+ expect(html).to include('<!DOCTYPE html>')
290
+ expect(html).to include('No layout here.')
291
+ end
292
+ end
293
+
294
+ it 'exits with a friendly error when a page references a missing layout' do
295
+ in_otto_project do
296
+ File.write('pages/index.adoc', "---\nlayout: missing\n---\n= Hi\n\nHello.\n")
297
+
298
+ buffer = StringIO.new
299
+ original = $stdout
300
+ $stdout = buffer
301
+ begin
302
+ expect { described_class.build }.to raise_error(SystemExit)
303
+ ensure
304
+ $stdout = original
305
+ end
306
+
307
+ expect(buffer.string).to include('❌')
308
+ expect(buffer.string).to include('missing')
309
+ end
310
+ end
311
+
312
+ it 'exits with a friendly error when a page has malformed front matter' do
313
+ in_otto_project do
314
+ File.write('pages/index.adoc', "---\ntitle: 'unclosed\n---\nBody\n")
315
+
316
+ buffer = StringIO.new
317
+ original = $stdout
318
+ $stdout = buffer
319
+ begin
320
+ expect { described_class.build }.to raise_error(SystemExit)
321
+ ensure
322
+ $stdout = original
323
+ end
324
+
325
+ expect(buffer.string).to include('❌')
326
+ expect(buffer.string).to include('pages/index.adoc')
327
+ end
328
+ end
329
+ end
330
+
331
+ describe '.init' do
332
+ it 'writes a config.yml with title, description, url, and baseurl keys' do
333
+ in_tmp_dir do
334
+ capture_stdout { described_class.init('site') }
335
+
336
+ config = YAML.safe_load_file('site/config.yml')
337
+ expect(config.keys).to include('title', 'description', 'url', 'baseurl')
338
+ end
339
+ end
340
+
341
+ it 'scaffolds _layouts/default.html.erb with a starter template' do
342
+ in_tmp_dir do
343
+ capture_stdout { described_class.init('site') }
344
+
345
+ path = 'site/_layouts/default.html.erb'
346
+ expect(File.exist?(path)).to be true
347
+ expect(File.read(path)).to include('<%= content %>')
348
+ end
349
+ end
350
+
351
+ it 'scaffolds an _includes/ directory' do
352
+ in_tmp_dir do
353
+ capture_stdout { described_class.init('site') }
354
+
355
+ expect(Dir.exist?('site/_includes')).to be true
356
+ end
357
+ end
358
+
359
+ it 'scaffolds a _data/ directory' do
360
+ in_tmp_dir do
361
+ capture_stdout { described_class.init('site') }
362
+
363
+ expect(Dir.exist?('site/_data')).to be true
364
+ end
365
+ end
366
+
367
+ it 'scaffolds a _posts/ directory' do
368
+ in_tmp_dir do
369
+ capture_stdout { described_class.init('site') }
370
+
371
+ expect(Dir.exist?('site/_posts')).to be true
372
+ end
373
+ end
374
+
375
+ it 'scaffolds a _drafts/ directory' do
376
+ in_tmp_dir do
377
+ capture_stdout { described_class.init('site') }
378
+
379
+ expect(Dir.exist?('site/_drafts')).to be true
380
+ end
381
+ end
382
+ end
383
+
384
+ describe '.new_post' do
385
+ it 'creates _posts/<date>-<slug>.adoc with title and date front matter' do
386
+ in_otto_project do
387
+ capture_stdout { described_class.new_post('Hello World') }
388
+
389
+ expected = "_posts/#{Date.today.iso8601}-hello-world.adoc"
390
+ expect(File.exist?(expected)).to be true
391
+ body = File.read(expected)
392
+ expect(body).to include('title: Hello World')
393
+ expect(body).to include("date: #{Date.today.iso8601}")
394
+ end
395
+ end
396
+
397
+ it 'slugifies the title (lowercase, dashed)' do
398
+ in_otto_project do
399
+ capture_stdout { described_class.new_post('My Cool, Fancy Post!') }
400
+
401
+ expected = "_posts/#{Date.today.iso8601}-my-cool-fancy-post.adoc"
402
+ expect(File.exist?(expected)).to be true
403
+ end
404
+ end
405
+ end
406
+
407
+ describe '.doctor' do
408
+ it 'reports OK in a healthy project' do
409
+ in_otto_project do
410
+ output = capture_stdout { described_class.doctor }
411
+
412
+ expect(output).to include('✅')
413
+ end
414
+ end
415
+
416
+ it 'exits non-zero and reports problems when required files are missing' do
417
+ in_tmp_dir do
418
+ FileUtils.touch('.otto')
419
+ # config.yml and pages/ deliberately missing
420
+
421
+ buffer = StringIO.new
422
+ original = $stdout
423
+ $stdout = buffer
424
+ begin
425
+ expect { described_class.doctor }.to raise_error(SystemExit)
426
+ ensure
427
+ $stdout = original
428
+ end
429
+
430
+ expect(buffer.string).to include('config.yml')
431
+ end
7
432
  end
8
433
  end
9
434
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Ottogen::Page do
4
+ describe '.read' do
5
+ it 'parses YAML front matter delimited by ---' do
6
+ in_tmp_dir do
7
+ File.write('post.adoc', <<~ADOC)
8
+ ---
9
+ title: Hello
10
+ author: Ada
11
+ ---
12
+ = Hello
13
+
14
+ Body.
15
+ ADOC
16
+
17
+ page = described_class.read('post.adoc')
18
+
19
+ expect(page.front_matter).to eq('title' => 'Hello', 'author' => 'Ada')
20
+ end
21
+ end
22
+
23
+ it 'strips the front matter block from the body' do
24
+ in_tmp_dir do
25
+ File.write('post.adoc', <<~ADOC)
26
+ ---
27
+ title: Hello
28
+ ---
29
+ = Hello
30
+
31
+ Body.
32
+ ADOC
33
+
34
+ page = described_class.read('post.adoc')
35
+
36
+ expect(page.body).to eq("= Hello\n\nBody.\n")
37
+ end
38
+ end
39
+
40
+ it 'returns empty front matter when the file has no --- block' do
41
+ in_tmp_dir do
42
+ File.write('post.adoc', "= Plain\n\nBody.\n")
43
+
44
+ page = described_class.read('post.adoc')
45
+
46
+ expect(page.front_matter).to eq({})
47
+ end
48
+ end
49
+
50
+ it 'returns the full file as body when there is no front matter' do
51
+ in_tmp_dir do
52
+ File.write('post.adoc', "= Plain\n\nBody.\n")
53
+
54
+ page = described_class.read('post.adoc')
55
+
56
+ expect(page.body).to eq("= Plain\n\nBody.\n")
57
+ end
58
+ end
59
+
60
+ it 'raises Page::Error for unclosed front matter' do
61
+ in_tmp_dir do
62
+ File.write('post.adoc', "---\ntitle: Hello\n\n= Body without close\n")
63
+
64
+ expect { described_class.read('post.adoc') }
65
+ .to raise_error(Ottogen::Page::Error)
66
+ end
67
+ end
68
+
69
+ it 'raises Page::Error for malformed YAML in front matter' do
70
+ in_tmp_dir do
71
+ File.write('post.adoc', "---\ntitle: 'unclosed\n---\nBody\n")
72
+
73
+ expect { described_class.read('post.adoc') }
74
+ .to raise_error(Ottogen::Page::Error)
75
+ end
76
+ end
77
+ end
78
+
79
+ describe '#asciidoctor_attributes' do
80
+ it 'prefixes every front matter key with page_' do
81
+ in_tmp_dir do
82
+ File.write('post.adoc', "---\ntitle: Hi\nauthor: Ada\n---\nBody.\n")
83
+
84
+ attrs = described_class.read('post.adoc').asciidoctor_attributes
85
+
86
+ expect(attrs).to include('page_title' => 'Hi', 'page_author' => 'Ada')
87
+ end
88
+ end
89
+
90
+ it 'includes page_url derived from the source path' do
91
+ in_tmp_dir do
92
+ FileUtils.mkdir_p('pages')
93
+ File.write('pages/about.adoc', "---\ntitle: About\n---\nBody.\n")
94
+
95
+ attrs = described_class.read('pages/about.adoc').asciidoctor_attributes
96
+
97
+ expect(attrs['page_url']).to eq('/about.html')
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ RSpec.describe Ottogen::Permalink do
6
+ let(:post) do
7
+ Ottogen::Post.new(
8
+ path: '_posts/2026-01-15-hello-world.adoc',
9
+ date: Date.new(2026, 1, 15),
10
+ slug: 'hello-world',
11
+ front_matter: { 'title' => 'Hello World!' },
12
+ body: ''
13
+ )
14
+ end
15
+
16
+ describe '.expand' do
17
+ it 'replaces :year, :month, :day from the date' do
18
+ expanded = described_class.expand('/:year/:month/:day/', post)
19
+
20
+ expect(expanded.path).to eq('/2026/01/15/')
21
+ end
22
+
23
+ it 'replaces :slug' do
24
+ expanded = described_class.expand('/:slug.html', post)
25
+
26
+ expect(expanded.path).to eq('/hello-world.html')
27
+ end
28
+
29
+ it 'replaces :title with a slugified version' do
30
+ expanded = described_class.expand('/:title/', post)
31
+
32
+ expect(expanded.path).to eq('/hello-world/')
33
+ end
34
+ end
35
+
36
+ describe '#output_path' do
37
+ it 'appends index.html when the path ends with /' do
38
+ permalink = described_class.new('/2026/01/15/hello/')
39
+
40
+ expect(permalink.output_path('_build')).to eq('_build/2026/01/15/hello/index.html')
41
+ end
42
+
43
+ it 'uses the path as-is otherwise' do
44
+ permalink = described_class.new('/2026/01/15/hello.html')
45
+
46
+ expect(permalink.output_path('_build')).to eq('_build/2026/01/15/hello.html')
47
+ end
48
+ end
49
+ end