coradoc-html 1.1.14 → 1.1.15

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.
@@ -1,116 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nokogiri'
4
-
5
3
  module Coradoc
6
4
  module Html
7
5
  module Config
8
6
  DEFAULT_LANG = 'en'
9
7
  DEFAULT_TITLE = 'Untitled'
10
8
 
11
- # Default HTML output options
12
9
  DEFAULT_OPTIONS = {
13
- # Theme system options
14
10
  theme: :classic,
15
11
  modern: {
16
- # Appearance
17
12
  color_scheme: :glass,
18
13
  primary_color: '#6366f1',
19
14
  accent_color: '#8b5cf6',
20
-
21
- # Layout
22
15
  max_width: '1200px',
23
16
  content_width: '65ch',
24
17
  sidebar_width: '280px',
25
-
26
- # Features
27
18
  theme_toggle: true,
28
19
  reading_progress: true,
29
20
  back_to_top: true,
30
21
  toc_sticky: true,
31
22
  copy_code_buttons: true,
32
-
33
- # Animation
34
23
  enable_animations: true,
35
24
  animation_duration: '300ms',
36
-
37
- # Performance
38
25
  lazy_load_images: true
39
26
  }.freeze,
40
-
41
- # HTML version
42
27
  html_version: :html5,
43
-
44
- # Formatting options
45
28
  pretty_print: false,
46
29
  indent: ' ',
47
30
  line_wrap: 0,
48
-
49
- # Content options
50
31
  escape_content: true,
51
32
  preserve_whitespace: false,
52
33
  convert_line_breaks: true,
53
34
  preserve_comments: false,
54
-
55
- # Element options
56
35
  use_semantic_elements: true,
57
36
  add_css_classes: true,
58
37
  add_data_attributes: false,
59
-
60
- # Link options
61
38
  external_link_target: nil,
62
39
  link_rel: nil,
63
-
64
- # Image options
65
40
  image_loading: nil,
66
41
  image_decoding: nil,
67
-
68
- # Code block options
69
42
  syntax_highlighter: nil,
70
43
  syntax_highlighter_opts: {},
71
-
72
- # Table options
73
44
  table_border: false,
74
45
  table_stripes: false,
75
-
76
- # Attribute options
77
46
  preserve_custom_attributes: true,
78
47
  attribute_prefix: 'data-',
79
-
80
- # CSS & Styling options
81
48
  stylesheet: 'coradoc.css',
82
49
  stylesdir: './css',
83
50
  linkcss: false,
84
51
  copycss: true,
85
52
  css_theme: 'professional',
86
53
  custom_css: nil,
87
-
88
- # JavaScript options
89
54
  javascript: 'coradoc.js',
90
55
  jsdir: './js',
91
56
  linkjs: false,
92
57
  theme_toggle: true,
93
58
  toc_interactive: nil,
94
-
95
- # Document metadata options
96
59
  author: nil,
97
60
  description: nil,
98
61
  keywords: nil,
99
62
  lang: 'en',
100
63
  embedded: false,
101
64
  meta_tags: {},
102
-
103
- # Table of contents options
104
65
  toc: false,
105
66
  toclevels: 2,
106
67
  toc_title: 'Table of Contents',
107
68
  toc_placement: :auto,
108
-
109
- # Section numbering options
110
69
  sectnums: false,
111
70
  sectnumlevels: 3,
112
-
113
- # Syntax highlighting options
114
71
  source_highlighter: nil,
115
72
  highlightjs_theme: 'github',
116
73
  pygments_style: 'default',
@@ -118,17 +75,14 @@ module Coradoc
118
75
  }.freeze
119
76
 
120
77
  class << self
121
- # Get default options
122
78
  def default_options
123
79
  DEFAULT_OPTIONS.dup
124
80
  end
125
81
 
126
- # Merge user options with defaults
127
82
  def merge_options(user_options = {})
128
83
  default_options.merge(user_options)
129
84
  end
130
85
 
131
- # Validate options
132
86
  def validate_options(options)
133
87
  valid_keys = DEFAULT_OPTIONS.keys
134
88
  invalid_keys = options.keys - valid_keys
@@ -138,19 +92,16 @@ module Coradoc
138
92
  options
139
93
  end
140
94
 
141
- # Get CSS class for element type
142
95
  def css_class_for(element_type, role = nil)
143
96
  classes = [element_type.to_s.tr('_', '-')]
144
97
  classes << role if role
145
98
  classes.join(' ')
146
99
  end
147
100
 
148
- # Get data attribute name
149
101
  def data_attribute_name(name, prefix: 'data-')
150
102
  "#{prefix}#{name.to_s.tr('_', '-')}"
151
103
  end
152
104
 
153
- # Build element configuration
154
105
  def element_config(element_type, options = {})
155
106
  {
156
107
  tag: html_tag_for(element_type),
@@ -159,333 +110,42 @@ module Coradoc
159
110
  }
160
111
  end
161
112
 
162
- # Map element type to HTML tag
163
113
  def html_tag_for(element_type)
164
- TAG_MAPPING[element_type] || 'div'
165
- end
166
-
167
- # Get stylesheet path
168
- def stylesheet_path(options = {})
169
- # When linking, use css_theme-based filename, not the stylesheet option
170
- css_theme = options[:css_theme] || DEFAULT_OPTIONS[:css_theme]
171
- stylesheet = "#{css_theme}.css"
172
- stylesdir = options[:stylesdir] || DEFAULT_OPTIONS[:stylesdir]
173
-
174
- if stylesdir && stylesdir != '.'
175
- File.join(stylesdir, stylesheet)
176
- else
177
- stylesheet
178
- end
179
- end
180
-
181
- # Get embedded stylesheet content
182
- def embedded_stylesheet(options = {})
183
- css_theme = options[:css_theme] || DEFAULT_OPTIONS[:css_theme]
184
- stylesheet_name = "#{css_theme}.css"
185
-
186
- # Try themes directory first
187
- themes_path = File.join(__dir__, 'assets', 'themes', stylesheet_name)
188
- asset_path = if File.exist?(themes_path)
189
- themes_path
190
- else
191
- # Fall back to assets directory for backward compatibility
192
- File.join(__dir__, 'assets', stylesheet_name)
193
- end
194
-
195
- css_content = if File.exist?(asset_path)
196
- File.read(asset_path)
197
- else
198
- # Fallback to default coradoc.css
199
- default_path = File.join(__dir__, 'assets', 'coradoc.css')
200
- File.exist?(default_path) ? File.read(default_path) : ''
201
- end
202
-
203
- # Resolve @import statements for embedded CSS
204
- # @import doesn't work in inline <style> tags
205
- resolve_css_imports(css_content, File.dirname(asset_path))
206
- end
207
-
208
- # Resolve @import statements in CSS content
209
- # @param css_content [String] CSS content with potential @import statements
210
- # @param base_dir [String] Base directory for resolving relative imports
211
- # @return [String] CSS content with imports resolved
212
- def resolve_css_imports(css_content, base_dir)
213
- # Match @import url('...') or @import url("...") or @import '...' or @import "..."
214
- css_content.gsub(/@import\s+(?:url\()?['"]([^'"]+)['"]\)?;?/) do
215
- import_path = ::Regexp.last_match(1)
216
- full_path = File.join(base_dir, import_path)
217
-
218
- if File.exist?(full_path)
219
- # Read the imported file and recursively resolve its imports
220
- imported_content = File.read(full_path)
221
- resolve_css_imports(imported_content, File.dirname(full_path))
222
- else
223
- # Keep the original import if file not found
224
- ::Regexp.last_match(0)
225
- end
226
- end
227
- end
228
-
229
- # Build CSS link tag
230
- def css_link_tag(options = {})
231
- href = stylesheet_path(options)
232
- doc = Nokogiri::HTML::Document.new
233
- node = Nokogiri::XML::Node.new('link', doc)
234
- node['rel'] = 'stylesheet'
235
- node['href'] = href
236
- node.to_html
237
- end
238
-
239
- # Build CSS style tag with embedded content
240
- def css_style_tag(options = {})
241
- css_content = embedded_stylesheet(options)
242
- custom_css = options[:custom_css]
243
-
244
- content = css_content
245
- content += "\n\n#{custom_css}" if custom_css && !custom_css.empty?
246
-
247
- build_text_element('style', content)
248
- end
249
-
250
- # Build custom CSS style tag
251
- def custom_css_tag(custom_css)
252
- return '' unless custom_css && !custom_css.empty?
253
-
254
- build_text_element('style', custom_css)
255
- end
256
-
257
- # Determine whether to embed or link CSS
258
- def embed_css?(options = {})
259
- # Embed if linkcss is false or embedded mode is true
260
- !options.fetch(:linkcss, DEFAULT_OPTIONS[:linkcss]) ||
261
- options.fetch(:embedded, DEFAULT_OPTIONS[:embedded])
262
- end
263
-
264
- # Build complete CSS tags (link or embedded, plus custom)
265
- def css_tags(options = {})
266
- tags = []
267
-
268
- if embed_css?(options)
269
- # Embedded mode: include full stylesheet in style tag
270
- tags << css_style_tag(options)
271
- else
272
- # Linked mode: link to external stylesheet
273
- tags << css_link_tag(options)
274
- # Add custom CSS separately if provided
275
- tags << custom_css_tag(options[:custom_css]) if options[:custom_css]
276
- end
277
-
278
- tags.join("\n")
114
+ TagMapping.tag_for(element_type)
279
115
  end
280
116
 
281
- # Determine whether to embed or link JavaScript
282
- def embed_js?(options = {})
283
- # Embed if linkjs is false, embedded mode is true, or linkcss is false (to match CSS behavior)
284
- !options.fetch(:linkjs, DEFAULT_OPTIONS[:linkjs]) ||
285
- options.fetch(:embedded, DEFAULT_OPTIONS[:embedded]) ||
286
- !options.fetch(:linkcss, DEFAULT_OPTIONS[:linkcss])
287
- end
288
-
289
- # Get JavaScript file path
290
- def javascript_path(options = {})
291
- javascript = options[:javascript] || DEFAULT_OPTIONS[:javascript]
292
- jsdir = options[:jsdir] || DEFAULT_OPTIONS[:jsdir]
293
-
294
- if jsdir && jsdir != '.'
295
- File.join(jsdir, javascript)
296
- else
297
- javascript
298
- end
299
- end
300
-
301
- # Get embedded JavaScript content
302
- def embedded_javascript(options = {})
303
- javascript_name = options[:javascript] || DEFAULT_OPTIONS[:javascript]
304
- asset_path = File.join(__dir__, 'assets', 'js', javascript_name)
305
-
306
- if File.exist?(asset_path)
307
- File.read(asset_path)
308
- else
309
- ''
310
- end
311
- end
312
-
313
- # Build JavaScript link tag
314
- def js_link_tag(options = {})
315
- src = javascript_path(options)
316
- doc = Nokogiri::HTML::Document.new
317
- node = Nokogiri::XML::Node.new('script', doc)
318
- node['src'] = src
319
- node['defer'] = ''
320
- node.to_html
321
- end
322
-
323
- # Build JavaScript script tag with embedded content
324
- def js_script_tag(options = {})
325
- js_content = embedded_javascript(options)
326
- return '' if js_content.empty?
327
-
328
- build_text_element('script', js_content)
329
- end
330
-
331
- # Build complete JavaScript tags (link or embedded)
332
- def js_tags(options = {})
333
- return '' if options[:javascript] == false
334
-
335
- tags = []
117
+ def code_block_attributes(language, options = {})
118
+ attrs = {}
119
+ attrs[:class] = "language-#{language}" if language && !language.empty?
336
120
 
337
- tags << if embed_js?(options)
338
- # Embedded mode: include full JavaScript in script tag
339
- js_script_tag(options)
340
- else
341
- # Linked mode: link to external JavaScript file
342
- js_link_tag(options)
343
- end
121
+ attrs[:class] = [attrs[:class], 'line-numbers'].compact.join(' ') if options[:linenums] || options[:line_numbers]
344
122
 
345
- tags.join("\n")
123
+ attrs
346
124
  end
347
125
 
348
- # Check if theme toggle should be enabled
349
126
  def theme_toggle?(options = {})
350
127
  options.fetch(:theme_toggle, DEFAULT_OPTIONS[:theme_toggle])
351
128
  end
352
129
 
353
- # Check if interactive TOC should be enabled
354
130
  def toc_interactive?(options = {})
355
- # Default to true if TOC is enabled and toc_interactive is not explicitly set to false
356
131
  toc_enabled = options.fetch(:toc, DEFAULT_OPTIONS[:toc])
357
132
  toc_interactive = options[:toc_interactive]
358
-
359
- # If toc_interactive is nil, default to true when TOC is enabled
360
- if toc_interactive.nil?
361
- toc_enabled
362
- else
363
- toc_interactive
364
- end
365
- end
366
-
367
- # Build syntax highlighter tags (CSS and JS)
368
- # @param options [Hash] Configuration options
369
- # @return [String] HTML tags for syntax highlighting
370
- def syntax_highlighter_tags(options = {})
371
- highlighter = options[:source_highlighter]
372
- return '' unless highlighter
373
-
374
- case highlighter.to_sym
375
- when :highlightjs, :highlight_js, :'highlight.js'
376
- highlightjs_tags(options)
377
- when :pygments
378
- # Pygments requires server-side processing, not implemented for client-side HTML
379
- ''
380
- when :rouge
381
- # Rouge requires server-side processing, not implemented for client-side HTML
382
- ''
383
- else
384
- ''
385
- end
386
- end
387
-
388
- # Build Highlight.js tags
389
- # @param options [Hash] Configuration options
390
- # @return [String] HTML tags for Highlight.js
391
- def highlightjs_tags(options = {})
392
- theme = options[:highlightjs_theme] || DEFAULT_OPTIONS[:highlightjs_theme]
393
- doc = Nokogiri::HTML::Document.new
394
-
395
- link_node = Nokogiri::XML::Node.new('link', doc)
396
- link_node['rel'] = 'stylesheet'
397
- link_node['href'] = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/#{theme}.min.css"
398
-
399
- script_node = Nokogiri::XML::Node.new('script', doc)
400
- script_node['src'] = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js'
401
-
402
- init_node = Nokogiri::XML::Node.new('script', doc)
403
- init_node.content = 'hljs.highlightAll();'
404
-
405
- [link_node.to_html, script_node.to_html, init_node.to_html].join("\n")
133
+ toc_interactive.nil? ? toc_enabled : toc_interactive
406
134
  end
135
+ end
407
136
 
408
- # Get data attributes for code block
409
- # @param language [String] Programming language
410
- # @param options [Hash] Code block options
411
- # @return [Hash] Data attributes
412
- def code_block_attributes(language, options = {})
413
- attrs = {}
414
-
415
- attrs[:class] = "language-#{language}" if language && !language.empty?
416
-
417
- if options[:linenums] || options[:line_numbers]
418
- attrs[:class] =
419
- [attrs[:class], 'line-numbers'].compact.join(' ')
420
- end
421
-
422
- attrs
423
- end
424
-
425
- # Mapping of Coradoc elements to HTML tags
426
- TAG_MAPPING = {
427
- # Sections
428
- section: 'section',
429
- header: 'header',
430
-
431
- # Blocks
432
- paragraph: 'p',
433
- example: 'div',
434
- sidebar: 'aside',
435
- quote: 'blockquote',
436
- verse: 'div',
437
- listing: 'pre',
438
- literal: 'pre',
439
- source: 'pre',
440
- open: 'div',
441
-
442
- # Lists
443
- ordered_list: 'ol',
444
- unordered_list: 'ul',
445
- list_item: 'li',
446
- description_list: 'dl',
447
- description_term: 'dt',
448
- description_detail: 'dd',
449
-
450
- # Tables
451
- table: 'table',
452
- table_row: 'tr',
453
- table_cell: 'td',
454
- table_header: 'th',
455
-
456
- # Inline
457
- bold: 'strong',
458
- italic: 'em',
459
- monospace: 'code',
460
- highlight: 'mark',
461
- superscript: 'sup',
462
- subscript: 'sub',
463
- underline: 'u',
464
- strikethrough: 'del',
465
- small_caps: 'span',
466
-
467
- # Links
468
- anchor: 'a',
469
- cross_reference: 'a',
470
-
471
- # Media
472
- image: 'img',
473
- video: 'video',
474
- audio: 'audio',
475
-
476
- # Other
477
- break: 'hr',
478
- line_break: 'br',
479
- admonition: 'div'
480
- }.freeze
137
+ ASSET_METHODS = %i[
138
+ stylesheet_path embedded_stylesheet resolve_css_imports
139
+ css_link_tag css_style_tag custom_css_tag embed_css? css_tags
140
+ javascript_path embedded_javascript
141
+ js_link_tag js_script_tag embed_js? js_tags
142
+ syntax_highlighter_tags highlightjs_tags
143
+ ].freeze
481
144
 
482
- def build_text_element(tag_name, content)
483
- doc = Nokogiri::HTML::Document.new
484
- node = Nokogiri::XML::Node.new(tag_name, doc)
485
- node.content = content
486
- node.to_html
487
- end
145
+ ASSET_METHODS.each do |method|
146
+ define_method(method) { |*args, **kwargs, &blk| AssetResolver.public_send(method, *args, **kwargs, &blk) }
488
147
  end
148
+ module_function(*ASSET_METHODS)
489
149
  end
490
150
  end
491
151
  end
@@ -28,14 +28,53 @@ module Coradoc
28
28
  # Provides shared `merge` and `defaults` patterns.
29
29
  # Subclasses define `initialize`, `to_h`, and `validate!`.
30
30
  class ConfigurationBase
31
- def self.defaults
32
- new
31
+ class << self
32
+ # Declare a configuration attribute with an optional default.
33
+ # Replaces manual attr_accessor + initialize + to_h boilerplate.
34
+ def attribute(name, default: nil)
35
+ attr_accessor name
36
+
37
+ configuration_attributes[name] = default
38
+ end
39
+
40
+ # Registry of declared attributes and their defaults
41
+ def configuration_attributes
42
+ @configuration_attributes ||= {}
43
+ end
44
+
45
+ def defaults
46
+ new
47
+ end
48
+ end
49
+
50
+ def initialize(**options)
51
+ self.class.configuration_attributes.each do |name, default|
52
+ value = options.fetch(name) { default.respond_to?(:call) ? default.call : default }
53
+ public_send(:"#{name}=", value)
54
+ end
55
+ end
56
+
57
+ def to_h
58
+ self.class.configuration_attributes.each_with_object({}) do |(name, _), hash|
59
+ hash[name] = public_send(name)
60
+ end
33
61
  end
34
62
 
35
63
  def merge(other)
36
64
  other_hash = other.is_a?(self.class) ? other.to_h : other.to_h.transform_keys(&:to_sym)
37
65
  self.class.new(**to_h, **other_hash)
38
66
  end
67
+
68
+ protected
69
+
70
+ def range_check(name, min, max, label: nil)
71
+ value = public_send(name)
72
+ return if value.is_a?(Integer) && value.between?(min, max)
73
+
74
+ display = label || name.to_s.tr('_', ' ').gsub(/\b\w/, &:upcase)
75
+ raise ConverterBase::ValidationError,
76
+ "#{display} must be an integer between #{min} and #{max}"
77
+ end
39
78
  end
40
79
 
41
80
  attr_reader :document, :config
@@ -4,23 +4,12 @@ module Coradoc
4
4
  module Html
5
5
  module Drop
6
6
  class BlockDrop < Base
7
- SEMANTIC_TAG_MAP = {
8
- paragraph: 'p', source_code: 'pre', quote: 'blockquote',
9
- verse: 'blockquote', example: 'div', sidebar: 'aside',
10
- literal: 'pre', listing: 'pre', open: 'div',
11
- horizontal_rule: 'hr'
12
- }.freeze
13
-
14
- SEMANTIC_CLASS_MAP = {
15
- example: 'example', sidebar: 'sidebar', literal: 'literal'
16
- }.freeze
17
-
18
7
  def semantic_type
19
8
  resolved_semantic_type.to_s
20
9
  end
21
10
 
22
11
  def html_tag
23
- SEMANTIC_TAG_MAP[resolved_semantic_type] || 'div'
12
+ TagMapping.tag_for(resolved_semantic_type)
24
13
  end
25
14
 
26
15
  def language
@@ -28,7 +17,7 @@ module Coradoc
28
17
  end
29
18
 
30
19
  def css_class
31
- cls = SEMANTIC_CLASS_MAP[resolved_semantic_type]
20
+ cls = TagMapping.css_class_for(resolved_semantic_type)
32
21
  cls ? "block-#{semantic_type} #{cls}" : "block-#{semantic_type}"
33
22
  end
34
23
 
@@ -4,31 +4,12 @@ module Coradoc
4
4
  module Html
5
5
  module Drop
6
6
  class InlineElementDrop < Base
7
- FORMAT_TAG_MAP = {
8
- 'bold' => 'strong',
9
- 'italic' => 'em',
10
- 'monospace' => 'code',
11
- 'superscript' => 'sup',
12
- 'subscript' => 'sub',
13
- 'underline' => 'u',
14
- 'strikethrough' => 'del',
15
- 'highlight' => 'mark',
16
- 'quotation' => 'q',
17
- 'small' => 'small',
18
- 'stem' => 'code'
19
- }.freeze
20
-
21
7
  def format_type
22
8
  @model.resolve_format_type
23
9
  end
24
10
 
25
11
  def html_tag
26
- case format_type
27
- when 'link', 'xref' then 'a'
28
- when 'footnote' then 'sup'
29
- when 'span', 'term' then 'span'
30
- else FORMAT_TAG_MAP[format_type]
31
- end
12
+ TagMapping.tag_for(format_type)
32
13
  end
33
14
 
34
15
  def href
@@ -5,11 +5,7 @@ module Coradoc
5
5
  module Drop
6
6
  class ListBlockDrop < Base
7
7
  def html_tag
8
- case @model.marker_type
9
- when 'ordered' then 'ol'
10
- when 'definition' then 'dl'
11
- else 'ul'
12
- end
8
+ TagMapping.tag_for(@model.marker_type)
13
9
  end
14
10
 
15
11
  def items
@@ -9,7 +9,7 @@ module Coradoc
9
9
  end
10
10
 
11
11
  def html_tag
12
- header? ? 'th' : 'td'
12
+ TagMapping.tag_for(header? ? :table_header : :table_cell)
13
13
  end
14
14
 
15
15
  def colspan