bindery-cli 0.1.1 → 0.2.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.
@@ -0,0 +1,135 @@
1
+ require "zlib"
2
+ require "digest"
3
+
4
+ module Bindery
5
+ # 纯 Ruby 生成装饰性封面 PNG:配色由书名稳定派生,不含文字(文字渲染需要字体光栅化,超出标准库能力)
6
+ module Cover
7
+ WIDTH = 600
8
+ HEIGHT = 800
9
+
10
+ module_function
11
+
12
+ # 为书籍生成 cover.png,并把 metadata.yaml 的 cover 字段指向它;返回文件路径
13
+ def generate(book)
14
+ png = encode_png(WIDTH, HEIGHT, build_pixels(WIDTH, HEIGHT, book.title))
15
+ path = book.dir + "cover.png"
16
+ path.binwrite(png)
17
+ update_metadata_cover(book)
18
+ path
19
+ end
20
+
21
+ def update_metadata_cover(book)
22
+ meta_file = book.metadata_file
23
+ return unless meta_file.file?
24
+
25
+ text = meta_file.read
26
+ text = if text =~ /^cover:.*$/
27
+ text.sub(/^cover:.*$/, "cover: cover.png")
28
+ else
29
+ text.chomp + "\ncover: cover.png\n"
30
+ end
31
+ meta_file.write(text)
32
+ end
33
+
34
+ # 逐像素生成 RGB 位图,返回行数组(每行是一串 RGB 字节)
35
+ def build_pixels(width, height, title)
36
+ hue = Digest::MD5.hexdigest(title).to_i(16) % 360
37
+ top = hsl_to_rgb(hue, 0.45, 0.92)
38
+ bottom = hsl_to_rgb(hue, 0.60, 0.28)
39
+ accent = hsl_to_rgb((hue + 40) % 360, 0.55, 0.50)
40
+ frame = hsl_to_rgb(hue, 0.50, 0.18)
41
+
42
+ margin = 44
43
+ thickness = 4
44
+
45
+ (0...height).map do |y|
46
+ t = y.to_f / (height - 1)
47
+ base = lerp(top, bottom, t)
48
+ row = (+"").b
49
+ (0...width).each do |x|
50
+ c =
51
+ if border?(x, y, width, height, margin, thickness)
52
+ frame
53
+ elsif ring?(x, y, width, height)
54
+ accent
55
+ else
56
+ base
57
+ end
58
+ row << c.pack("C3")
59
+ end
60
+ row
61
+ end
62
+ end
63
+
64
+ def border?(x, y, w, h, margin, thickness)
65
+ in_x = x >= margin && x < w - margin
66
+ in_y = y >= margin && y < h - margin
67
+ return false unless in_x && in_y
68
+
69
+ near_x = (x - margin).abs < thickness || (x - (w - margin - 1)).abs < thickness
70
+ near_y = (y - margin).abs < thickness || (y - (h - margin - 1)).abs < thickness
71
+ near_x || near_y
72
+ end
73
+
74
+ # 居中的方框纹样(外方框 + 内镂空)
75
+ def ring?(x, y, w, h)
76
+ cx = w / 2
77
+ cy = h / 2
78
+ outer = 220
79
+ inner = 150
80
+ dx = (x - cx).abs
81
+ dy = (y - cy).abs
82
+ in_outer = dx <= outer / 2 && dy <= outer / 2
83
+ in_inner = dx <= inner / 2 && dy <= inner / 2
84
+ in_outer && !in_inner
85
+ end
86
+
87
+ def lerp(c1, c2, t)
88
+ [
89
+ c1[0] + (c2[0] - c1[0]) * t,
90
+ c1[1] + (c2[1] - c1[1]) * t,
91
+ c1[2] + (c2[2] - c1[2]) * t,
92
+ ].map(&:round)
93
+ end
94
+
95
+ def hsl_to_rgb(h, s, l)
96
+ h = (h % 360) / 360.0
97
+ if s.zero?
98
+ r = g = b = l
99
+ else
100
+ q = l < 0.5 ? l * (1 + s) : l + s - l * s
101
+ p = 2 * l - q
102
+ r = hue_to_rgb(p, q, h + 1 / 3.0)
103
+ g = hue_to_rgb(p, q, h)
104
+ b = hue_to_rgb(p, q, h - 1 / 3.0)
105
+ end
106
+ [(r * 255).round, (g * 255).round, (b * 255).round]
107
+ end
108
+
109
+ def hue_to_rgb(p, q, t)
110
+ t += 1 if t.negative?
111
+ t -= 1 if t > 1
112
+ if t < 1 / 6.0
113
+ p + (q - p) * 6 * t
114
+ elsif t < 1 / 2.0
115
+ q
116
+ elsif t < 2 / 3.0
117
+ p + (q - p) * (2 / 3.0 - t) * 6
118
+ else
119
+ p
120
+ end
121
+ end
122
+
123
+ def encode_png(width, height, rows)
124
+ sig = "\x89PNG\r\n\x1a\n".b
125
+ ihdr = [width, height, 8, 2, 0, 0, 0].pack("N2C5")
126
+ raw = rows.map { |row| "\x00".b + row }.join
127
+ idat = Zlib::Deflate.deflate(raw, 9)
128
+ sig + chunk("IHDR", ihdr) + chunk("IDAT", idat) + chunk("IEND", "")
129
+ end
130
+
131
+ def chunk(type, data)
132
+ [data.bytesize].pack("N") + type + data + [Zlib.crc32(type + data)].pack("N")
133
+ end
134
+ end
135
+ end
@@ -1,26 +1,42 @@
1
+ require "securerandom"
1
2
  require_relative "base"
2
3
 
3
4
  module Bindery
4
5
  module Generators
5
- # bindery generate book <id> <title> [--author] [--dynasty] [--category]
6
+ # bindery new book <title> [--author] [--dynasty] [--category] [--translator] [--isbn] [--id]
6
7
  class BookGenerator < Base
7
- def self.call(id, title, author:, dynasty:, category:, project_root: Project.root!)
8
+ # 根据书名自动生成书籍 ID(目录名):英文/数字转 slug,纯中文等回退为随机 ID
9
+ def self.auto_id(title)
10
+ slug = title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
11
+ slug.empty? ? "book-#{SecureRandom.hex(4)}" : slug
12
+ end
13
+
14
+ def self.call(id, title, author:, dynasty:, category:, translator: "", isbn: "", project_root: Project.root!)
8
15
  dest = Project.books_dir(project_root) + id
9
16
  raise Bindery::Error, "书籍目录已存在: #{dest}" if dest.exist?
10
17
 
18
+ config = Project.config(project_root)
11
19
  puts "创建书籍: #{id}"
12
- new(dest).generate(id: id, title: title, author: author, dynasty: dynasty, category: category)
20
+ new(dest).generate(
21
+ id: id, title: title, author: author, dynasty: dynasty, category: category,
22
+ translator: translator, isbn: isbn,
23
+ lang: config["lang"] || "zh-CN",
24
+ publisher: config["publisher"] || "再读经典",
25
+ rights: config["rights"] || "公共领域"
26
+ )
13
27
  Bindery::Index.rebuild(project_root)
14
28
  puts ""
15
29
  puts "完成!接下来编辑 #{dest}/chapters/ 下的章节文件,然后:"
16
30
  puts " bindery build #{id}"
17
31
  end
18
32
 
19
- def generate(id:, title:, author:, dynasty:, category:)
33
+ def generate(id:, title:, author:, dynasty:, category:, translator:, isbn:, lang:, publisher:, rights:)
20
34
  empty_directory("chapters")
21
35
 
22
36
  template("book", "metadata.yaml", locals: {
23
37
  id: id, title: title, author: author, dynasty: dynasty, category: category,
38
+ translator: translator, isbn: isbn,
39
+ lang: lang, publisher: publisher, rights: rights,
24
40
  })
25
41
  template("book", "chapters/01-chapter.md", locals: { title: title })
26
42
  end
@@ -0,0 +1,49 @@
1
+ require "fileutils"
2
+ require_relative "base"
3
+
4
+ module Bindery
5
+ module Generators
6
+ # bindery new chapter <book-id> <数量>
7
+ # 在指定书籍里批量创建章节文件,编号自动接续已有章节(01-chapter.md 之后是 02-chapter.md…)
8
+ class ChapterGenerator < Base
9
+ def self.call(book_id, count, project_root: Project.root!)
10
+ book = Bindery::Book.find(book_id, project_root)
11
+ count = count.to_i
12
+ raise Bindery::Error, "章节数量必须是正整数" unless count.positive?
13
+
14
+ chapters_dir = book.chapters_dir
15
+ FileUtils.mkdir_p(chapters_dir) unless chapters_dir.directory?
16
+
17
+ start = next_number(chapters_dir)
18
+ puts "创建章节: #{book.id}"
19
+ count.times do |i|
20
+ n = start + i
21
+ file = chapters_dir + format("%02d-chapter.md", n)
22
+ file.write("# 第#{n}章\n\n")
23
+ rel = begin
24
+ file.relative_path_from(Pathname.pwd)
25
+ rescue StandardError
26
+ file
27
+ end
28
+ puts " create #{rel}"
29
+ end
30
+ puts ""
31
+ puts "完成!已创建 #{count} 个章节(从第 #{start} 章开始)"
32
+ end
33
+
34
+ # 扫描已有章节文件名开头的数字,返回下一个可用编号
35
+ def self.next_number(chapters_dir)
36
+ max = 0
37
+ return max + 1 unless chapters_dir.directory?
38
+
39
+ chapters_dir.children.each do |f|
40
+ next unless f.file? && f.extname == ".md"
41
+
42
+ n = f.basename.to_s[/\A(\d+)/, 1].to_i
43
+ max = n if n > max
44
+ end
45
+ max + 1
46
+ end
47
+ end
48
+ end
49
+ end
@@ -2,23 +2,29 @@ require_relative "base"
2
2
 
3
3
  module Bindery
4
4
  module Generators
5
- # bindery new <项目名>
6
- # 生成一整套项目骨架:books/、templates/(样式+字体说明)、output/、
5
+ # bindery init
6
+ # 在当前目录就地生成一整套项目骨架:books/、templates/(样式)、output/、
7
7
  # config/bindery.yml(项目标记文件)、README、.gitignore、GitHub Actions
8
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}"
9
+ def self.call(project_name = Pathname.pwd.basename.to_s)
10
+ dest = Pathname.pwd
11
+ if (dest + "config/bindery.yml").file?
12
+ raise Bindery::Error, "当前目录已经是 bindery 项目(已存在 config/bindery.yml)"
13
13
  end
14
14
 
15
- puts "创建项目: #{project_name}"
15
+ conflicts = ["books", "templates", "output", "README.md", ".gitignore",
16
+ ".github/workflows/build.yml"]
17
+ existing = conflicts.select { |f| (dest + f).exist? }
18
+ unless existing.empty?
19
+ raise Bindery::Error, "以下文件/目录已存在,为避免覆盖,无法初始化:#{existing.join('、')}"
20
+ end
21
+
22
+ puts "初始化项目: #{project_name}"
16
23
  new(dest).generate(project_name)
17
24
  puts ""
18
25
  puts "完成!接下来:"
19
- puts " cd #{project_name}"
20
- puts " bindery generate book lunyu \"论语\" --author \"孔子及弟子\""
21
- puts " bindery build lunyu"
26
+ puts " bindery new book \"论语\" --author \"孔子及弟子\" --dynasty \"先秦\""
27
+ puts " bindery build --all"
22
28
  end
23
29
 
24
30
  def generate(project_name)
@@ -1,9 +1,11 @@
1
1
  id: <%= id.inspect %>
2
2
  title: <%= title.inspect %>
3
3
  author: <%= author.inspect %>
4
- lang: zh-CN
4
+ translator: <%= translator.inspect %>
5
+ lang: <%= lang.inspect %>
5
6
  dynasty: <%= dynasty.inspect %>
6
7
  category: <%= category.inspect %>
7
- rights: 公共领域
8
- publisher: 再读经典
8
+ isbn: <%= isbn.inspect %>
9
+ rights: <%= rights.inspect %>
10
+ publisher: <%= publisher.inspect %>
9
11
  cover: cover.jpg
@@ -5,10 +5,19 @@
5
5
  ## 常用命令
6
6
 
7
7
  ```bash
8
- # 新建一本书
9
- bindery generate book <书籍ID> "<书名>" --author "<作者>"
10
- # 简写
11
- bindery g book <书籍ID> "<书名>"
8
+ # 新建一本书(ID 自动生成,可用 --id 指定;可带译者 / ISBN;--cover 自动生成封面)
9
+ bindery new book "<书名>" --author "<作者>" --translator "<译者>" --isbn "<ISBN>" --cover
10
+ # 一步到位建好章节(N = 正文章节数,封面/目录由构建自动生成)
11
+ bindery new book "<书名>" chapter 20 --author "<作者>"
12
+
13
+ # 卷式结构:每个卷在 EPUB 中独立成页(h2=卷名,h3=章名)
14
+ bindery new volume <书籍ID> "<卷名>" --chapters <数量>
15
+
16
+ # 单独批量创建章节(编号自动接续已有章节)
17
+ bindery new chapter <书籍ID> <数量>
18
+
19
+ # 单独生成封面
20
+ bindery cover <书籍ID>
12
21
 
13
22
  # 构建一本书
14
23
  bindery build <书籍ID>
@@ -16,8 +25,24 @@ bindery build <书籍ID>
16
25
  # 批量构建全部
17
26
  bindery build --all
18
27
 
28
+ # 构建前校验(不实际构建)
29
+ bindery validate <书籍ID>
30
+ bindery build <书籍ID> --check
31
+
32
+ # 构建后用 epubcheck 校验
33
+ bindery build <书籍ID> --epubcheck
34
+
19
35
  # 查看所有书籍状态
20
36
  bindery status
37
+
38
+ # 监听书籍目录,改动自动重建
39
+ bindery watch
40
+
41
+ # 本地启动书库站点
42
+ bindery serve
43
+
44
+ # 清理构建产物
45
+ bindery clean --all
21
46
  ```
22
47
 
23
48
  ## 目录结构
@@ -1,3 +1,2 @@
1
1
  /output/
2
- books.json
3
2
  .DS_Store
@@ -0,0 +1,66 @@
1
+ require "fileutils"
2
+ require_relative "base"
3
+
4
+ module Bindery
5
+ module Generators
6
+ # bindery new volume <book-id> <卷名> [--chapters N]
7
+ # 在书籍下创建一个卷目录(编号自动接续),可选批量创建卷内章节
8
+ class VolumeGenerator < Base
9
+ def self.call(book_id, volume_name, chapters: 0, project_root: Project.root!)
10
+ book = Bindery::Book.find(book_id, project_root)
11
+ volume_name = volume_name.to_s.strip
12
+ raise Bindery::Error, "卷名不能为空" if volume_name.empty?
13
+
14
+ if book.volumes.empty? && book.chapters.any?
15
+ warn "注意:chapters/ 下已有平铺章节文件(#{book.chapters.size} 个),卷式书构建时会忽略它们"
16
+ end
17
+
18
+ chapters_dir = book.chapters_dir
19
+ FileUtils.mkdir_p(chapters_dir) unless chapters_dir.directory?
20
+
21
+ n = next_volume_number(chapters_dir)
22
+ vol_dir = chapters_dir + format("%02d-%s", n, volume_name)
23
+ raise Bindery::Error, "卷目录已存在: #{vol_dir}" if vol_dir.exist?
24
+
25
+ chapters = chapters.to_i
26
+ raise Bindery::Error, "章节数必须是正整数" if chapters.negative?
27
+
28
+ FileUtils.mkdir_p(vol_dir)
29
+ puts "创建卷: #{volume_name}"
30
+ say_rel(vol_dir, trailing: "/")
31
+
32
+ chapters.times do |i|
33
+ file = vol_dir + format("%02d.md", i + 1)
34
+ file.write("# 第#{i + 1}章\n\n")
35
+ say_rel(file)
36
+ end
37
+
38
+ puts ""
39
+ summary = chapters.positive? ? ",含 #{chapters} 章" : ""
40
+ puts "完成!已创建卷 #{format('%02d', n)}(#{volume_name})#{summary}"
41
+ end
42
+
43
+ def self.next_volume_number(chapters_dir)
44
+ max = 0
45
+ if chapters_dir.directory?
46
+ chapters_dir.children.each do |d|
47
+ next unless d.directory?
48
+
49
+ m = d.basename.to_s[/\A(\d+)/, 1]
50
+ max = m.to_i if m && m.to_i > max
51
+ end
52
+ end
53
+ max + 1
54
+ end
55
+
56
+ def self.say_rel(path, trailing: "")
57
+ rel = begin
58
+ path.relative_path_from(Pathname.pwd)
59
+ rescue StandardError
60
+ path
61
+ end
62
+ puts " create #{rel}#{trailing}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,64 @@
1
+ module Bindery
2
+ # 读取 JPEG/PNG 图片尺寸(纯标准库实现,不依赖第三方 gem)
3
+ module Image
4
+ module_function
5
+
6
+ # 返回 [width, height];无法识别格式时返回 nil
7
+ def dimensions(path)
8
+ return nil unless path.file?
9
+
10
+ head = path.binread(24)
11
+ if png?(head)
12
+ width = head.byteslice(16, 4).unpack1("N")
13
+ height = head.byteslice(20, 4).unpack1("N")
14
+ [width, height]
15
+ elsif jpeg?(head)
16
+ jpeg_dimensions(path)
17
+ end
18
+ rescue StandardError
19
+ nil
20
+ end
21
+
22
+ def png?(head)
23
+ head.byteslice(0, 8) == "\x89PNG\r\n\x1a\n".b
24
+ end
25
+
26
+ def jpeg?(head)
27
+ head.byteslice(0, 2) == "\xFF\xD8".b
28
+ end
29
+
30
+ def jpeg_dimensions(path)
31
+ File.open(path, "rb") do |f|
32
+ f.read(2) # SOI
33
+ loop do
34
+ marker = f.read(2)
35
+ return nil if marker.nil? || marker.bytesize < 2
36
+ return nil unless marker.getbyte(0) == 0xFF
37
+
38
+ code = marker.getbyte(1)
39
+ # 跳过 0xFF 填充字节
40
+ while code == 0xFF
41
+ next_byte = f.read(1)
42
+ return nil if next_byte.nil?
43
+ code = next_byte.getbyte(0)
44
+ end
45
+
46
+ # SOF 段携带图片尺寸
47
+ if (0xC0..0xCF).cover?(code) && ![0xC4, 0xC8, 0xCC].include?(code)
48
+ f.read(2) # length
49
+ f.read(1) # precision
50
+ height = f.read(2).unpack1("n")
51
+ width = f.read(2).unpack1("n")
52
+ return [width, height]
53
+ else
54
+ length = f.read(2).unpack1("n")
55
+ return nil if length.nil? || length < 2
56
+ f.read(length - 2)
57
+ end
58
+ end
59
+ end
60
+ rescue StandardError
61
+ nil
62
+ end
63
+ end
64
+ end
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require "yaml"
2
3
 
3
4
  module Bindery
4
5
  # 负责在目录树中定位 bindery 项目根目录(含 config/bindery.yml 标记文件的目录)
@@ -21,7 +22,7 @@ module Bindery
21
22
  root(start_dir) || raise(
22
23
  Bindery::ProjectNotFoundError,
23
24
  "找不到 bindery 项目(未发现 #{MARKER})。请在项目目录内执行," \
24
- "或先用 `bindery new <项目名>` 创建一个新项目。"
25
+ "或先用 `bindery init` 初始化一个项目。"
25
26
  )
26
27
  end
27
28
 
@@ -40,5 +41,14 @@ module Bindery
40
41
  def index_file(project_root = root!)
41
42
  project_root + "books.json"
42
43
  end
44
+
45
+ # 读取项目配置 config/bindery.yml(即项目标记文件本身),返回 Hash
46
+ # 文件缺失或解析失败时返回空 Hash,由调用方提供默认值
47
+ def config(project_root = root!)
48
+ path = project_root + MARKER
49
+ return {} unless path.file?
50
+
51
+ YAML.safe_load(path.read, permitted_classes: [Symbol]) || {}
52
+ end
43
53
  end
44
54
  end
@@ -0,0 +1,185 @@
1
+ require "socket"
2
+ require "uri"
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Bindery
7
+ # 极简静态服务器:展示书库并提供 EPUB 下载。
8
+ # 仅用标准库 socket 实现(Ruby 3.0 起 WEBrick 已移出标准库)。
9
+ class Server
10
+ MIME = {
11
+ ".html" => "text/html; charset=utf-8",
12
+ ".json" => "application/json; charset=utf-8",
13
+ ".epub" => "application/epub+zip",
14
+ ".css" => "text/css; charset=utf-8",
15
+ ".jpg" => "image/jpeg",
16
+ ".jpeg" => "image/jpeg",
17
+ ".png" => "image/png",
18
+ ".svg" => "image/svg+xml",
19
+ }.freeze
20
+
21
+ def initialize(project_root, host: "127.0.0.1", port: 8000)
22
+ @project_root = project_root
23
+ @host = host
24
+ @port = port
25
+ end
26
+
27
+ def start
28
+ server = TCPServer.new(@host, @port)
29
+ puts "服务已启动: http://#{@host}:#{@port} (Ctrl-C 退出)"
30
+ loop do
31
+ client = server.accept
32
+ begin
33
+ respond(client)
34
+ rescue StandardError => e
35
+ warn "请求处理失败: #{e.message}"
36
+ ensure
37
+ begin
38
+ client.close
39
+ rescue StandardError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ rescue Interrupt
45
+ puts "\n服务已停止"
46
+ ensure
47
+ begin
48
+ server.close
49
+ rescue StandardError
50
+ nil
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def respond(client)
57
+ request_line = client.gets
58
+ return unless request_line
59
+
60
+ method, raw_path, = request_line.split(" ")
61
+ # 丢弃其余请求头
62
+ while (line = client.gets)
63
+ break if line.nil? || line == "\r\n" || line == "\n"
64
+ end
65
+ return unless method == "GET" || method == "HEAD"
66
+
67
+ path = (URI::DEFAULT_PARSER.unescape(raw_path.split("?").first) rescue raw_path)
68
+ path = "/" if path.nil? || path.empty?
69
+ path = path.gsub("..", "") # 防目录穿越
70
+
71
+ case path
72
+ when "/", "/index.html"
73
+ body = index_html
74
+ send_response(client, 200, MIME[".html"], body, method)
75
+ when "/books.json"
76
+ serve_file(client, Project.index_file(@project_root), MIME[".json"], method)
77
+ when %r{\A/epub/([\w\-.]+\.epub)\z}
78
+ serve_file(client, Project.output_dir(@project_root) + "epub" + Regexp.last_match(1), MIME[".epub"], method)
79
+ when %r{\A/cover/([\w\-]+)\z}
80
+ serve_cover(client, Regexp.last_match(1), method)
81
+ else
82
+ not_found(client, method)
83
+ end
84
+ end
85
+
86
+ def index_html
87
+ index_file = Project.index_file(@project_root)
88
+ data = if index_file.file?
89
+ JSON.parse(index_file.read)
90
+ else
91
+ Bindery::Index.rebuild(@project_root)
92
+ end
93
+ books = data["books"] || []
94
+
95
+ rows = books.map do |b|
96
+ id = escape_html(b["id"].to_s)
97
+ title = escape_html(b["title"].to_s)
98
+ author = escape_html(b["author"].to_s)
99
+ epub_link = if b["epub"]
100
+ %(<a href="/epub/#{id}.epub" download>下载 EPUB</a>)
101
+ else
102
+ "<em>未构建</em>"
103
+ end
104
+ cover = %(<img src="/cover/#{id}" class="cover" alt="">)
105
+ "<li class=\"book\">#{cover}<div class=\"info\"><h2>#{title}</h2><p class=\"author\">#{author}</p>#{epub_link}</div></li>"
106
+ end.join("\n")
107
+
108
+ <<~HTML
109
+ <!doctype html>
110
+ <html lang="zh-CN">
111
+ <head>
112
+ <meta charset="utf-8">
113
+ <meta name="viewport" content="width=device-width, initial-scale=1">
114
+ <title>#{escape_html(@project_root.basename.to_s)} · 书库</title>
115
+ <style>
116
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
117
+ h1 { font-size: 1.6em; border-bottom: 1px solid #eee; padding-bottom: .4em; }
118
+ ul { list-style: none; padding: 0; }
119
+ .book { display: flex; gap: 1em; padding: 1em 0; border-bottom: 1px solid #f0f0f0; }
120
+ .cover { width: 64px; height: 96px; object-fit: cover; background: #f5f5f5; border-radius: 4px; }
121
+ .info h2 { margin: 0; font-size: 1.1em; }
122
+ .author { color: #666; margin: .3em 0 .5em; }
123
+ a { color: #b45309; }
124
+ em { color: #999; }
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <h1>#{escape_html(@project_root.basename.to_s)} · 书库</h1>
129
+ <p>共 #{books.size} 本书</p>
130
+ <ul>#{rows}</ul>
131
+ </body>
132
+ </html>
133
+ HTML
134
+ end
135
+
136
+ def serve_cover(client, id, method)
137
+ book_dir = Project.books_dir(@project_root) + id
138
+ return not_found(client, method) unless book_dir.directory?
139
+
140
+ name = "cover.jpg"
141
+ meta_file = book_dir + "metadata.yaml"
142
+ if meta_file.file?
143
+ begin
144
+ meta = YAML.safe_load(meta_file.read, permitted_classes: [Symbol]) || {}
145
+ name = meta["cover"] || "cover.jpg"
146
+ rescue StandardError
147
+ nil
148
+ end
149
+ end
150
+
151
+ path = book_dir + name
152
+ path = (book_dir + "cover.jpg") unless path.file?
153
+ path = (book_dir + "cover.png") unless path.file?
154
+
155
+ serve_file(client, path, nil, method)
156
+ end
157
+
158
+ def serve_file(client, path, content_type, method)
159
+ return not_found(client, method) unless path.file?
160
+
161
+ body = path.binread
162
+ content_type ||= MIME[path.extname.downcase] || "application/octet-stream"
163
+ send_response(client, 200, content_type, body, method)
164
+ end
165
+
166
+ def not_found(client, method)
167
+ body = "<h1>404 Not Found</h1>"
168
+ send_response(client, 404, MIME[".html"], body, method)
169
+ end
170
+
171
+ def send_response(client, status, content_type, body, method)
172
+ reason = { 200 => "OK", 404 => "Not Found" }[status] || "OK"
173
+ headers = "HTTP/1.1 #{status} #{reason}\r\n" \
174
+ "Content-Type: #{content_type}\r\n" \
175
+ "Content-Length: #{body.bytesize}\r\n" \
176
+ "Connection: close\r\n\r\n"
177
+ client.write(headers)
178
+ client.write(body) unless method == "HEAD"
179
+ end
180
+
181
+ def escape_html(str)
182
+ str.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
183
+ end
184
+ end
185
+ end