From f064b097765a5a8c318794b6214c2abc6893a086 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 30 May 2025 16:26:40 +0530 Subject: [PATCH] feat: move embedding config to a yaml file (#11611) Co-authored-by: Muhsin Keloth Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- config/markdown_embeds.yml | 132 ++++++++++++++++++++++++++++ lib/custom_markdown_renderer.rb | 95 ++++++++------------ lib/embed_renderer.rb | 102 --------------------- spec/config/markdown_embeds_spec.rb | 103 ++++++++++++++++++++++ 4 files changed, 270 insertions(+), 162 deletions(-) create mode 100644 config/markdown_embeds.yml delete mode 100644 lib/embed_renderer.rb create mode 100644 spec/config/markdown_embeds_spec.rb diff --git a/config/markdown_embeds.yml b/config/markdown_embeds.yml new file mode 100644 index 000000000..878276c5a --- /dev/null +++ b/config/markdown_embeds.yml @@ -0,0 +1,132 @@ +# Markdown Embed Configuration +# +# This file defines patterns and templates for converting URLs into embedded content +# in markdown rendering. Each embed type has: +# - regex: Pattern with named capture groups (?...) +# - template: HTML template with %{capture_group_name} placeholders +# +# To add a new embed type: +# 1. Add a new top-level key +# 2. Define the regex pattern with named capture groups: (?pattern) +# 3. Create an HTML template using %{name} placeholders matching the capture groups + +youtube: + regex: 'https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)(?[^&/]+)' + template: | +
+ +
+ +loom: + regex: 'https?://(?:www\.)?loom\.com/share/(?[^&/]+)' + template: | +
+ +
+ +vimeo: + regex: 'https?://(?:www\.)?vimeo\.com/(?\d+)' + template: | +
+ +
+ +mp4: + regex: '(?https?://(?:www\.)?.+\.mp4)' + template: | + + +arcade: + regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?[^&/]+)' + template: | +
+ +
+ +wistia: + regex: 'https?://(?:www\.)?[^/]+\.wistia\.com/medias/(?[^&/]+)' + template: | +
+ + + + + +
+ +bunny: + regex: 'https?://iframe\.mediadelivery\.net/play/(?\d+)/(?[^&/?]+)' + template: | +
+ +
+ +codepen: + regex: 'https?://(?:www\.)?codepen\.io/(?[^/]+)/pen/(?[^/?]+)' + template: | +
+ +
+ +github_gist: + regex: 'https?://gist\.github\.com/(?[^/]+)/(?[a-f0-9]+)' + template: | + + diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb index 902fc20a3..fea10388c 100644 --- a/lib/custom_markdown_renderer.rb +++ b/lib/custom_markdown_renderer.rb @@ -1,13 +1,13 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer - # TODO: let move this regex from here to a config file where we can update this list much more easily - # the config file will also have the matching embed template as well. - YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)} - LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)} - VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)} - MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)} - ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)} - WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)} - BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)} + CONFIG_PATH = Rails.root.join('config/markdown_embeds.yml') + + def self.config + @config ||= YAML.load_file(CONFIG_PATH) + end + + def self.embed_regexes + @embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) } + end def text(node) content = node.string_content @@ -23,7 +23,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer def link(node) return if surrounded_by_empty_lines?(node) && render_embedded_content(node) - # If it's not YouTube or Vimeo link, render normally + # If it's not a supported embed link, render normally super end @@ -47,25 +47,35 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer def render_embedded_content(node) link_url = node.url - embedding_methods = { - YOUTUBE_REGEX => :make_youtube_embed, - VIMEO_REGEX => :make_vimeo_embed, - MP4_REGEX => :make_video_embed, - LOOM_REGEX => :make_loom_embed, - ARCADE_REGEX => :make_arcade_embed, - WISTIA_REGEX => :make_wistia_embed, - BUNNY_REGEX => :make_bunny_embed - } + embed_html = find_matching_embed(link_url) - embedding_methods.each do |regex, method| + return false unless embed_html + + out(embed_html) + true + end + + def find_matching_embed(link_url) + self.class.embed_regexes.each do |embed_key, regex| match = link_url.match(regex) - if match - out(send(method, match)) - return true - end + next unless match + + return render_embed_from_match(embed_key, match) end - false + nil + end + + def render_embed_from_match(embed_key, match_data) + embed_config = self.class.config[embed_key] + return nil unless embed_config + + template = embed_config['template'] + # Use Ruby's built-in named captures with gsub to handle CSS % values + match_data.named_captures.each do |var_name, value| + template = template.gsub("%{#{var_name}}", value) + end + template end def parse_sup(content) @@ -77,39 +87,4 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer end end end - - def make_youtube_embed(youtube_match) - video_id = youtube_match[1] - EmbedRenderer.youtube(video_id) - end - - def make_loom_embed(loom_match) - video_id = loom_match[1] - EmbedRenderer.loom(video_id) - end - - def make_vimeo_embed(vimeo_match) - video_id = vimeo_match[1] - EmbedRenderer.vimeo(video_id) - end - - def make_video_embed(link_url) - EmbedRenderer.video(link_url) - end - - def make_wistia_embed(wistia_match) - video_id = wistia_match[2] - EmbedRenderer.wistia(video_id) - end - - def make_arcade_embed(arcade_match) - video_id = arcade_match[1] - EmbedRenderer.arcade(video_id) - end - - def make_bunny_embed(bunny_match) - library_id = bunny_match[1] - video_id = bunny_match[2] - EmbedRenderer.bunny(library_id, video_id) - end end diff --git a/lib/embed_renderer.rb b/lib/embed_renderer.rb deleted file mode 100644 index 78f620376..000000000 --- a/lib/embed_renderer.rb +++ /dev/null @@ -1,102 +0,0 @@ -module EmbedRenderer - def self.youtube(video_id) - %( -
- -
- ) - end - - def self.loom(video_id) - %( -
- -
- ) - end - - def self.vimeo(video_id) - %( -
- -
- ) - end - - def self.video(link_url) - %( - - ) - end - - # Generates an HTML embed for a Wistia video. - # @param wistia_match [MatchData] A match object from the WISTIA_REGEX regex, where wistia_match[2] contains the video ID. - def self.wistia(video_id) - %( -
- - - - - -
- ) - end - - def self.arcade(video_id) - %( -
- -
- ) - end - - def self.bunny(library_id, video_id) - %( -
- -
- ) - end -end diff --git a/spec/config/markdown_embeds_spec.rb b/spec/config/markdown_embeds_spec.rb new file mode 100644 index 000000000..2eff75bcd --- /dev/null +++ b/spec/config/markdown_embeds_spec.rb @@ -0,0 +1,103 @@ +require 'rails_helper' + +# rubocop:disable RSpec/DescribeClass +describe 'Markdown Embeds Configuration' do + # rubocop:enable RSpec/DescribeClass + let(:config) { YAML.load_file(Rails.root.join('config/markdown_embeds.yml')) } + + describe 'YAML structure' do + it 'loads valid YAML' do + expect(config).to be_a(Hash) + expect(config).not_to be_empty + end + + it 'has required keys for each embed type' do + config.each do |embed_type, embed_config| + expect(embed_config).to have_key('regex'), "#{embed_type} missing regex" + expect(embed_config).to have_key('template'), "#{embed_type} missing template" + expect(embed_config['regex']).to be_a(String), "#{embed_type} regex should be string" + expect(embed_config['template']).to be_a(String), "#{embed_type} template should be string" + end + end + + it 'contains expected embed types' do + expected_types = %w[youtube loom vimeo mp4 arcade wistia bunny codepen github_gist] + expect(config.keys).to match_array(expected_types) + end + end + + describe 'regex patterns and named capture groups' do + let(:test_cases) do + { + 'youtube' => [ + { url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } }, + { url: 'https://youtu.be/dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } }, + { url: 'https://youtube.com/watch?v=abc123XYZ', expected: { 'video_id' => 'abc123XYZ' } } + ], + 'loom' => [ + { url: 'https://www.loom.com/share/abc123def456', expected: { 'video_id' => 'abc123def456' } }, + { url: 'https://loom.com/share/xyz789', expected: { 'video_id' => 'xyz789' } } + ], + 'vimeo' => [ + { url: 'https://vimeo.com/123456789', expected: { 'video_id' => '123456789' } }, + { url: 'https://www.vimeo.com/987654321', expected: { 'video_id' => '987654321' } } + ], + 'mp4' => [ + { url: 'https://example.com/video.mp4', expected: { 'link_url' => 'https://example.com/video.mp4' } }, + { url: 'https://www.test.com/path/to/movie.mp4', expected: { 'link_url' => 'https://www.test.com/path/to/movie.mp4' } } + ], + 'arcade' => [ + { url: 'https://app.arcade.software/share/arcade123', expected: { 'video_id' => 'arcade123' } }, + { url: 'https://www.app.arcade.software/share/demo456', expected: { 'video_id' => 'demo456' } } + ], + 'wistia' => [ + { url: 'https://chatwoot.wistia.com/medias/kjwjeq6f9i', expected: { 'video_id' => 'kjwjeq6f9i' } }, + { url: 'https://www.company.wistia.com/medias/abc123def', expected: { 'video_id' => 'abc123def' } } + ], + 'bunny' => [ + { url: 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c', + expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } }, + { url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } } + ], + 'codepen' => [ + { url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } }, + { url: 'https://www.codepen.io/testuser/pen/xyz123', expected: { 'user' => 'testuser', 'pen_id' => 'xyz123' } } + ], + 'github_gist' => [ + { url: 'https://gist.github.com/username/1234567890abcdef1234567890abcdef', + expected: { 'username' => 'username', 'gist_id' => '1234567890abcdef1234567890abcdef' } }, + { url: 'https://gist.github.com/testuser/fedcba0987654321fedcba0987654321', expected: { 'username' => 'testuser', 'gist_id' => 'fedcba0987654321fedcba0987654321' } } + ] + } + end + + it 'correctly captures named groups for all embed types' do + test_cases.each do |embed_type, cases| + regex = Regexp.new(config[embed_type]['regex']) + + cases.each do |test_case| + match = regex.match(test_case[:url]) + expect(match).not_to be_nil, "#{embed_type} regex failed to match URL: #{test_case[:url]}" + expect(match.named_captures).to eq(test_case[:expected]), + "#{embed_type} captured groups don't match expected for URL: #{test_case[:url]}" + end + end + end + + it 'validates that template variables match capture group names' do + config.each do |embed_type, embed_config| + regex = Regexp.new(embed_config['regex']) + template = embed_config['template'] + + # Extract template variables like %{video_id} + template_vars = template.scan(/%\{(\w+)\}/).flatten.uniq + + # Get named capture groups from regex + capture_names = regex.names + + expect(capture_names).to match_array(template_vars), + "#{embed_type}: Template variables #{template_vars} don't match capture groups #{capture_names}" + end + end + end +end