ottogen 0.2.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,31 +1,34 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'asciidoctor'
4
+ require 'date'
2
5
  require 'fileutils'
3
6
  require 'listen'
4
7
  require 'webrick'
5
8
 
9
+ require_relative 'collection_item'
10
+ require_relative 'config'
11
+ require_relative 'layout'
12
+ require_relative 'page'
13
+ require_relative 'permalink'
14
+ require_relative 'post'
15
+ require_relative 'scaffold'
16
+
6
17
  module Ottogen
7
18
  class Ottogen
8
- BUILD_DIR = '_build'.freeze
9
- CONFIG = <<~YAML
10
- title: "Otto site"
11
- YAML
12
- WELCOME = <<~ADOC
13
- = Welcome to Otto!
14
-
15
- Otto is a static site generator that uses AsciiDoc as a markup language.
16
- ADOC
19
+ BUILD_DIR = '_build'
17
20
 
18
21
  def self.init(dir)
19
- puts "✨ Initializing static site..."
20
- if !dir.nil? and Dir.exist?(dir)
21
- puts "❌ Error: Directory already exists"
22
+ puts '✨ Initializing static site...'
23
+ if !dir.nil? && Dir.exist?(dir)
24
+ puts '❌ Error: Directory already exists'
22
25
  exit(1)
23
26
  end
24
27
 
25
28
  if dir.nil?
26
29
  files_in_current_dir = Dir.glob('**/*')
27
- if !files_in_current_dir.empty?
28
- puts "❌ Error: Directory must be empty"
30
+ unless files_in_current_dir.empty?
31
+ puts '❌ Error: Directory must be empty'
29
32
  exit(1)
30
33
  end
31
34
  init_in_current_dir
@@ -33,88 +36,156 @@ ADOC
33
36
  init_with_dir(dir)
34
37
  end
35
38
 
36
- puts ""
39
+ puts ''
37
40
  end
38
41
 
39
- def self.build
40
- puts "🔨 Building static site..."
42
+ def self.build(drafts: false)
43
+ puts '🔨 Building static site...'
41
44
  error_if_not_otto_project
42
- Dir.mkdir(BUILD_DIR) unless Dir.exist?(BUILD_DIR)
45
+ config = load_config(drafts: drafts)
46
+ FileUtils.mkdir_p(BUILD_DIR)
43
47
  FileUtils.cp_r 'assets/', "#{BUILD_DIR}/assets"
44
- Dir.glob('pages/**/*.adoc').map do |name|
45
- name.split('.').first
46
- end.each do |doc|
47
- page = doc.sub(/^pages\//, '')
48
- Asciidoctor.convert_file "#{doc}.adoc",
48
+ documents_for(config).each { |doc| convert_document(doc, config) }
49
+ puts ''
50
+ end
51
+
52
+ def self.documents_for(config)
53
+ collection_items = config.collections.values.flat_map { |c| c.output? ? c.items : [] }
54
+ load_pages + config.posts + collection_items
55
+ end
56
+
57
+ def self.load_pages
58
+ Dir.glob('pages/**/*.adoc').map { |path| Page.read(path) }
59
+ rescue Page::Error => e
60
+ puts "❌ Error: #{e.message}"
61
+ exit(1)
62
+ end
63
+
64
+ def self.convert_document(doc, config)
65
+ apply_permalink(doc, config)
66
+ html = render_document(doc, config)
67
+ write_output(doc.output_path(BUILD_DIR), html)
68
+ rescue Layout::Error => e
69
+ puts "❌ Error in #{doc.path}: #{e.message}"
70
+ exit(1)
71
+ end
72
+
73
+ def self.apply_permalink(doc, config)
74
+ template = doc.front_matter['permalink']
75
+ template ||= config['permalink'] if doc.is_a?(Post)
76
+ return if template.nil?
77
+
78
+ doc.permalink = Permalink.expand(template, doc)
79
+ end
80
+
81
+ def self.render_document(doc, config)
82
+ layout_name = doc.front_matter['layout']
83
+ attributes = config.asciidoctor_attributes.merge(doc.asciidoctor_attributes)
84
+ body = Asciidoctor.convert(doc.body,
49
85
  safe: :safe,
50
- mkdirs: true,
51
- to_file: "#{BUILD_DIR}/#{page}.html"
52
- end
53
- puts "✅"
86
+ standalone: layout_name.nil?,
87
+ attributes: attributes)
88
+ return body unless layout_name
89
+
90
+ Layout.find(layout_name).render(content: body, site: config, page: doc)
91
+ end
92
+
93
+ def self.write_output(path, html)
94
+ FileUtils.mkdir_p(File.dirname(path))
95
+ File.write(path, html)
54
96
  end
55
97
 
56
98
  def self.generate(page)
57
- puts "📝 Generating a new page..."
99
+ puts '📝 Generating a new page...'
58
100
  error_if_not_otto_project
59
101
  page_title = page.split('-').map(&:capitalize).join(' ')
60
102
  File.write("pages/#{page}.adoc", "= #{page_title}\n")
61
103
  end
62
104
 
105
+ def self.new_post(title)
106
+ error_if_not_otto_project
107
+ FileUtils.mkdir_p('_posts')
108
+ slug = Permalink.slugify(title)
109
+ date = Date.today
110
+ path = "_posts/#{date.iso8601}-#{slug}.adoc"
111
+ File.write(path, "---\ntitle: #{title}\ndate: #{date.iso8601}\n---\n\n")
112
+ puts "📝 Created #{path}"
113
+ end
114
+
115
+ def self.doctor
116
+ problems = collect_problems
117
+ if problems.empty?
118
+ puts '✅ All checks passed'
119
+ else
120
+ puts '❌ Problems:'
121
+ problems.each { |p| puts " - #{p}" }
122
+ exit(1)
123
+ end
124
+ end
125
+
126
+ def self.collect_problems
127
+ problems = []
128
+ problems << '.otto marker missing (run `otto init` first)' unless File.exist?('.otto')
129
+ problems << 'config.yml missing' unless File.exist?('config.yml')
130
+ problems << 'pages/ directory missing' unless Dir.exist?('pages')
131
+ problems
132
+ end
133
+
63
134
  def self.clean
64
- puts "🧽 Cleaning build directory..."
135
+ puts '🧽 Cleaning build directory...'
65
136
  error_if_not_otto_project
66
137
  return unless Dir.exist?(BUILD_DIR)
138
+
67
139
  FileUtils.rmtree(BUILD_DIR)
68
- puts ""
140
+ puts ''
69
141
  end
70
142
 
71
143
  def self.serve
72
- puts "🤖 Starting server..."
144
+ puts '🤖 Starting server...'
73
145
  error_if_not_otto_project
74
146
  root = File.expand_path("#{Dir.pwd}/#{Ottogen::BUILD_DIR}")
75
- server = WEBrick::HTTPServer.new :Port => 8778, :DocumentRoot => root
76
- trap 'INT' do server.shutdown end
147
+ server = WEBrick::HTTPServer.new Port: 8778, DocumentRoot: root
148
+ trap 'INT' do
149
+ server.shutdown
150
+ end
77
151
  server.start
78
152
  rescue Errno::EADDRINUSE
79
- puts "❌ Server port already in use"
153
+ puts '❌ Server port already in use'
80
154
  exit(1)
81
155
  end
82
156
 
83
- def self.watch
84
- puts "👀 Watching files..."
157
+ def self.watch(drafts: false)
158
+ puts '👀 Watching files...'
85
159
  error_if_not_otto_project
86
160
  listener = Listen.to(Dir.pwd, ignore: [/_build/]) do |modified, added, removed|
87
161
  puts(modified: modified, added: added, removed: removed)
88
- build
162
+ build(drafts: drafts)
89
163
  end
90
164
  listener.start
91
165
  sleep
92
166
  end
93
167
 
94
- private
95
-
96
168
  def self.init_with_dir(dir)
97
169
  Dir.mkdir(dir)
98
- FileUtils.touch("#{dir}/.otto")
99
- File.write("#{dir}/config.yml", CONFIG)
100
- FileUtils.mkdir_p("#{dir}/assets")
101
- FileUtils.mkdir_p("#{dir}/pages")
102
- File.write("#{dir}/pages/index.adoc", WELCOME)
170
+ Scaffold.write(dir)
103
171
  end
104
172
 
105
173
  def self.init_in_current_dir
106
- FileUtils.touch(".otto")
107
- File.write("config.yml", CONFIG)
108
- FileUtils.mkdir_p("assets")
109
- FileUtils.mkdir_p("pages")
110
- File.write("pages/index.adoc", WELCOME)
174
+ Scaffold.write('.')
111
175
  end
112
176
 
113
177
  def self.error_if_not_otto_project
114
- if !File.exist?(".otto")
115
- puts "❌ Error: Current directory is not an otto project"
116
- exit(1)
117
- end
178
+ return if File.exist?('.otto')
179
+
180
+ puts '❌ Error: Current directory is not an otto project'
181
+ exit(1)
182
+ end
183
+
184
+ def self.load_config(drafts: false)
185
+ Config.load(drafts: drafts)
186
+ rescue Config::Error => e
187
+ puts "❌ Error: #{e.message}"
188
+ exit(1)
118
189
  end
119
190
  end
120
191
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'front_matter'
4
+
5
+ module Ottogen
6
+ class Page
7
+ class Error < StandardError; end
8
+
9
+ def self.read(path)
10
+ raw = File.read(path)
11
+ front_matter, body = FrontMatter.split(raw, path)
12
+ new(path: path, front_matter: front_matter, body: body)
13
+ rescue FrontMatter::Error => e
14
+ raise Error, e.message
15
+ end
16
+
17
+ attr_reader :path, :front_matter, :body
18
+ attr_accessor :permalink
19
+
20
+ def initialize(front_matter:, body:, path: nil)
21
+ @path = path
22
+ @front_matter = front_matter
23
+ @body = body
24
+ end
25
+
26
+ def url
27
+ return @permalink.url if @permalink
28
+ return nil unless @path
29
+
30
+ "/#{@path.sub(%r{^pages/}, '').sub(/\.adoc\z/, '.html')}"
31
+ end
32
+
33
+ def output_path(build_dir)
34
+ return @permalink.output_path(build_dir) if @permalink
35
+
36
+ relative = @path.sub(%r{^pages/}, '').sub(/\.adoc\z/, '.html')
37
+ File.join(build_dir, relative)
38
+ end
39
+
40
+ def asciidoctor_attributes
41
+ attrs = @front_matter.transform_keys { |key| "page_#{key}" }
42
+ page_url = url
43
+ attrs['page_url'] = page_url if page_url
44
+ attrs
45
+ end
46
+
47
+ def respond_to_missing?(name, include_private = false)
48
+ @front_matter.key?(name.to_s) || super
49
+ end
50
+
51
+ def method_missing(name, *args)
52
+ key = name.to_s
53
+ return @front_matter[key] if @front_matter.key?(key)
54
+
55
+ super
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ottogen
4
+ class Permalink
5
+ TOKEN_PATTERN = /:(year|month|day|slug|title)/
6
+
7
+ TOKEN_RESOLVERS = {
8
+ ':year' => ->(doc) { doc.respond_to?(:date) && doc.date ? doc.date.strftime('%Y') : '' },
9
+ ':month' => ->(doc) { doc.respond_to?(:date) && doc.date ? doc.date.strftime('%m') : '' },
10
+ ':day' => ->(doc) { doc.respond_to?(:date) && doc.date ? doc.date.strftime('%d') : '' },
11
+ ':slug' => ->(doc) { doc.respond_to?(:slug) ? doc.slug.to_s : '' },
12
+ ':title' => ->(doc) { doc.respond_to?(:title) ? slugify(doc.title) : '' }
13
+ }.freeze
14
+
15
+ def self.expand(template, doc)
16
+ expanded = template.gsub(TOKEN_PATTERN) { |token| TOKEN_RESOLVERS.fetch(token).call(doc) }
17
+ new(expanded)
18
+ end
19
+
20
+ def self.slugify(str)
21
+ str.to_s.downcase.gsub(/[^a-z0-9]+/, '-').sub(/\A-/, '').sub(/-\z/, '')
22
+ end
23
+
24
+ attr_reader :path
25
+ alias url path
26
+
27
+ def initialize(path)
28
+ @path = path
29
+ end
30
+
31
+ def output_path(build_dir)
32
+ File.join(build_dir, @path.end_with?('/') ? "#{@path}index.html" : @path)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ require_relative 'front_matter'
6
+
7
+ module Ottogen
8
+ class Post
9
+ class Error < StandardError; end
10
+
11
+ POSTS_DIR = '_posts'
12
+ DRAFTS_DIR = '_drafts'
13
+ FILENAME_PATTERN = /\A(\d{4})-(\d{2})-(\d{2})-(.+)\.adoc\z/
14
+
15
+ def self.read(path)
16
+ date, slug = parse_filename(path)
17
+ front_matter, body = FrontMatter.split(File.read(path), path)
18
+ new(path: path, date: date, slug: slug, front_matter: front_matter, body: body)
19
+ rescue FrontMatter::Error => e
20
+ raise Error, e.message
21
+ end
22
+
23
+ def self.read_draft(path)
24
+ slug = File.basename(path, '.adoc')
25
+ front_matter, body = FrontMatter.split(File.read(path), path)
26
+ new(path: path, date: Date.today, slug: slug, front_matter: front_matter, body: body)
27
+ rescue FrontMatter::Error => e
28
+ raise Error, e.message
29
+ end
30
+
31
+ def self.parse_filename(path)
32
+ match = FILENAME_PATTERN.match(File.basename(path))
33
+ raise Error, "post filename must match YYYY-MM-DD-slug.adoc: #{path}" unless match
34
+
35
+ year, month, day, slug = match.captures
36
+ [Date.new(year.to_i, month.to_i, day.to_i), slug]
37
+ end
38
+ private_class_method :parse_filename
39
+
40
+ def self.discover(dir = POSTS_DIR)
41
+ return [] unless Dir.exist?(dir)
42
+
43
+ Dir.glob(File.join(dir, '*.adoc')).map { |path| read(path) }
44
+ end
45
+
46
+ def self.discover_drafts(dir = DRAFTS_DIR)
47
+ return [] unless Dir.exist?(dir)
48
+
49
+ Dir.glob(File.join(dir, '*.adoc')).map { |path| read_draft(path) }
50
+ end
51
+
52
+ attr_reader :path, :date, :slug, :front_matter, :body
53
+ attr_accessor :permalink
54
+
55
+ def initialize(path:, date:, slug:, front_matter:, body:)
56
+ @path = path
57
+ @date = date
58
+ @slug = slug
59
+ @front_matter = front_matter
60
+ @body = body
61
+ end
62
+
63
+ def title
64
+ @front_matter['title'] || slug.split('-').map(&:capitalize).join(' ')
65
+ end
66
+
67
+ def tags
68
+ Array(@front_matter['tags'])
69
+ end
70
+
71
+ def categories
72
+ Array(@front_matter['categories'])
73
+ end
74
+
75
+ def url
76
+ return @permalink.url if @permalink
77
+
78
+ "/#{slug}.html"
79
+ end
80
+
81
+ def output_path(build_dir)
82
+ return @permalink.output_path(build_dir) if @permalink
83
+
84
+ File.join(build_dir, "#{slug}.html")
85
+ end
86
+
87
+ def asciidoctor_attributes
88
+ attrs = @front_matter.transform_keys { |key| "page_#{key}" }
89
+ attrs['page_title'] ||= title
90
+ attrs['page_date'] = date.iso8601
91
+ attrs['page_slug'] = slug
92
+ attrs['page_url'] = url
93
+ attrs.delete('page_permalink')
94
+ attrs
95
+ end
96
+
97
+ def respond_to_missing?(name, include_private = false)
98
+ name == :title || name == :url || @front_matter.key?(name.to_s) || super
99
+ end
100
+
101
+ def method_missing(name, *args)
102
+ key = name.to_s
103
+ return @front_matter[key] if @front_matter.key?(key)
104
+
105
+ super
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Ottogen
6
+ module Scaffold
7
+ CONFIG = <<~YAML
8
+ title: "Otto site"
9
+ description: ""
10
+ url: ""
11
+ baseurl: ""
12
+ YAML
13
+
14
+ WELCOME = <<~ADOC
15
+ ---
16
+ layout: default
17
+ title: Welcome
18
+ ---
19
+ = Welcome to Otto!
20
+
21
+ Otto is a static site generator that uses AsciiDoc as a markup language.
22
+ ADOC
23
+
24
+ DEFAULT_LAYOUT = <<~ERB
25
+ <!DOCTYPE html>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <title><%= page.respond_to?(:title) ? page.title : site.title %></title>
30
+ </head>
31
+ <body>
32
+ <%= content %>
33
+ </body>
34
+ </html>
35
+ ERB
36
+
37
+ DIRS = %w[assets pages _layouts _includes _data _posts _drafts].freeze
38
+ FILES = {
39
+ 'config.yml' => CONFIG,
40
+ 'pages/index.adoc' => WELCOME,
41
+ '_layouts/default.html.erb' => DEFAULT_LAYOUT
42
+ }.freeze
43
+
44
+ def self.write(root)
45
+ FileUtils.touch(File.join(root, '.otto'))
46
+ DIRS.each { |dir| FileUtils.mkdir_p(File.join(root, dir)) }
47
+ FILES.each { |path, content| File.write(File.join(root, path), content) }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Ottogen::Collection do
4
+ describe '#items' do
5
+ it 'reads items from _<name>/' do
6
+ in_tmp_dir do
7
+ FileUtils.mkdir_p('_recipes')
8
+ File.write('_recipes/pizza.adoc', "= Pizza\n")
9
+ File.write('_recipes/bread.adoc', "= Bread\n")
10
+
11
+ collection = described_class.from_config('recipes', 'output' => true)
12
+
13
+ expect(collection.items.map(&:slug)).to contain_exactly('pizza', 'bread')
14
+ end
15
+ end
16
+
17
+ it 'returns an empty items list when the directory is missing' do
18
+ in_tmp_dir do
19
+ collection = described_class.from_config('recipes', 'output' => true)
20
+
21
+ expect(collection.items).to eq([])
22
+ end
23
+ end
24
+ end
25
+
26
+ describe '#output?' do
27
+ it 'reflects the output: setting' do
28
+ expect(described_class.from_config('a', 'output' => true).output?).to be true
29
+ expect(described_class.from_config('b', 'output' => false).output?).to be false
30
+ expect(described_class.from_config('c', {}).output?).to be false
31
+ end
32
+ end
33
+ end
34
+
35
+ RSpec.describe Ottogen::CollectionItem do
36
+ describe '#url' do
37
+ it 'is /<collection>/<slug>.html' do
38
+ in_tmp_dir do
39
+ FileUtils.mkdir_p('_recipes')
40
+ File.write('_recipes/pizza.adoc', "= Pizza\n")
41
+
42
+ item = described_class.read('_recipes/pizza.adoc', 'recipes')
43
+
44
+ expect(item.url).to eq('/recipes/pizza.html')
45
+ end
46
+ end
47
+ end
48
+
49
+ describe '#output_path' do
50
+ it 'is <build_dir>/<collection>/<slug>.html' do
51
+ in_tmp_dir do
52
+ FileUtils.mkdir_p('_recipes')
53
+ File.write('_recipes/pizza.adoc', "= Pizza\n")
54
+
55
+ item = described_class.read('_recipes/pizza.adoc', 'recipes')
56
+
57
+ expect(item.output_path('_build')).to eq('_build/recipes/pizza.html')
58
+ end
59
+ end
60
+ end
61
+ end