trix_embed 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,102 @@
1
+ import { createURL } from './urls'
2
+
3
+ const audioMediaTypes = {
4
+ mp3: 'audio/mpeg', // MP3 audio format
5
+ ogg: 'audio/ogg', // OGG audio format
6
+ wav: 'audio/wav' // WAV audio format
7
+ }
8
+
9
+ const imageMediaTypes = {
10
+ avif: 'image/avif', // AVIF image format
11
+ bmp: 'image/bmp', // BMP image format
12
+ gif: 'image/gif', // GIF image format
13
+ heic: 'image/heic', // HEIC image format
14
+ heif: 'image/heif', // HEIF image format
15
+ ico: 'image/x-icon', // ICO image format
16
+ jp2: 'image/jp2', // JPEG 2000 image format
17
+ jpeg: 'image/jpeg', // JPEG image format
18
+ jpg: 'image/jpeg', // JPEG image format (alternative extension)
19
+ jxr: 'image/vnd.ms-photo', // JPEG XR image format
20
+ png: 'image/png', // PNG image format
21
+ svg: 'image/svg+xml', // SVG image format
22
+ tif: 'image/tiff', // TIFF image format
23
+ tiff: 'image/tiff', // TIFF image format (alternative extension)
24
+ webp: 'image/webp' // WebP image format
25
+ }
26
+
27
+ const videoMediaTypes = {
28
+ mp4: 'video/mp4', // MP4 video format
29
+ ogv: 'video/ogg', // OGG video format
30
+ webm: 'video/webm' // WebM video format
31
+ }
32
+
33
+ // TODO: Expand to all media types once proper templates are implemented
34
+ const mediaTypes = imageMediaTypes
35
+
36
+ const tagsWithHrefAttribute = [
37
+ 'animate', // SVG: Animation
38
+ 'animateMotion', // SVG: Animation
39
+ 'animateTransform', // SVG: Animation
40
+ 'area', // HTML: Image map area
41
+ 'audio', // HTML: Audio content
42
+ 'base', // HTML: Base URL
43
+ 'embed', // HTML: Embedded content
44
+ 'feDisplacementMap', // SVG: Filter primitive
45
+ 'feImage', // SVG: Filter primitive
46
+ 'feTile', // SVG: Filter primitive
47
+ 'filter', // SVG: Filter container
48
+ 'font-face-uri', // SVG: Font reference
49
+ 'iframe', // HTML: Inline frame
50
+ 'image', // SVG: Image
51
+ 'link', // HTML: External resources (e.g., stylesheets)
52
+ 'object', // HTML: Embedded content (fallback for non-HTML5 browsers)
53
+ 'script', // HTML: External scripts
54
+ 'source', // HTML: Media source
55
+ 'track', // HTML: Text tracks for media elements
56
+ 'use', // SVG: Reuse shapes from other documents
57
+ 'video' // HTML: Video content
58
+ ]
59
+
60
+ const tagsWithSrcAttribute = [
61
+ 'audio', // HTML: Audio content
62
+ 'embed', // HTML: Embedded content
63
+ 'iframe', // HTML: Inline frame
64
+ 'img', // HTML: Images
65
+ 'input', // HTML: Input elements with type="image"
66
+ 'script', // HTML: External scripts
67
+ 'source', // HTML: Media source
68
+ 'track', // HTML: Text tracks for media elements
69
+ 'video', // HTML: Video content
70
+ 'frame', // HTML: Deprecated (use iframe instead)
71
+ 'frameset', // HTML: Deprecated (use iframe instead)
72
+ 'object', // HTML: Embedded content
73
+ 'picture', // HTML: Responsive images
74
+ 'use' // SVG: Reuse shapes from other documents
75
+ ]
76
+
77
+ export const mediaTags = tagsWithHrefAttribute.concat(tagsWithSrcAttribute)
78
+
79
+ export function isAudio(url) {
80
+ return !!Object.values(audioMediaTypes).find(t => t === getMediaType(url))
81
+ }
82
+
83
+ export function isImage(url) {
84
+ return !!Object.values(imageMediaTypes).find(t => t === getMediaType(url))
85
+ }
86
+
87
+ export function isVideo(url) {
88
+ return !!Object.values(videoMediaTypes).find(t => t === getMediaType(url))
89
+ }
90
+
91
+ export function getMediaType(value) {
92
+ let url
93
+
94
+ createURL(value, u => (url = u))
95
+ if (!url) return null
96
+
97
+ const index = url.pathname.lastIndexOf('.')
98
+ if (!index) return null
99
+
100
+ const extension = url.pathname.substring(index + 1)
101
+ return mediaTypes[extension]
102
+ }
@@ -0,0 +1,127 @@
1
+ import { createURL, extractURLHosts } from './urls'
2
+ import { isImage, getMediaType } from './media'
3
+ import { getTemplate } from './templates'
4
+
5
+ export default class Renderer {
6
+ // Constructs a new Renderer instance
7
+ //
8
+ // @param {Controller} controller - a Stimulus Controller instance
9
+ constructor(controller) {
10
+ this.controller = controller
11
+ this.initializeTempates()
12
+ }
13
+
14
+ initializeTempates() {
15
+ const templates = ['error', 'exception', 'header', 'iframe', 'image']
16
+ templates.forEach(name => this.initializeTemplate(name))
17
+ }
18
+
19
+ initializeTemplate(name) {
20
+ let template
21
+
22
+ if (this.controller[`has${name.charAt(0).toUpperCase() + name.slice(1)}TemplateValue`])
23
+ template = document.getElementById(this.controller[`${name}TemplateValue`])
24
+
25
+ this[`${name}Template`] = template || getTemplate(name)
26
+ }
27
+
28
+ // Renders an embed header
29
+ //
30
+ // @param {String} html - HTML
31
+ // @returns {String} HTML
32
+ //
33
+ renderHeader(html) {
34
+ const header = this.headerTemplate.content.firstElementChild.cloneNode(true)
35
+ const h1 = header.tagName.match(/h1/i) ? header : header.querySelector('h1')
36
+ h1.innerHTML = html
37
+ return header.outerHTML
38
+ }
39
+
40
+ // TODO: Add templates for links
41
+ // Renders a list of URLs as a list of HTML links i.e. anchor tags <a>
42
+ //
43
+ // @param {String[]} urls - list of URLs
44
+ // @returns {String[]} list of individual HTML links
45
+ //
46
+ renderLinks(urls = ['https://example.com', 'https://test.com']) {
47
+ urls = urls
48
+ .filter(url => {
49
+ let ok = false
50
+ createURL(url, u => (ok = true))
51
+ return ok
52
+ })
53
+ .sort()
54
+
55
+ if (!urls.length) return
56
+ const links = urls.map(url => `<li><a href='${url}'>${url}</a></li>`)
57
+ return `<ul>${links.join('')}</ul><br>`
58
+ }
59
+
60
+ // TOOO: add support for audio and video
61
+ // Renders a URL as an HTML embed i.e. an iframe or media tag (img, video, audio etc.)
62
+ //
63
+ // @param {String} url - URL
64
+ // @returns {String} HTML
65
+ //
66
+ renderEmbed(url = 'https://example.com') {
67
+ let embed
68
+
69
+ if (isImage(url)) {
70
+ embed = this.imageTemplate.content.firstElementChild.cloneNode(true)
71
+ const img = embed.tagName.match(/img/i) ? embed : embed.querySelector('img')
72
+ img.src = url
73
+ } else {
74
+ embed = this.iframeTemplate.content.firstElementChild.cloneNode(true)
75
+ const iframe = embed.tagName.match(/iframe/i) ? embed : embed.querySelector('iframe')
76
+ iframe.src = url
77
+ }
78
+
79
+ return embed.outerHTML
80
+ }
81
+
82
+ // Renders a list of URLs as HTML embeds i.e. iframes or media tags (img, video, audio etc.)
83
+ //
84
+ // @param {String[]} urls - list of URLs
85
+ // @returns {String[]} list of individual HTML embeds
86
+ //
87
+ renderEmbeds(urls = ['https://example.com', 'https://test.com']) {
88
+ if (!urls?.length) return
89
+ return urls.map(url => this.renderEmbed(url))
90
+ }
91
+
92
+ // Renders embed errors
93
+ //
94
+ // @param {String[]} urls - list of URLs
95
+ // @param {String[]} allowedHosts - list of allowed hosts
96
+ // @returns {String} HTML
97
+ //
98
+ renderErrors(urls = ['https://example.com', 'https://test.com'], allowedHosts = []) {
99
+ if (!urls?.length) return
100
+
101
+ const element = this.errorTemplate.content.firstElementChild.cloneNode(true)
102
+ const prohibitedHostsElement = element.querySelector('[data-list="prohibited-hosts"]')
103
+ const allowedHostsElement = element.querySelector('[data-list="allowed-hosts"]')
104
+
105
+ if (prohibitedHostsElement) {
106
+ const hosts = extractURLHosts(urls).sort()
107
+ if (hosts.length) prohibitedHostsElement.innerHTML = hosts.map(host => `<li>${host}</li>`).join('')
108
+ }
109
+
110
+ if (allowedHostsElement && allowedHosts.length)
111
+ allowedHostsElement.innerHTML = allowedHosts.map(host => `<li>${host}</li>`).join('')
112
+
113
+ return element.outerHTML
114
+ }
115
+
116
+ // Renders an exception
117
+ //
118
+ // @param {String[]} ex - The exception
119
+ // @returns {String} HTML
120
+ //
121
+ renderException(ex) {
122
+ const element = this.exceptionTemplate.content.firstElementChild.cloneNode(true)
123
+ const code = element.querySelector('code')
124
+ code.innerHTML = ex.message
125
+ return element.outerHTML
126
+ }
127
+ }
@@ -0,0 +1,35 @@
1
+ export default class Store {
2
+ constructor(controller) {
3
+ this.controller = controller
4
+ this.base = this.obfuscate([location.pathname, this.controller.element.closest('[id]')?.id].join('/'))
5
+ }
6
+
7
+ split(list) {
8
+ const index = Math.ceil(list.length / 2)
9
+ return [list.slice(0, index), list.slice(index)]
10
+ }
11
+
12
+ obfuscate(value) {
13
+ const chars = [...value].map(char => char.charCodeAt(0))
14
+ const parts = this.split(chars)
15
+ return [parts[1]?.reverse(), chars[0]].flat().join('')
16
+ }
17
+
18
+ read(key) {
19
+ return sessionStorage.getItem(this.generateStorageKey(key))
20
+ }
21
+
22
+ write(key, value) {
23
+ return sessionStorage.setItem(this.generateStorageKey(key), value)
24
+ }
25
+
26
+ remove(key) {
27
+ return sessionStorage.removeItem(this.generateStorageKey(key))
28
+ }
29
+
30
+ generateStorageKey(value) {
31
+ const chars = [...this.obfuscate(value)]
32
+ const [prefix, suffix] = this.split(chars)
33
+ return btoa(`${prefix}/${this.base}/${suffix}`)
34
+ }
35
+ }
@@ -0,0 +1,36 @@
1
+ const defaults = {
2
+ header: `<h1></h1>`,
3
+ iframe: `<iframe></iframe>`,
4
+ image: `<img></img>`,
5
+
6
+ error: `
7
+ <div>
8
+ <h1>Copy/Paste Info</h1>
9
+ <h3>The pasted content includes media from unsupported hosts.</h3>
10
+
11
+ <h2>Prohibited Hosts / Domains</h2>
12
+ <ul data-list="prohibited-hosts">
13
+ <li>Media is only supported from allowed hosts.</li>
14
+ </ul>
15
+
16
+ <h2>Allowed Hosts / Domains</h2>
17
+ <ul data-list="allowed-hosts">
18
+ <li>Allowed hosts not configured.</li>
19
+ </ul>
20
+ </div>
21
+ `,
22
+
23
+ exception: `
24
+ <div style='background-color:lightyellow; color:red; border:solid 1px red; padding:20px;'>
25
+ <h1>Unhandled Exception!</h1>
26
+ <p>Show a programmer the message below.</p>
27
+ <pre style="background-color:darkslategray; color:whitesmoke; padding:10px;"><code></code></pre>
28
+ </div>
29
+ `
30
+ }
31
+
32
+ export function getTemplate(name) {
33
+ const template = document.createElement('template')
34
+ template.innerHTML = defaults[name]
35
+ return template
36
+ }
@@ -0,0 +1,97 @@
1
+ // Creates a URL object from a value and yields the result
2
+ //
3
+ // @param {String} value - Value to convert to a URL (coerced to a string)
4
+ // @param {Function} callback - Function to be called with the URL object
5
+ // @returns {URL, null} URL object
6
+ //
7
+ export function createURL(value, callback = url => {}) {
8
+ try {
9
+ const url = new URL(String(value).trim())
10
+ if (callback) callback(url)
11
+ return url
12
+ } catch (_error) {
13
+ console.info(`Failed to parse URL! value='${value}']`)
14
+ }
15
+ return null
16
+ }
17
+
18
+ // Creates a URL host from a value and yields the result
19
+ //
20
+ // @param {String} value - Value to convert to a URL host (coerced to a string)
21
+ // @param {Function} callback - Function to be called with the URL host
22
+ // @returns {String, null} URL host
23
+ //
24
+ function createURLHost(value, callback = host => {}) {
25
+ let host = null
26
+ createURL(value, url => (host = url.host))
27
+ if (host && callback) callback(host)
28
+ return host
29
+ }
30
+
31
+ function extractURLsFromTextNodes(element) {
32
+ const urls = []
33
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, node => {
34
+ const value = node.nodeValue
35
+ if (!value.includes('http')) return NodeFilter.FILTER_REJECT
36
+ return NodeFilter.FILTER_ACCEPT
37
+ })
38
+
39
+ let node
40
+ while ((node = walker.nextNode()))
41
+ node.nodeValue
42
+ .split(/\s+/)
43
+ .filter(val => val.startsWith('http'))
44
+ .forEach(match =>
45
+ createURL(match, url => {
46
+ if (!urls.includes(url.href)) urls.push(url.href)
47
+ })
48
+ )
49
+
50
+ return urls
51
+ }
52
+
53
+ function extractURLsFromElements(element) {
54
+ const urls = []
55
+
56
+ if (element.src) createURL(element.src, url => urls.push(url.href))
57
+ if (element.href)
58
+ createURL(element.href, url => {
59
+ if (!urls.includes(url.href)) urls.push(url.href)
60
+ })
61
+
62
+ const elements = element.querySelectorAll('[src], [href]')
63
+ elements.forEach(el => {
64
+ createURL(el.src || el.href, url => {
65
+ if (!urls.includes(url.href)) urls.push(url.href)
66
+ })
67
+ })
68
+
69
+ return urls
70
+ }
71
+
72
+ export function validateURL(value, allowedHosts = []) {
73
+ let valid = false
74
+ createURLHost(value, host => (valid = !!allowedHosts.find(allowedHost => host.includes(allowedHost))))
75
+ return valid
76
+ }
77
+
78
+ export function extractURLHosts(values) {
79
+ return values.reduce((hosts, value) => {
80
+ createURLHost(value, host => {
81
+ if (!hosts.includes(host)) hosts.push(host)
82
+ })
83
+ return hosts
84
+ }, [])
85
+ }
86
+
87
+ // Extracts all URLs from an HTML element (all inclusive i.e. elements and text nodes)
88
+ //
89
+ // @param {HTMLElement} element - HTML element
90
+ // @returns {String[]} list of unique URLs
91
+ //
92
+ export function extractURLsFromElement(element) {
93
+ const elementURLs = extractURLsFromElements(element)
94
+ const textNodeURLs = extractURLsFromTextNodes(element)
95
+ const uniqueURLs = new Set([...elementURLs, ...textNodeURLs])
96
+ return [...uniqueURLs]
97
+ }
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrixEmbed
4
+ class Attachment
5
+ include ActiveModel::Model
6
+ include GlobalID::Identification
7
+ include ActionText::Attachable
8
+
9
+ CONTENT_TYPE = "application/vnd.trix-embed"
10
+ ALLOWED_TAGS = ActionText::ContentHelper.allowed_tags + %w[iframe]
11
+ ALLOWED_ATTRIBUTES = ActionText::ContentHelper.allowed_attributes + %w[allow allowfullscreen allowpaymentrequest credentialless csp loading referrerpolicy sandbox srcdoc]
12
+
13
+ class << self
14
+ def rewrite_action_text_content(content)
15
+ fragment = Nokogiri::HTML.fragment(content)
16
+ matches = fragment.css("#{ActionText::Attachment.tag_name}[sgid][content-type='#{CONTENT_TYPE}']")
17
+
18
+ matches.each do |match|
19
+ attachment = ActionText::Attachment.from_node(match)
20
+ attachable = attachment.attachable
21
+
22
+ html = ActionText::Content.render(
23
+ partial: attachable.to_action_text_content_partial_path,
24
+ locals: {attachable: attachable}
25
+ )
26
+
27
+ html = ActionText::ContentHelper.sanitizer.sanitize(
28
+ html,
29
+ tags: ALLOWED_TAGS,
30
+ attributes: ALLOWED_ATTRIBUTES,
31
+ scrubber: nil
32
+ )
33
+
34
+ match.replace html
35
+ end
36
+
37
+ fragment.to_html.html_safe
38
+ end
39
+
40
+ def rewrite_trix_html(trix_html)
41
+ fragment = Nokogiri::HTML.fragment(trix_html)
42
+ matches = fragment.css("[data-trix-attachment][data-trix-content-type='#{CONTENT_TYPE}']")
43
+
44
+ matches.each do |match|
45
+ data = JSON.parse(match["data-trix-attachment"]).deep_transform_keys(&:underscore)
46
+
47
+ attachable = TrixEmbed::Attachment.new(data)
48
+ attachment = ActionText::Attachment.from_attachable(attachable)
49
+
50
+ match.replace ActionText::Content.render(
51
+ partial: attachable.to_partial_path,
52
+ locals: {attachment: attachment}
53
+ )
54
+ end
55
+
56
+ fragment.to_html
57
+ end
58
+
59
+ def find(id)
60
+ new JSON.parse(id)
61
+ end
62
+ end
63
+
64
+ attr_reader :attributes
65
+ attr_accessor :content_type, :content
66
+
67
+ def initialize(attributes = {})
68
+ super @attributes = attributes.with_indifferent_access.slice(:content_type, :content)
69
+ end
70
+
71
+ def id
72
+ attributes.to_json
73
+ end
74
+
75
+ def persisted?
76
+ true
77
+ end
78
+
79
+ # What gets saved to the database
80
+ def to_partial_path
81
+ "trix_embed/action_text_attachment"
82
+ end
83
+
84
+ # What gets presented in the browser (show view)
85
+ def to_action_text_content_partial_path
86
+ "trix_embed/action_text_content_show"
87
+ end
88
+
89
+ # What gets presented in the browser (edit view)
90
+ def to_trix_content_attachment_partial_path
91
+ "trix_embed/action_text_content_edit"
92
+ end
93
+ end
94
+ end
@@ -0,0 +1 @@
1
+ <%= TrixEmbed::Attachment.rewrite_action_text_content render_action_text_content(content) %>
@@ -0,0 +1,3 @@
1
+ <%= tag.send ActionText::Attachment.tag_name,
2
+ sgid: trix_embed_attachment(local_assigns)&.attachable_sgid,
3
+ content_type: trix_embed_attachment(local_assigns)&.content_type %>
@@ -0,0 +1 @@
1
+ <%= trix_embed_attachment(local_assigns)&.content&.html_safe %>
@@ -0,0 +1,3 @@
1
+ <figure class="attachment attachment--preview">
2
+ <%= trix_embed_attachment(local_assigns)&.content&.html_safe %>
3
+ </figure>
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_text"
4
+ require "active_model"
5
+ require "active_support/all"
6
+ require "globalid"
7
+ require_relative "version"
8
+
9
+ module TrixEmbed
10
+ def self.config
11
+ Rails.application.config.trix_embed
12
+ end
13
+
14
+ class Engine < ::Rails::Engine
15
+ isolate_namespace TrixEmbed
16
+ config.trix_embed = ActiveSupport::OrderedOptions.new
17
+
18
+ initializer "trix_embed.configuration" do
19
+ Mime::Type.register "application/vnd.trix-embed", :trix_embed
20
+
21
+ ActiveSupport.on_load :action_controller do
22
+ helper TrixEmbed::ApplicationHelper
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrixEmbed
4
+ VERSION = "0.0.2"
5
+ end
data/lib/trix_embed.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "trix_embed/engine"