coradoc-html 1.1.7

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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/lib/coradoc/html/base.rb +157 -0
  4. data/lib/coradoc/html/config.rb +467 -0
  5. data/lib/coradoc/html/converter_base.rb +177 -0
  6. data/lib/coradoc/html/converters/admonition.rb +180 -0
  7. data/lib/coradoc/html/converters/attribute.rb +68 -0
  8. data/lib/coradoc/html/converters/attribute_reference.rb +60 -0
  9. data/lib/coradoc/html/converters/audio.rb +165 -0
  10. data/lib/coradoc/html/converters/base.rb +615 -0
  11. data/lib/coradoc/html/converters/bibliography.rb +82 -0
  12. data/lib/coradoc/html/converters/bibliography_entry.rb +108 -0
  13. data/lib/coradoc/html/converters/block_image.rb +72 -0
  14. data/lib/coradoc/html/converters/bold.rb +34 -0
  15. data/lib/coradoc/html/converters/break.rb +32 -0
  16. data/lib/coradoc/html/converters/comment_block.rb +42 -0
  17. data/lib/coradoc/html/converters/comment_line.rb +54 -0
  18. data/lib/coradoc/html/converters/cross_reference.rb +59 -0
  19. data/lib/coradoc/html/converters/document.rb +108 -0
  20. data/lib/coradoc/html/converters/example.rb +114 -0
  21. data/lib/coradoc/html/converters/highlight.rb +34 -0
  22. data/lib/coradoc/html/converters/include.rb +68 -0
  23. data/lib/coradoc/html/converters/inline_image.rb +41 -0
  24. data/lib/coradoc/html/converters/italic.rb +34 -0
  25. data/lib/coradoc/html/converters/line_break.rb +31 -0
  26. data/lib/coradoc/html/converters/link.rb +46 -0
  27. data/lib/coradoc/html/converters/list_item.rb +75 -0
  28. data/lib/coradoc/html/converters/listing.rb +99 -0
  29. data/lib/coradoc/html/converters/literal.rb +102 -0
  30. data/lib/coradoc/html/converters/monospace.rb +34 -0
  31. data/lib/coradoc/html/converters/open.rb +78 -0
  32. data/lib/coradoc/html/converters/ordered.rb +53 -0
  33. data/lib/coradoc/html/converters/paragraph.rb +46 -0
  34. data/lib/coradoc/html/converters/quote.rb +113 -0
  35. data/lib/coradoc/html/converters/reviewer_comment.rb +74 -0
  36. data/lib/coradoc/html/converters/reviewer_note.rb +134 -0
  37. data/lib/coradoc/html/converters/section.rb +90 -0
  38. data/lib/coradoc/html/converters/sidebar.rb +113 -0
  39. data/lib/coradoc/html/converters/source.rb +137 -0
  40. data/lib/coradoc/html/converters/source_code.rb +16 -0
  41. data/lib/coradoc/html/converters/span.rb +61 -0
  42. data/lib/coradoc/html/converters/strikethrough.rb +34 -0
  43. data/lib/coradoc/html/converters/subscript.rb +34 -0
  44. data/lib/coradoc/html/converters/superscript.rb +34 -0
  45. data/lib/coradoc/html/converters/table.rb +85 -0
  46. data/lib/coradoc/html/converters/table_cell.rb +203 -0
  47. data/lib/coradoc/html/converters/table_row.rb +45 -0
  48. data/lib/coradoc/html/converters/template_html_converter.rb +105 -0
  49. data/lib/coradoc/html/converters/term.rb +58 -0
  50. data/lib/coradoc/html/converters/text_element.rb +44 -0
  51. data/lib/coradoc/html/converters/underline.rb +34 -0
  52. data/lib/coradoc/html/converters/unordered.rb +47 -0
  53. data/lib/coradoc/html/converters/verse.rb +105 -0
  54. data/lib/coradoc/html/converters/video.rb +179 -0
  55. data/lib/coradoc/html/element_mapping.rb +210 -0
  56. data/lib/coradoc/html/entity.rb +137 -0
  57. data/lib/coradoc/html/input/cleaner.rb +163 -0
  58. data/lib/coradoc/html/input/config.rb +79 -0
  59. data/lib/coradoc/html/input/converters/a.rb +90 -0
  60. data/lib/coradoc/html/input/converters/aside.rb +23 -0
  61. data/lib/coradoc/html/input/converters/audio.rb +50 -0
  62. data/lib/coradoc/html/input/converters/base.rb +116 -0
  63. data/lib/coradoc/html/input/converters/blockquote.rb +25 -0
  64. data/lib/coradoc/html/input/converters/br.rb +19 -0
  65. data/lib/coradoc/html/input/converters/bypass.rb +83 -0
  66. data/lib/coradoc/html/input/converters/code.rb +25 -0
  67. data/lib/coradoc/html/input/converters/div.rb +25 -0
  68. data/lib/coradoc/html/input/converters/dl.rb +106 -0
  69. data/lib/coradoc/html/input/converters/drop.rb +28 -0
  70. data/lib/coradoc/html/input/converters/em.rb +23 -0
  71. data/lib/coradoc/html/input/converters/figure.rb +58 -0
  72. data/lib/coradoc/html/input/converters/h.rb +76 -0
  73. data/lib/coradoc/html/input/converters/head.rb +30 -0
  74. data/lib/coradoc/html/input/converters/hr.rb +20 -0
  75. data/lib/coradoc/html/input/converters/ignore.rb +22 -0
  76. data/lib/coradoc/html/input/converters/img.rb +110 -0
  77. data/lib/coradoc/html/input/converters/li.rb +35 -0
  78. data/lib/coradoc/html/input/converters/mark.rb +21 -0
  79. data/lib/coradoc/html/input/converters/markup.rb +107 -0
  80. data/lib/coradoc/html/input/converters/math.rb +46 -0
  81. data/lib/coradoc/html/input/converters/ol.rb +46 -0
  82. data/lib/coradoc/html/input/converters/p.rb +81 -0
  83. data/lib/coradoc/html/input/converters/pass_through.rb +19 -0
  84. data/lib/coradoc/html/input/converters/pre.rb +59 -0
  85. data/lib/coradoc/html/input/converters/q.rb +24 -0
  86. data/lib/coradoc/html/input/converters/strong.rb +22 -0
  87. data/lib/coradoc/html/input/converters/sub.rb +40 -0
  88. data/lib/coradoc/html/input/converters/sup.rb +40 -0
  89. data/lib/coradoc/html/input/converters/table.rb +64 -0
  90. data/lib/coradoc/html/input/converters/td.rb +70 -0
  91. data/lib/coradoc/html/input/converters/text.rb +67 -0
  92. data/lib/coradoc/html/input/converters/th.rb +20 -0
  93. data/lib/coradoc/html/input/converters/tr.rb +28 -0
  94. data/lib/coradoc/html/input/converters/video.rb +53 -0
  95. data/lib/coradoc/html/input/converters.rb +122 -0
  96. data/lib/coradoc/html/input/errors.rb +22 -0
  97. data/lib/coradoc/html/input/html_converter.rb +170 -0
  98. data/lib/coradoc/html/input/plugin.rb +169 -0
  99. data/lib/coradoc/html/input/plugins/plateau.rb +229 -0
  100. data/lib/coradoc/html/input/postprocessor.rb +31 -0
  101. data/lib/coradoc/html/input.rb +68 -0
  102. data/lib/coradoc/html/output.rb +95 -0
  103. data/lib/coradoc/html/renderer.rb +409 -0
  104. data/lib/coradoc/html/spa.rb +309 -0
  105. data/lib/coradoc/html/static.rb +293 -0
  106. data/lib/coradoc/html/template_config.rb +151 -0
  107. data/lib/coradoc/html/template_helpers.rb +58 -0
  108. data/lib/coradoc/html/template_locator.rb +114 -0
  109. data/lib/coradoc/html/theme/base.rb +231 -0
  110. data/lib/coradoc/html/theme/classic_renderer.rb +390 -0
  111. data/lib/coradoc/html/theme/modern/components/ui_components.rb +344 -0
  112. data/lib/coradoc/html/theme/modern/css_generator.rb +311 -0
  113. data/lib/coradoc/html/theme/modern/javascript_generator.rb +314 -0
  114. data/lib/coradoc/html/theme/modern/serializers/document_serializer.rb +382 -0
  115. data/lib/coradoc/html/theme/modern/tailwind_config_builder.rb +164 -0
  116. data/lib/coradoc/html/theme/modern/vue_template_generator.rb +374 -0
  117. data/lib/coradoc/html/theme/modern_renderer.rb +250 -0
  118. data/lib/coradoc/html/theme/registry.rb +153 -0
  119. data/lib/coradoc/html/theme.rb +13 -0
  120. data/lib/coradoc/html/transform/from_core_model.rb +32 -0
  121. data/lib/coradoc/html/transform/to_core_model.rb +39 -0
  122. data/lib/coradoc/html/version.rb +7 -0
  123. data/lib/coradoc/html.rb +255 -0
  124. metadata +264 -0
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Coradoc
6
+ module Html
7
+ # Configuration for the Liquid template system
8
+ #
9
+ # This class manages template directories and provides utilities
10
+ # for template discovery and customization.
11
+ #
12
+ # @example Global configuration
13
+ # Coradoc::Html.configure do |config|
14
+ # config.template_dirs = ["/path/to/custom/templates"]
15
+ # end
16
+ #
17
+ # @example Per-render configuration
18
+ # Coradoc::Html.serialize(document, template_dirs: ["/custom/templates"])
19
+ #
20
+ class TemplateConfig
21
+ # Default template directory within the gem
22
+ DEFAULT_TEMPLATE_DIR = Pathname.new(File.join(
23
+ File.dirname(__FILE__), 'templates', 'core_model'
24
+ )).freeze
25
+
26
+ # @return [Array<Pathname>] List of user-provided template directories
27
+ attr_accessor :template_dirs
28
+
29
+ # Initialize a new configuration
30
+ #
31
+ # @param template_dirs [Array<String, Pathname>] Custom template directories
32
+ def initialize(template_dirs: [])
33
+ @template_dirs = Array(template_dirs).map { |dir| Pathname.new(dir) }
34
+ end
35
+
36
+ # Get all template directories (user + default)
37
+ #
38
+ # @return [Array<Pathname>] All template directories in search order
39
+ def all_template_dirs
40
+ @template_dirs + [DEFAULT_TEMPLATE_DIR]
41
+ end
42
+
43
+ # List all available default templates
44
+ #
45
+ # @return [Array<Symbol>] List of template names (without .liquid extension)
46
+ def self.available_templates
47
+ @available_templates ||= begin
48
+ return [] unless DEFAULT_TEMPLATE_DIR.exist?
49
+
50
+ DEFAULT_TEMPLATE_DIR
51
+ .glob('*.liquid')
52
+ .map { |f| f.basename('.liquid').to_s.to_sym }
53
+ .sort
54
+ end
55
+ end
56
+
57
+ # Get the path to a specific default template
58
+ #
59
+ # @param name [Symbol, String] Template name (e.g., :bibliography)
60
+ # @return [Pathname, nil] Path to the template file, or nil if not found
61
+ def self.template_path_for(name)
62
+ path = DEFAULT_TEMPLATE_DIR.join("#{name}.liquid")
63
+ path.exist? ? path : nil
64
+ end
65
+
66
+ # Check if a template exists
67
+ #
68
+ # @param name [Symbol, String] Template name
69
+ # @return [Boolean] True if template exists in any directory
70
+ def template_exists?(name)
71
+ all_template_dirs.any? do |dir|
72
+ dir.join("#{name}.liquid").exist?
73
+ end
74
+ end
75
+
76
+ # Find a template by name
77
+ #
78
+ # @param name [Symbol, String] Template name
79
+ # @return [Pathname, nil] Path to the first matching template
80
+ def find_template(name)
81
+ all_template_dirs.each do |dir|
82
+ path = dir.join("#{name}.liquid")
83
+ return path if path.exist?
84
+ end
85
+ nil
86
+ end
87
+
88
+ # Reset configuration to defaults
89
+ #
90
+ # @return [void]
91
+ def reset!
92
+ @template_dirs = []
93
+ end
94
+
95
+ # Create a copy of this configuration with additional directories
96
+ #
97
+ # @param additional_dirs [Array<String, Pathname>] Extra directories
98
+ # @return [TemplateConfig] New configuration with merged directories
99
+ def with_dirs(additional_dirs)
100
+ self.class.new(
101
+ template_dirs: @template_dirs + Array(additional_dirs).map { |d| Pathname.new(d) }
102
+ )
103
+ end
104
+ end
105
+
106
+ # Module-level configuration storage
107
+ class << self
108
+ # Get the global configuration
109
+ #
110
+ # @return [TemplateConfig] The global configuration
111
+ def configuration
112
+ @configuration ||= TemplateConfig.new
113
+ end
114
+
115
+ # Configure the template system
116
+ #
117
+ # @yield [TemplateConfig] Yields the configuration object
118
+ # @return [void]
119
+ #
120
+ # @example
121
+ # Coradoc::Html.configure do |config|
122
+ # config.template_dirs = ["/path/to/templates"]
123
+ # end
124
+ def configure
125
+ yield(configuration) if block_given?
126
+ end
127
+
128
+ # Reset configuration to defaults
129
+ #
130
+ # @return [void]
131
+ def reset_configuration!
132
+ @configuration = nil
133
+ end
134
+
135
+ # List all available default templates
136
+ #
137
+ # @return [Array<Symbol>] List of template names
138
+ def available_templates
139
+ TemplateConfig.available_templates
140
+ end
141
+
142
+ # Get the path to a default template
143
+ #
144
+ # @param name [Symbol, String] Template name
145
+ # @return [Pathname, nil] Path to template or nil
146
+ def template_path_for(name)
147
+ TemplateConfig.template_path_for(name)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'liquid'
4
+
5
+ module Coradoc
6
+ module Html
7
+ # Liquid filters for template rendering
8
+ module TemplateFilters
9
+ # Render a CoreModel element by looking up its template
10
+ #
11
+ # Usage in templates:
12
+ # {{ child | render_element }}
13
+ # {% for item in children %}{{ item | render_element }}{% endfor %}
14
+ #
15
+ def render_element(input, renderer = nil)
16
+ return '' if input.nil?
17
+ return input.map { |i| render_element(i, renderer) }.join("\n") if input.is_a?(Array)
18
+
19
+ # Get the renderer from context registers
20
+ renderer ||= @context.registers[:renderer]
21
+
22
+ # If input is a Liquid::Drop, extract the original model object.
23
+ # Liquid::Drop stores the wrapped object as @object with no public
24
+ # reader — this is the only way to unwrap it.
25
+ original = if input.is_a?(::Liquid::Drop)
26
+ input.instance_variable_get(:@object)
27
+ else
28
+ input
29
+ end
30
+
31
+ # Renderer is always a Coradoc::Html::Renderer which has a render method
32
+ renderer.render(original)
33
+ end
34
+
35
+ # Escape HTML entities
36
+ def escape_html(input)
37
+ input.to_s
38
+ .gsub(/&/, '&amp;')
39
+ .gsub(/</, '&lt;')
40
+ .gsub(/>/, '&gt;')
41
+ .gsub(/"/, '&quot;')
42
+ .gsub(/'/, '&#39;')
43
+ end
44
+
45
+ # Escape HTML attribute values
46
+ def escape_attr(input)
47
+ input.to_s
48
+ .gsub(/&/, '&amp;')
49
+ .gsub(/"/, '&quot;')
50
+ .gsub(/</, '&lt;')
51
+ .gsub(/>/, '&gt;')
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ # Register filters with Liquid (using the non-deprecated API)
58
+ Liquid::Environment.default.register_filter(Coradoc::Html::TemplateFilters)
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'liquid'
4
+ require 'pathname'
5
+
6
+ module Coradoc
7
+ module Html
8
+ # Locates Liquid templates from filesystem with fallback support
9
+ #
10
+ # This class implements a template lookup algorithm:
11
+ # 1. Check user's template directories in order (first match wins)
12
+ # 2. Fall back to default templates if not found
13
+ # 3. Return nil if no template found (caller decides fallback)
14
+ #
15
+ # @example Basic usage
16
+ # locator = TemplateLocator.new(
17
+ # user_dirs: ["/path/to/templates"],
18
+ # default_dir: "/default/templates"
19
+ # )
20
+ # locator.find("bibliography") # => "/path/to/templates/bibliography.liquid"
21
+ #
22
+ class TemplateLocator
23
+ # Default template subdirectory within each template root
24
+ CORE_MODEL_DIR = 'core_model'
25
+
26
+ attr_reader :user_dirs, :default_dir
27
+
28
+ # Initialize the locator
29
+ #
30
+ # @param user_dirs [Array<String>] User template directories (checked first)
31
+ # @param default_dir [String] Default templates directory (fallback)
32
+ def initialize(user_dirs: [], default_dir: nil)
33
+ @user_dirs = Array(user_dirs).map { |d| Pathname.new(d) }
34
+ @default_dir = default_dir ? Pathname.new(default_dir) : default_template_dir
35
+ @cache = {}
36
+ end
37
+
38
+ # Find a template by type name
39
+ #
40
+ # @param type_name [String] The template type (e.g., "bibliography", "section")
41
+ # @return [Pathname, nil] Path to template file or nil if not found
42
+ def find(type_name)
43
+ return @cache[type_name] if @cache.key?(type_name)
44
+
45
+ # First check user directories
46
+ @user_dirs.each do |dir|
47
+ template_path = dir / CORE_MODEL_DIR / "#{type_name}.liquid"
48
+ if template_path.exist?
49
+ @cache[type_name] = template_path
50
+ return template_path
51
+ end
52
+ end
53
+
54
+ # Then check default directory
55
+ if @default_dir
56
+ template_path = @default_dir / "#{type_name}.liquid"
57
+ if template_path.exist?
58
+ @cache[type_name] = template_path
59
+ return template_path
60
+ end
61
+ end
62
+
63
+ @cache[type_name] = nil
64
+ nil
65
+ end
66
+
67
+ # Check if a template exists (without loading it)
68
+ #
69
+ # @param type_name [String] The template type
70
+ # @return [Boolean] True if template exists
71
+ def exists?(type_name)
72
+ !find(type_name).nil?
73
+ end
74
+
75
+ # Get all available template types
76
+ #
77
+ # @return [Array<String>] List of available template names
78
+ def available_templates
79
+ types = Set.new
80
+
81
+ # Collect from user directories
82
+ @user_dirs.each do |dir|
83
+ core_model_path = dir / CORE_MODEL_DIR
84
+ next unless core_model_path.exist? && core_model_path.directory?
85
+
86
+ core_model_path.glob('*.liquid') do |f|
87
+ types.add(f.basename('.liquid').to_s)
88
+ end
89
+ end
90
+
91
+ # Collect from default directory
92
+ if @default_dir&.exist? && @default_dir.directory?
93
+ @default_dir.glob('*.liquid') do |f|
94
+ types.add(f.basename('.liquid').to_s)
95
+ end
96
+ end
97
+
98
+ types.to_a.sort
99
+ end
100
+
101
+ # Get the default template directory path
102
+ #
103
+ # @return [Pathname] Path to default templates
104
+ def default_template_dir
105
+ Pathname.new(File.join(File.dirname(__FILE__), 'templates', CORE_MODEL_DIR))
106
+ end
107
+
108
+ # Clear the template cache (useful when template directories change)
109
+ def clear_cache
110
+ @cache.clear
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'coradoc/html/base'
4
+
5
+ module Coradoc
6
+ module Html
7
+ module Theme
8
+ # Abstract base class for all HTML themes
9
+ #
10
+ # This class defines the interface that all theme renderers must implement.
11
+ # Themes are responsible for converting Coradoc document models to HTML output.
12
+ #
13
+ # @abstract Subclass and implement {#render} to create a custom theme
14
+ class Base
15
+ attr_reader :document, :options
16
+
17
+ # Initialize a new theme instance
18
+ #
19
+ # @param document [Coradoc::CoreModel::StructuralElement] The document to render
20
+ # @param options [Hash] Rendering options
21
+ def initialize(document, options = {})
22
+ @document = document
23
+ @options = options
24
+ end
25
+
26
+ # Render the document to HTML
27
+ #
28
+ # This method must be implemented by subclasses.
29
+ #
30
+ # @abstract
31
+ # @return [String] The rendered HTML content
32
+ def render
33
+ raise NotImplementedError,
34
+ "#{self.class.name} must implement #render method"
35
+ end
36
+
37
+ # Render the complete HTML5 document
38
+ #
39
+ # @return [String] Complete HTML5 document
40
+ def render_html5
41
+ html_body = render
42
+ build_html5_document(html_body)
43
+ end
44
+
45
+ # Get the theme name
46
+ #
47
+ # @return [Symbol] Theme name (e.g., :classic, :modern)
48
+ def theme_name
49
+ @theme_name ||= self.class.name.split('::').last
50
+ .gsub(/Renderer$/, '')
51
+ .downcase
52
+ .to_sym
53
+ end
54
+
55
+ # Check if this theme supports a specific feature
56
+ #
57
+ # @param feature [Symbol] Feature to check (e.g., :dark_mode, :interactive_toc)
58
+ # @return [Boolean] true if the feature is supported
59
+ def supports?(feature)
60
+ supported_features.include?(feature)
61
+ end
62
+
63
+ # List of features supported by this theme
64
+ #
65
+ # Subclasses can override to declare supported features.
66
+ #
67
+ # @return [Array<Symbol>] List of supported features
68
+ def supported_features
69
+ []
70
+ end
71
+
72
+ protected
73
+
74
+ # Build complete HTML5 document
75
+ #
76
+ # @param body_html [String] HTML body content
77
+ # @return [String] Complete HTML5 document
78
+ def build_html5_document(body_html)
79
+ lang = @options[:lang] || 'en'
80
+ body_classes = build_body_classes
81
+
82
+ <<~HTML
83
+ <!DOCTYPE html>
84
+ <html lang="#{lang}">
85
+ <head>
86
+ #{build_head_content}
87
+ </head>
88
+ <body#{body_classes}>
89
+ #{body_html}
90
+ </body>
91
+ </html>
92
+ HTML
93
+ end
94
+
95
+ # Build HTML head content
96
+ #
97
+ # @return [String] Head section HTML
98
+ def build_head_content
99
+ parts = []
100
+ parts << build_meta_tags
101
+ parts << build_title_tag
102
+ parts << build_css_tags
103
+ parts << build_script_tags
104
+ parts.compact.reject(&:empty?).join("\n")
105
+ end
106
+
107
+ # Build meta tags
108
+ #
109
+ # @return [String] Meta tags HTML
110
+ def build_meta_tags
111
+ meta = []
112
+ meta << ' <meta charset="UTF-8">'
113
+ meta << ' <meta name="viewport" content="width=device-width, initial-scale=1.0">'
114
+
115
+ # Author meta tag
116
+ if @options[:author]
117
+ author = escape_attr(@options[:author])
118
+ meta << %( <meta name="author" content="#{author}">)
119
+ end
120
+
121
+ # Description meta tag
122
+ if @options[:description]
123
+ description = escape_attr(@options[:description])
124
+ meta << %( <meta name="description" content="#{description}">)
125
+ end
126
+
127
+ # Keywords meta tag
128
+ if @options[:keywords]
129
+ keywords = escape_attr(@options[:keywords])
130
+ meta << %( <meta name="keywords" content="#{keywords}">)
131
+ end
132
+
133
+ # Generator meta tag with version
134
+ meta << %( <meta name="generator" content="Coradoc #{Coradoc::VERSION}">)
135
+
136
+ # Generation timestamp
137
+ meta << %( <meta name="generated" content="#{Time.now.utc.iso8601}">)
138
+
139
+ # Custom meta tags
140
+ if @options[:meta_tags].is_a?(Hash)
141
+ @options[:meta_tags].each do |name, content|
142
+ meta << %( <meta name="#{escape_attr(name)}" content="#{escape_attr(content)}">)
143
+ end
144
+ end
145
+
146
+ meta.join("\n")
147
+ end
148
+
149
+ # Build title tag
150
+ #
151
+ # @return [String] Title tag HTML
152
+ def build_title_tag
153
+ title = extract_document_title
154
+ " <title>#{escape_html(title)}</title>"
155
+ end
156
+
157
+ # Extract document title
158
+ #
159
+ # @return [String] Document title
160
+ def extract_document_title
161
+ if @document.is_a?(Coradoc::CoreModel::StructuralElement) && @document.title
162
+ title = @document.title
163
+ return title if title.is_a?(String)
164
+ return title.text if title.is_a?(Coradoc::CoreModel::Base) && title.text
165
+
166
+ return title.to_s
167
+ end
168
+
169
+ 'Untitled Document'
170
+ end
171
+
172
+ # Extract text from content (array of inline elements)
173
+ #
174
+ # @param content [Array] Content elements
175
+ # @return [String] Extracted text
176
+ def extract_text_from_content(content)
177
+ case content
178
+ when Array
179
+ content.map { |item| extract_text_from_content(item) }.join
180
+ when String
181
+ content
182
+ when Coradoc::CoreModel::InlineElement
183
+ content.text
184
+ else
185
+ content.to_s
186
+ end
187
+ end
188
+
189
+ # Build body classes
190
+ #
191
+ # @return [String] Body class attribute
192
+ def build_body_classes
193
+ classes = []
194
+ classes << "theme-#{theme_name}"
195
+
196
+ classes.empty? ? '' : %( class="#{classes.join(' ')}")
197
+ end
198
+
199
+ # Build CSS tags
200
+ #
201
+ # @return [String] CSS link or style tags
202
+ def build_css_tags
203
+ ''
204
+ end
205
+
206
+ # Build script tags
207
+ #
208
+ # @return [String] Script tags
209
+ def build_script_tags
210
+ ''
211
+ end
212
+
213
+ # Escape HTML content
214
+ #
215
+ # @param text [String] Text to escape
216
+ # @return [String] Escaped text
217
+ def escape_html(text)
218
+ Coradoc::Html::Base.escape_html(text.to_s)
219
+ end
220
+
221
+ # Escape HTML attribute value
222
+ #
223
+ # @param value [String] Value to escape
224
+ # @return [String] Escaped value
225
+ def escape_attr(value)
226
+ value.to_s.gsub('"', '&quot;').gsub('<', '&lt;').gsub('>', '&gt;')
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end