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,51 +1,191 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'asciidoctor'
4
+ require 'date'
2
5
  require 'fileutils'
3
6
  require 'listen'
7
+ require 'webrick'
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'
4
16
 
5
17
  module Ottogen
6
18
  class Ottogen
7
- BUILD_DIR = '_build'.freeze
8
- WELCOME = <<~ADOC
9
- = Welcome to Otto!
10
-
11
- Otto is a static site generator that uses AsciiDoc as a markup language.
12
- ADOC
13
-
14
- def self.init
15
- puts "✨ ..."
16
- File.write("index.adoc", WELCOME)
17
- puts "✅"
18
- end
19
-
20
- def self.build
21
- puts "🔨 ..."
22
- Dir.mkdir(BUILD_DIR) unless Dir.exist?(BUILD_DIR)
23
- Dir.glob('**/*.adoc').map do |name|
24
- name.split('.').first
25
- end.each do |doc|
26
- Asciidoctor.convert_file "#{doc}.adoc",
19
+ BUILD_DIR = '_build'
20
+
21
+ def self.init(dir)
22
+ puts '✨ Initializing static site...'
23
+ if !dir.nil? && Dir.exist?(dir)
24
+ puts '❌ Error: Directory already exists'
25
+ exit(1)
26
+ end
27
+
28
+ if dir.nil?
29
+ files_in_current_dir = Dir.glob('**/*')
30
+ unless files_in_current_dir.empty?
31
+ puts '❌ Error: Directory must be empty'
32
+ exit(1)
33
+ end
34
+ init_in_current_dir
35
+ else
36
+ init_with_dir(dir)
37
+ end
38
+
39
+ puts '✅'
40
+ end
41
+
42
+ def self.build(drafts: false)
43
+ puts '🔨 Building static site...'
44
+ error_if_not_otto_project
45
+ config = load_config(drafts: drafts)
46
+ FileUtils.mkdir_p(BUILD_DIR)
47
+ FileUtils.cp_r 'assets/', "#{BUILD_DIR}/assets"
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,
27
85
  safe: :safe,
28
- mkdirs: true,
29
- to_file: "#{BUILD_DIR}/#{doc}.html"
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)
96
+ end
97
+
98
+ def self.generate(page)
99
+ puts '📝 Generating a new page...'
100
+ error_if_not_otto_project
101
+ page_title = page.split('-').map(&:capitalize).join(' ')
102
+ File.write("pages/#{page}.adoc", "= #{page_title}\n")
103
+ end
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)
30
123
  end
31
- puts "✅"
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
32
132
  end
33
133
 
34
134
  def self.clean
35
- puts "🧽 ..."
135
+ puts '🧽 Cleaning build directory...'
136
+ error_if_not_otto_project
36
137
  return unless Dir.exist?(BUILD_DIR)
138
+
37
139
  FileUtils.rmtree(BUILD_DIR)
38
- puts ""
140
+ puts ''
141
+ end
142
+
143
+ def self.serve
144
+ puts '🤖 Starting server...'
145
+ error_if_not_otto_project
146
+ root = File.expand_path("#{Dir.pwd}/#{Ottogen::BUILD_DIR}")
147
+ server = WEBrick::HTTPServer.new Port: 8778, DocumentRoot: root
148
+ trap 'INT' do
149
+ server.shutdown
150
+ end
151
+ server.start
152
+ rescue Errno::EADDRINUSE
153
+ puts '❌ Server port already in use'
154
+ exit(1)
39
155
  end
40
156
 
41
- def self.watch
42
- puts "👀 Watching files..."
157
+ def self.watch(drafts: false)
158
+ puts '👀 Watching files...'
159
+ error_if_not_otto_project
43
160
  listener = Listen.to(Dir.pwd, ignore: [/_build/]) do |modified, added, removed|
44
161
  puts(modified: modified, added: added, removed: removed)
45
- build
162
+ build(drafts: drafts)
46
163
  end
47
164
  listener.start
48
165
  sleep
49
166
  end
167
+
168
+ def self.init_with_dir(dir)
169
+ Dir.mkdir(dir)
170
+ Scaffold.write(dir)
171
+ end
172
+
173
+ def self.init_in_current_dir
174
+ Scaffold.write('.')
175
+ end
176
+
177
+ def self.error_if_not_otto_project
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)
189
+ end
50
190
  end
51
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