require 'commonmarker' module AboutHelper PROSE_CLASSES = { 'h3' => 'text-base-content mt-8 mb-3 text-base font-semibold tracking-tight first:mt-0', 'p' => 'text-base-content/80 mb-4 leading-relaxed', 'ul' => 'text-base-content/80 mb-4 list-disc space-y-1.5 pl-5', 'li' => 'leading-relaxed', 'code' => 'bg-base-content/10 rounded px-1.5 py-0.5 font-mono text-[0.85em]', }.freeze private_constant :PROSE_CLASSES SAFE_LINK_SCHEMES = %w[http:// https:// #].freeze private_constant :SAFE_LINK_SCHEMES def render_markdown(markdown) return ''.html_safe if markdown.blank? html = Commonmarker.to_html( markdown.to_s, options: { # Disable heading anchors/ids; we render clean prose without them. extension: { header_ids: nil }, render: { escape: true, unsafe: false }, }, ) transform_html(html).html_safe # rubocop:disable Rails/OutputSafety end def parse_components(markdown) parse_tables(markdown).filter_map { |section| build_group(section) } end private # Markdown heading of a table in THIRD_PARTY_LICENSES.md → i18n label key and # component category. A nil category means no license text ships for those # packages, so their rows render as plain text instead of a link. COMPONENT_GROUPS = { 'Base Image' => { key: :base_image, category: nil }, 'Ruby Gems' => { key: :gems, category: 'gem' }, 'JavaScript Packages' => { key: :js, category: 'js' }, }.freeze private_constant :COMPONENT_GROUPS def transform_html(html) fragment = Nokogiri::HTML5.fragment(html) # Drop the first H1 entirely — the surrounding section already has one. fragment.at_css('h1')&.remove # Demote ## headings one level since the page already has h1 + h2. fragment.css('h2').each { |node| node.name = 'h3' } PROSE_CLASSES.each do |tag, css_class| fragment.css(tag).each { |node| node['class'] = css_class } end fragment.css('a').each { |node| transform_link(node) } fragment.to_html end def transform_link(node) href = node['href'].to_s if href.end_with?('.md') # Same-repo *.md references would 404 — point at the components list. node['href'] = '#components' elsif SAFE_LINK_SCHEMES.none? { |scheme| href.start_with?(scheme) } node.replace(Nokogiri::XML::Text.new(node.content, node.document)) return end node['class'] = 'link link-primary' return if node['href'].start_with?('#') node['target'] = '_blank' node['rel'] = 'noopener noreferrer' end def build_group(section) group = COMPONENT_GROUPS[section[:heading]] return nil unless group components = section[:rows].map do |name, license| { category: group[:category], name: name.to_s.delete('`'), license: license } end group.merge(components: components.sort_by { |component| component[:name].downcase }) end def parse_tables(markdown) return [] if markdown.blank? sections = [] current = nil markdown.each_line do |raw| line = raw.chomp if line.start_with?('## ') current = { heading: line[3..], rows: [] } sections << current elsif current && table_row?(line) cells = parse_row(line) current[:rows] << cells if cells end end sections end def table_row?(line) line.start_with?('|') && !table_separator_row?(line) end def table_separator_row?(line) line.match?(/\|[\s\-:|]+\|/) end def parse_row(line) cells = line.split('|').map(&:strip).reject(&:empty?) return nil if cells.size != 2 || cells.first == 'Package' cells end end