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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Html
5
+ # Abstract base class for HTML output converters
6
+ #
7
+ # This class defines the interface that all HTML output converters must implement.
8
+ # It provides common functionality for document validation, configuration building,
9
+ # and HTML output generation.
10
+ #
11
+ # @abstract Subclass and implement {#convert} to create a custom converter
12
+ #
13
+ # @example Creating a custom converter
14
+ # class MyConverter < Coradoc::Html::ConverterBase
15
+ # def convert
16
+ # # Generate HTML from document
17
+ # end
18
+ # end
19
+ class ConverterBase
20
+ # Error class for converter validation errors
21
+ class ValidationError < Coradoc::Error; end
22
+
23
+ # Error class for unsupported document types
24
+ class UnsupportedDocumentError < Coradoc::Error; end
25
+
26
+ attr_reader :document, :config
27
+
28
+ # Initialize a new converter instance
29
+ #
30
+ # @param document [Coradoc::CoreModel::StructuralElement] The document to convert
31
+ # @param config [Hash, Configuration] Converter configuration
32
+ # @raise [UnsupportedDocumentError] if document is not a valid type
33
+ def initialize(document, config = {})
34
+ @document = validate_input(document)
35
+ @config = build_config(config)
36
+ end
37
+
38
+ # Convert the document to HTML
39
+ #
40
+ # @abstract Subclasses must implement this method
41
+ # @return [String] HTML output
42
+ # @raise [NotImplementedError] if not implemented by subclass
43
+ def convert
44
+ raise NotImplementedError,
45
+ "#{self.class.name} must implement #convert method"
46
+ end
47
+
48
+ # Convert and write to file
49
+ #
50
+ # @param output_path [String] Path to write the output file
51
+ # @return [String] The output path
52
+ def to_file(output_path)
53
+ html = convert
54
+
55
+ # Ensure parent directory exists
56
+ output_dir = File.dirname(output_path)
57
+ FileUtils.mkdir_p(output_dir) unless output_dir == '.'
58
+
59
+ File.write(output_path, html)
60
+
61
+ output_path
62
+ end
63
+
64
+ # Class method to convert a document
65
+ #
66
+ # @param document [Coradoc::CoreModel::StructuralElement] The document to convert
67
+ # @param config [Hash] Converter configuration
68
+ # @return [String] HTML output
69
+ def self.convert(document, config = {})
70
+ new(document, config).convert
71
+ end
72
+
73
+ # Class method to convert and write to file
74
+ #
75
+ # @param document [Coradoc::CoreModel::StructuralElement] The document to convert
76
+ # @param output_path [String] Path to write the output file
77
+ # @param config [Hash] Converter configuration
78
+ # @return [String] The output path
79
+ def self.to_file(document, output_path, config = {})
80
+ new(document, config).to_file(output_path)
81
+ end
82
+
83
+ # Get the converter name
84
+ #
85
+ # @return [Symbol] Converter name (e.g., :static, :spa)
86
+ def converter_name
87
+ @converter_name ||= self.class.name.split('::').last
88
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
89
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
90
+ .downcase
91
+ .to_sym
92
+ end
93
+
94
+ protected
95
+
96
+ # Validate the input document
97
+ #
98
+ # @param document [Object] The document to validate
99
+ # @return [Coradoc::CoreModel::Base] The validated document
100
+ # @raise [UnsupportedDocumentError] if document is not valid
101
+ def validate_input(document)
102
+ # Handle transformer hash output
103
+ document = Coradoc::Transformer.transform(document) if document.is_a?(Hash) && document.key?(:document)
104
+
105
+ # Validate document type - ONLY accept CoreModel types
106
+ unless document.is_a?(Coradoc::CoreModel::Base)
107
+ raise UnsupportedDocumentError,
108
+ "Expected CoreModel document, got: #{document.class}. " \
109
+ 'Transform your document to CoreModel first using the appropriate ' \
110
+ 'format transformer (e.g., ToCoreModel for your source format).'
111
+ end
112
+
113
+ document
114
+ end
115
+
116
+ # Build configuration from options
117
+ #
118
+ # @param config [Hash, Object] Configuration options or object
119
+ # @return [Object] Built configuration object
120
+ def build_config(config)
121
+ if config.public_methods.include?(:validate!)
122
+ config.validate!
123
+ return config
124
+ end
125
+
126
+ config
127
+ end
128
+
129
+ def extract_document_title
130
+ if @document.is_a?(Coradoc::CoreModel::StructuralElement) && @document.title
131
+ title = @document.title
132
+ return title if title.is_a?(String)
133
+ return title.text if title.is_a?(Coradoc::CoreModel::Base) && title.text
134
+
135
+ return title.to_s
136
+ end
137
+
138
+ 'Untitled Document'
139
+ end
140
+
141
+ def extract_text_from_content(content)
142
+ case content
143
+ when Array
144
+ content.map { |item| extract_text_from_content(item) }.join
145
+ when String
146
+ content
147
+ when Coradoc::CoreModel::InlineElement
148
+ content.content.to_s
149
+ when Coradoc::CoreModel::Base
150
+ if content.content
151
+ extract_text_from_content(content.content)
152
+ else
153
+ content.to_s
154
+ end
155
+ else
156
+ content.to_s
157
+ end
158
+ end
159
+
160
+ # Escape HTML content
161
+ #
162
+ # @param text [String] Text to escape
163
+ # @return [String] Escaped text
164
+ def escape_html(text)
165
+ Coradoc::Html::Base.escape_html(text.to_s)
166
+ end
167
+
168
+ # Escape HTML attribute value
169
+ #
170
+ # @param value [String] Value to escape
171
+ # @return [String] Escaped value
172
+ def escape_attr(value)
173
+ value.to_s.gsub('"', '&quot;').gsub('<', '&lt;').gsub('>', '&gt;')
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Html
5
+ module Converters
6
+ # Converter for CoreModel::AnnotationBlock to HTML admonition block
7
+ class Admonition < Base
8
+ # Convert CoreModel::AnnotationBlock to HTML admonition block
9
+ def self.to_html(admonition, _options = {})
10
+ return '' unless admonition
11
+
12
+ # Get admonition type (NOTE, TIP, IMPORTANT, WARNING, CAUTION)
13
+ type = admonition.annotation_type ? admonition.annotation_type.to_s.upcase : 'NOTE'
14
+
15
+ # Build div attributes
16
+ attrs = build_attributes(admonition, type)
17
+
18
+ # Build title if present
19
+ title_html = build_title(admonition)
20
+
21
+ # Build admonition label
22
+ label = build_label(type)
23
+
24
+ # Process admonition content
25
+ content = process_content(admonition.content)
26
+
27
+ # Combine label, title, and content
28
+ admonition_html = ''
29
+ admonition_html += "#{label}\n"
30
+ admonition_html += "#{title_html}\n" if title_html
31
+ admonition_html += content
32
+
33
+ %(<div#{attrs}>\n#{admonition_html}\n</div>)
34
+ end
35
+
36
+ # Convert HTML admonition div to CoreModel::AnnotationBlock
37
+ def self.to_coradoc(element, _options = {})
38
+ return nil unless element.name == 'div'
39
+ return nil unless element['class']&.include?('admonition')
40
+
41
+ # Extract admonition type from class
42
+ type = extract_type(element)
43
+ return nil unless type
44
+
45
+ # Extract title if present
46
+ title_elem = element.at_css('.admonition-title')
47
+ title = title_elem&.text&.strip
48
+
49
+ # Extract content - all children except label and title
50
+ content_nodes = element.children.reject do |node|
51
+ node == element.at_css('.admonition-label') ||
52
+ node == title_elem ||
53
+ (node.text? && node.text.strip.empty?)
54
+ end
55
+
56
+ content = extract_content(content_nodes)
57
+
58
+ # Extract ID if present
59
+ id = element['id']
60
+
61
+ Coradoc::CoreModel::AnnotationBlock.new(
62
+ annotation_type: type.downcase,
63
+ content: content,
64
+ title: title,
65
+ id: id
66
+ )
67
+ end
68
+
69
+ def self.build_attributes(admonition, type)
70
+ attrs = [%( class="admonition admonition-#{type.downcase}")]
71
+
72
+ # Add ID if present
73
+ attrs << %( id="#{escape_attribute(admonition.id)}") if admonition.id
74
+
75
+ attrs.join
76
+ end
77
+
78
+ def self.build_label(type)
79
+ %(<div class="admonition-label">#{escape_html(type)}</div>)
80
+ end
81
+
82
+ def self.build_title(admonition)
83
+ return nil unless admonition.title
84
+
85
+ title_text = admonition.title.to_s
86
+ return nil if title_text.empty?
87
+
88
+ %(<div class="admonition-title">#{escape_html(title_text)}</div>)
89
+ end
90
+
91
+ def self.process_content(content)
92
+ return '' if content.nil?
93
+
94
+ if content.is_a?(Array)
95
+ # Group consecutive lines into paragraphs, handling inline elements
96
+ result = []
97
+ current_para = []
98
+
99
+ content.each do |item|
100
+ case item
101
+ when String
102
+ current_para << item
103
+ else
104
+ # End current paragraph and start a new one
105
+ if current_para.any?
106
+ result << build_paragraph(current_para)
107
+ current_para = []
108
+ end
109
+ result << convert_item(item)
110
+ end
111
+ end
112
+
113
+ # Don't forget the last paragraph
114
+ result << build_paragraph(current_para) if current_para.any?
115
+
116
+ result.compact.join("\n")
117
+ elsif content.is_a?(String)
118
+ "<p>#{escape_html(content)}</p>"
119
+ else
120
+ convert_item(content)
121
+ end
122
+ end
123
+
124
+ def self.build_paragraph(items)
125
+ return nil if items.nil? || items.empty?
126
+
127
+ # Convert all items to HTML and join them
128
+ content_html = items.map do |item|
129
+ case item
130
+ when String
131
+ escape_html(item)
132
+ else
133
+ convert_content_to_html(item)
134
+ end
135
+ end.join
136
+
137
+ return nil if content_html.empty?
138
+
139
+ "<p>#{content_html}</p>"
140
+ end
141
+
142
+ def self.convert_item(item)
143
+ case item
144
+ when String
145
+ "<p>#{escape_html(item)}</p>"
146
+ else
147
+ convert_content_to_html(item)
148
+ end
149
+ end
150
+
151
+ def self.extract_type(element)
152
+ return nil unless element['class']
153
+
154
+ # Extract type from class like "admonition-note", "admonition-warning", etc.
155
+ classes = element['class'].split
156
+ type_class = classes.find { |c| c.start_with?('admonition-') && c != 'admonition' }
157
+ return nil unless type_class
158
+
159
+ type_class.sub(/^admonition-/, '').upcase
160
+ end
161
+
162
+ def self.extract_content(nodes)
163
+ # Extract and convert content nodes
164
+ nodes.map do |node|
165
+ if node.text? && !node.text.strip.empty?
166
+ node.text.strip
167
+ elsif node.element?
168
+ case node.name
169
+ when 'p'
170
+ Paragraph.to_coradoc(node)
171
+ else
172
+ node.text.strip
173
+ end
174
+ end
175
+ end.compact
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module Coradoc
6
+ module Html
7
+ module Converters
8
+ # Converts document attributes to/from HTML
9
+ #
10
+ # Attributes are document-level directives (e.g., :author:, :toc:).
11
+ # In HTML, we represent them as HTML comments to preserve the attribute
12
+ # information without affecting the rendered output.
13
+ #
14
+ # Examples:
15
+ # :author: John Doe => <!-- :author: John Doe -->
16
+ # :toc: => <!-- :toc: -->
17
+ class Attribute
18
+ # Convert CoreModel::Block (attribute) to HTML comment
19
+ def self.to_html(model, _options = {})
20
+ key = model.metadata&.dig(:key).to_s
21
+ key = escape_html(key)
22
+
23
+ # Handle single value or array of values
24
+ values = Array(model.metadata&.dig(:value)).compact
25
+
26
+ if values.empty?
27
+ # Attribute with no value (e.g., :toc:)
28
+ "<!-- :#{key}: -->"
29
+ else
30
+ # Attribute with value(s)
31
+ value_str = values.map { |v| escape_html(v.to_s) }.join(', ')
32
+ "<!-- :#{key}: #{value_str} -->"
33
+ end
34
+ end
35
+
36
+ # Convert HTML comment to CoreModel::Block (attribute)
37
+ def self.to_coradoc(element, _options = {})
38
+ return nil unless element.is_a?(Nokogiri::XML::Comment)
39
+
40
+ content = element.content.strip
41
+
42
+ # Match attribute pattern: :key: or :key: value
43
+ return nil unless content.match?(/^:([^:]+):(.*)$/)
44
+
45
+ match = content.match(/^:([^:]+):(.*)$/)
46
+ key = match[1].strip
47
+ value_part = match[2].strip
48
+
49
+ # Parse value(s) - could be comma-separated
50
+ values = if value_part.empty?
51
+ []
52
+ else
53
+ value_part.split(',').map(&:strip)
54
+ end
55
+
56
+ Coradoc::CoreModel::Block.new(
57
+ element_type: 'attribute',
58
+ content: key,
59
+ metadata: {
60
+ key: key,
61
+ value: values
62
+ }
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Html
5
+ module Converters
6
+ # Converts CoreModel::InlineElement with format_type "attribute_reference"
7
+ #
8
+ # Attribute references are inline placeholders that reference document attributes
9
+ # (e.g., {author}, {docname}, {revnumber}).
10
+ #
11
+ # In HTML, we render them as-is with the curly braces to preserve the
12
+ # reference syntax. Actual substitution of attribute values would happen
13
+ # at a different processing layer if needed.
14
+ #
15
+ # Examples:
16
+ # {author} => {author}
17
+ # {docname} => {docname}
18
+ class AttributeReference < Base
19
+ # Convert CoreModel::InlineElement (attribute_reference) to HTML
20
+ #
21
+ # @param model [Coradoc::CoreModel::InlineElement] the attribute reference model
22
+ # @param options [Hash] conversion options
23
+ # @option options [Hash] :document_attributes Document attributes for substitution
24
+ # @return [String] HTML representation of the attribute reference
25
+ def self.to_html(model, options = {})
26
+ name = model.target.to_s
27
+
28
+ # Try to substitute with actual attribute value if document_attributes provided
29
+ if options[:document_attributes]
30
+ value = options[:document_attributes][name] || options[:document_attributes][name]
31
+ return escape_html(value.to_s) if value
32
+ end
33
+
34
+ # Fallback: render as-is with curly braces
35
+ escape_html("{#{name}}")
36
+ end
37
+
38
+ # Convert HTML text to CoreModel::InlineElement (attribute_reference)
39
+ #
40
+ # @param text [String] the HTML text
41
+ # @param _options [Hash] conversion options (unused)
42
+ # @return [Coradoc::CoreModel::InlineElement, nil] the attribute reference model or nil
43
+ def self.to_coradoc(text, _options = {})
44
+ return nil unless text.is_a?(String)
45
+
46
+ # Match attribute reference pattern: {name}
47
+ match = text.match(/^\{([^}]+)\}$/)
48
+ return nil unless match
49
+
50
+ name = match[1]
51
+
52
+ Coradoc::CoreModel::InlineElement.new(
53
+ format_type: 'attribute_reference',
54
+ target: name
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Html
5
+ module Converters
6
+ # Converter for audio elements
7
+ class Audio < Base
8
+ # Convert CoreModel::Block (audio) to HTML <audio>
9
+ def self.to_html(audio, _options = {})
10
+ return '' unless audio
11
+
12
+ # Build audio attributes
13
+ attrs = build_attributes(audio)
14
+
15
+ # Get audio source from metadata or content
16
+ src = audio.metadata&.dig(:src) || audio.content
17
+
18
+ # Build source element
19
+ source_tag = %(<source src="#{escape_attribute(src)}"#{build_type_attr(src)}>)
20
+
21
+ # Build optional caption/title
22
+ caption = audio.title
23
+
24
+ # Determine if we need a wrapper (for block audio with caption)
25
+ if caption
26
+ <<~HTML.strip
27
+ <figure#{build_figure_attrs(audio)}>
28
+ <audio#{attrs}>
29
+ #{source_tag}
30
+ Your browser does not support the audio tag.
31
+ </audio>
32
+ <figcaption>#{escape_html(caption)}</figcaption>
33
+ </figure>
34
+ HTML
35
+ else
36
+ <<~HTML.strip
37
+ <audio#{attrs}>
38
+ #{source_tag}
39
+ Your browser does not support the audio tag.
40
+ </audio>
41
+ HTML
42
+ end
43
+ end
44
+
45
+ # Convert HTML <audio> to CoreModel::Block (audio)
46
+ def self.to_coradoc(element, _options = {})
47
+ # Handle both <audio> and <figure><audio> structures
48
+ audio_elem = if element.name == 'figure'
49
+ element.at_css('audio')
50
+ elsif element.name == 'audio'
51
+ element
52
+ else
53
+ return nil
54
+ end
55
+
56
+ return nil unless audio_elem
57
+
58
+ # Extract source from <source> tag or src attribute
59
+ src = extract_audio_src(audio_elem)
60
+ return nil unless src
61
+
62
+ # Extract caption if in figure
63
+ caption = if element.name == 'figure'
64
+ figcaption = element.at_css('figcaption')
65
+ figcaption&.text&.strip
66
+ end
67
+
68
+ # Extract ID if present
69
+ id = audio_elem['id'] || element['id']
70
+
71
+ # Extract audio attributes
72
+ metadata = extract_audio_metadata(audio_elem)
73
+ metadata[:src] = src
74
+
75
+ Coradoc::CoreModel::Block.new(
76
+ element_type: 'audio',
77
+ content: src,
78
+ title: caption,
79
+ id: id,
80
+ metadata: metadata
81
+ )
82
+ end
83
+
84
+ def self.build_attributes(audio)
85
+ attrs = []
86
+
87
+ # Extract options from metadata
88
+ options = audio.metadata&.dig(:options) || []
89
+
90
+ # Add controls by default (unless nocontrols option is set)
91
+ has_controls = !options.include?('nocontrols')
92
+ attrs << ' controls' if has_controls
93
+
94
+ # Add autoplay if specified in options
95
+ attrs << ' autoplay' if options.include?('autoplay')
96
+
97
+ # Add loop if specified in options
98
+ attrs << ' loop' if options.include?('loop')
99
+
100
+ # Add muted if specified in options
101
+ attrs << ' muted' if options.include?('muted')
102
+
103
+ # Add ID if present
104
+ attrs << %( id="#{escape_attribute(audio.id)}") if audio.id
105
+
106
+ attrs.join
107
+ end
108
+
109
+ def self.build_type_attr(src)
110
+ # Determine audio MIME type from extension
111
+ ext = File.extname(src).downcase
112
+ type = case ext
113
+ when '.mp3'
114
+ 'audio/mpeg'
115
+ when '.ogg', '.oga'
116
+ 'audio/ogg'
117
+ when '.wav'
118
+ 'audio/wav'
119
+ when '.m4a'
120
+ 'audio/mp4'
121
+ when '.aac'
122
+ 'audio/aac'
123
+ when '.flac'
124
+ 'audio/flac'
125
+ end
126
+
127
+ type ? %( type="#{type}") : ''
128
+ end
129
+
130
+ def self.build_figure_attrs(audio)
131
+ attrs = []
132
+
133
+ # Add ID to figure if present
134
+ attrs << %( id="#{escape_attribute(audio.id)}-figure") if audio.id
135
+
136
+ attrs.join
137
+ end
138
+
139
+ def self.extract_audio_src(element)
140
+ # Try to get src from <source> tag first
141
+ source = element.at_css('source')
142
+ return source['src'] if source && source['src']
143
+
144
+ # Fall back to src attribute on <audio>
145
+ element['src']
146
+ end
147
+
148
+ def self.extract_audio_metadata(element)
149
+ metadata = {}
150
+ options = []
151
+
152
+ # Extract boolean attributes
153
+ options << 'controls' if element.has_attribute?('controls')
154
+ options << 'autoplay' if element.has_attribute?('autoplay')
155
+ options << 'loop' if element.has_attribute?('loop')
156
+ options << 'muted' if element.has_attribute?('muted')
157
+
158
+ metadata[:options] = options unless options.empty?
159
+
160
+ metadata
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end