bindery-cli 0.1.1
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/LICENSE +21 -0
- data/README.md +76 -0
- data/exe/bindery +6 -0
- data/lib/bindery/book.rb +78 -0
- data/lib/bindery/builder.rb +100 -0
- data/lib/bindery/cli.rb +167 -0
- data/lib/bindery/errors.rb +5 -0
- data/lib/bindery/generators/base.rb +63 -0
- data/lib/bindery/generators/book_generator.rb +29 -0
- data/lib/bindery/generators/project_generator.rb +36 -0
- data/lib/bindery/generators/templates/book/chapters/01-chapter.md.erb +6 -0
- data/lib/bindery/generators/templates/book/metadata.yaml.erb +9 -0
- data/lib/bindery/generators/templates/project/README.md.erb +46 -0
- data/lib/bindery/generators/templates/project/config/bindery.yml.erb +14 -0
- data/lib/bindery/generators/templates/project/gitignore +3 -0
- data/lib/bindery/generators/templates/project/style.css +30 -0
- data/lib/bindery/generators/templates/project/workflow.yml +44 -0
- data/lib/bindery/index.rb +31 -0
- data/lib/bindery/project.rb +44 -0
- data/lib/bindery/version.rb +3 -0
- data/lib/bindery.rb +9 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: af953a6d3a3f8b05cff050e4a9e7a6ced73a8bfc59a6c9269793b5f53e60cbde
|
|
4
|
+
data.tar.gz: 877069de98701d6a504b010f3268cb1f2a1ce471fc4e5b56d3ea058ec769e35d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 95295349a768fd7abb2c8c2a557391ae789b581265a07d299b9dfb617bcad57f6a902956fb53dc87dd7a8bc1bf480a390d4f28a08f0f07f4598cfb28d0f06cfc
|
|
7
|
+
data.tar.gz: c05bfa81b2fa39164c2d0d21497b1cbebb1ae090fb95f6b87590796cd58a6542b9e69531849c62a2ac7989486082c3aaf201b0b17d93f63caf4c18d328079441
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Billow Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# bindery
|
|
2
|
+
|
|
3
|
+
把 Markdown 公版书批量构建为 EPUB 的项目脚手架工具。
|
|
4
|
+
|
|
5
|
+
- `bindery new <项目名>` 创建整套项目结构
|
|
6
|
+
- `bindery generate book <id> <书名>` 为单本书生成骨架
|
|
7
|
+
- `bindery build` 调用 [Pandoc](https://pandoc.org) 批量构建 EPUB
|
|
8
|
+
|
|
9
|
+
仅依赖 Ruby 标准库,不引入任何第三方 gem。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install bindery-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
系统需要另外安装 Pandoc:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Debian/Ubuntu
|
|
21
|
+
apt-get install -y pandoc
|
|
22
|
+
|
|
23
|
+
# macOS
|
|
24
|
+
brew install pandoc
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 快速开始
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 1. 创建项目
|
|
31
|
+
bindery new my-books
|
|
32
|
+
cd my-books
|
|
33
|
+
|
|
34
|
+
# 2. 新建一本书
|
|
35
|
+
bindery generate book lunyu "论语" --author "孔子及弟子" --dynasty "先秦"
|
|
36
|
+
|
|
37
|
+
# 3. 编辑 books/lunyu/chapters/ 下的章节文件
|
|
38
|
+
|
|
39
|
+
# 4. 构建 EPUB
|
|
40
|
+
bindery build lunyu
|
|
41
|
+
|
|
42
|
+
# 批量构建所有书
|
|
43
|
+
bindery build --all
|
|
44
|
+
|
|
45
|
+
# 查看书籍状态
|
|
46
|
+
bindery status
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 项目结构
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
my-books/
|
|
53
|
+
├── config/bindery.yml # 项目标记文件,不要删除
|
|
54
|
+
├── books/ # 每本书一个子目录
|
|
55
|
+
│ └── <id>/
|
|
56
|
+
│ ├── metadata.yaml # 书籍元信息
|
|
57
|
+
│ ├── cover.jpg # 封面(可选)
|
|
58
|
+
│ └── chapters/ # Markdown 章节,按文件名排序合并
|
|
59
|
+
├── templates/style.css # 全项目共用的 EPUB 样式
|
|
60
|
+
├── output/epub/ # 构建产物
|
|
61
|
+
└── books.json # 自动生成的书目索引
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 开发
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# 运行本地版本
|
|
68
|
+
ruby -Ilib exe/bindery --help
|
|
69
|
+
|
|
70
|
+
# 构建 gem
|
|
71
|
+
gem build bindery.gemspec
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
data/exe/bindery
ADDED
data/lib/bindery/book.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "pathname"
|
|
3
|
+
|
|
4
|
+
module Bindery
|
|
5
|
+
# 代表一本书:对应 books/<id>/ 目录
|
|
6
|
+
class Book
|
|
7
|
+
attr_reader :id, :dir
|
|
8
|
+
|
|
9
|
+
def initialize(dir)
|
|
10
|
+
@dir = Pathname.new(dir)
|
|
11
|
+
@id = @dir.basename.to_s
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.all(project_root = Project.root!)
|
|
15
|
+
books_dir = Project.books_dir(project_root)
|
|
16
|
+
return [] unless books_dir.directory?
|
|
17
|
+
|
|
18
|
+
books_dir.children.select(&:directory?).sort.map { |d| new(d) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.find(id, project_root = Project.root!)
|
|
22
|
+
dir = Project.books_dir(project_root) + id
|
|
23
|
+
raise Bindery::BookNotFoundError, "找不到书籍: #{id}(#{dir} 不存在)" unless dir.directory?
|
|
24
|
+
|
|
25
|
+
new(dir)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def metadata_file
|
|
29
|
+
dir + "metadata.yaml"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def metadata
|
|
33
|
+
raise Bindery::Error, "缺少 metadata.yaml: #{metadata_file}" unless metadata_file.file?
|
|
34
|
+
|
|
35
|
+
@metadata ||= YAML.safe_load(metadata_file.read, permitted_classes: [Symbol]) || {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def title
|
|
39
|
+
metadata["title"] || id
|
|
40
|
+
rescue Bindery::Error
|
|
41
|
+
id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def author
|
|
45
|
+
metadata["author"] || "佚名"
|
|
46
|
+
rescue Bindery::Error
|
|
47
|
+
"佚名"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cover_path
|
|
51
|
+
name = metadata["cover"] || "cover.jpg"
|
|
52
|
+
path = dir + name
|
|
53
|
+
path.file? ? path : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def chapters_dir
|
|
57
|
+
dir + "chapters"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def chapters
|
|
61
|
+
return [] unless chapters_dir.directory?
|
|
62
|
+
|
|
63
|
+
chapters_dir.children.select { |f| f.file? && f.extname == ".md" }.sort
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def valid?
|
|
67
|
+
metadata_file.file? && chapters.any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def epub_output(project_root = Project.root!)
|
|
71
|
+
Project.output_dir(project_root) + "epub" + "#{id}.epub"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def built?(project_root = Project.root!)
|
|
75
|
+
epub_output(project_root).file?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Bindery
|
|
8
|
+
# 负责把一本书的多个章节 md 文件合并,并调用 Pandoc 生成 EPUB
|
|
9
|
+
class Builder
|
|
10
|
+
class BuildError < Bindery::Error; end
|
|
11
|
+
|
|
12
|
+
def initialize(book, project_root: Project.root!)
|
|
13
|
+
@book = book
|
|
14
|
+
@project_root = project_root
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 返回 true/false 表示是否成功;详细错误信息可通过 #last_error 获取
|
|
18
|
+
def build_epub
|
|
19
|
+
chapters = @book.chapters
|
|
20
|
+
if chapters.empty?
|
|
21
|
+
@last_error = "#{@book.id} 的 chapters/ 目录下没有任何 .md 文件"
|
|
22
|
+
return false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
meta = @book.metadata
|
|
26
|
+
ensure_pandoc!
|
|
27
|
+
|
|
28
|
+
Dir.mktmpdir("bindery-#{@book.id}-") do |tmp|
|
|
29
|
+
merged = merge_chapters(chapters, tmp)
|
|
30
|
+
meta_file = write_pandoc_metadata(tmp, meta)
|
|
31
|
+
|
|
32
|
+
out_dir = Project.output_dir(@project_root) + "epub"
|
|
33
|
+
FileUtils.mkdir_p(out_dir)
|
|
34
|
+
out_path = out_dir + "#{@book.id}.epub"
|
|
35
|
+
|
|
36
|
+
cmd = ["pandoc", merged.to_s, "-o", out_path.to_s,
|
|
37
|
+
"--metadata-file", meta_file.to_s, "--toc"]
|
|
38
|
+
|
|
39
|
+
css = Project.templates_dir(@project_root) + "style.css"
|
|
40
|
+
cmd += ["--css", css.to_s] if css.file?
|
|
41
|
+
|
|
42
|
+
cover = @book.cover_path
|
|
43
|
+
cmd += ["--epub-cover-image", cover.to_s] if cover
|
|
44
|
+
|
|
45
|
+
run(cmd)
|
|
46
|
+
end
|
|
47
|
+
rescue Bindery::Error => e
|
|
48
|
+
@last_error = e.message
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def last_error
|
|
53
|
+
@last_error
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def ensure_pandoc!
|
|
59
|
+
return if system("pandoc", "--version", out: File::NULL, err: File::NULL)
|
|
60
|
+
|
|
61
|
+
raise BuildError, "找不到 pandoc 命令,请先安装:apt-get install -y pandoc(或参考 https://pandoc.org/installing.html)"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def merge_chapters(chapters, tmp_dir)
|
|
65
|
+
path = Pathname.new(tmp_dir) + "merged.md"
|
|
66
|
+
path.open("w") do |out|
|
|
67
|
+
chapters.each_with_index do |chapter, i|
|
|
68
|
+
out.puts if i.positive?
|
|
69
|
+
out.puts chapter.read
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
path
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_pandoc_metadata(tmp_dir, meta)
|
|
76
|
+
pandoc_meta = {
|
|
77
|
+
"title" => meta["title"] || @book.id,
|
|
78
|
+
"author" => meta["author"] || "佚名",
|
|
79
|
+
"lang" => meta["lang"] || "zh-CN",
|
|
80
|
+
"rights" => meta["rights"] || "公共领域",
|
|
81
|
+
"publisher" => meta["publisher"] || "再读经典",
|
|
82
|
+
}
|
|
83
|
+
pandoc_meta["date"] = meta["dynasty"] if meta["dynasty"] && !meta["dynasty"].to_s.empty?
|
|
84
|
+
|
|
85
|
+
path = Pathname.new(tmp_dir) + "metadata.yaml"
|
|
86
|
+
path.write(pandoc_meta.to_yaml)
|
|
87
|
+
path
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run(cmd)
|
|
91
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
92
|
+
if status.success?
|
|
93
|
+
true
|
|
94
|
+
else
|
|
95
|
+
@last_error = stderr.empty? ? stdout : stderr
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/bindery/cli.rb
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Bindery
|
|
4
|
+
# 命令行入口:解析子命令并分发
|
|
5
|
+
# 支持: new / generate(g) / build / status / version / help
|
|
6
|
+
class CLI
|
|
7
|
+
def self.start(argv)
|
|
8
|
+
new.run(argv)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run(argv)
|
|
12
|
+
command, *rest = argv
|
|
13
|
+
case command
|
|
14
|
+
when "new"
|
|
15
|
+
cmd_new(rest)
|
|
16
|
+
when "generate", "g"
|
|
17
|
+
cmd_generate(rest)
|
|
18
|
+
when "build"
|
|
19
|
+
cmd_build(rest)
|
|
20
|
+
when "status"
|
|
21
|
+
cmd_status(rest)
|
|
22
|
+
when "version", "-v", "--version"
|
|
23
|
+
puts "bindery #{Bindery::VERSION}"
|
|
24
|
+
when nil, "help", "-h", "--help"
|
|
25
|
+
print_help
|
|
26
|
+
else
|
|
27
|
+
warn "未知命令: #{command}"
|
|
28
|
+
print_help
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
rescue OptionParser::ParseError => e
|
|
32
|
+
warn "参数错误: #{e.message}"
|
|
33
|
+
exit 1
|
|
34
|
+
rescue Bindery::Error => e
|
|
35
|
+
warn "错误: #{e.message}"
|
|
36
|
+
exit 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# ---------- bindery new <项目名> ----------
|
|
42
|
+
def cmd_new(args)
|
|
43
|
+
name = args.first
|
|
44
|
+
if name.nil? || name.empty?
|
|
45
|
+
warn "用法: bindery new <项目名>"
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
Generators::ProjectGenerator.call(name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ---------- bindery generate book <id> <title> [选项] ----------
|
|
52
|
+
def cmd_generate(args)
|
|
53
|
+
type, *rest = args
|
|
54
|
+
case type
|
|
55
|
+
when "book"
|
|
56
|
+
cmd_generate_book(rest)
|
|
57
|
+
when nil
|
|
58
|
+
warn "用法: bindery generate book <id> <书名> [--author NAME] [--dynasty 朝代] [--category 分类]"
|
|
59
|
+
exit 1
|
|
60
|
+
else
|
|
61
|
+
warn "未知的生成器类型: #{type}(目前只支持 book)"
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cmd_generate_book(args)
|
|
67
|
+
options = { author: "佚名", dynasty: "", category: "" }
|
|
68
|
+
parser = OptionParser.new do |o|
|
|
69
|
+
o.on("--author NAME") { |v| options[:author] = v }
|
|
70
|
+
o.on("--dynasty NAME") { |v| options[:dynasty] = v }
|
|
71
|
+
o.on("--category NAME") { |v| options[:category] = v }
|
|
72
|
+
end
|
|
73
|
+
positional = parser.parse(args)
|
|
74
|
+
id, title = positional
|
|
75
|
+
if id.nil? || title.nil?
|
|
76
|
+
warn "用法: bindery generate book <id> <书名> [--author NAME] [--dynasty 朝代] [--category 分类]"
|
|
77
|
+
exit 1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Generators::BookGenerator.call(
|
|
81
|
+
id, title,
|
|
82
|
+
author: options[:author], dynasty: options[:dynasty], category: options[:category]
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ---------- bindery build [<id>] [--all] ----------
|
|
87
|
+
def cmd_build(args)
|
|
88
|
+
options = { all: false }
|
|
89
|
+
parser = OptionParser.new do |o|
|
|
90
|
+
o.on("--all") { options[:all] = true }
|
|
91
|
+
end
|
|
92
|
+
positional = parser.parse(args)
|
|
93
|
+
|
|
94
|
+
project_root = Project.root!
|
|
95
|
+
books = options[:all] ? Bindery::Book.all(project_root) : [book_from_arg(positional.first, project_root)]
|
|
96
|
+
|
|
97
|
+
if books.empty?
|
|
98
|
+
warn "books/ 目录下没有任何书籍"
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
success = 0
|
|
103
|
+
failure = 0
|
|
104
|
+
books.each do |book|
|
|
105
|
+
builder = Builder.new(book, project_root: project_root)
|
|
106
|
+
if builder.build_epub
|
|
107
|
+
puts "✅ [#{book.id}] epub 构建成功"
|
|
108
|
+
success += 1
|
|
109
|
+
else
|
|
110
|
+
puts "❌ [#{book.id}] epub 构建失败"
|
|
111
|
+
puts " #{builder.last_error}"
|
|
112
|
+
failure += 1
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Index.rebuild(project_root)
|
|
117
|
+
puts "" if options[:all]
|
|
118
|
+
puts "完成:成功 #{success} 本,失败 #{failure} 本" if options[:all]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def book_from_arg(id, project_root)
|
|
122
|
+
if id.nil?
|
|
123
|
+
warn "请指定书籍 id,或使用 --all 构建全部"
|
|
124
|
+
exit 1
|
|
125
|
+
end
|
|
126
|
+
Bindery::Book.find(id, project_root)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# ---------- bindery status ----------
|
|
130
|
+
def cmd_status(_args)
|
|
131
|
+
project_root = Project.root!
|
|
132
|
+
books = Bindery::Book.all(project_root)
|
|
133
|
+
if books.empty?
|
|
134
|
+
puts "books/ 目录下没有任何书籍"
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
printf("%-16s %-16s %-8s %-6s\n", "书籍ID", "标题", "章节数", "EPUB")
|
|
139
|
+
puts "-" * 50
|
|
140
|
+
books.each do |book|
|
|
141
|
+
printf(
|
|
142
|
+
"%-16s %-16s %-8d %-6s\n",
|
|
143
|
+
book.id, book.title, book.chapters.size,
|
|
144
|
+
book.built?(project_root) ? "✅" : "—"
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
puts "-" * 50
|
|
148
|
+
puts "共 #{books.size} 本书"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def print_help
|
|
152
|
+
puts <<~HELP
|
|
153
|
+
bindery #{Bindery::VERSION} — 再读经典项目脚手架工具
|
|
154
|
+
|
|
155
|
+
用法:
|
|
156
|
+
bindery new <项目名> 创建新项目
|
|
157
|
+
bindery generate book <id> <书名> [选项] 为项目生成一本新书
|
|
158
|
+
简写: bindery g book <id> <书名>
|
|
159
|
+
选项: --author NAME --dynasty 朝代 --category 分类
|
|
160
|
+
bindery build <id> 构建单本书的 epub
|
|
161
|
+
bindery build --all 批量构建所有书
|
|
162
|
+
bindery status 查看所有书籍状态
|
|
163
|
+
bindery version 查看版本号
|
|
164
|
+
HELP
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Bindery
|
|
6
|
+
module Generators
|
|
7
|
+
# 所有生成器的基类:提供"渲染ERB模板 -> 写入目标文件"的公共能力
|
|
8
|
+
# 风格类似 Rails::Generators::Base,但不依赖 Thor / Rails,纯标准库实现
|
|
9
|
+
class Base
|
|
10
|
+
TEMPLATES_ROOT = Pathname.new(__dir__) + "templates"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_reader :log_lines
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(destination)
|
|
17
|
+
@destination = Pathname.new(destination)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# 把 templates/<template_dir>/foo.erb 渲染后写到 destination 下的 foo
|
|
23
|
+
def template(template_dir, relative_path, locals: {})
|
|
24
|
+
src = TEMPLATES_ROOT + template_dir + "#{relative_path}.erb"
|
|
25
|
+
dest = @destination + relative_path
|
|
26
|
+
render_and_write(src, dest, locals)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 直接复制一个非模板文件(比如 .gitignore、style.css)
|
|
30
|
+
def copy_file(template_dir, relative_path, dest_relative_path = relative_path)
|
|
31
|
+
src = TEMPLATES_ROOT + template_dir + relative_path
|
|
32
|
+
dest = @destination + dest_relative_path
|
|
33
|
+
FileUtils.mkdir_p(dest.dirname)
|
|
34
|
+
FileUtils.cp(src, dest)
|
|
35
|
+
say "create", dest
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def empty_directory(relative_path)
|
|
39
|
+
dest = @destination + relative_path
|
|
40
|
+
FileUtils.mkdir_p(dest)
|
|
41
|
+
say "create", "#{dest}/"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_and_write(src, dest, locals)
|
|
45
|
+
FileUtils.mkdir_p(dest.dirname)
|
|
46
|
+
content = ERB.new(src.read, trim_mode: "-").result(binding_for(locals))
|
|
47
|
+
dest.write(content)
|
|
48
|
+
say "create", dest
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def binding_for(locals)
|
|
52
|
+
b = binding
|
|
53
|
+
locals.each { |k, v| b.local_variable_set(k, v) }
|
|
54
|
+
b
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def say(action, path)
|
|
58
|
+
rel = Pathname.new(path).relative_path_from(Pathname.pwd) rescue path
|
|
59
|
+
puts " #{action.rjust(8)} #{rel}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module Bindery
|
|
4
|
+
module Generators
|
|
5
|
+
# bindery generate book <id> <title> [--author] [--dynasty] [--category]
|
|
6
|
+
class BookGenerator < Base
|
|
7
|
+
def self.call(id, title, author:, dynasty:, category:, project_root: Project.root!)
|
|
8
|
+
dest = Project.books_dir(project_root) + id
|
|
9
|
+
raise Bindery::Error, "书籍目录已存在: #{dest}" if dest.exist?
|
|
10
|
+
|
|
11
|
+
puts "创建书籍: #{id}"
|
|
12
|
+
new(dest).generate(id: id, title: title, author: author, dynasty: dynasty, category: category)
|
|
13
|
+
Bindery::Index.rebuild(project_root)
|
|
14
|
+
puts ""
|
|
15
|
+
puts "完成!接下来编辑 #{dest}/chapters/ 下的章节文件,然后:"
|
|
16
|
+
puts " bindery build #{id}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def generate(id:, title:, author:, dynasty:, category:)
|
|
20
|
+
empty_directory("chapters")
|
|
21
|
+
|
|
22
|
+
template("book", "metadata.yaml", locals: {
|
|
23
|
+
id: id, title: title, author: author, dynasty: dynasty, category: category,
|
|
24
|
+
})
|
|
25
|
+
template("book", "chapters/01-chapter.md", locals: { title: title })
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module Bindery
|
|
4
|
+
module Generators
|
|
5
|
+
# bindery new <项目名>
|
|
6
|
+
# 生成一整套项目骨架:books/、templates/(样式+字体说明)、output/、
|
|
7
|
+
# config/bindery.yml(项目标记文件)、README、.gitignore、GitHub Actions
|
|
8
|
+
class ProjectGenerator < Base
|
|
9
|
+
def self.call(project_name)
|
|
10
|
+
dest = Pathname.pwd + project_name
|
|
11
|
+
if dest.exist?
|
|
12
|
+
raise Bindery::Error, "目录已存在: #{dest}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
puts "创建项目: #{project_name}"
|
|
16
|
+
new(dest).generate(project_name)
|
|
17
|
+
puts ""
|
|
18
|
+
puts "完成!接下来:"
|
|
19
|
+
puts " cd #{project_name}"
|
|
20
|
+
puts " bindery generate book lunyu \"论语\" --author \"孔子及弟子\""
|
|
21
|
+
puts " bindery build lunyu"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def generate(project_name)
|
|
25
|
+
empty_directory("books")
|
|
26
|
+
empty_directory("output/epub")
|
|
27
|
+
|
|
28
|
+
template("project", "config/bindery.yml", locals: { project_name: project_name })
|
|
29
|
+
template("project", "README.md", locals: { project_name: project_name })
|
|
30
|
+
copy_file("project", "gitignore", ".gitignore")
|
|
31
|
+
copy_file("project", "style.css", "templates/style.css")
|
|
32
|
+
copy_file("project", "workflow.yml", ".github/workflows/build.yml")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# <%= project_name %>
|
|
2
|
+
|
|
3
|
+
用 bindery 管理的公版经典书籍项目。Markdown 源文件 -> EPUB。
|
|
4
|
+
|
|
5
|
+
## 常用命令
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 新建一本书
|
|
9
|
+
bindery generate book <书籍ID> "<书名>" --author "<作者>"
|
|
10
|
+
# 简写
|
|
11
|
+
bindery g book <书籍ID> "<书名>"
|
|
12
|
+
|
|
13
|
+
# 构建一本书
|
|
14
|
+
bindery build <书籍ID>
|
|
15
|
+
|
|
16
|
+
# 批量构建全部
|
|
17
|
+
bindery build --all
|
|
18
|
+
|
|
19
|
+
# 查看所有书籍状态
|
|
20
|
+
bindery status
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 目录结构
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
<%= project_name %>/
|
|
27
|
+
├── config/bindery.yml # 项目标记文件,不要删除
|
|
28
|
+
├── books/ # 每本书一个子目录
|
|
29
|
+
├── templates/ # 全项目共用的 EPUB 样式
|
|
30
|
+
├── output/epub/ # 构建产物
|
|
31
|
+
└── books.json # 自动生成的书目索引
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 环境依赖
|
|
35
|
+
|
|
36
|
+
只需要系统装有 `pandoc`:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Debian/Ubuntu
|
|
40
|
+
apt-get install -y pandoc
|
|
41
|
+
|
|
42
|
+
# macOS
|
|
43
|
+
brew install pandoc
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
bindery 本身不依赖任何第三方 gem,只用 Ruby 标准库。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# bindery 项目配置文件
|
|
2
|
+
# 这个文件的存在本身就是"项目根目录"的标记(类似 Rails 的 config/application.rb)
|
|
3
|
+
# 在这个目录树下的任意子目录执行 bindery 命令,都能自动定位到项目根
|
|
4
|
+
|
|
5
|
+
project_name: <%= project_name.inspect %>
|
|
6
|
+
|
|
7
|
+
# 默认语言
|
|
8
|
+
lang: zh-CN
|
|
9
|
+
|
|
10
|
+
# 默认出版方(写入每本书的 metadata)
|
|
11
|
+
publisher: 再读经典
|
|
12
|
+
|
|
13
|
+
# 默认版权声明
|
|
14
|
+
rights: 公共领域
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
body {
|
|
2
|
+
font-family: "Noto Serif CJK SC", "Songti SC", serif;
|
|
3
|
+
line-height: 1.8;
|
|
4
|
+
margin: 1em;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
h1 {
|
|
8
|
+
font-size: 1.6em;
|
|
9
|
+
text-align: center;
|
|
10
|
+
margin-top: 2em;
|
|
11
|
+
margin-bottom: 1em;
|
|
12
|
+
border-bottom: 1px solid #ccc;
|
|
13
|
+
padding-bottom: 0.3em;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
h2 {
|
|
17
|
+
font-size: 1.3em;
|
|
18
|
+
margin-top: 1.5em;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
p {
|
|
22
|
+
text-indent: 2em;
|
|
23
|
+
margin: 0.5em 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
blockquote {
|
|
27
|
+
margin: 1em 2em;
|
|
28
|
+
color: #444;
|
|
29
|
+
font-style: italic;
|
|
30
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: Build books
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
paths:
|
|
6
|
+
- "books/**/*.md"
|
|
7
|
+
- "books/**/metadata.yaml"
|
|
8
|
+
workflow_dispatch: {}
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Install pandoc
|
|
17
|
+
run: sudo apt-get update && sudo apt-get install -y pandoc
|
|
18
|
+
|
|
19
|
+
- name: Set up Ruby
|
|
20
|
+
uses: ruby/setup-ruby@v1
|
|
21
|
+
with:
|
|
22
|
+
ruby-version: "3.2"
|
|
23
|
+
|
|
24
|
+
- name: Install bindery
|
|
25
|
+
run: gem install bindery-cli
|
|
26
|
+
|
|
27
|
+
- name: Build all books
|
|
28
|
+
run: bindery build --all
|
|
29
|
+
|
|
30
|
+
- name: Upload build artifacts
|
|
31
|
+
uses: actions/upload-artifact@v4
|
|
32
|
+
with:
|
|
33
|
+
name: books-output
|
|
34
|
+
path: |
|
|
35
|
+
output/
|
|
36
|
+
books.json
|
|
37
|
+
|
|
38
|
+
- name: Commit updated books.json
|
|
39
|
+
run: |
|
|
40
|
+
git config user.name "github-actions[bot]"
|
|
41
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
42
|
+
git add books.json
|
|
43
|
+
git diff --cached --quiet || git commit -m "chore: update books.json [skip ci]"
|
|
44
|
+
git push
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Bindery
|
|
5
|
+
# 扫描所有书籍,重新生成 books.json(供后续静态网站读取)
|
|
6
|
+
module Index
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def rebuild(project_root = Project.root!)
|
|
10
|
+
books = Bindery::Book.all(project_root).select(&:valid?).map do |book|
|
|
11
|
+
{
|
|
12
|
+
"id" => book.id,
|
|
13
|
+
"title" => book.title,
|
|
14
|
+
"author" => book.author,
|
|
15
|
+
"dynasty" => book.metadata["dynasty"],
|
|
16
|
+
"category" => book.metadata["category"],
|
|
17
|
+
"epub" => book.built?(project_root) ? "output/epub/#{book.id}.epub" : nil,
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
data = {
|
|
22
|
+
"generated_at" => Time.now.iso8601,
|
|
23
|
+
"count" => books.size,
|
|
24
|
+
"books" => books,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Project.index_file(project_root).write(JSON.pretty_generate(data))
|
|
28
|
+
data
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module Bindery
|
|
4
|
+
# 负责在目录树中定位 bindery 项目根目录(含 config/bindery.yml 标记文件的目录)
|
|
5
|
+
# 用法和 Rails / Bundler 一样:在项目内任意子目录执行命令都能定位到项目根
|
|
6
|
+
module Project
|
|
7
|
+
MARKER = "config/bindery.yml"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def root(start_dir = Dir.pwd)
|
|
12
|
+
dir = Pathname.new(start_dir).expand_path
|
|
13
|
+
loop do
|
|
14
|
+
return dir if (dir + MARKER).file?
|
|
15
|
+
return nil if dir.root?
|
|
16
|
+
dir = dir.parent
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def root!(start_dir = Dir.pwd)
|
|
21
|
+
root(start_dir) || raise(
|
|
22
|
+
Bindery::ProjectNotFoundError,
|
|
23
|
+
"找不到 bindery 项目(未发现 #{MARKER})。请在项目目录内执行," \
|
|
24
|
+
"或先用 `bindery new <项目名>` 创建一个新项目。"
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def books_dir(project_root = root!)
|
|
29
|
+
project_root + "books"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def templates_dir(project_root = root!)
|
|
33
|
+
project_root + "templates"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def output_dir(project_root = root!)
|
|
37
|
+
project_root + "output"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def index_file(project_root = root!)
|
|
41
|
+
project_root + "books.json"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/bindery.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
require_relative "bindery/version"
|
|
2
|
+
require_relative "bindery/errors"
|
|
3
|
+
require_relative "bindery/project"
|
|
4
|
+
require_relative "bindery/book"
|
|
5
|
+
require_relative "bindery/builder"
|
|
6
|
+
require_relative "bindery/index"
|
|
7
|
+
require_relative "bindery/generators/project_generator"
|
|
8
|
+
require_relative "bindery/generators/book_generator"
|
|
9
|
+
require_relative "bindery/cli"
|
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bindery-cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Billow Wang
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
bindery 是手架工具:
|
|
14
|
+
`bindery new` 创建整套项目结构,
|
|
15
|
+
`bindery generate book` 为单本书生成骨架,
|
|
16
|
+
`bindery build` 调用 Pandoc 批量构建 EPUB。
|
|
17
|
+
仅依赖 Ruby 标准库,不引入任何第三方 gem。
|
|
18
|
+
executables:
|
|
19
|
+
- bindery
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- exe/bindery
|
|
26
|
+
- lib/bindery.rb
|
|
27
|
+
- lib/bindery/book.rb
|
|
28
|
+
- lib/bindery/builder.rb
|
|
29
|
+
- lib/bindery/cli.rb
|
|
30
|
+
- lib/bindery/errors.rb
|
|
31
|
+
- lib/bindery/generators/base.rb
|
|
32
|
+
- lib/bindery/generators/book_generator.rb
|
|
33
|
+
- lib/bindery/generators/project_generator.rb
|
|
34
|
+
- lib/bindery/generators/templates/book/chapters/01-chapter.md.erb
|
|
35
|
+
- lib/bindery/generators/templates/book/metadata.yaml.erb
|
|
36
|
+
- lib/bindery/generators/templates/project/README.md.erb
|
|
37
|
+
- lib/bindery/generators/templates/project/config/bindery.yml.erb
|
|
38
|
+
- lib/bindery/generators/templates/project/gitignore
|
|
39
|
+
- lib/bindery/generators/templates/project/style.css
|
|
40
|
+
- lib/bindery/generators/templates/project/workflow.yml
|
|
41
|
+
- lib/bindery/index.rb
|
|
42
|
+
- lib/bindery/project.rb
|
|
43
|
+
- lib/bindery/version.rb
|
|
44
|
+
homepage: https://github.com/gamepunk/bindery-gem
|
|
45
|
+
licenses:
|
|
46
|
+
- MIT
|
|
47
|
+
metadata:
|
|
48
|
+
source_code_uri: https://github.com/gamepunk/bindery-gem
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '3.0'
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 4.0.16
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: 把 Markdown 公版书批量构建为 EPUB 的项目脚手架工具
|
|
67
|
+
test_files: []
|