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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b34377b90101dd7d052eebf2846afb77637ddfbd4e6e21015a93237d0ef4a20
4
- data.tar.gz: 311b1d378b4bcaa1a6b220f01a1fe17fdcc05628a4bac5f76a0e368d076b81cc
3
+ metadata.gz: 6f607f53a4e24feac72ea03754c6b81917fcbe0301afb76d5dcf95f46d5d1d01
4
+ data.tar.gz: b87bc716dc2b0810c0de784aef321c1e774c3fbc0071e919b2cf3f78990c3f75
5
5
  SHA512:
6
- metadata.gz: 0a3f16584748a1670aa78f3f0303a4332003ffd3f25891d3e433471a07b671113e705b53b5a073b249d1c817510942331e5ba005728762c0e7d873997859f86e
7
- data.tar.gz: 86cbc9e917ec3d3f4a9d8c82148c7dddebc1d4f06043a085a9d2395e8c986cd09f134351a01dde64fd16a455cc241ba926b3941b88924d062a1941f5b2b4c8d5
6
+ metadata.gz: bb1148fac045000ea0e7d62fb11782162a6109fd14ae79c5ea4b36e687acd58f5f28f0954a76cdd79adae9b95c779f5e9f5c19e16fedd46530359817840bf030
7
+ data.tar.gz: a22313f2c238d677d10b4f43e778dc432ff3e4966f0455c0949933fece794d53e7830fb0a3f968e84edfcc02e6c0dfccbe4d2fb5041c04a330dab29cebafcc14
data/README.md CHANGED
@@ -1,74 +1,90 @@
1
1
  # Otto
2
2
 
3
- AsciiDoc static site generator.
3
+ AsciiDoc-powered static site generator with Jekyll-style conventions: layouts, includes, data files, posts, drafts, permalinks, and custom collections.
4
4
 
5
- # Quickstart
5
+ ## Install
6
6
 
7
- **Install Otto**
8
-
9
- ``` sh
7
+ ```sh
10
8
  gem install ottogen
11
9
  ```
12
10
 
13
- **Initialize a new site**
11
+ Requires Ruby 3.0 or newer.
12
+
13
+ ## Quickstart
14
14
 
15
- ``` sh
15
+ ```sh
16
16
  mkdir mysite && cd mysite
17
17
  otto init
18
- ```
19
-
20
- **Build the site**
21
-
22
- ``` sh
23
18
  otto build
24
- ```
25
-
26
- **Serve the site**
27
-
28
- ``` sh
29
19
  otto serve
30
- ```
31
-
32
- **View the site**
33
-
34
- ``` sh
35
20
  open http://127.0.0.1:8778/
36
21
  ```
37
22
 
38
- # AsciiDoc Crash Course
39
-
40
- TODO
41
-
42
- **Paragraphs**
23
+ For a longer walkthrough including AsciiDoc syntax, see [GUIDE.md](GUIDE.md).
43
24
 
44
- **Text formatting**
25
+ ## Commands
45
26
 
46
- **Links**
27
+ | Command | Description |
28
+ |---|---|
29
+ | `otto init [DIR]` | Scaffold a new site (current dir if omitted) |
30
+ | `otto build` | Render the site to `_build/` |
31
+ | `otto build --drafts` | Include posts from `_drafts/` |
32
+ | `otto watch` | Rebuild on file change |
33
+ | `otto serve` | Serve `_build/` on port 8778 |
34
+ | `otto generate PAGE` | Create a new page in `pages/` |
35
+ | `otto post "Title"` | Create a new dated post in `_posts/` |
36
+ | `otto clean` | Delete `_build/` |
37
+ | `otto doctor` | Sanity-check project layout |
47
38
 
48
- **Document header**
39
+ ## Project layout
49
40
 
50
- **Section titles**
41
+ ```
42
+ my-site/
43
+ ├── .otto # marker
44
+ ├── config.yml # site config
45
+ ├── assets/ # copied verbatim into _build/
46
+ ├── pages/ # AsciiDoc pages, output mirrors path
47
+ ├── _layouts/ # ERB layouts (.html.erb)
48
+ ├── _includes/ # ERB partials
49
+ ├── _data/ # YAML/JSON files exposed as site.data.*
50
+ ├── _posts/ # YYYY-MM-DD-slug.adoc
51
+ └── _drafts/ # undated drafts (excluded by default)
52
+ ```
51
53
 
52
- **Automatic TOC**
54
+ ## Configuration (`config.yml`)
53
55
 
54
- **Includes**
56
+ ```yaml
57
+ title: My Otto Site
58
+ description: Things I write
59
+ url: https://example.com
60
+ baseurl: ""
55
61
 
56
- **Lists**
62
+ permalink: /:year/:month/:day/:slug/
57
63
 
58
- **Images**
64
+ collections:
65
+ recipes:
66
+ output: true
67
+ ```
59
68
 
60
- **Audio**
69
+ `permalink` accepts these tokens: `:year`, `:month`, `:day`, `:slug`, `:title`. Templates ending in `/` produce pretty URLs (`<path>/index.html`).
61
70
 
62
- **Videos**
71
+ ## Pages and posts
63
72
 
64
- **Keyboard, button, and menu macros**
73
+ Both support YAML front matter:
65
74
 
66
- **Literals and source code**
75
+ ```adoc
76
+ ---
77
+ layout: default
78
+ title: Hello
79
+ tags: [ruby, cli]
80
+ ---
81
+ = Hello
67
82
 
68
- **Admonitions**
83
+ Welcome to {site_title}. This page is at {page_url}.
84
+ ```
69
85
 
70
- **More delimited blocks**
86
+ Pages live under `pages/`; posts under `_posts/` with `YYYY-MM-DD-slug.adoc` names. Layouts wrap rendered AsciiDoc; partials in `_includes/` are pulled in via `<%= partial 'header.html' %>`.
71
87
 
72
- **Tables**
88
+ ## License
73
89
 
74
- **IDs, roles, and options**
90
+ MIT
data/bin/otto CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require_relative '../lib/ottogen/otto'
4
5
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'collection_item'
4
+
5
+ module Ottogen
6
+ class Collection
7
+ DIR_PREFIX = '_'
8
+
9
+ def self.from_config(name, settings)
10
+ output = settings.is_a?(Hash) && settings['output'] == true
11
+ new(name: name, output: output)
12
+ end
13
+
14
+ attr_reader :name, :items
15
+
16
+ def initialize(name:, output:)
17
+ @name = name
18
+ @output = output
19
+ @items = discover_items
20
+ end
21
+
22
+ def output?
23
+ @output
24
+ end
25
+
26
+ private
27
+
28
+ def discover_items
29
+ dir = "#{DIR_PREFIX}#{@name}"
30
+ return [] unless Dir.exist?(dir)
31
+
32
+ Dir.glob(File.join(dir, '*.adoc')).map { |path| CollectionItem.read(path, @name) }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'front_matter'
4
+
5
+ module Ottogen
6
+ class CollectionItem
7
+ class Error < StandardError; end
8
+
9
+ def self.read(path, collection_name)
10
+ raw = File.read(path)
11
+ front_matter, body = FrontMatter.split(raw, path)
12
+ new(path: path, collection_name: collection_name, front_matter: front_matter, body: body)
13
+ rescue FrontMatter::Error => e
14
+ raise Error, e.message
15
+ end
16
+
17
+ attr_reader :path, :collection_name, :front_matter, :body
18
+ attr_accessor :permalink
19
+
20
+ def initialize(path:, collection_name:, front_matter:, body:)
21
+ @path = path
22
+ @collection_name = collection_name
23
+ @front_matter = front_matter
24
+ @body = body
25
+ end
26
+
27
+ def slug
28
+ File.basename(@path, '.adoc')
29
+ end
30
+
31
+ def url
32
+ return @permalink.url if @permalink
33
+
34
+ "/#{@collection_name}/#{slug}.html"
35
+ end
36
+
37
+ def output_path(build_dir)
38
+ return @permalink.output_path(build_dir) if @permalink
39
+
40
+ File.join(build_dir, @collection_name, "#{slug}.html")
41
+ end
42
+
43
+ def asciidoctor_attributes
44
+ attrs = @front_matter.transform_keys { |key| "page_#{key}" }
45
+ attrs['page_url'] = url
46
+ attrs['page_slug'] = slug
47
+ attrs
48
+ end
49
+
50
+ def respond_to_missing?(name, include_private = false)
51
+ @front_matter.key?(name.to_s) || super
52
+ end
53
+
54
+ def method_missing(name, *args)
55
+ key = name.to_s
56
+ return @front_matter[key] if @front_matter.key?(key)
57
+
58
+ super
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ require_relative 'collection'
7
+ require_relative 'post'
8
+
9
+ module Ottogen
10
+ class Config
11
+ class Error < StandardError; end
12
+
13
+ DEFAULT_PATH = 'config.yml'
14
+ DATA_DIR = '_data'
15
+ DATA_EXTENSIONS = %w[.yml .yaml .json].freeze
16
+
17
+ def self.load(path = DEFAULT_PATH, drafts: false)
18
+ raise Error, "config.yml not found at #{path}" unless File.exist?(path)
19
+
20
+ values = YAML.safe_load_file(path) || {}
21
+ new(values, load_data_files, load_posts(drafts: drafts), load_collections(values['collections']))
22
+ rescue Psych::SyntaxError => e
23
+ raise Error, "malformed YAML in #{path}: #{e.message}"
24
+ end
25
+
26
+ def self.load_collections(config)
27
+ return {} unless config.is_a?(Hash)
28
+
29
+ config.to_h { |name, settings| [name, Collection.from_config(name, settings || {})] }
30
+ end
31
+ private_class_method :load_collections
32
+
33
+ def self.load_posts(drafts: false)
34
+ posts = Post.discover
35
+ posts += Post.discover_drafts if drafts
36
+ posts.sort_by(&:date).reverse
37
+ rescue Post::Error => e
38
+ raise Error, e.message
39
+ end
40
+ private_class_method :load_posts
41
+
42
+ def self.load_data_files
43
+ return {} unless Dir.exist?(DATA_DIR)
44
+
45
+ Dir.glob(File.join(DATA_DIR, '*.{yml,yaml,json}')).to_h do |file|
46
+ [File.basename(file, '.*'), parse_data_file(file)]
47
+ end
48
+ end
49
+
50
+ def self.parse_data_file(file)
51
+ if file.end_with?('.json')
52
+ JSON.parse(File.read(file))
53
+ else
54
+ YAML.safe_load_file(file)
55
+ end
56
+ rescue Psych::SyntaxError, JSON::ParserError => e
57
+ raise Error, "malformed data file at #{file}: #{e.message}"
58
+ end
59
+ private_class_method :load_data_files, :parse_data_file
60
+
61
+ def initialize(values, data_files = {}, posts = [], collections = {})
62
+ @values = values
63
+ @data = Data.new(data_files)
64
+ @posts = posts
65
+ @collections = collections
66
+ end
67
+
68
+ attr_reader :data, :posts, :collections
69
+
70
+ def tags
71
+ group_posts_by(&:tags)
72
+ end
73
+
74
+ def categories
75
+ group_posts_by(&:categories)
76
+ end
77
+
78
+ def group_posts_by(&block)
79
+ @posts.each_with_object({}) do |post, acc|
80
+ block.call(post).each { |key| (acc[key] ||= []) << post }
81
+ end
82
+ end
83
+ private :group_posts_by
84
+
85
+ def [](key)
86
+ @values[key.to_s]
87
+ end
88
+
89
+ def asciidoctor_attributes
90
+ @values.transform_keys { |key| "site_#{key}" }
91
+ end
92
+
93
+ def respond_to_missing?(name, include_private = false)
94
+ key = name.to_s
95
+ @values.key?(key) || @collections.key?(key) || super
96
+ end
97
+
98
+ def method_missing(name, *args)
99
+ key = name.to_s
100
+ return @collections[key].items if @collections.key?(key)
101
+ return @values[key] if @values.key?(key)
102
+
103
+ super
104
+ end
105
+
106
+ class Data
107
+ def initialize(files)
108
+ @files = files
109
+ end
110
+
111
+ def [](key)
112
+ @files[key.to_s]
113
+ end
114
+
115
+ def respond_to_missing?(name, include_private = false)
116
+ @files.key?(name.to_s) || super
117
+ end
118
+
119
+ def method_missing(name, *args)
120
+ return @files[name.to_s] if @files.key?(name.to_s)
121
+
122
+ super
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Ottogen
6
+ module FrontMatter
7
+ class Error < StandardError; end
8
+
9
+ OPENERS = ["---\n", "---\r\n"].freeze
10
+
11
+ def self.split(raw, path)
12
+ return [{}, raw] unless OPENERS.any? { |opener| raw.start_with?(opener) }
13
+
14
+ lines = raw.lines
15
+ closing = lines[1..].index { |line| line.chomp == '---' }
16
+ raise Error, "unclosed front matter in #{path}" if closing.nil?
17
+
18
+ yaml_text = lines[1..closing].join
19
+ body = (lines[(closing + 2)..] || []).join
20
+ [parse_yaml(yaml_text, path), body]
21
+ end
22
+
23
+ def self.parse_yaml(text, path)
24
+ YAML.safe_load(text) || {}
25
+ rescue Psych::SyntaxError => e
26
+ raise Error, "malformed YAML front matter in #{path}: #{e.message}"
27
+ end
28
+ private_class_method :parse_yaml
29
+ end
30
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ require_relative 'front_matter'
6
+
7
+ module Ottogen
8
+ class Layout
9
+ class Error < StandardError; end
10
+
11
+ LAYOUTS_DIR = '_layouts'
12
+ EXTENSION = '.html.erb'
13
+
14
+ def self.find(name)
15
+ path = File.join(LAYOUTS_DIR, "#{name}#{EXTENSION}")
16
+ raise Error, "layout '#{name}' not found at #{path}" unless File.exist?(path)
17
+
18
+ raw = File.read(path)
19
+ front_matter, body = FrontMatter.split(raw, path)
20
+ new(name: name, front_matter: front_matter, body: body)
21
+ rescue FrontMatter::Error => e
22
+ raise Error, e.message
23
+ end
24
+
25
+ attr_reader :name, :front_matter, :body
26
+
27
+ def initialize(name:, front_matter:, body:)
28
+ @name = name
29
+ @front_matter = front_matter
30
+ @body = body
31
+ end
32
+
33
+ def render(content:, site:, page:)
34
+ context = Context.new(content: content, site: site, page: page)
35
+ result = ERB.new(@body, trim_mode: '-').result(context.binding_for_erb)
36
+ parent_name = @front_matter['layout']
37
+ return result unless parent_name
38
+
39
+ Layout.find(parent_name).render(content: result, site: site, page: page)
40
+ end
41
+
42
+ class Context
43
+ INCLUDES_DIR = '_includes'
44
+
45
+ attr_reader :content, :site, :page
46
+
47
+ def initialize(content:, site:, page:)
48
+ @content = content
49
+ @site = site
50
+ @page = page
51
+ end
52
+
53
+ def binding_for_erb
54
+ binding
55
+ end
56
+
57
+ def partial(name)
58
+ path = File.join(INCLUDES_DIR, name)
59
+ raise Error, "include '#{name}' not found at #{path}" unless File.exist?(path)
60
+
61
+ ERB.new(File.read(path), trim_mode: '-').result(binding_for_erb)
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/ottogen/otto.rb CHANGED
@@ -1,42 +1,56 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thor'
2
- require_relative './ottogen'
4
+ require_relative 'ottogen'
3
5
 
4
6
  module Ottogen
5
7
  class Otto < Thor
6
- desc "init [DIR]", "Initialize a new otto static site in DIR (defaults to the current directory)"
7
- def init(dir=nil)
8
+ desc 'init [DIR]', 'Initialize a new otto static site in DIR (defaults to the current directory)'
9
+ def init(dir = nil)
8
10
  Ottogen.init(dir)
9
11
  end
10
12
 
11
- desc "build", "Build the static site"
13
+ desc 'build', 'Build the static site'
14
+ option :drafts, type: :boolean, default: false, desc: 'Include drafts from _drafts/'
12
15
  def build
13
- Ottogen.build
16
+ Ottogen.build(drafts: options[:drafts])
14
17
  end
15
18
 
16
- map "b" => :build
19
+ map 'b' => :build
17
20
 
18
- desc "generate PAGE", "Generate a new page"
21
+ desc 'generate PAGE', 'Generate a new page'
19
22
  def generate(page)
20
23
  Ottogen.generate(page)
21
24
  end
22
25
 
23
- map "g" => :generate
26
+ map 'g' => :generate
24
27
 
25
- desc "clean", "Clean the static site"
28
+ desc 'clean', 'Clean the static site'
26
29
  def clean
27
30
  Ottogen.clean
28
31
  end
29
32
 
30
- desc "watch", "Watch changes to static site"
33
+ desc 'watch', 'Watch changes to static site'
34
+ option :drafts, type: :boolean, default: false, desc: 'Include drafts from _drafts/'
31
35
  def watch
32
- Ottogen.watch
36
+ Ottogen.watch(drafts: options[:drafts])
33
37
  end
34
38
 
35
- desc "serve", "Serve the static site"
39
+ desc 'serve', 'Serve the static site'
36
40
  def serve
37
41
  Ottogen.serve
38
42
  end
39
43
 
40
- map "s" => :serve
44
+ map 's' => :serve
45
+
46
+ desc 'post TITLE', "Generate a new post in _posts/ with today's date"
47
+ def post(title)
48
+ Ottogen.new_post(title)
49
+ end
50
+
51
+ desc 'doctor', 'Check the project for common configuration problems'
52
+ def doctor
53
+ Ottogen.doctor
54
+ end
41
55
  end
42
56
  end