trix_embed 0.0.2

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.
@@ -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"