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.
Files changed (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -1,66 +1,39 @@
1
1
  import { marked } from 'marked'
2
2
  import DOMPurify from 'dompurify'
3
3
  import { addTableDownloadButtons } from './table_download'
4
- import hljs from 'highlight.js/lib/core'
5
-
6
- // Register only commonly used languages to keep the bundle small
7
- import javascript from 'highlight.js/lib/languages/javascript'
8
- import typescript from 'highlight.js/lib/languages/typescript'
9
- import ruby from 'highlight.js/lib/languages/ruby'
10
- import python from 'highlight.js/lib/languages/python'
11
- import css from 'highlight.js/lib/languages/css'
12
- import xml from 'highlight.js/lib/languages/xml'
13
- import json from 'highlight.js/lib/languages/json'
14
- import yaml from 'highlight.js/lib/languages/yaml'
15
- import bash from 'highlight.js/lib/languages/bash'
16
- import sql from 'highlight.js/lib/languages/sql'
17
- import markdownLang from 'highlight.js/lib/languages/markdown'
18
- import diff from 'highlight.js/lib/languages/diff'
19
- import erb from 'highlight.js/lib/languages/erb'
20
- import go from 'highlight.js/lib/languages/go'
21
- import java from 'highlight.js/lib/languages/java'
22
- import plaintext from 'highlight.js/lib/languages/plaintext'
23
-
24
- hljs.registerLanguage('javascript', javascript)
25
- hljs.registerLanguage('js', javascript)
26
- hljs.registerLanguage('typescript', typescript)
27
- hljs.registerLanguage('ts', typescript)
28
- hljs.registerLanguage('ruby', ruby)
29
- hljs.registerLanguage('rb', ruby)
30
- hljs.registerLanguage('python', python)
31
- hljs.registerLanguage('py', python)
32
- hljs.registerLanguage('css', css)
33
- hljs.registerLanguage('html', xml)
34
- hljs.registerLanguage('xml', xml)
35
- hljs.registerLanguage('json', json)
36
- hljs.registerLanguage('yaml', yaml)
37
- hljs.registerLanguage('yml', yaml)
38
- hljs.registerLanguage('bash', bash)
39
- hljs.registerLanguage('sh', bash)
40
- hljs.registerLanguage('shell', bash)
41
- hljs.registerLanguage('sql', sql)
42
- hljs.registerLanguage('markdown', markdownLang)
43
- hljs.registerLanguage('md', markdownLang)
44
- hljs.registerLanguage('diff', diff)
45
- hljs.registerLanguage('erb', erb)
46
- hljs.registerLanguage('go', go)
47
- hljs.registerLanguage('java', java)
48
- hljs.registerLanguage('plaintext', plaintext)
49
- hljs.registerLanguage('text', plaintext)
50
-
51
- function highlightCode(code, lang) {
52
- if (lang && hljs.getLanguage(lang)) {
53
- try {
54
- return hljs.highlight(code, { language: lang }).value
55
- } catch (_) { /* fall through */ }
56
- }
57
- // Auto-detect for unlabeled code blocks
58
- try {
59
- return hljs.highlightAuto(code).value
60
- } catch (_) {
61
- return code
62
- }
63
- }
4
+
5
+ // Prism syntax highlighting for ALL rendered code blocks. The Lexical editor
6
+ // highlights code with Prism (@lexical/code) and tags each token with a
7
+ // `lexical-token-*` class (see lib/editor/code_token_theme.js). Every other
8
+ // surface that renders a fenced block — the rendered creative description, the
9
+ // markdown-mode preview, and chat/comments — tokenizes with the SAME Prism
10
+ // instance, the SAME language components @lexical/code loads, and the SAME
11
+ // token→class map, so a code block looks identical everywhere. The shared
12
+ // code_languages module below registers the extra grammars @lexical/code omits
13
+ // (ruby, bash, …) on the same Prism singleton and resolves each block's language
14
+ // identically to the editor, so the tokenizers stay aligned.
15
+ import Prism from 'prismjs'
16
+ import 'prismjs/components/prism-clike'
17
+ import 'prismjs/components/prism-javascript'
18
+ import 'prismjs/components/prism-markup'
19
+ import 'prismjs/components/prism-markdown'
20
+ import 'prismjs/components/prism-c'
21
+ import 'prismjs/components/prism-css'
22
+ import 'prismjs/components/prism-objectivec'
23
+ import 'prismjs/components/prism-sql'
24
+ import 'prismjs/components/prism-powershell'
25
+ import 'prismjs/components/prism-python'
26
+ import 'prismjs/components/prism-rust'
27
+ import 'prismjs/components/prism-swift'
28
+ import 'prismjs/components/prism-typescript'
29
+ import 'prismjs/components/prism-java'
30
+ import 'prismjs/components/prism-cpp'
31
+ import { CODE_TOKEN_THEME } from '../editor/code_token_theme'
32
+ import { detectCodeLanguage, normalizeFenceLang } from '../editor/code_languages'
33
+
34
+ // We tokenize manually; stop Prism from auto-highlighting `code[class*=language-]`
35
+ // on DOMContentLoaded (which would double-process comment code blocks).
36
+ Prism.manual = true
64
37
 
65
38
  // Sanitize language identifier to prevent class attribute injection
66
39
  function sanitizeLang(lang) {
@@ -68,6 +41,12 @@ function sanitizeLang(lang) {
68
41
  return lang.replace(/[^a-zA-Z0-9_-]/g, '')
69
42
  }
70
43
 
44
+ // `breaks: true` so a single newline renders as <br> (GitHub/Slack style),
45
+ // matching the canonical markdown_source where consecutive rich-editor lines are
46
+ // stored one-per-line instead of separated by a blank line. Applies app-wide
47
+ // (creative descriptions and comments) so a line break always means a line break.
48
+ marked.use({ breaks: true })
49
+
71
50
  // Custom renderer for code blocks with syntax highlighting + mermaid
72
51
  marked.use({
73
52
  renderer: {
@@ -77,27 +56,38 @@ marked.use({
77
56
  const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
78
57
  return `<div class="mermaid-chart">${escaped}</div>`
79
58
  }
80
- const highlighted = highlightCode(text, safeLang)
81
- const langClass = safeLang ? ` language-${safeLang}` : ''
82
- return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`
59
+ // Highlight with the SAME Prism engine + `lexical-token-*` classes the
60
+ // editor and rendered creative description use (see highlightToLexicalHtml),
61
+ // so a fenced block is colored identically across the markdown-mode preview,
62
+ // chat/comments, the editor, and the rendered creative — one engine, one
63
+ // palette. An explicit fence language is honored verbatim (matching the
64
+ // editor); only genuinely unlabeled blocks are content-detected.
65
+ const resolved = safeLang ? normalizeFenceLang(safeLang) : detectCodeLanguage(text, '')
66
+ const highlighted = highlightToLexicalHtml(text, resolved)
67
+ const langAttr = resolved ? ` lang="${resolved}"` : ''
68
+ const codeClass = resolved ? ` class="language-${resolved}"` : ''
69
+ return `<pre${langAttr}><code${codeClass}>${highlighted}</code></pre>`
83
70
  }
84
71
  }
85
72
  })
86
73
 
87
- // Allow hljs span classes through DOMPurify
74
+ // Allow the shared Prism `lexical-token-*` span classes through DOMPurify
88
75
  DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
89
76
  if (node.tagName === 'SPAN' && data.attrName === 'class') {
90
77
  const classes = data.attrValue.split(/\s+/)
91
- const safe = classes.filter(c => c.startsWith('hljs-'))
78
+ // `lexical-token-*` is the single token-class family every surface now emits
79
+ // (editor, rendered creative, markdown preview, comments) so they highlight
80
+ // identically.
81
+ const safe = classes.filter(c => c.startsWith('lexical-token-'))
92
82
  if (safe.length > 0) {
93
83
  data.attrValue = safe.join(' ')
94
84
  data.forceKeepAttr = true
95
85
  }
96
86
  }
97
- // Allow hljs and language-* classes on code elements
87
+ // Allow language-* classes on code elements (the language hint Prism reads)
98
88
  if (node.tagName === 'CODE' && data.attrName === 'class') {
99
89
  const classes = data.attrValue.split(/\s+/)
100
- const safe = classes.filter(c => c === 'hljs' || c.startsWith('language-'))
90
+ const safe = classes.filter(c => c.startsWith('language-'))
101
91
  if (safe.length > 0) {
102
92
  data.attrValue = safe.join(' ')
103
93
  data.forceKeepAttr = true
@@ -137,6 +127,103 @@ export function renderCommentMarkdown(text) {
137
127
  return sanitize(html.trim())
138
128
  }
139
129
 
130
+ function escapeHtml(text) {
131
+ return text
132
+ .replace(/&/g, '&amp;')
133
+ .replace(/</g, '&lt;')
134
+ .replace(/>/g, '&gt;')
135
+ }
136
+
137
+ function wrapToken(text, type) {
138
+ const cls = type ? CODE_TOKEN_THEME[type] : undefined
139
+ if (!cls) return escapeHtml(text)
140
+ // Mirror @lexical/code, which splits a token's text on newlines/tabs into
141
+ // separate highlight nodes (the whitespace between them carries no token
142
+ // class), so the rendered span structure matches the editor exactly — not
143
+ // just the colors.
144
+ return text
145
+ .split(/(\n|\t)/)
146
+ .map((piece) =>
147
+ piece === '\n' || piece === '\t' || piece === ''
148
+ ? escapeHtml(piece)
149
+ : `<span class="${cls}">${escapeHtml(piece)}</span>`
150
+ )
151
+ .join('')
152
+ }
153
+
154
+ // Flatten a Prism token stream into `lexical-token-*` span HTML, mirroring how
155
+ // @lexical/code's registerCodeHighlighting assigns exactly ONE class (the
156
+ // nearest enclosing token type) to each leaf text node. `type` is the enclosing
157
+ // token type handed down to bare string leaves, so the rendered view tokenizes
158
+ // and classes identically to the editor.
159
+ function tokensToLexicalHtml(tokens, type) {
160
+ let html = ''
161
+ for (const token of tokens) {
162
+ if (typeof token === 'string') {
163
+ html += wrapToken(token, type)
164
+ } else if (typeof token.content === 'string') {
165
+ const leafType = token.type === 'prefix' && typeof token.alias === 'string'
166
+ ? token.alias
167
+ : token.type
168
+ html += wrapToken(token.content, leafType)
169
+ } else if (Array.isArray(token.content)) {
170
+ html += tokensToLexicalHtml(token.content, token.type === 'unchanged' ? undefined : token.type)
171
+ }
172
+ }
173
+ return html
174
+ }
175
+
176
+ function highlightToLexicalHtml(code, lang) {
177
+ // No grammar (unlabeled / unsupported language) → render as plaintext rather
178
+ // than forcing JavaScript, so a block we can't confidently classify isn't
179
+ // mis-colored as JS.
180
+ const grammar = lang ? Prism.languages[lang] : null
181
+ if (!grammar) return escapeHtml(code)
182
+ return tokensToLexicalHtml(Prism.tokenize(code, grammar), undefined)
183
+ }
184
+
185
+ // Re-tokenize server-rendered creative description code blocks with Prism.
186
+ //
187
+ // Creative descriptions are rendered server-side by commonmarker. We disable its
188
+ // built-in syntect highlighter (which bakes a fixed dark theme inline), so the
189
+ // stored HTML arrives as plain `<pre lang="ruby"><code>raw source</code></pre>`.
190
+ // This pass re-tokenizes that source with the SAME Prism instance, language set,
191
+ // and `lexical-token-*` token classes the editor uses, so edit mode and rendered
192
+ // mode are colored token-for-token identically (not just the same palette) and
193
+ // follow the light/dark theme via the shared `--syntax-*` variables.
194
+ //
195
+ // Reading `textContent` (not innerHTML) means legacy descriptions whose stored
196
+ // HTML still carries baked-in inline-styled spans get re-highlighted too — no
197
+ // data migration needed. Idempotent via the `data-hljs-highlighted` marker.
198
+ export function highlightCodeBlocks(container) {
199
+ if (!container) return
200
+ const blocks = container.querySelectorAll('pre code:not([data-hljs-highlighted])')
201
+ blocks.forEach((code) => {
202
+ const pre = code.closest('pre')
203
+ let lang = sanitizeLang(pre && pre.getAttribute('lang'))
204
+ if (!lang) {
205
+ const match = /(?:^|\s)language-([\w-]+)/.exec(code.className || '')
206
+ if (match) lang = sanitizeLang(match[1])
207
+ }
208
+ // Resolve the language the same way the editor does. In the rendered view the
209
+ // language always comes from the stored source (a real fence / <pre lang>), so
210
+ // an explicit label is honored verbatim — including "javascript" — exactly as
211
+ // the editor honors an import-resolved language. Only genuinely unlabeled
212
+ // blocks are content-detected. This keeps edit and view in lock-step; without
213
+ // it the view would re-detect an explicit ```javascript to ruby while the
214
+ // editor honored javascript, and the two would disagree.
215
+ const resolved = lang ? normalizeFenceLang(lang) : detectCodeLanguage(code.textContent, '')
216
+ // Build the markup ourselves with escaped text and only `lexical-token-*`
217
+ // classes, then sanitize as defense-in-depth (DOMPurify keeps those spans
218
+ // via the class hook and neutralizes anything unexpected).
219
+ code.innerHTML = sanitize(highlightToLexicalHtml(code.textContent, resolved))
220
+ code.dataset.hljsHighlighted = 'true'
221
+ // Drop any baked-in inline background (e.g. syntect's dark `<pre style=…>`)
222
+ // so the theme-aware --color-code-bg from code_highlight.css wins.
223
+ if (pre) pre.removeAttribute('style')
224
+ })
225
+ }
226
+
140
227
  // Lazy-load mermaid and render diagrams in a container
141
228
  let mermaidReady = false
142
229
 
@@ -0,0 +1,31 @@
1
+ import DOMPurify from 'dompurify'
2
+
3
+ // Trusted YouTube embed origins. The server's `embed_youtube_iframe` helper
4
+ // (app/helpers/application_helper.rb) only ever emits `youtube.com/embed/...`
5
+ // iframes, so we mirror that here and refuse every other iframe source.
6
+ const YOUTUBE_EMBED_SRC =
7
+ /^https:\/\/(www\.)?(youtube\.com|youtube-nocookie\.com)\/embed\//i
8
+
9
+ // DOMPurify's default config strips <iframe> entirely, which silently removed
10
+ // the YouTube preview iframe the server had already generated — the description
11
+ // rendered blank. Re-allow iframes, but only from trusted YouTube embed origins
12
+ // so arbitrary iframe injection (clickjacking / XSS) stays blocked.
13
+ DOMPurify.addHook('uponSanitizeElement', (node, data) => {
14
+ if (data.tagName !== 'iframe') return
15
+ const src = node.getAttribute && node.getAttribute('src')
16
+ if (!src || !YOUTUBE_EMBED_SRC.test(src)) {
17
+ node.parentNode && node.parentNode.removeChild(node)
18
+ }
19
+ })
20
+
21
+ // Mirrors the iframe attributes in EMBED_ALLOWED_ATTRS server-side.
22
+ const PURIFY_CONFIG = {
23
+ ADD_TAGS: ['iframe'],
24
+ ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder']
25
+ }
26
+
27
+ // Sanitize creative description HTML for client-side rendering while keeping the
28
+ // server-generated YouTube preview iframe intact.
29
+ export function sanitizeDescriptionHtml(html) {
30
+ return DOMPurify.sanitize(html ?? '', PURIFY_CONFIG)
31
+ }
@@ -161,3 +161,18 @@ export function addTableDownloadButtons(contentElement) {
161
161
  wrapper.appendChild(table)
162
162
  })
163
163
  }
164
+
165
+ /**
166
+ * Wraps only the tables in a creative row's *display* areas
167
+ * (.creative-content / .creative-title-content). The inline edit form is
168
+ * appended into .creative-tree as a sibling of these areas, so scanning the
169
+ * whole row would move the live Lexical editor / markdown-preview <table>
170
+ * into a wrapper and corrupt in-progress table edits.
171
+ */
172
+ export function addCreativeTableDownloadButtons(rowElement) {
173
+ if (!rowElement) return
174
+
175
+ rowElement
176
+ .querySelectorAll('.creative-content, .creative-title-content')
177
+ .forEach((area) => addTableDownloadButtons(area))
178
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Characterization tests for the inline creative payload mapping cluster
3
+ * extracted from creative_row_editor.js (slice 3 of the god-file decomposition).
4
+ *
5
+ * These pin the CURRENT behavior of the inverse pair that drives instant UI:
6
+ * - inlinePayloadFromTree: reads a payload OUT of a rendered tree row's dataset
7
+ * - updateRowFromData: writes a server/API payload INTO a row's dataset
8
+ *
9
+ * The fixture mirrors the real creative-tree render: a `creative-tree-row`
10
+ * custom element wrapping a `.creative-tree` node whose `data-id`/`data-parent-id`
11
+ * carry the tree identity.
12
+ *
13
+ * Intentionally behavior-preserving: any surprising result below (e.g. missing
14
+ * fields defaulting to 0/'' rather than being omitted, or `hasOwnProperty`-gated
15
+ * writes) documents existing behavior, not an endorsement of it.
16
+ */
17
+ import { inlinePayloadFromTree, updateRowFromData } from '../creative_inline_payload';
18
+
19
+ // Build one row: <creative-tree-row><div.creative-tree/></creative-tree-row>
20
+ function makeRow(id, { parentId = '', level = 1 } = {}) {
21
+ const row = document.createElement('creative-tree-row');
22
+ row.setAttribute('creative-id', String(id));
23
+ row.setAttribute('level', String(level));
24
+ const tree = document.createElement('div');
25
+ tree.className = 'creative-tree';
26
+ tree.id = `creative-${id}`;
27
+ tree.dataset.id = String(id);
28
+ tree.dataset.parentId = parentId;
29
+ tree.dataset.level = String(level);
30
+ row.appendChild(tree);
31
+ return { row, tree };
32
+ }
33
+
34
+ describe('inlinePayloadFromTree', () => {
35
+ test('returns null for falsy tree', () => {
36
+ expect(inlinePayloadFromTree(null)).toBeNull();
37
+ expect(inlinePayloadFromTree(undefined)).toBeNull();
38
+ });
39
+
40
+ test('returns null when tree has no wrapping creative-tree-row', () => {
41
+ const orphan = document.createElement('div');
42
+ orphan.dataset.id = '5';
43
+ expect(inlinePayloadFromTree(orphan)).toBeNull();
44
+ });
45
+
46
+ test('returns null when row exists but tree has no data-id', () => {
47
+ const { tree } = makeRow('9');
48
+ delete tree.dataset.id;
49
+ expect(inlinePayloadFromTree(tree)).toBeNull();
50
+ });
51
+
52
+ test('builds a fully-defaulted payload from a minimal row (only id)', () => {
53
+ const { tree } = makeRow('42', { parentId: '' });
54
+ expect(inlinePayloadFromTree(tree)).toEqual({
55
+ id: '42',
56
+ description: '',
57
+ description_raw_html: '',
58
+ origin_id: '',
59
+ parent_id: '',
60
+ progress: 0,
61
+ content_type: null,
62
+ markdown_editor: null,
63
+ markdown_source: null,
64
+ });
65
+ });
66
+
67
+ test('reads all fields from a fully-populated row', () => {
68
+ const { row, tree } = makeRow('7', { parentId: '3' });
69
+ row.dataset.descriptionHtml = '<p>hi</p>';
70
+ row.dataset.descriptionRawHtml = '<p>raw</p>';
71
+ row.dataset.progressValue = '55';
72
+ row.dataset.originId = '99';
73
+ row.dataset.contentType = 'markdown';
74
+ row.dataset.markdownEditor = 'rich';
75
+ row.dataset.markdownSource = '# hi';
76
+ expect(inlinePayloadFromTree(tree)).toEqual({
77
+ id: '7',
78
+ description: '<p>hi</p>',
79
+ description_raw_html: '<p>raw</p>',
80
+ origin_id: '99',
81
+ parent_id: '3',
82
+ progress: 55,
83
+ content_type: 'markdown',
84
+ markdown_editor: 'rich',
85
+ markdown_source: '# hi',
86
+ });
87
+ });
88
+
89
+ test('description falls back to raw html when descriptionHtml absent', () => {
90
+ const { row, tree } = makeRow('8');
91
+ row.dataset.descriptionRawHtml = '<p>only-raw</p>';
92
+ const payload = inlinePayloadFromTree(tree);
93
+ expect(payload.description).toBe('<p>only-raw</p>');
94
+ expect(payload.description_raw_html).toBe('<p>only-raw</p>');
95
+ });
96
+
97
+ test('non-numeric progress coerces to 0 (NaN guard)', () => {
98
+ const { row, tree } = makeRow('11');
99
+ row.dataset.progressValue = 'abc';
100
+ expect(inlinePayloadFromTree(tree).progress).toBe(0);
101
+ });
102
+
103
+ test('progress present but empty-string reads as 0', () => {
104
+ const { row, tree } = makeRow('12');
105
+ row.dataset.progressValue = '';
106
+ // Number('' ?? 0) === Number('') === 0
107
+ expect(inlinePayloadFromTree(tree).progress).toBe(0);
108
+ });
109
+ });
110
+
111
+ describe('updateRowFromData', () => {
112
+ test('no-ops on falsy row or data', () => {
113
+ const { row } = makeRow('1');
114
+ expect(() => updateRowFromData(null, { description: 'x' })).not.toThrow();
115
+ expect(() => updateRowFromData(row, null)).not.toThrow();
116
+ expect(row.dataset.descriptionHtml).toBeUndefined();
117
+ });
118
+
119
+ test('writes description + raw html (raw falls back to description)', () => {
120
+ const { row } = makeRow('1');
121
+ updateRowFromData(row, { description: '<p>d</p>' });
122
+ expect(row.descriptionHtml).toBe('<p>d</p>');
123
+ expect(row.dataset.descriptionHtml).toBe('<p>d</p>');
124
+ expect(row.dataset.descriptionRawHtml).toBe('<p>d</p>');
125
+ });
126
+
127
+ test('explicit description_raw_html is preserved distinctly', () => {
128
+ const { row } = makeRow('1');
129
+ updateRowFromData(row, { description: '<p>d</p>', description_raw_html: '<p>r</p>' });
130
+ expect(row.dataset.descriptionHtml).toBe('<p>d</p>');
131
+ expect(row.dataset.descriptionRawHtml).toBe('<p>r</p>');
132
+ });
133
+
134
+ test('missing description writes empty string (not skipped)', () => {
135
+ const { row } = makeRow('1');
136
+ updateRowFromData(row, { progress: 10 });
137
+ expect(row.dataset.descriptionHtml).toBe('');
138
+ expect(row.dataset.descriptionRawHtml).toBe('');
139
+ });
140
+
141
+ test('progress_html only written when non-null', () => {
142
+ const { row } = makeRow('1');
143
+ updateRowFromData(row, { description: 'd', progress_html: '<b>50%</b>' });
144
+ expect(row.progressHtml).toBe('<b>50%</b>');
145
+ expect(row.dataset.progressHtml).toBe('<b>50%</b>');
146
+
147
+ const { row: row2 } = makeRow('2');
148
+ updateRowFromData(row2, { description: 'd', progress_html: null });
149
+ expect(row2.dataset.progressHtml).toBeUndefined();
150
+ });
151
+
152
+ test('progress written from own key; null clears dataset value', () => {
153
+ const { row } = makeRow('1');
154
+ updateRowFromData(row, { description: 'd', progress: 30 });
155
+ expect(row.dataset.progressValue).toBe('30');
156
+
157
+ // `?? ''` maps null/undefined to '', and setRowDatasetValue deletes on ''? no:
158
+ // value '' is a string -> stored as ''. null -> '' -> stored as ''.
159
+ const { row: row2 } = makeRow('2');
160
+ updateRowFromData(row2, { description: 'd', progress: null });
161
+ expect(row2.dataset.progressValue).toBe('');
162
+ });
163
+
164
+ test('hasOwnProperty gating: absent keys leave dataset untouched', () => {
165
+ const { row } = makeRow('1');
166
+ row.dataset.originId = 'pre';
167
+ row.dataset.contentType = 'pre';
168
+ updateRowFromData(row, { description: 'd' });
169
+ // origin_id / content_type not in payload -> not overwritten
170
+ expect(row.dataset.originId).toBe('pre');
171
+ expect(row.dataset.contentType).toBe('pre');
172
+ });
173
+
174
+ test('origin_id / content_type / markdown_source / markdown_editor round-trip', () => {
175
+ const { row } = makeRow('1');
176
+ updateRowFromData(row, {
177
+ description: 'd',
178
+ origin_id: '77',
179
+ content_type: 'markdown',
180
+ markdown_source: '# s',
181
+ markdown_editor: 'rich',
182
+ });
183
+ expect(row.dataset.originId).toBe('77');
184
+ expect(row.dataset.contentType).toBe('markdown');
185
+ expect(row.dataset.markdownSource).toBe('# s');
186
+ expect(row.dataset.markdownEditor).toBe('rich');
187
+ });
188
+
189
+ test('has_children truthy sets attribute + property; falsy removes', () => {
190
+ const { row } = makeRow('1');
191
+ updateRowFromData(row, { description: 'd', has_children: true });
192
+ expect(row.hasAttribute('has-children')).toBe(true);
193
+ expect(row.hasChildren).toBe(true);
194
+
195
+ updateRowFromData(row, { description: 'd', has_children: false });
196
+ expect(row.hasAttribute('has-children')).toBe(false);
197
+ expect(row.hasChildren).toBe(false);
198
+ });
199
+
200
+ test('calls requestUpdate when the row exposes it', () => {
201
+ const { row } = makeRow('1');
202
+ let called = 0;
203
+ row.requestUpdate = () => { called += 1; };
204
+ updateRowFromData(row, { description: 'd' });
205
+ expect(called).toBe(1);
206
+ });
207
+
208
+ test('round-trip: updateRowFromData then inlinePayloadFromTree', () => {
209
+ const { row, tree } = makeRow('50', { parentId: '2' });
210
+ updateRowFromData(row, {
211
+ description: '<p>body</p>',
212
+ description_raw_html: '<p>raw</p>',
213
+ progress: 40,
214
+ origin_id: '5',
215
+ content_type: 'markdown',
216
+ markdown_source: '# md',
217
+ markdown_editor: 'source',
218
+ });
219
+ expect(inlinePayloadFromTree(tree)).toEqual({
220
+ id: '50',
221
+ description: '<p>body</p>',
222
+ description_raw_html: '<p>raw</p>',
223
+ origin_id: '5',
224
+ parent_id: '2',
225
+ progress: 40,
226
+ content_type: 'markdown',
227
+ markdown_editor: 'source',
228
+ markdown_source: '# md',
229
+ });
230
+ });
231
+ });