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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/bindery"
5
+
6
+ Bindery::CLI.start(ARGV)
@@ -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
@@ -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,5 @@
1
+ module Bindery
2
+ class Error < StandardError; end
3
+ class ProjectNotFoundError < Error; end
4
+ class BookNotFoundError < Error; end
5
+ 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,6 @@
1
+ # 第一章
2
+
3
+ 《<%= title %>》正文从这里开始……
4
+
5
+ 在 chapters/ 目录下按顺序添加更多章节文件(如 02-xxx.md, 03-xxx.md),
6
+ 文件名的字典序决定了它们在成书中的先后顺序。
@@ -0,0 +1,9 @@
1
+ id: <%= id.inspect %>
2
+ title: <%= title.inspect %>
3
+ author: <%= author.inspect %>
4
+ lang: zh-CN
5
+ dynasty: <%= dynasty.inspect %>
6
+ category: <%= category.inspect %>
7
+ rights: 公共领域
8
+ publisher: 再读经典
9
+ cover: cover.jpg
@@ -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,3 @@
1
+ /output/
2
+ books.json
3
+ .DS_Store
@@ -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
@@ -0,0 +1,3 @@
1
+ module Bindery
2
+ VERSION = "0.1.1"
3
+ 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: []