prt 0.3.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 +7 -0
- data/.githooks/pre-commit +17 -0
- data/.github/workflows/ci.yml +29 -0
- data/.gitignore +24 -0
- data/.rspec +2 -0
- data/.rubocop.yml +28 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +8 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +340 -0
- data/Rakefile +26 -0
- data/bin/setup +11 -0
- data/exe/parrot +12 -0
- data/lib/helpers.rb +19 -0
- data/lib/parrot/commands/build.rb +1274 -0
- data/lib/parrot/commands/new.rb +25 -0
- data/lib/parrot/commands/post.rb +83 -0
- data/lib/parrot/commands/serve.rb +129 -0
- data/lib/parrot/constants.rb +45 -0
- data/lib/parrot/file_cache.rb +45 -0
- data/lib/parrot/logger.rb +23 -0
- data/lib/parrot/metadata.rb +4 -0
- data/lib/parrot/runner.rb +29 -0
- data/lib/parrot/template_handler.rb +19 -0
- data/lib/parrot.rb +120 -0
- data/parrot.gemspec +36 -0
- data/skel/.gitignore +3 -0
- data/skel/config.yaml +68 -0
- data/skel/css/app.scss +189 -0
- data/skel/images/.keep +0 -0
- data/skel/images/apple-touch-icon.png +0 -0
- data/skel/images/favicon.ico +0 -0
- data/skel/images/favicon.svg +40 -0
- data/skel/images/parrot.jpeg +0 -0
- data/skel/javascripts/app.js +37 -0
- data/skel/public/.keep +0 -0
- data/skel/views/404.md +7 -0
- data/skel/views/about.md +17 -0
- data/skel/views/layout.html.erb +72 -0
- data/skel/views/posts/about_parrot.md +63 -0
- data/skel/views/posts/sample.md +37 -0
- data/spec/parrot/commands/build_spec.rb +747 -0
- data/spec/parrot/commands/new_spec.rb +43 -0
- data/spec/parrot/commands/post_spec.rb +80 -0
- data/spec/parrot/commands/serve_spec.rb +66 -0
- data/spec/parrot/logger_spec.rb +39 -0
- data/spec/parrot/metadata_spec.rb +11 -0
- data/spec/parrot/runner_spec.rb +28 -0
- data/spec/parrot_spec.rb +37 -0
- data/spec/spec_helper.rb +7 -0
- metadata +237 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Parrot
|
|
2
|
+
module Commands
|
|
3
|
+
# New command is to create a new html app
|
|
4
|
+
# @usage parrot new myblog
|
|
5
|
+
class NewCommand
|
|
6
|
+
attr_reader :config, :app_root
|
|
7
|
+
|
|
8
|
+
def initialize(args = [], config)
|
|
9
|
+
@config = config
|
|
10
|
+
@app_root = args.first
|
|
11
|
+
raise ArgumentError if @app_root.nil? || @config.nil?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
raise "Directory #{@app_root} already exists" if Dir.exist? @app_root
|
|
16
|
+
|
|
17
|
+
config.logger.info "Creating new blog at #{app_root}"
|
|
18
|
+
skeleton_path = File.expand_path('../../../skel', __dir__)
|
|
19
|
+
|
|
20
|
+
config.logger.info "Copying skeleten from #{skeleton_path}"
|
|
21
|
+
FileUtils.cp_r(skeleton_path, @app_root)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'optparse'
|
|
3
|
+
|
|
4
|
+
module Parrot
|
|
5
|
+
module Commands
|
|
6
|
+
# Post command scaffolds a new Markdown post. The index page's listing is
|
|
7
|
+
# generated at build time from every file in views/posts, so nothing here
|
|
8
|
+
# needs to link it in.
|
|
9
|
+
# @usage parrot post --title "My first post"
|
|
10
|
+
# Runs from the blog's root, like `build` and `serve`.
|
|
11
|
+
class PostCommand
|
|
12
|
+
USAGE = 'Usage: parrot post --title "My new post"'.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :config, :app_root, :title, :slug
|
|
15
|
+
|
|
16
|
+
def initialize(args = [], config)
|
|
17
|
+
@config = config
|
|
18
|
+
@title = extract_title(Array(args)).strip
|
|
19
|
+
@app_root = @config&.root_dir
|
|
20
|
+
raise ArgumentError, USAGE if @title.empty?
|
|
21
|
+
raise ArgumentError if @app_root.nil?
|
|
22
|
+
|
|
23
|
+
@slug = Helpers.slugify(@title)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
raise ArgumentError, "Title has no letters or digits to build a filename from.\n#{USAGE}" if slug.empty?
|
|
28
|
+
if slug.match?(RESERVED_POST_NAMES)
|
|
29
|
+
raise ArgumentError,
|
|
30
|
+
"\"#{slug}\" is a reserved post name (index*, 404, about, categories, now, post(s), note(s)). " \
|
|
31
|
+
"Pick a different title.\n#{USAGE}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
posts_dir = File.join(app_root, 'views', 'posts')
|
|
35
|
+
|
|
36
|
+
raise "Run this from the blog's root (no views/posts found)" unless Dir.exist?(posts_dir)
|
|
37
|
+
|
|
38
|
+
post_path = File.join(posts_dir, "#{slug}.md")
|
|
39
|
+
raise "Post #{post_path} already exists" if File.exist?(post_path)
|
|
40
|
+
|
|
41
|
+
File.write(post_path, post_template(Date.today.strftime('%d/%m/%Y')))
|
|
42
|
+
config.logger.info "Created #{post_path}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# The title comes from `--title`. Any words left over after the option are
|
|
48
|
+
# treated as part of the title too, so all of these give "My new post":
|
|
49
|
+
# parrot post --title "My new post"
|
|
50
|
+
# parrot post --title 'My new post'
|
|
51
|
+
# parrot post --title My new post
|
|
52
|
+
def extract_title(args)
|
|
53
|
+
from_option = []
|
|
54
|
+
rest = args.dup
|
|
55
|
+
|
|
56
|
+
OptionParser.new do |opts|
|
|
57
|
+
opts.on('--title TITLE', 'Title for the new post') { |t| from_option << t }
|
|
58
|
+
end.parse!(rest)
|
|
59
|
+
|
|
60
|
+
(from_option + rest).join(' ')
|
|
61
|
+
rescue OptionParser::ParseError
|
|
62
|
+
raise ArgumentError, USAGE
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# {post_date} is filled in at build time, formatted per
|
|
66
|
+
# config.yaml's post_date_format.on_post.
|
|
67
|
+
def post_template(header_date)
|
|
68
|
+
<<~MARKDOWN
|
|
69
|
+
<!--
|
|
70
|
+
title: #{title}
|
|
71
|
+
date: #{header_date}
|
|
72
|
+
lang: en
|
|
73
|
+
category:
|
|
74
|
+
-->
|
|
75
|
+
|
|
76
|
+
# {post_title}
|
|
77
|
+
|
|
78
|
+
Write your post here.
|
|
79
|
+
MARKDOWN
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
require 'webrick'
|
|
2
|
+
require_relative '../template_handler'
|
|
3
|
+
require 'pp'
|
|
4
|
+
require 'watchr'
|
|
5
|
+
require_relative '../file_cache'
|
|
6
|
+
require_relative 'build'
|
|
7
|
+
|
|
8
|
+
module Parrot
|
|
9
|
+
module Commands
|
|
10
|
+
class ServeCommand
|
|
11
|
+
# Combined checksum of the last successful build, kept inside the build
|
|
12
|
+
# directory. It is regenerated output, so it is gitignored and must be
|
|
13
|
+
# excluded from anything that ships the build directory.
|
|
14
|
+
CHECKSUM_FILE = '.checksum'.freeze
|
|
15
|
+
|
|
16
|
+
# Serves the build directory like WEBrick's FileHandler, but answers a
|
|
17
|
+
# missing path with the built 404.html (still with a 404 status), the
|
|
18
|
+
# way static hosts do, instead of WEBrick's generic error page.
|
|
19
|
+
class FileHandler < WEBrick::HTTPServlet::FileHandler
|
|
20
|
+
def initialize(server, root, options = {}, default = WEBrick::Config::FileHandler)
|
|
21
|
+
super
|
|
22
|
+
@not_found_page = File.join(root, '404.html')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def service(req, res)
|
|
26
|
+
super
|
|
27
|
+
rescue WEBrick::HTTPStatus::NotFound
|
|
28
|
+
raise unless File.file?(@not_found_page)
|
|
29
|
+
|
|
30
|
+
res.status = 404
|
|
31
|
+
res['Content-Type'] = 'text/html; charset=utf-8'
|
|
32
|
+
res.body = File.read(@not_found_page)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :config, :document_root, :app_root
|
|
37
|
+
|
|
38
|
+
def initialize(args = [], config)
|
|
39
|
+
@config = config
|
|
40
|
+
@args = args
|
|
41
|
+
@app_root = config.root_dir
|
|
42
|
+
@document_root = "#{app_root}/public"
|
|
43
|
+
@port = 8000
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
Thread.new do
|
|
48
|
+
config.logger.info 'Running watch for changes'
|
|
49
|
+
watch_app
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
run_server
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run_server
|
|
58
|
+
server = WEBrick::HTTPServer.new Port: @port
|
|
59
|
+
server.mount('/', FileHandler, @document_root)
|
|
60
|
+
|
|
61
|
+
trap 'INT' do
|
|
62
|
+
server.shutdown
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
server.start
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def watch_app
|
|
69
|
+
ENV['HANDLER'] = `uname`.strip
|
|
70
|
+
watcher = Watchr::Script.new
|
|
71
|
+
all_files = Dir.glob('**/*').select { |item| File.file?(item) && !item.start_with?('public/') }
|
|
72
|
+
@cache = FileCache.instance
|
|
73
|
+
builder = BuildCommand.new([], config)
|
|
74
|
+
builder.unset_build_mode!
|
|
75
|
+
|
|
76
|
+
all_files.each do |file|
|
|
77
|
+
absolute_path = File.join(app_root, file)
|
|
78
|
+
@cache.set(absolute_path)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
build_if_stale(builder, @cache.checksum)
|
|
82
|
+
|
|
83
|
+
watcher.watch(all_files.join('|')) do |file|
|
|
84
|
+
config.logger.info "File changed #{file}"
|
|
85
|
+
path = "#{app_root}/#{file}"
|
|
86
|
+
|
|
87
|
+
if @cache.changed? path
|
|
88
|
+
@cache.set(path)
|
|
89
|
+
builder.build(file)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
controller = Watchr::Controller.new(watcher, Watchr.handler.new)
|
|
94
|
+
controller.run
|
|
95
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- also log Ctrl-C/exit from the watcher loop
|
|
96
|
+
config.logger.error("#{e.message}\n#{e.backtrace.join("\n")}")
|
|
97
|
+
exit(-1)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Rebuild the whole blog only when the source tree as a whole has moved
|
|
101
|
+
# since the last build. The given FileCache checksum is compared with the
|
|
102
|
+
# one stored in the build directory; a missing or mismatched file triggers
|
|
103
|
+
# a full build, after which the stored checksum is refreshed.
|
|
104
|
+
def build_if_stale(builder, checksum)
|
|
105
|
+
checksum_path = File.join(document_root, CHECKSUM_FILE)
|
|
106
|
+
stored = File.read(checksum_path).strip if File.exist?(checksum_path)
|
|
107
|
+
|
|
108
|
+
if stored == checksum
|
|
109
|
+
config.logger.info 'No source changes since last build, skipping full build'
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
builder.run
|
|
114
|
+
File.write(checksum_path, checksum)
|
|
115
|
+
config.logger.info "Wrote build checksum to #{checksum_path}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def handle_path(path)
|
|
119
|
+
path.chop! while path[-1] == '/'
|
|
120
|
+
|
|
121
|
+
path = '/index.html' if path == ''
|
|
122
|
+
|
|
123
|
+
template_type = path.split('.').last.to_sym
|
|
124
|
+
handler = TemplateHandler.new(root: document_root, path: path, handle: template_type)
|
|
125
|
+
handler.compile
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
MATHJAX_URL = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js'.freeze
|
|
2
|
+
|
|
3
|
+
# Rouge theme used for syntax highlighting of code blocks in posts.
|
|
4
|
+
# Any theme name from Rouge::Themes works here (e.g. "monokai", "github",
|
|
5
|
+
# "gruvbox", "colorful", "molokai", "thankful_eyes").
|
|
6
|
+
HIGHLIGHT_THEME = 'monokai'.freeze
|
|
7
|
+
|
|
8
|
+
# `{post_<key>}` expands to that key from the post's header (so
|
|
9
|
+
# `{post_title}`, `{post_lang}`, or any custom header field), as plain
|
|
10
|
+
# text; `{post_date}` is the same header field but run through
|
|
11
|
+
# `post_date_format.on_list` below rather than shown as-authored.
|
|
12
|
+
# `{post_link}` is the one field Parrot computes itself rather than
|
|
13
|
+
# reading from the header: the post's href. Wrap whichever span should
|
|
14
|
+
# be clickable in ordinary Markdown link syntax, [...]({post_link}).
|
|
15
|
+
# `{post_category_tag}` is the post's header `category` as a link to that
|
|
16
|
+
# category's listing (`<a class="category-tag">`), or nothing when the post
|
|
17
|
+
# has no category. This default links the title and shows that tag:
|
|
18
|
+
# "{post_date} ~ [{post_title}]({post_link}) {post_category_tag}"
|
|
19
|
+
# To make the whole line a link instead:
|
|
20
|
+
# "[{post_date} ~ {post_title}]({post_link})"
|
|
21
|
+
# A bare strftime format string like {%d/%m/%Y} also still works here,
|
|
22
|
+
# shown exactly as formatted rather than through post_date_format.
|
|
23
|
+
DEFAULT_LIST_FORMAT = '{post_date} ~ [{post_title}]({post_link}) {post_category_tag}'.freeze
|
|
24
|
+
DEFAULT_GROUP_BY = 'none'.freeze
|
|
25
|
+
DEFAULT_LIST_TITLE = 'Post listing'.freeze
|
|
26
|
+
DEFAULT_BACK_LINK_TEXT = '← Back to all posts'.freeze
|
|
27
|
+
DEFAULT_NEWER_LINK_TEXT = '← Newer posts'.freeze
|
|
28
|
+
DEFAULT_OLDER_LINK_TEXT = 'Older posts →'.freeze
|
|
29
|
+
|
|
30
|
+
# `{post_date}` is the one header field with its own dedicated config,
|
|
31
|
+
# config.yaml's `post_date_format` — a Ruby strftime format string (see
|
|
32
|
+
# Date#strftime) per context: `on_list` when `{post_date}` appears in a
|
|
33
|
+
# post_listing `list_format`, `on_post` when it appears as a literal
|
|
34
|
+
# placeholder inside a post's own Markdown body.
|
|
35
|
+
DEFAULT_POST_DATE_FORMAT = { 'on_list' => '%m/%Y', 'on_post' => '%d/%m/%Y' }.freeze
|
|
36
|
+
|
|
37
|
+
# Post filenames (without .md) that `parrot post` refuses to create and
|
|
38
|
+
# `parrot build` fails on. index*, 404 and about would build to the same
|
|
39
|
+
# public/*.html as a page Parrot generates itself (the index pages,
|
|
40
|
+
# 404.html, about.html, categories.html); category, now, post(s) and note(s)
|
|
41
|
+
# are kept free for pages of their own. A post named like a category page
|
|
42
|
+
# (category-<slug>.html) is caught by the build instead, since those names
|
|
43
|
+
# depend on which categories exist. Case-insensitive, since macOS and
|
|
44
|
+
# Windows filesystems are.
|
|
45
|
+
RESERVED_POST_NAMES = /\A(?:index\d*|404|about|categor(?:y|ies)|now|posts?|notes?)\z/i
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
require 'singleton'
|
|
3
|
+
|
|
4
|
+
class FileCache
|
|
5
|
+
include Singleton
|
|
6
|
+
|
|
7
|
+
attr_accessor :cache
|
|
8
|
+
|
|
9
|
+
# Digest over the combined state of every tracked file. Recomputed on
|
|
10
|
+
# every #set, so a single value tells you whether the cache as a whole
|
|
11
|
+
# has moved.
|
|
12
|
+
attr_reader :checksum
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@cache = {}
|
|
16
|
+
@checksum = compute_checksum
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def fetch(path)
|
|
20
|
+
cache[path]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def set(path)
|
|
24
|
+
digest = Digest::SHA256.hexdigest(File.read(path))
|
|
25
|
+
cache[path] = digest
|
|
26
|
+
@checksum = compute_checksum
|
|
27
|
+
digest
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def changed?(path)
|
|
31
|
+
return true unless cache.key?(path)
|
|
32
|
+
|
|
33
|
+
data = File.read(path)
|
|
34
|
+
cache[path] != Digest::SHA256.hexdigest(data)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Hash the whole cache. Entries are sorted by path so the result depends
|
|
40
|
+
# only on the contents, not on insertion order.
|
|
41
|
+
def compute_checksum
|
|
42
|
+
payload = cache.sort.map { |path, sha| "#{path}:#{sha}" }.join("\n")
|
|
43
|
+
Digest::SHA256.hexdigest(payload)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require 'logger'
|
|
2
|
+
|
|
3
|
+
module Parrot
|
|
4
|
+
class ParrotLoggerBuilder
|
|
5
|
+
attr_reader :logger, :device
|
|
6
|
+
|
|
7
|
+
def initialize(quiet = false)
|
|
8
|
+
@device = if Helpers.testing?
|
|
9
|
+
File.new('parrot.test.log', 'a+')
|
|
10
|
+
else
|
|
11
|
+
quiet ? File.new('parrot.log', 'a+') : $stdout
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# shift_age = 10, keep 10 log files
|
|
15
|
+
# shift_size = 1048576, maximum 1 MB of log file size
|
|
16
|
+
@logger = Logger.new(@device, 10, 1_048_576, progname: 'ParrotLogger')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def log(message)
|
|
20
|
+
@logger.info(message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require_relative 'commands/new'
|
|
3
|
+
require_relative 'commands/build'
|
|
4
|
+
require_relative 'commands/serve'
|
|
5
|
+
require_relative 'commands/post'
|
|
6
|
+
|
|
7
|
+
module Parrot
|
|
8
|
+
class Runner
|
|
9
|
+
include Commands
|
|
10
|
+
|
|
11
|
+
attr_reader :command
|
|
12
|
+
|
|
13
|
+
def initialize(command, args = [], config)
|
|
14
|
+
@config = config
|
|
15
|
+
klass = to_command_class(command)
|
|
16
|
+
@command = klass.new(args, @config)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run_command
|
|
20
|
+
@command.run
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def to_command_class(command)
|
|
26
|
+
Commands.const_get("#{command.capitalize}Command")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Parrot
|
|
2
|
+
class TemplateHandler
|
|
3
|
+
HANDLERS = {
|
|
4
|
+
html: [:slim],
|
|
5
|
+
css: [:scss],
|
|
6
|
+
js: [:coffee]
|
|
7
|
+
}.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(options = {})
|
|
10
|
+
@root = options[:root]
|
|
11
|
+
@path = options[:path]
|
|
12
|
+
@handler = HANDLERS[options[:handle]]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def handler_engines(type)
|
|
16
|
+
HANDLERS[type]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/parrot.rb
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require 'optparse'
|
|
2
|
+
|
|
3
|
+
require_relative 'helpers'
|
|
4
|
+
require_relative 'parrot/metadata'
|
|
5
|
+
require_relative 'parrot/runner'
|
|
6
|
+
require_relative 'parrot/logger'
|
|
7
|
+
require_relative 'parrot/constants'
|
|
8
|
+
|
|
9
|
+
module Parrot
|
|
10
|
+
Config = Struct.new(:root_dir, :logger, :build_mode)
|
|
11
|
+
|
|
12
|
+
SubcommandEntry = Struct.new(:usage, :docstr)
|
|
13
|
+
|
|
14
|
+
SUB_COMMANDS_DOC = {
|
|
15
|
+
new: SubcommandEntry.new('new <blog_name>', 'Create new blog'),
|
|
16
|
+
build: SubcommandEntry.new('build', 'Build the blog'),
|
|
17
|
+
serve: SubcommandEntry.new('serve', 'Start development server locally'),
|
|
18
|
+
post: SubcommandEntry.new('post --title <post title>', 'Add new post with a title')
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
USAGE_LINE = 'parrot [options] [subcommand] [args]'.freeze
|
|
22
|
+
|
|
23
|
+
HELP_TEXT =
|
|
24
|
+
<<~HELP_TEXT.freeze
|
|
25
|
+
Examples:
|
|
26
|
+
- Create new blog
|
|
27
|
+
parrot new blog
|
|
28
|
+
#{' '}
|
|
29
|
+
- Start development server
|
|
30
|
+
cd blog
|
|
31
|
+
parrot serve
|
|
32
|
+
|
|
33
|
+
- Add a new post
|
|
34
|
+
parrot post --title "My first post"
|
|
35
|
+
|
|
36
|
+
- Build the blog
|
|
37
|
+
parrot build
|
|
38
|
+
HELP_TEXT
|
|
39
|
+
|
|
40
|
+
HELP_HEADER =
|
|
41
|
+
<<~HEADER_TEXT.freeze
|
|
42
|
+
Version: #{Parrot::VERSION}
|
|
43
|
+
Usage: #{USAGE_LINE}
|
|
44
|
+
Repository: #{Parrot::HOMEPAGE}
|
|
45
|
+
Repository: #{Parrot::HOMEPAGE}/blob/master/README.md
|
|
46
|
+
HEADER_TEXT
|
|
47
|
+
|
|
48
|
+
class Parrot
|
|
49
|
+
SUB_COMMANDS = SUB_COMMANDS_DOC.keys.map(&:to_s).freeze
|
|
50
|
+
|
|
51
|
+
attr_accessor :root_dir, :logger, :config
|
|
52
|
+
|
|
53
|
+
def initialize(args = [])
|
|
54
|
+
@options = { quiet: Helpers.testing? || false }
|
|
55
|
+
extract_options!(args)
|
|
56
|
+
@command = args.shift
|
|
57
|
+
@args = args
|
|
58
|
+
@logger = ParrotLoggerBuilder.new(@options[:quiet]).logger
|
|
59
|
+
@root_dir = Dir.pwd
|
|
60
|
+
@config = Config.new(@root_dir, @logger)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def run
|
|
64
|
+
return if @command.nil?
|
|
65
|
+
|
|
66
|
+
exit_if_invalid(@command)
|
|
67
|
+
Runner.new(@command, @args, config).run_command
|
|
68
|
+
rescue ArgumentError => e
|
|
69
|
+
# A command was called with missing or malformed arguments; show how to
|
|
70
|
+
# call it instead of dumping a backtrace.
|
|
71
|
+
warn(e.message)
|
|
72
|
+
exit(1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def exit_if_invalid(command)
|
|
76
|
+
return if SUB_COMMANDS.include?(command)
|
|
77
|
+
|
|
78
|
+
puts('That is not a valid command. View detailed help with parrot -h')
|
|
79
|
+
puts USAGE_LINE
|
|
80
|
+
exit!
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def quiet?
|
|
84
|
+
@options[:quiet]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def usage(parser = nil)
|
|
88
|
+
max_length = SUB_COMMANDS_DOC.map { |_k, v| v.usage.length }.max + 10
|
|
89
|
+
sub_command_doc = SUB_COMMANDS_DOC.map do |_command, entry|
|
|
90
|
+
entry_text = entry.usage.to_s.ljust(max_length)
|
|
91
|
+
"#{entry_text}#{entry.docstr}"
|
|
92
|
+
end.join("\n")
|
|
93
|
+
|
|
94
|
+
sub_command_doc = "Sub Commands:\n#{sub_command_doc}\n"
|
|
95
|
+
line_sep = "#{'-' * 80}\n"
|
|
96
|
+
[
|
|
97
|
+
HELP_HEADER,
|
|
98
|
+
parser,
|
|
99
|
+
sub_command_doc,
|
|
100
|
+
HELP_TEXT
|
|
101
|
+
].join(line_sep)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def extract_options!(args)
|
|
107
|
+
OptionParser.new("Usage: #{USAGE_LINE}") do |parser|
|
|
108
|
+
parser.on('-q', '--quiet', 'Quiet mode') { @options[:quiet] = true }
|
|
109
|
+
parser.on_tail('-v', '--version', 'Prints version information') do
|
|
110
|
+
puts("Parrot: #{VERSION}")
|
|
111
|
+
end
|
|
112
|
+
parser.on_tail('-h', '--help', 'Prints usage instruction') do
|
|
113
|
+
puts usage(parser)
|
|
114
|
+
end
|
|
115
|
+
end.order!(args)
|
|
116
|
+
# Stop at the first non-option (the sub-command) so flags that belong to
|
|
117
|
+
# the sub-command, e.g. `parrot post --title "..."`, are left untouched.
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
data/parrot.gemspec
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative 'lib/parrot/metadata'
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = 'prt'
|
|
5
|
+
spec.version = Parrot::VERSION
|
|
6
|
+
spec.authors = ['Deepak Kumar']
|
|
7
|
+
spec.email = ['deepakkumarnd@gmail.com']
|
|
8
|
+
spec.summary = 'A static markdown blog builder written in ruby for minimalist bloggers'
|
|
9
|
+
spec.description = 'A static blogging tool written in ruby, posts are in markdown format'
|
|
10
|
+
spec.homepage = Parrot::HOMEPAGE
|
|
11
|
+
spec.license = 'MIT'
|
|
12
|
+
|
|
13
|
+
# Runtime Dependencies
|
|
14
|
+
spec.add_dependency 'kramdown'
|
|
15
|
+
spec.add_dependency 'kramdown-parser-gfm'
|
|
16
|
+
spec.add_dependency 'logger'
|
|
17
|
+
spec.add_dependency 'nokogiri'
|
|
18
|
+
spec.add_dependency 'observer'
|
|
19
|
+
spec.add_dependency 'rouge'
|
|
20
|
+
spec.add_dependency 'sassc'
|
|
21
|
+
spec.add_dependency 'tilt'
|
|
22
|
+
spec.add_dependency 'watchr'
|
|
23
|
+
spec.add_dependency 'webrick'
|
|
24
|
+
|
|
25
|
+
# File Management
|
|
26
|
+
spec.bindir = 'exe'
|
|
27
|
+
spec.executables = 'parrot'
|
|
28
|
+
spec.files = `git ls-files`.split("\n")
|
|
29
|
+
spec.require_paths = ['lib']
|
|
30
|
+
spec.required_ruby_version = '>= 3.2'
|
|
31
|
+
|
|
32
|
+
spec.post_install_message = <<~MESSAGE
|
|
33
|
+
Refer to readme at README.md to know how to get started with Parrot
|
|
34
|
+
MESSAGE
|
|
35
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
36
|
+
end
|
data/skel/.gitignore
ADDED
data/skel/config.yaml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Settings for the post listing Parrot generates on the index page from
|
|
2
|
+
# everything in views/posts/*.md. There is no views/index.md to hand-edit —
|
|
3
|
+
# the listing is always built from the posts themselves, newest first.
|
|
4
|
+
#
|
|
5
|
+
# Posts with a `category: Name` header also get a listing page per category
|
|
6
|
+
# (category-name.html, then category-name_2.html, … once it outgrows
|
|
7
|
+
# per_page), all linked from categories.html. Those pages use the same
|
|
8
|
+
# group_by, per_page, pager text and list_format as the index.
|
|
9
|
+
post_listing:
|
|
10
|
+
# The index page's <h1>. Set to "" to omit the heading entirely.
|
|
11
|
+
list_title: "Post listing"
|
|
12
|
+
|
|
13
|
+
# How to group posts in the listing.
|
|
14
|
+
# none - a single flat list
|
|
15
|
+
# year - a "## <year>" heading per year
|
|
16
|
+
# month - a "## <Month Year>" heading per month
|
|
17
|
+
group_by: none
|
|
18
|
+
|
|
19
|
+
# Text for the link back to the index shown at the top of every post. It
|
|
20
|
+
# always points at whichever index page that post actually appears on.
|
|
21
|
+
# Set to "" to omit the link entirely.
|
|
22
|
+
back_link_text: "← Back to all posts"
|
|
23
|
+
|
|
24
|
+
# Maximum number of posts on one index page. Once there are more posts
|
|
25
|
+
# than this, Parrot splits the listing across index.html (newest posts),
|
|
26
|
+
# index2.html, index3.html, etc., each with a pager linking to the next
|
|
27
|
+
# and previous page. Leave unset (or 0) to keep every post on index.html.
|
|
28
|
+
# per_page: 10
|
|
29
|
+
|
|
30
|
+
# Text for the pager links at the bottom of a paginated index page. Set
|
|
31
|
+
# either to "" to omit that link (e.g. on the first/last page, where it
|
|
32
|
+
# wouldn't point anywhere anyway).
|
|
33
|
+
newer_link_text: "← Newer posts"
|
|
34
|
+
older_link_text: "Older posts →"
|
|
35
|
+
|
|
36
|
+
# Markdown format string for each post's list entry.
|
|
37
|
+
# {post_<key>} - that key from the post's header, as plain text:
|
|
38
|
+
# {post_title}, {post_lang}, or any custom field you add
|
|
39
|
+
# to a post's <!-- key: value --> header
|
|
40
|
+
# {post_date} - the header's `date`, formatted per post_date_format.on_list
|
|
41
|
+
# below rather than shown as-authored
|
|
42
|
+
# {post_category_tag} - the post's `category` as a link to its category
|
|
43
|
+
# page (class "category-tag"); empty for posts without one
|
|
44
|
+
# {post_link} - the post's href, the one field Parrot computes itself
|
|
45
|
+
# rather than reading from the header — wrap whichever
|
|
46
|
+
# part should be clickable in Markdown link syntax
|
|
47
|
+
# yourself: [...]({post_link})
|
|
48
|
+
# {...} - anything else is a Ruby strftime format string (see
|
|
49
|
+
# Ruby's Date#strftime) applied to the post's header
|
|
50
|
+
# `date`, shown as-is rather than through
|
|
51
|
+
# post_date_format.on_list, e.g. {%A, %B %d %Y}
|
|
52
|
+
#
|
|
53
|
+
# Link just the title and show the category (default):
|
|
54
|
+
# list_format: "{post_date} ~ [{post_title}]({post_link}) {post_category_tag}"
|
|
55
|
+
# Link the whole line instead:
|
|
56
|
+
# list_format: "[{post_date} ~ {post_title}]({post_link})"
|
|
57
|
+
list_format: "{post_date} ~ [{post_title}]({post_link}) {post_category_tag}"
|
|
58
|
+
|
|
59
|
+
# How to format a post's header `date` wherever it's shown via the
|
|
60
|
+
# {post_date} token — as opposed to a bare strftime pattern like {%d/%m/%Y},
|
|
61
|
+
# which is already a fixed format. A Ruby strftime format string (see
|
|
62
|
+
# Ruby's Date#strftime), same as list_format's bare {...} patterns.
|
|
63
|
+
post_date_format:
|
|
64
|
+
# Used for {post_date} inside post_listing's list_format above.
|
|
65
|
+
on_list: "%m/%Y"
|
|
66
|
+
# Used for a literal {post_date} placeholder inside a post's own Markdown
|
|
67
|
+
# body — `parrot post` writes new posts with one under the title.
|
|
68
|
+
on_post: "%d/%m/%Y"
|