collavre 0.22.0 → 0.24.0
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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +42 -10
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +27 -0
- data/app/models/collavre/user.rb +90 -10
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +96 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -12,85 +12,99 @@ module Creatives
|
|
|
12
12
|
@max_level = max_level
|
|
13
13
|
@allowed_creative_ids = allowed_creative_ids
|
|
14
14
|
@progress_map = progress_map
|
|
15
|
-
@
|
|
15
|
+
@permission_rank = {}
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def build(collection, level: 1)
|
|
19
19
|
return [] if collection.blank?
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
# Preload permissions for all creatives in this batch to avoid N+1
|
|
23
|
-
preload_permissions(creatives)
|
|
24
|
-
|
|
25
|
-
build_nodes(creatives, level: level)
|
|
21
|
+
build_nodes(Array(collection), level: level)
|
|
26
22
|
end
|
|
27
23
|
|
|
28
24
|
private
|
|
29
25
|
|
|
30
|
-
attr_reader :user, :view_context, :raw_params, :expanded_state_map, :select_mode, :max_level, :allowed_creative_ids, :progress_map
|
|
26
|
+
attr_reader :user, :view_context, :raw_params, :expanded_state_map, :select_mode, :max_level, :allowed_creative_ids, :progress_map
|
|
31
27
|
|
|
32
|
-
def
|
|
33
|
-
|
|
28
|
+
def children_index
|
|
29
|
+
@children_index ||= ChildrenIndex.new(
|
|
30
|
+
user: user,
|
|
31
|
+
show_archived: raw_params["show_archived"].present?,
|
|
32
|
+
allowed_creative_ids: allowed_creative_ids
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def comment_badge_index
|
|
37
|
+
@comment_badge_index ||= CommentBadgeIndex.new(user: user)
|
|
38
|
+
end
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
# Everything a level of nodes needs, resolved in a fixed number of queries
|
|
41
|
+
# rather than a handful per node. The recursion re-enters here for each level,
|
|
42
|
+
# so total cost tracks tree *depth*, not node count.
|
|
43
|
+
def prepare_level(creatives)
|
|
44
|
+
ActiveRecord::Associations::Preloader.new(
|
|
45
|
+
records: creatives,
|
|
46
|
+
associations: [ :origin, { tags: :label } ]
|
|
47
|
+
).call
|
|
37
48
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return if uncached_ids.empty?
|
|
49
|
+
preload_permissions(creatives)
|
|
50
|
+
preload_comment_badges(creatives)
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
return if children_suppressed?
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
children_index.index(creatives)
|
|
55
|
+
expanding = creatives.select { |creative| load_children_now?(creative) }
|
|
56
|
+
children_index.load(expanding) if expanding.any?
|
|
57
|
+
end
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
# Batch effective-origin permission ranks via the single PermissionFilter
|
|
60
|
+
# read path (owner wins, then the user's own cache entry incl. a no_access
|
|
61
|
+
# deny, then the public entry). Every pending creative gets an explicit
|
|
62
|
+
# entry — nil when no share resolves — so #allowed? can trust
|
|
63
|
+
# @permission_rank.key? and never falls back per-item for absent ids.
|
|
64
|
+
def preload_permissions(creatives)
|
|
65
|
+
pending = creatives.reject { |creative| @permission_rank.key?(creative.id) }
|
|
66
|
+
return if pending.empty?
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
public_permissions = CreativeSharesCache
|
|
58
|
-
.where(creative_id: still_uncached, user_id: nil)
|
|
59
|
-
.pluck(:creative_id, :permission)
|
|
60
|
-
|
|
61
|
-
public_permissions.each do |cid, perm|
|
|
62
|
-
next if @permission_cache.key?(cid)
|
|
63
|
-
perm_rank = CreativeSharesCache.permissions[perm]
|
|
64
|
-
@permission_cache[cid] = perm_rank && perm_rank >= write_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
|
|
65
|
-
end
|
|
66
|
-
end
|
|
68
|
+
ranks = PermissionFilter.new(user: user).ranks_for(pending.map(&:id))
|
|
69
|
+
pending.each { |creative| @permission_rank[creative.id] = ranks[creative.id] }
|
|
70
|
+
end
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
# Equivalent to `creative.has_permission?(user, permission)`, served from the
|
|
73
|
+
# level's batched ranks. Falls back for a creative the batch never saw.
|
|
74
|
+
def allowed?(creative, permission)
|
|
75
|
+
return creative.has_permission?(user, permission) unless @permission_rank.key?(creative.id)
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
rank = @permission_rank[creative.id]
|
|
78
|
+
return false if rank.nil?
|
|
79
|
+
|
|
80
|
+
rank >= CreativeShare.permissions[permission.to_s]
|
|
74
81
|
end
|
|
75
82
|
|
|
76
|
-
def
|
|
83
|
+
def can_write?(creative)
|
|
77
84
|
return false unless user
|
|
78
|
-
#
|
|
79
|
-
return false if creative.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
# Read-only-source creatives are never writable
|
|
86
|
+
return false if creative.read_only_source?
|
|
87
|
+
|
|
88
|
+
allowed?(creative, :write)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def can_feedback?(creative)
|
|
92
|
+
allowed?(creative, :feedback)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Only rows that will actually render a badge are worth batching.
|
|
96
|
+
def preload_comment_badges(creatives)
|
|
97
|
+
visible = creatives.reject(&:archived?).select { |creative| can_feedback?(creative) }
|
|
98
|
+
return if visible.empty?
|
|
99
|
+
|
|
100
|
+
comment_badge_index.index(visible.map(&:effective_origin))
|
|
87
101
|
end
|
|
88
102
|
|
|
89
103
|
def build_nodes(creatives, level:)
|
|
90
104
|
return [] if level > max_level
|
|
105
|
+
return [] if creatives.empty?
|
|
91
106
|
|
|
92
|
-
|
|
93
|
-
preload_permissions(creatives) if creatives.any?
|
|
107
|
+
prepare_level(creatives)
|
|
94
108
|
|
|
95
109
|
creatives.flat_map do |creative|
|
|
96
110
|
build_nodes_for_creative(creative, level: level)
|
|
@@ -102,16 +116,18 @@ module Creatives
|
|
|
102
116
|
creative.filtered_progress = progress_map[creative.id.to_s]
|
|
103
117
|
end
|
|
104
118
|
|
|
105
|
-
filtered_children = filtered_children_for(creative)
|
|
106
119
|
expanded = expanded?(creative.id)
|
|
107
120
|
skip = skip_creative?(creative)
|
|
108
121
|
child_level = level + 1
|
|
109
122
|
child_render_level = skip ? level : child_level
|
|
110
|
-
load_children_now =
|
|
111
|
-
|
|
123
|
+
load_children_now = load_children_now?(creative)
|
|
124
|
+
has_children = !children_suppressed? && children_index.has_children?(creative)
|
|
125
|
+
children_nodes = load_children_now ? build_nodes(children_index.children_for(creative), level: child_render_level) : []
|
|
112
126
|
|
|
113
127
|
return children_nodes if skip
|
|
114
128
|
|
|
129
|
+
can_write = can_write?(creative)
|
|
130
|
+
|
|
115
131
|
[
|
|
116
132
|
{
|
|
117
133
|
id: creative.id,
|
|
@@ -119,19 +135,22 @@ module Creatives
|
|
|
119
135
|
parent_id: creative.parent_id,
|
|
120
136
|
level: level,
|
|
121
137
|
select_mode: !!select_mode,
|
|
122
|
-
can_write:
|
|
123
|
-
has_children:
|
|
138
|
+
can_write: can_write,
|
|
139
|
+
has_children: has_children,
|
|
124
140
|
expanded: expanded,
|
|
125
|
-
is_root: creative.
|
|
141
|
+
is_root: creative.parent_id.nil?,
|
|
126
142
|
archived: creative.archived?,
|
|
127
|
-
github_source
|
|
143
|
+
# `github_source` is the wire key the tree renderer JS reads; its value
|
|
144
|
+
# is now the neutral read-only-source flag (GitHub-synced content is one
|
|
145
|
+
# such source) so core names no vendor here.
|
|
146
|
+
github_source: creative.read_only_source?,
|
|
128
147
|
sequence: creative.sequence,
|
|
129
148
|
link_url: view_context.collavre.creative_path(creative),
|
|
130
|
-
templates: template_payload_for(creative, has_children:
|
|
131
|
-
inline_editor_payload: inline_editor_payload_for(creative, can_write:
|
|
149
|
+
templates: template_payload_for(creative, has_children: has_children, can_write: can_write),
|
|
150
|
+
inline_editor_payload: inline_editor_payload_for(creative, can_write: can_write),
|
|
132
151
|
children_container: children_container_payload(
|
|
133
152
|
creative,
|
|
134
|
-
|
|
153
|
+
has_children: has_children,
|
|
135
154
|
child_level: child_level,
|
|
136
155
|
children_nodes: children_nodes,
|
|
137
156
|
expanded: expanded,
|
|
@@ -149,16 +168,17 @@ module Creatives
|
|
|
149
168
|
false
|
|
150
169
|
end
|
|
151
170
|
|
|
152
|
-
|
|
153
|
-
|
|
171
|
+
# The chats feed and flat search render rows, not a tree: no node has children
|
|
172
|
+
# there, so the whole child resolution is skipped.
|
|
173
|
+
def children_suppressed?
|
|
174
|
+
return @children_suppressed if defined?(@children_suppressed)
|
|
154
175
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
end
|
|
176
|
+
@children_suppressed = raw_params["comment"] == "true" ||
|
|
177
|
+
(raw_params["search"].present? && raw_params["search_mode"] != "tree")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def load_children_now?(creative)
|
|
181
|
+
filters_applied? || expanded?(creative.id) || skip_creative?(creative)
|
|
162
182
|
end
|
|
163
183
|
|
|
164
184
|
def expanded?(creative_id)
|
|
@@ -174,9 +194,17 @@ module Creatives
|
|
|
174
194
|
end
|
|
175
195
|
end
|
|
176
196
|
|
|
177
|
-
def template_payload_for(creative, has_children: nil)
|
|
197
|
+
def template_payload_for(creative, has_children: nil, can_write: nil)
|
|
178
198
|
description_html = view_context.embed_youtube_iframe(creative.effective_description(raw_params["tags"]&.first))
|
|
179
|
-
|
|
199
|
+
can_feedback = can_feedback?(creative)
|
|
200
|
+
progress_html = view_context.render_creative_progress(
|
|
201
|
+
creative,
|
|
202
|
+
select_mode: !!select_mode,
|
|
203
|
+
has_children: has_children,
|
|
204
|
+
can_write: can_write,
|
|
205
|
+
can_feedback: can_feedback,
|
|
206
|
+
unread_count: can_feedback ? comment_badge_index.unread_count_for(creative.effective_origin) : nil
|
|
207
|
+
)
|
|
180
208
|
|
|
181
209
|
{
|
|
182
210
|
description_html: description_html,
|
|
@@ -189,18 +217,19 @@ module Creatives
|
|
|
189
217
|
|
|
190
218
|
def inline_editor_payload_for(creative, can_write:)
|
|
191
219
|
effective = creative.effective_origin(Set.new)
|
|
192
|
-
origin_writable = effective.id == creative.id ? can_write :
|
|
220
|
+
origin_writable = effective.id == creative.id ? can_write : allowed?(creative, :write)
|
|
193
221
|
{
|
|
194
222
|
description_raw_html: creative.effective_description(nil, true),
|
|
195
223
|
progress: creative.progress,
|
|
196
224
|
origin_id: creative.origin_id,
|
|
197
225
|
content_type: effective.data&.dig("content_type"),
|
|
198
|
-
markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil
|
|
226
|
+
markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil,
|
|
227
|
+
markdown_editor: effective.data&.dig("editor")
|
|
199
228
|
}
|
|
200
229
|
end
|
|
201
230
|
|
|
202
|
-
def children_container_payload(creative,
|
|
203
|
-
return nil unless
|
|
231
|
+
def children_container_payload(creative, has_children:, child_level:, children_nodes:, expanded:, load_children_now:)
|
|
232
|
+
return nil unless has_children
|
|
204
233
|
|
|
205
234
|
{
|
|
206
235
|
id: "creative-children-#{creative.id}",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Collavre
|
|
8
|
+
# Thin, dependency-free HTTP wrapper over Net::HTTP shared across engines.
|
|
9
|
+
#
|
|
10
|
+
# It centralizes timeout configuration, TLS negotiation and transport-error
|
|
11
|
+
# handling so vendor engines no longer each hand-roll their own Net::HTTP /
|
|
12
|
+
# HTTParty / Faraday setup. Callers keep full ownership of their own response
|
|
13
|
+
# parsing and domain error mapping — this wrapper only performs the request and
|
|
14
|
+
# returns a small Response value object.
|
|
15
|
+
#
|
|
16
|
+
# Transport-layer failures (DNS, connection refused/reset, TLS handshake,
|
|
17
|
+
# open/read timeouts) are wrapped in Collavre::HttpClient::ConnectionError so a
|
|
18
|
+
# caller can rescue a single, stable error type regardless of the underlying
|
|
19
|
+
# exception class.
|
|
20
|
+
class HttpClient
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
class ConnectionError < Error; end
|
|
23
|
+
|
|
24
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
25
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
26
|
+
|
|
27
|
+
# Transport-level exceptions that occur before any HTTP response exists.
|
|
28
|
+
TRANSPORT_ERRORS = [
|
|
29
|
+
SocketError, SystemCallError, Timeout::Error, IOError,
|
|
30
|
+
OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
REQUEST_CLASSES = {
|
|
34
|
+
get: Net::HTTP::Get,
|
|
35
|
+
post: Net::HTTP::Post,
|
|
36
|
+
patch: Net::HTTP::Patch,
|
|
37
|
+
put: Net::HTTP::Put,
|
|
38
|
+
delete: Net::HTTP::Delete
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# A minimal, read-only view over a Net::HTTP response.
|
|
42
|
+
class Response
|
|
43
|
+
attr_reader :code, :message, :body, :headers
|
|
44
|
+
|
|
45
|
+
def initialize(net_response)
|
|
46
|
+
@net_response = net_response
|
|
47
|
+
@code = net_response.code.to_i
|
|
48
|
+
@message = net_response.message
|
|
49
|
+
@body = net_response.body
|
|
50
|
+
@headers = net_response.to_hash
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def success?
|
|
54
|
+
@net_response.is_a?(Net::HTTPSuccess)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Parse the response body as JSON, or nil when the body is empty.
|
|
58
|
+
def json
|
|
59
|
+
return nil if body.nil? || body.empty?
|
|
60
|
+
|
|
61
|
+
JSON.parse(body)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, default_headers: {})
|
|
66
|
+
@open_timeout = open_timeout
|
|
67
|
+
@read_timeout = read_timeout
|
|
68
|
+
@default_headers = default_headers
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def get(url, headers: {})
|
|
72
|
+
request(:get, url, headers: headers)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def post(url, body: nil, headers: {})
|
|
76
|
+
request(:post, url, body: body, headers: headers)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def patch(url, body: nil, headers: {})
|
|
80
|
+
request(:patch, url, body: body, headers: headers)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def put(url, body: nil, headers: {})
|
|
84
|
+
request(:put, url, body: body, headers: headers)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def delete(url, headers: {})
|
|
88
|
+
request(:delete, url, headers: headers)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def request(method, url, body: nil, headers: {})
|
|
94
|
+
uri = URI.parse(url)
|
|
95
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
96
|
+
http.use_ssl = uri.scheme == "https"
|
|
97
|
+
http.open_timeout = @open_timeout
|
|
98
|
+
http.read_timeout = @read_timeout
|
|
99
|
+
|
|
100
|
+
req = build_request(method, uri, body, headers)
|
|
101
|
+
Response.new(http.request(req))
|
|
102
|
+
rescue *TRANSPORT_ERRORS => e
|
|
103
|
+
raise ConnectionError, "#{e.class}: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_request(method, uri, body, headers)
|
|
107
|
+
path = uri.path.presence || "/"
|
|
108
|
+
path = "#{path}?#{uri.query}" if uri.query.present?
|
|
109
|
+
|
|
110
|
+
request_class = REQUEST_CLASSES.fetch(method) do
|
|
111
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
112
|
+
end
|
|
113
|
+
req = request_class.new(path)
|
|
114
|
+
@default_headers.merge(headers).each { |key, value| req[key] = value }
|
|
115
|
+
req.body = body unless body.nil?
|
|
116
|
+
req
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -64,6 +64,11 @@ module Collavre
|
|
|
64
64
|
content: @comment.content,
|
|
65
65
|
user: @comment.user,
|
|
66
66
|
quoted_comment: original,
|
|
67
|
+
# quoted_comment is set only for linkage to the message being answered.
|
|
68
|
+
# Mark it as a "question" so review_message? stays false — otherwise the
|
|
69
|
+
# agent's response would update the quoted comment in place (the review
|
|
70
|
+
# flow) instead of posting a new reply. See Comment#review_message?.
|
|
71
|
+
review_type: :question,
|
|
67
72
|
private: false
|
|
68
73
|
)
|
|
69
74
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Collavre
|
|
2
|
-
require "
|
|
2
|
+
require "net/http"
|
|
3
3
|
require "nokogiri"
|
|
4
4
|
require "uri"
|
|
5
5
|
require "ipaddr"
|
|
@@ -29,18 +29,22 @@ module Collavre
|
|
|
29
29
|
IPAddr.new("ff00::/8")
|
|
30
30
|
].freeze
|
|
31
31
|
|
|
32
|
+
# Minimal HTTP result the fetcher reasons about. `body` is only populated for
|
|
33
|
+
# successful (2xx) responses; redirects carry a `location` instead.
|
|
34
|
+
Response = Struct.new(:code, :content_type, :body, :location, keyword_init: true)
|
|
35
|
+
|
|
32
36
|
def self.fetch(url)
|
|
33
37
|
new(url).fetch
|
|
34
38
|
end
|
|
35
39
|
|
|
36
|
-
def initialize(url,
|
|
40
|
+
def initialize(url, http_client: nil, logger: Rails.logger)
|
|
37
41
|
@url = url
|
|
38
|
-
@
|
|
42
|
+
@http_client = http_client || PinnedHttpClient.new
|
|
39
43
|
@logger = logger
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
def fetch
|
|
43
|
-
uri =
|
|
47
|
+
uri = parse_http_uri(@url)
|
|
44
48
|
return {} unless uri
|
|
45
49
|
|
|
46
50
|
html, base_uri = read_html(uri)
|
|
@@ -56,47 +60,53 @@ module Collavre
|
|
|
56
60
|
private
|
|
57
61
|
|
|
58
62
|
def read_html(uri, redirect_limit = MAX_REDIRECTS)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
# Resolve+validate the host ONCE per hop and pin the request to that exact
|
|
64
|
+
# IP. This closes the DNS-rebinding TOCTOU gap: a hostname that validates
|
|
65
|
+
# as public here can no longer be re-resolved to a private/metadata IP at
|
|
66
|
+
# connect time, because the connection targets the pinned address (with the
|
|
67
|
+
# original hostname preserved for Host/SNI).
|
|
68
|
+
addresses = safe_addresses(uri)
|
|
69
|
+
return [ nil, nil ] if addresses.empty?
|
|
70
|
+
|
|
71
|
+
response = fetch_via_pinned_addresses(uri, addresses)
|
|
72
|
+
return [ nil, nil ] unless response
|
|
73
|
+
|
|
74
|
+
if redirect?(response.code)
|
|
75
|
+
return [ nil, nil ] if redirect_limit <= 0
|
|
76
|
+
|
|
77
|
+
redirected_uri = normalize_redirect_uri(uri, response.location)
|
|
78
|
+
return [ nil, nil ] unless redirected_uri
|
|
79
|
+
|
|
80
|
+
# Recurse so the new host is resolved+validated+pinned from scratch;
|
|
81
|
+
# never follow a hop to an internal address.
|
|
82
|
+
return read_html(redirected_uri, redirect_limit - 1)
|
|
69
83
|
end
|
|
70
|
-
[ html, base_uri ]
|
|
71
|
-
rescue OpenURI::HTTPRedirect => e
|
|
72
|
-
return [ nil, nil ] if redirect_limit <= 0
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
return [ nil, nil ] unless redirected_uri
|
|
85
|
+
return [ nil, nil ] unless success?(response.code)
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
content_type = response.content_type
|
|
88
|
+
if content_type && HTML_CONTENT_TYPES.none? { |type| content_type.include?(type) }
|
|
89
|
+
return [ nil, nil ]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
[ response.body, uri ]
|
|
93
|
+
rescue SocketError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
|
|
94
|
+
OpenSSL::SSL::SSLError, URI::InvalidURIError => e
|
|
79
95
|
@logger&.info("Link preview fetch skipped for #{@url}: #{e.class} #{e.message}")
|
|
80
96
|
[ nil, nil ]
|
|
81
97
|
end
|
|
82
98
|
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
return unless uri
|
|
86
|
-
return unless allowed_destination?(uri)
|
|
87
|
-
|
|
88
|
-
uri
|
|
99
|
+
def redirect?(code)
|
|
100
|
+
code.to_i.between?(300, 399)
|
|
89
101
|
end
|
|
90
102
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
return unless new_uri
|
|
94
|
-
return unless allowed_destination?(new_uri)
|
|
95
|
-
|
|
96
|
-
new_uri
|
|
103
|
+
def success?(code)
|
|
104
|
+
code.to_i.between?(200, 299)
|
|
97
105
|
end
|
|
98
106
|
|
|
99
107
|
def normalize_redirect_uri(current_uri, redirected)
|
|
108
|
+
return if redirected.blank?
|
|
109
|
+
|
|
100
110
|
target_uri = redirected.is_a?(URI) ? redirected : URI.parse(redirected.to_s)
|
|
101
111
|
target_uri = current_uri.merge(target_uri) if target_uri.relative?
|
|
102
112
|
return unless %w[http https].include?(target_uri.scheme)
|
|
@@ -116,15 +126,45 @@ module Collavre
|
|
|
116
126
|
nil
|
|
117
127
|
end
|
|
118
128
|
|
|
119
|
-
|
|
129
|
+
# Resolve the host, reject if it maps to ANY unsafe address, and return every
|
|
130
|
+
# validated public IP to pin against. Rejecting when any resolved address is
|
|
131
|
+
# unsafe (not just the one we pick) keeps defense-in-depth against
|
|
132
|
+
# split-horizon DNS returning a mix of public and private records. Returning
|
|
133
|
+
# all safe addresses lets the caller fall back to the next one when the first
|
|
134
|
+
# is unreachable (e.g. an AAAA record with no IPv6 egress).
|
|
135
|
+
def safe_addresses(uri)
|
|
120
136
|
host = uri.hostname
|
|
121
|
-
return
|
|
137
|
+
return [] if host.nil? || host.empty?
|
|
122
138
|
|
|
123
139
|
addresses = resolve_addresses(host)
|
|
124
|
-
return
|
|
125
|
-
return
|
|
140
|
+
return [] if addresses.empty?
|
|
141
|
+
return [] if addresses.any? { |address| unsafe_ip?(address) }
|
|
126
142
|
|
|
127
|
-
|
|
143
|
+
addresses
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Try each pre-validated address in turn, pinning the connection to it, and
|
|
147
|
+
# return the first response we can actually open. Connection-level failures
|
|
148
|
+
# fall through to the next candidate; if none connect, the caller treats it
|
|
149
|
+
# as an empty preview. Every address here already passed safe_addresses, so
|
|
150
|
+
# falling back never targets an unsafe IP.
|
|
151
|
+
def fetch_via_pinned_addresses(uri, addresses)
|
|
152
|
+
last_error = nil
|
|
153
|
+
addresses.each do |address|
|
|
154
|
+
return @http_client.get(
|
|
155
|
+
uri,
|
|
156
|
+
ip: address,
|
|
157
|
+
headers: REQUEST_OPTIONS,
|
|
158
|
+
open_timeout: OPEN_TIMEOUT,
|
|
159
|
+
read_timeout: READ_TIMEOUT,
|
|
160
|
+
max_bytes: MAX_BYTES
|
|
161
|
+
)
|
|
162
|
+
rescue SocketError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
|
|
163
|
+
OpenSSL::SSL::SSLError => e
|
|
164
|
+
last_error = e
|
|
165
|
+
end
|
|
166
|
+
@logger&.info("Link preview fetch skipped for #{@url}: #{last_error.class} #{last_error.message}") if last_error
|
|
167
|
+
nil
|
|
128
168
|
end
|
|
129
169
|
|
|
130
170
|
def resolve_addresses(host)
|
|
@@ -226,5 +266,56 @@ module Collavre
|
|
|
226
266
|
|
|
227
267
|
text.to_s.gsub(/\s+/, " ").strip
|
|
228
268
|
end
|
|
269
|
+
|
|
270
|
+
# Performs a single (non-redirect-following) GET against a pre-resolved IP
|
|
271
|
+
# while keeping the original hostname for the Host header and TLS SNI/cert
|
|
272
|
+
# verification. Net::HTTP#ipaddr= pins the socket to `ip`, so no second DNS
|
|
273
|
+
# lookup happens between validation and connect. Enforces the caller's open/
|
|
274
|
+
# read timeouts and byte cap.
|
|
275
|
+
class PinnedHttpClient
|
|
276
|
+
def get(uri, ip:, headers:, open_timeout:, read_timeout:, max_bytes:)
|
|
277
|
+
# Pass an explicit nil proxy: Net::HTTP.new defaults p_addr to :ENV, so a
|
|
278
|
+
# set http_proxy/HTTP_PROXY would route through the proxy, which resolves
|
|
279
|
+
# the hostname itself and defeats `http.ipaddr = ip` pinning — reopening
|
|
280
|
+
# the DNS-rebinding/SSRF gap. nil forces a direct, pinned connection.
|
|
281
|
+
http = Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
282
|
+
http.use_ssl = uri.scheme == "https"
|
|
283
|
+
http.ipaddr = ip
|
|
284
|
+
http.open_timeout = open_timeout
|
|
285
|
+
http.read_timeout = read_timeout
|
|
286
|
+
|
|
287
|
+
request = Net::HTTP::Get.new(uri, headers)
|
|
288
|
+
response_struct = nil
|
|
289
|
+
|
|
290
|
+
http.start do |conn|
|
|
291
|
+
conn.request(request) do |response|
|
|
292
|
+
body = read_capped_body(response, max_bytes)
|
|
293
|
+
response_struct = Response.new(
|
|
294
|
+
code: response.code.to_i,
|
|
295
|
+
content_type: response.content_type,
|
|
296
|
+
body: body,
|
|
297
|
+
location: response["location"]
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
response_struct
|
|
303
|
+
ensure
|
|
304
|
+
http&.finish if http&.started?
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
def read_capped_body(response, max_bytes)
|
|
310
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
311
|
+
|
|
312
|
+
body = +""
|
|
313
|
+
response.read_body do |chunk|
|
|
314
|
+
body << chunk
|
|
315
|
+
break if body.bytesize >= max_bytes
|
|
316
|
+
end
|
|
317
|
+
body.byteslice(0, max_bytes)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
229
320
|
end
|
|
230
321
|
end
|
|
@@ -48,12 +48,22 @@ module Collavre
|
|
|
48
48
|
source
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
# Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter)
|
|
51
|
+
# Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter).
|
|
52
|
+
# hardbreaks: a single newline becomes <br>, mirroring the JS renderer (marked breaks:true)
|
|
53
|
+
# and the canonical markdown_source, which stores consecutive rich-editor lines one-per-line
|
|
54
|
+
# rather than separated by a blank line.
|
|
55
|
+
#
|
|
56
|
+
# syntax_highlighter: nil disables comrak's built-in syntect highlighter, which otherwise
|
|
57
|
+
# bakes a fixed base16-ocean.dark theme into the stored HTML as inline `style=` attributes
|
|
58
|
+
# (e.g. `<pre style="background-color:#2b303b"><span style="color:#bf616a">`). Those inline
|
|
59
|
+
# styles override our theme-aware code_highlight.css palette and are blind to light/dark mode.
|
|
60
|
+
# Disabling it emits a plain `<pre lang="ruby"><code>…</code></pre>`; the client re-tokenizes
|
|
61
|
+
# with highlight.js so the rendered creative matches the editor (and respects the theme).
|
|
52
62
|
html = Commonmarker.to_html(input, options: {
|
|
53
63
|
parse: { smart: true },
|
|
54
|
-
render: { unsafe: true },
|
|
64
|
+
render: { unsafe: true, hardbreaks: true },
|
|
55
65
|
extension: { table: true, strikethrough: true, autolink: true, tasklist: true, tagfilter: true }
|
|
56
|
-
})
|
|
66
|
+
}, plugins: { syntax_highlighter: nil })
|
|
57
67
|
|
|
58
68
|
html.strip!
|
|
59
69
|
html
|