Class: Collavre::MarkdownImporter

Inherits:
Object
  • Object
show all
Defined in:
app/services/collavre/markdown_importer.rb

Constant Summary collapse

HORIZONTAL_RULE_REGEX =

Matches horizontal rules: —, ***, _ (with optional spaces)

/^\s*([-*_])\s*\1\s*\1[\s\1]*$/

Class Method Summary collapse

Class Method Details

.alignment_row?(line) ⇒ Boolean

Returns:

  • (Boolean)


160
161
162
163
164
165
# File 'app/services/collavre/markdown_importer.rb', line 160

def self.alignment_row?(line)
  return false unless table_row?(line)
  split_markdown_table_row(line).all? do |cell|
    cell.strip =~ /^:?-{3,}:?$/
  end
end

.build_code_block_html(code_content, language = nil) ⇒ Object



119
120
121
122
123
# File 'app/services/collavre/markdown_importer.rb', line 119

def self.build_code_block_html(code_content, language = nil)
  escaped_content = ERB::Util.html_escape(code_content)
  lang_class = language.present? ? " class=\"language-#{ERB::Util.html_escape(language)}\"" : ""
  "<pre><code#{lang_class}>#{escaped_content}</code></pre>"
end

.build_html_table(header_html, rows_html, alignments) ⇒ Object



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'app/services/collavre/markdown_importer.rb', line 217

def self.build_html_table(header_html, rows_html, alignments)
  table = +"<table>\n"
  table << "  <thead>\n"
  table << "    <tr>"
  header_html.each_with_index do |cell, idx|
    table << table_cell_tag("th", cell, alignments[idx])
  end
  table << "</tr>\n"
  table << "  </thead>\n"
  unless rows_html.empty?
    table << "  <tbody>\n"
    rows_html.each do |row|
      table << "    <tr>"
      row.each_with_index do |cell, idx|
        table << table_cell_tag("td", cell, alignments[idx])
      end
      table << "</tr>\n"
    end
    table << "  </tbody>\n"
  end
  table << "</table>"
  table
end

.build_table_html(table_data, image_refs) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'app/services/collavre/markdown_importer.rb', line 194

def self.build_table_html(table_data, image_refs)
  helper = ApplicationController.helpers
  header_cells = table_data[:header]
  alignments = table_data[:alignments]
  rows = table_data[:rows]

  header_html = header_cells.map { |cell| helper.markdown_links_to_html(cell, image_refs) }
  max_row_length = rows.map(&:length).max || 0
  column_count = [ header_html.length, max_row_length ].max

  row_html = rows.map do |row|
    normalized = row.first(column_count)
    normalized.fill("", normalized.length...column_count)
    normalized.map { |cell| helper.markdown_links_to_html(cell, image_refs) }
  end

  column_count = [ column_count, alignments.length ].max
  alignments = alignments.first(column_count)
  alignments.fill(nil, alignments.length...column_count)

  build_html_table(header_html, row_html, alignments)
end

.import(content, parent:, user:, create_root: false) ⇒ Object



6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'app/services/collavre/markdown_importer.rb', line 6

def self.import(content, parent:, user:, create_root: false)
  lines = content.to_s.lines
  image_refs = {}
  lines.reject! do |ln|
    if ln =~ /^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/
      image_refs[$1] = $2.strip
      true
    else
      false
    end
  end
  created = []
  root = parent
  i = 0
  if create_root
    while i < lines.size && lines[i].strip.empty?
      i += 1
    end
    if i < lines.size && lines[i] !~ /^\s*#/ && lines[i] !~ /^\s*[-*+]/
      page_title = lines[i].strip
      root = Creative.create(user: user, parent: parent, description: page_title)
      created << root
      i += 1
    end
  end
  stack = [ [ 0, root ] ]
  while i < lines.size
    line = lines[i]

    # Skip horizontal rules (---, ***, ___)
    if line =~ HORIZONTAL_RULE_REGEX
      i += 1
      next
    end

    # Handle fenced code blocks (``` or ~~~)
    if (fence_match = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/))
      fence_indent = fence_match[1]
      fence_marker = fence_match[2]
      fence_char = fence_marker[0]
      fence_length = fence_marker.length
      fence_info = fence_match[3].strip # language info (e.g., "ruby", "javascript")

      # Collect all lines until closing fence
      code_lines = []
      i += 1
      while i < lines.size
        close_line = lines[i]
        # Check for closing fence (same char, at least same length)
        if (close_match = close_line.match(/^\s*(`{3,}|~{3+})\s*$/))
          close_marker = close_match[1]
          if close_marker[0] == fence_char && close_marker.length >= fence_length
            i += 1
            break
          end
        end
        code_lines << close_line
        i += 1
      end

      # Build code block HTML
      code_content = code_lines.join
      code_html = build_code_block_html(code_content, fence_info)

      new_parent = stack.any? ? stack.last[1] : root
      c = Creative.create(user: user, parent: new_parent, description: code_html)
      created << c
      next
    end

    if (table_data = parse_markdown_table(lines, i))
      table_html = build_table_html(table_data, image_refs)
      new_parent = stack.any? ? stack.last[1] : root
      c = Creative.create(user: user, parent: new_parent, description: table_html)
      created << c
      i = table_data[:next_index]
    elsif line =~ /^(#+)\s+(.*)$/
      level = $1.length
      desc = ApplicationController.helpers.markdown_links_to_html($2.strip, image_refs)
      stack.pop while stack.any? && stack.last[0] >= level
      new_parent = stack.any? ? stack.last[1] : root
      c = Creative.create(user: user, parent: new_parent, description: desc)
      created << c
      stack << [ level, c ]
      i += 1
    elsif line =~ /^([ \t]*)([-*+])\s+(.*)$/
      indent = $1.length
      desc = ApplicationController.helpers.markdown_links_to_html($3.strip, image_refs)
      bullet_level = 10 + indent / 2
      stack.pop while stack.any? && stack.last[0] >= bullet_level
      new_parent = stack.any? ? stack.last[1] : root
      c = Creative.create(user: user, parent: new_parent, description: desc)
      created << c
      stack << [ bullet_level, c ]
      i += 1
    elsif !line.strip.empty?
      desc = ApplicationController.helpers.markdown_links_to_html(line.strip, image_refs)
      new_parent = stack.any? ? stack.last[1] : root
      c = Creative.create(user: user, parent: new_parent, description: desc)
      created << c
      i += 1
    else
      i += 1
    end
  end

  # Broadcast all created creatives in a single batch job to guarantee
  # parent-before-child ordering (prevents silent drops in frontend)
  Creative::RealtimeBroadcastable.broadcast_batch_created(created)

  created
end

.parse_alignment_row(line, expected_count) ⇒ Object



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/services/collavre/markdown_importer.rb', line 174

def self.parse_alignment_row(line, expected_count)
  cells = split_markdown_table_row(line)
  cells = cells.first(expected_count)
  cells.fill("", cells.length...expected_count)
  cells.map do |cell|
    trimmed = cell.strip
    left = trimmed.start_with?(":")
    right = trimmed.end_with?(":")
    if left && right
      :center
    elsif right
      :right
    elsif left
      :left
    else
      nil
    end
  end
end

.parse_markdown_table(lines, index) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'app/services/collavre/markdown_importer.rb', line 125

def self.parse_markdown_table(lines, index)
  return nil if index >= lines.length
  header_line = lines[index]
  return nil unless table_row?(header_line)

  align_index = index + 1
  return nil if align_index >= lines.length
  alignment_line = lines[align_index]
  return nil unless alignment_row?(alignment_line)

  header_cells = split_markdown_table_row(header_line)
  alignments = parse_alignment_row(alignment_line, header_cells.length)

  rows = []
  data_index = align_index + 1
  while data_index < lines.length && table_row?(lines[data_index])
    rows << split_markdown_table_row(lines[data_index])
    data_index += 1
  end

  {
    header: header_cells,
    alignments: alignments,
    rows: rows,
    next_index: data_index
  }
end

.split_markdown_table_row(line) ⇒ Object



167
168
169
170
171
172
# File 'app/services/collavre/markdown_importer.rb', line 167

def self.split_markdown_table_row(line)
  body = line.strip
  body = body.sub(/^\|/, "").sub(/\|\s*$/, "")
  return [] if body.strip.empty?
  body.split(/(?<!\\)\|/).map { |cell| cell.gsub(/\\\|/, "|").strip }
end

.table_cell_tag(tag_name, content, alignment) ⇒ Object



241
242
243
244
# File 'app/services/collavre/markdown_importer.rb', line 241

def self.table_cell_tag(tag_name, content, alignment)
  align_attr = alignment ? " style=\"text-align: #{alignment};\"" : ""
  "<#{tag_name}#{align_attr}>#{content}</#{tag_name}>"
end

.table_row?(line) ⇒ Boolean

Returns:

  • (Boolean)


153
154
155
156
157
158
# File 'app/services/collavre/markdown_importer.rb', line 153

def self.table_row?(line)
  return false if line.nil?
  stripped = line.strip
  return false if stripped.empty?
  stripped.include?("|") && split_markdown_table_row(line).any?
end