pocketbook 0.1.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/LICENSE +21 -0
- data/README.md +308 -0
- data/bin/pocketbook +5 -0
- data/bin/test-render +49 -0
- data/lib/pocketbook/book.rb +28 -0
- data/lib/pocketbook/book_renderer/chapter.rb +85 -0
- data/lib/pocketbook/book_renderer/front_matter.rb +27 -0
- data/lib/pocketbook/book_renderer/metadata.rb +70 -0
- data/lib/pocketbook/book_renderer/pdf.rb +42 -0
- data/lib/pocketbook/book_renderer/toc.rb +55 -0
- data/lib/pocketbook/book_renderer.rb +140 -0
- data/lib/pocketbook/book_template.rb +40 -0
- data/lib/pocketbook/cli/options_parser.rb +344 -0
- data/lib/pocketbook/cli/runner.rb +505 -0
- data/lib/pocketbook/cli/watch_command.rb +275 -0
- data/lib/pocketbook/cli.rb +12 -0
- data/lib/pocketbook/core_stylesheet.rb +20 -0
- data/lib/pocketbook/pdf_document.rb +96 -0
- data/lib/pocketbook/render_request.rb +67 -0
- data/lib/pocketbook/styles/core/01_tokens.css +11 -0
- data/lib/pocketbook/styles/core/02_pages.css +72 -0
- data/lib/pocketbook/styles/core/03_layout.css +162 -0
- data/lib/pocketbook/styles/core/04_toc.css +62 -0
- data/lib/pocketbook/styles/core/05_content.css +49 -0
- data/lib/pocketbook/styles/core/06_running.css +48 -0
- data/lib/pocketbook/styles/core/07_print.css +12 -0
- data/lib/pocketbook/theme/manifest.rb +244 -0
- data/lib/pocketbook/theme.rb +268 -0
- data/lib/pocketbook/version.rb +3 -0
- data/lib/pocketbook.rb +29 -0
- data/themes/basic/styles/plain.css +63 -0
- data/themes/basic/template.html.erb +30 -0
- data/themes/basic/theme.yml +8 -0
- data/themes/classic/styles/base.css +250 -0
- data/themes/classic/styles/dark.css +19 -0
- data/themes/classic/styles/light.css +12 -0
- data/themes/classic/styles/sepia.css +17 -0
- data/themes/classic/template.html.erb +72 -0
- data/themes/classic/theme.yml +17 -0
- metadata +136 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
.toc-inner {
|
|
2
|
+
padding-top: 0;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.toc-heading {
|
|
6
|
+
margin-top: 0;
|
|
7
|
+
margin-bottom: 1.1rem;
|
|
8
|
+
font-family: var(--font-display);
|
|
9
|
+
font-size: 1.4rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.toc-list {
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
list-style: none;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.toc-item + .toc-item {
|
|
19
|
+
margin-top: 0.45rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.toc-link {
|
|
23
|
+
color: inherit;
|
|
24
|
+
text-decoration: none;
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: baseline;
|
|
27
|
+
gap: 0.45rem;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.toc-title {
|
|
31
|
+
font-family: var(--font-display);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.toc-item-level-2 .toc-link,
|
|
35
|
+
.toc-item-level-3 .toc-link {
|
|
36
|
+
font-size: 0.95em;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.toc-item-level-2 .toc-link {
|
|
40
|
+
padding-left: 0.9rem;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.toc-item-level-3 .toc-link {
|
|
44
|
+
padding-left: 1.8rem;
|
|
45
|
+
font-size: 0.9em;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.toc-item-level-2 .toc-title,
|
|
49
|
+
.toc-item-level-3 .toc-title {
|
|
50
|
+
font-family: var(--font-body);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.toc-fill {
|
|
54
|
+
flex: 1;
|
|
55
|
+
border-bottom: 1px dotted rgba(0, 0, 0, 0.3);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.toc-page {
|
|
59
|
+
font-variant-numeric: tabular-nums;
|
|
60
|
+
min-width: 2ch;
|
|
61
|
+
text-align: right;
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
.book-content {
|
|
2
|
+
break-before: page;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.chapter + .chapter {
|
|
6
|
+
break-before: page;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.running-chapter-title {
|
|
10
|
+
margin: 0;
|
|
11
|
+
height: 0;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
font-size: 0;
|
|
14
|
+
line-height: 0;
|
|
15
|
+
position: running(chapter-title);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.chapter h1,
|
|
19
|
+
.chapter h2,
|
|
20
|
+
.chapter h3,
|
|
21
|
+
.chapter h4 {
|
|
22
|
+
font-family: var(--font-display);
|
|
23
|
+
line-height: 1.2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.chapter h1 {
|
|
27
|
+
string-set: chapter-title content(text);
|
|
28
|
+
font-size: 1.8rem;
|
|
29
|
+
margin-top: 0;
|
|
30
|
+
margin-bottom: 1.2rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.chapter h2 {
|
|
34
|
+
margin-top: 1.8rem;
|
|
35
|
+
margin-bottom: 0.6rem;
|
|
36
|
+
font-size: 1.3rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.chapter p,
|
|
40
|
+
.chapter li {
|
|
41
|
+
widows: 2;
|
|
42
|
+
orphans: 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.chapter img {
|
|
46
|
+
max-width: 100%;
|
|
47
|
+
height: auto;
|
|
48
|
+
break-inside: avoid;
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
.running-header,
|
|
2
|
+
.running-footer {
|
|
3
|
+
position: fixed;
|
|
4
|
+
left: 0;
|
|
5
|
+
right: 0;
|
|
6
|
+
display: flex;
|
|
7
|
+
justify-content: space-between;
|
|
8
|
+
align-items: center;
|
|
9
|
+
font-family: var(--font-display);
|
|
10
|
+
font-size: 8.5pt;
|
|
11
|
+
letter-spacing: 0.04em;
|
|
12
|
+
color: var(--folio-color);
|
|
13
|
+
z-index: 20;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.running-header {
|
|
17
|
+
top: 6mm;
|
|
18
|
+
padding-left: var(--pocketbook-screen-left-margin, 16mm);
|
|
19
|
+
padding-right: var(--pocketbook-screen-right-margin, 16mm);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.running-header-chapter::after {
|
|
23
|
+
content: attr(data-fallback);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@supports (content: string(chapter-title)) {
|
|
27
|
+
.running-header-chapter::after {
|
|
28
|
+
content: string(chapter-title);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.running-footer {
|
|
33
|
+
bottom: 6mm;
|
|
34
|
+
padding-left: var(--pocketbook-screen-left-margin, 16mm);
|
|
35
|
+
padding-right: var(--pocketbook-screen-right-margin, 16mm);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.page-number::after {
|
|
39
|
+
content: counter(page);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.running-divider {
|
|
43
|
+
display: inline-block;
|
|
44
|
+
width: 16px;
|
|
45
|
+
height: 1px;
|
|
46
|
+
margin: 0 8px;
|
|
47
|
+
background: var(--folio-color);
|
|
48
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Pocketbook
|
|
4
|
+
class Theme
|
|
5
|
+
class Manifest
|
|
6
|
+
OPTIONAL_FIELDS = %w[name version template styles defaults].freeze
|
|
7
|
+
SUPPORTED_FIELDS = OPTIONAL_FIELDS
|
|
8
|
+
DEFAULT_STYLE_NAME = "default"
|
|
9
|
+
SUPPORTED_DEFAULTS = %w[style title subtitle author publisher backcover_text size].freeze
|
|
10
|
+
|
|
11
|
+
class Defaults
|
|
12
|
+
def initialize(values, path)
|
|
13
|
+
@values = values
|
|
14
|
+
@path = path
|
|
15
|
+
validate!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def style_name
|
|
19
|
+
normalize_optional_string(fetch("style"))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def metadata
|
|
23
|
+
return {} if @values.nil?
|
|
24
|
+
|
|
25
|
+
@values.each_with_object({}) do |(key, value), output|
|
|
26
|
+
key_name = key.to_s
|
|
27
|
+
next if key_name == "style"
|
|
28
|
+
|
|
29
|
+
output[key_name.to_sym] = value
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate!
|
|
36
|
+
return if @values.nil?
|
|
37
|
+
|
|
38
|
+
unless @values.is_a?(Hash)
|
|
39
|
+
raise ArgumentError, "Manifest defaults must be a YAML object in #{@path}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
unknown_defaults = @values.keys.map(&:to_s) - SUPPORTED_DEFAULTS
|
|
43
|
+
unless unknown_defaults.empty?
|
|
44
|
+
raise ArgumentError,
|
|
45
|
+
"Unknown defaults keys in #{@path}: #{unknown_defaults.join(', ')}. Supported keys: #{SUPPORTED_DEFAULTS.join(', ')}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
style_default = fetch("style")
|
|
49
|
+
return if style_default.nil?
|
|
50
|
+
|
|
51
|
+
validate_non_empty_string!(style_default, "defaults.style")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fetch(key)
|
|
55
|
+
return nil unless @values.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
@values[key] || @values[key.to_sym]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_non_empty_string!(value, label)
|
|
61
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
62
|
+
raise ArgumentError, "Manifest #{label} must be a non-empty string in #{@path}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def normalize_optional_string(value)
|
|
67
|
+
return nil if value.nil?
|
|
68
|
+
|
|
69
|
+
normalized = value.to_s.strip
|
|
70
|
+
return nil if normalized.empty?
|
|
71
|
+
|
|
72
|
+
normalized
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class StyleCatalog
|
|
77
|
+
def initialize(styles, path)
|
|
78
|
+
@path = path
|
|
79
|
+
@style_map = normalize_style_map(styles)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def available_styles
|
|
83
|
+
@style_map.keys
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def style?(style_name)
|
|
87
|
+
@style_map.key?(style_name)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def paths_for(style_name)
|
|
91
|
+
@style_map.fetch(style_name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def normalize_style_map(styles)
|
|
97
|
+
return {} if styles.nil?
|
|
98
|
+
|
|
99
|
+
if styles.is_a?(String)
|
|
100
|
+
return { DEFAULT_STYLE_NAME => normalize_style_entries(styles, "styles") }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if styles.is_a?(Array)
|
|
104
|
+
return { DEFAULT_STYLE_NAME => normalize_style_entries(styles, "styles") }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
unless styles.is_a?(Hash) && !styles.empty?
|
|
108
|
+
raise ArgumentError, "Manifest styles must be a string, a non-empty map, or a non-empty array in #{@path}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
styles.each_with_object({}) do |(style_name, entries), output|
|
|
112
|
+
style_key = style_name.to_s.strip
|
|
113
|
+
if style_key.empty?
|
|
114
|
+
raise ArgumentError, "Manifest styles map contains an empty style name in #{@path}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
output[style_key] = normalize_style_entries(entries, "styles.#{style_key}")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def normalize_style_entries(entries, label)
|
|
122
|
+
values = if entries.is_a?(String)
|
|
123
|
+
[entries]
|
|
124
|
+
elsif entries.is_a?(Array) && !entries.empty?
|
|
125
|
+
entries
|
|
126
|
+
else
|
|
127
|
+
raise ArgumentError, "Manifest #{label} must be a string or a non-empty array in #{@path}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
values.each_with_index do |value, index|
|
|
131
|
+
validate_non_empty_string!(value, "#{label}[#{index}]")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
values
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_non_empty_string!(value, label)
|
|
138
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
139
|
+
raise ArgumentError, "Manifest #{label} must be a non-empty string in #{@path}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
attr_reader :path
|
|
145
|
+
|
|
146
|
+
def self.supported_fields
|
|
147
|
+
SUPPORTED_FIELDS
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.supported_defaults
|
|
151
|
+
SUPPORTED_DEFAULTS
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def initialize(path:)
|
|
155
|
+
@path = path
|
|
156
|
+
@values = load_manifest(path)
|
|
157
|
+
validate_manifest!
|
|
158
|
+
@defaults = Defaults.new(@values["defaults"], path)
|
|
159
|
+
@style_catalog = StyleCatalog.new(@values["styles"], path)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def name(default_name)
|
|
163
|
+
@values.fetch("name", default_name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def version(default_version = "0.1.0")
|
|
167
|
+
@values.fetch("version", default_version)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def template_reference
|
|
171
|
+
normalize_optional_string(@values["template"])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def styles_declared?
|
|
175
|
+
!@style_catalog.available_styles.empty?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def available_styles
|
|
179
|
+
@style_catalog.available_styles
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def style?(style_name)
|
|
183
|
+
@style_catalog.style?(style_name)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def style_paths_for(style_name)
|
|
187
|
+
@style_catalog.paths_for(style_name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def default_style_name
|
|
191
|
+
@defaults.style_name
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def defaults_metadata
|
|
195
|
+
@defaults.metadata
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def load_manifest(manifest_path)
|
|
201
|
+
loaded = YAML.safe_load(File.read(manifest_path), aliases: true)
|
|
202
|
+
raise ArgumentError, "Manifest must contain a YAML object: #{manifest_path}" unless loaded.is_a?(Hash)
|
|
203
|
+
|
|
204
|
+
loaded.each_with_object({}) do |(key, value), output|
|
|
205
|
+
output[key.to_s] = value
|
|
206
|
+
end
|
|
207
|
+
rescue Psych::SyntaxError => e
|
|
208
|
+
raise ArgumentError, "Invalid theme manifest YAML in #{manifest_path}: #{e.message}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def validate_manifest!
|
|
212
|
+
unknown_fields = @values.keys - SUPPORTED_FIELDS
|
|
213
|
+
unless unknown_fields.empty?
|
|
214
|
+
raise ArgumentError, "Unknown manifest keys in #{path}: #{unknown_fields.join(', ')}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
validate_optional_string!("template")
|
|
218
|
+
validate_optional_string!("name")
|
|
219
|
+
validate_optional_string!("version")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_optional_string!(key)
|
|
223
|
+
return unless @values.key?(key)
|
|
224
|
+
|
|
225
|
+
validate_non_empty_string!(@values[key], key)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def validate_non_empty_string!(value, label)
|
|
229
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
230
|
+
raise ArgumentError, "Manifest #{label} must be a non-empty string in #{path}"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def normalize_optional_string(value)
|
|
235
|
+
return nil if value.nil?
|
|
236
|
+
|
|
237
|
+
normalized = value.to_s.strip
|
|
238
|
+
return nil if normalized.empty?
|
|
239
|
+
|
|
240
|
+
normalized
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
require_relative "theme/manifest"
|
|
2
|
+
|
|
3
|
+
module Pocketbook
|
|
4
|
+
class Theme
|
|
5
|
+
DEFAULT_MANIFEST = "theme.yml"
|
|
6
|
+
DEFAULT_TEMPLATE_CANDIDATES = ["template.html.erb", "layout.html.erb"].freeze
|
|
7
|
+
|
|
8
|
+
class Location
|
|
9
|
+
attr_reader :root_path, :manifest_path
|
|
10
|
+
|
|
11
|
+
def initialize(theme_reference:, cwd: Dir.pwd)
|
|
12
|
+
input_path = Theme.resolve_theme_path(theme_reference, cwd: cwd)
|
|
13
|
+
@root_path = resolve_root(input_path)
|
|
14
|
+
|
|
15
|
+
raise ArgumentError, "Theme directory not found: #{theme_reference}" unless Dir.exist?(@root_path)
|
|
16
|
+
|
|
17
|
+
@manifest_path = resolve_manifest_path(input_path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def resolve_root(input_path)
|
|
23
|
+
return File.dirname(input_path) if File.file?(input_path)
|
|
24
|
+
|
|
25
|
+
input_path
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve_manifest_path(input_path)
|
|
29
|
+
return input_path if File.file?(input_path)
|
|
30
|
+
|
|
31
|
+
theme_manifest = File.join(@root_path, DEFAULT_MANIFEST)
|
|
32
|
+
return theme_manifest if File.file?(theme_manifest)
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class AssetPaths
|
|
39
|
+
attr_reader :template_path, :style_paths, :style_name, :available_styles
|
|
40
|
+
|
|
41
|
+
def initialize(root_path:, manifest:, style: nil, template_override: nil)
|
|
42
|
+
@root_path = root_path
|
|
43
|
+
@manifest = manifest
|
|
44
|
+
@style_map = build_style_map
|
|
45
|
+
@available_styles = @style_map.keys.sort
|
|
46
|
+
|
|
47
|
+
@template_path = resolve_template_path(template_override)
|
|
48
|
+
@style_name = resolve_style_name(style)
|
|
49
|
+
@style_paths = resolve_style_paths(@style_name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def resolve_template_path(override_path)
|
|
55
|
+
if present?(override_path)
|
|
56
|
+
resolved_override = resolve_local_reference(override_path, "template")
|
|
57
|
+
return resolved_override if resolved_override
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "Theme template override file not found: #{override_path}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
manifest_template = @manifest&.template_reference
|
|
63
|
+
if present?(manifest_template)
|
|
64
|
+
resolved_manifest_template = resolve_local_reference(manifest_template, "template")
|
|
65
|
+
return resolved_manifest_template if resolved_manifest_template
|
|
66
|
+
|
|
67
|
+
raise ArgumentError, "Theme template file not found: #{manifest_template}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
DEFAULT_TEMPLATE_CANDIDATES.each do |relative_path|
|
|
71
|
+
candidate = File.expand_path(relative_path, @root_path)
|
|
72
|
+
return candidate if File.file?(candidate)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
default_template_path
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def resolve_local_reference(reference, label)
|
|
79
|
+
candidate = File.expand_path(reference, @root_path)
|
|
80
|
+
validate_inside_root!(candidate, label)
|
|
81
|
+
|
|
82
|
+
return candidate if File.file?(candidate)
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def default_template_path
|
|
88
|
+
File.join(Pocketbook.bundled_theme_path("basic"), "template.html.erb")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def resolve_style_name(requested_style)
|
|
92
|
+
explicit_style = normalize_optional_string(requested_style)
|
|
93
|
+
if explicit_style
|
|
94
|
+
return explicit_style if @style_map.key?(explicit_style)
|
|
95
|
+
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"Unknown style '#{explicit_style}'. Available styles: #{available_styles.join(', ')}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
default_style = @manifest&.default_style_name
|
|
101
|
+
if default_style && @style_map.key?(default_style)
|
|
102
|
+
return default_style
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
return "default" if @style_map.key?("default")
|
|
106
|
+
|
|
107
|
+
available_styles.first
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resolve_style_paths(selected_style)
|
|
111
|
+
return [] if selected_style.nil?
|
|
112
|
+
|
|
113
|
+
@style_map.fetch(selected_style)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_style_map
|
|
117
|
+
if @manifest&.styles_declared?
|
|
118
|
+
return build_style_map_from_manifest
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
build_style_map_from_conventions
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_style_map_from_manifest
|
|
125
|
+
@manifest.available_styles.each_with_object({}) do |style_name, output|
|
|
126
|
+
output[style_name] = @manifest.style_paths_for(style_name).map.with_index do |relative_path, index|
|
|
127
|
+
candidate = File.expand_path(relative_path, @root_path)
|
|
128
|
+
|
|
129
|
+
validate_inside_root!(candidate, "styles.#{style_name}[#{index}]")
|
|
130
|
+
unless File.extname(candidate).downcase == ".css"
|
|
131
|
+
raise ArgumentError, "Theme styles.#{style_name}[#{index}] must point to a .css file: #{relative_path}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
raise ArgumentError, "Theme style file not found: #{candidate}" unless File.file?(candidate)
|
|
135
|
+
|
|
136
|
+
candidate
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def build_style_map_from_conventions
|
|
142
|
+
styles = {}
|
|
143
|
+
|
|
144
|
+
theme_css = File.expand_path("theme.css", @root_path)
|
|
145
|
+
styles["default"] = [theme_css] if File.file?(theme_css)
|
|
146
|
+
|
|
147
|
+
Dir.glob(File.join(@root_path, "styles", "*.css")).sort.each do |path|
|
|
148
|
+
style_name = File.basename(path, ".css")
|
|
149
|
+
styles[style_name] ||= [path]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
Dir.glob(File.join(@root_path, "*.css")).sort.each do |path|
|
|
153
|
+
next if File.basename(path) == "theme.css"
|
|
154
|
+
|
|
155
|
+
style_name = File.basename(path, ".css")
|
|
156
|
+
styles[style_name] ||= [path]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
styles
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_inside_root!(path, label)
|
|
163
|
+
expanded_root = File.expand_path(@root_path)
|
|
164
|
+
expanded_path = File.expand_path(path)
|
|
165
|
+
|
|
166
|
+
return if expanded_path == expanded_root || expanded_path.start_with?("#{expanded_root}/")
|
|
167
|
+
|
|
168
|
+
raise ArgumentError, "Theme #{label} path escapes theme root: #{path}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def present?(value)
|
|
172
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def normalize_optional_string(value)
|
|
176
|
+
return nil if value.nil?
|
|
177
|
+
|
|
178
|
+
normalized = value.to_s.strip
|
|
179
|
+
return nil if normalized.empty?
|
|
180
|
+
|
|
181
|
+
normalized
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
attr_reader :root_path, :template_path, :style_paths, :style_name, :available_styles
|
|
186
|
+
|
|
187
|
+
def self.supported_manifest_fields
|
|
188
|
+
Manifest.supported_fields
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def self.supported_manifest_defaults
|
|
192
|
+
Manifest.supported_defaults
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.resolve_theme_path(theme_reference, cwd: Dir.pwd)
|
|
196
|
+
reference = theme_reference.to_s.strip
|
|
197
|
+
return File.expand_path(reference, cwd) if path_reference?(reference)
|
|
198
|
+
|
|
199
|
+
project_theme_path = File.expand_path(File.join("themes", reference), cwd)
|
|
200
|
+
return project_theme_path if theme_location_exists?(project_theme_path)
|
|
201
|
+
|
|
202
|
+
user_theme_path = Pocketbook.user_themes_path(reference)
|
|
203
|
+
return user_theme_path if theme_location_exists?(user_theme_path)
|
|
204
|
+
|
|
205
|
+
bundled_path = Pocketbook.bundled_theme_path(reference)
|
|
206
|
+
return bundled_path if theme_location_exists?(bundled_path)
|
|
207
|
+
|
|
208
|
+
File.expand_path(reference, cwd)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def initialize(theme_path:, style: nil, template_override: nil, cwd: Dir.pwd)
|
|
212
|
+
location = Location.new(theme_reference: theme_path, cwd: cwd)
|
|
213
|
+
@root_path = location.root_path
|
|
214
|
+
@manifest = location.manifest_path ? Manifest.new(path: location.manifest_path) : nil
|
|
215
|
+
|
|
216
|
+
assets = AssetPaths.new(
|
|
217
|
+
root_path: @root_path,
|
|
218
|
+
manifest: @manifest,
|
|
219
|
+
style: style,
|
|
220
|
+
template_override: template_override
|
|
221
|
+
)
|
|
222
|
+
@template_path = assets.template_path
|
|
223
|
+
@style_name = assets.style_name
|
|
224
|
+
@style_paths = assets.style_paths
|
|
225
|
+
@available_styles = assets.available_styles
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def name
|
|
229
|
+
default_name = File.basename(root_path)
|
|
230
|
+
return default_name unless @manifest
|
|
231
|
+
|
|
232
|
+
@manifest.name(default_name)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def version
|
|
236
|
+
return "0.1.0" unless @manifest
|
|
237
|
+
|
|
238
|
+
@manifest.version
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def defaults_metadata
|
|
242
|
+
return {} unless @manifest
|
|
243
|
+
|
|
244
|
+
@manifest.defaults_metadata
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def template_source
|
|
248
|
+
File.read(template_path)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def styles_css
|
|
252
|
+
style_paths.map { |path| File.read(path) }.join("\n\n")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
def self.path_reference?(value)
|
|
258
|
+
return true if value.start_with?("~", ".", "/")
|
|
259
|
+
|
|
260
|
+
separators = [File::SEPARATOR, File::ALT_SEPARATOR].compact.uniq
|
|
261
|
+
separators.any? { |separator| value.include?(separator) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def self.theme_location_exists?(path)
|
|
265
|
+
File.directory?(path) || File.file?(path)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|