collavre 0.22.0 → 0.23.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,300 @@
1
+ /**
2
+ * dialog — Promise-based, in-app replacements for the native browser dialogs
3
+ * window.alert(), window.confirm() and window.prompt().
4
+ *
5
+ * Why: the packaged desktop app runs inside a Tauri (WKWebView) webview, which
6
+ * does not reliably present native dialogs — alert() shows nothing, confirm()
7
+ * returns false with no UI, and prompt() returns null. Every action gated on
8
+ * `if (confirm(...))` then silently no-ops, and every `alert(error)` swallows
9
+ * the message, so a failure looks like data loss. Rendering our own DOM modal
10
+ * behaves identically in a browser and in the webview, removing the native
11
+ * dialog dependency entirely.
12
+ *
13
+ * Public API (all return Promises so callers `await` them):
14
+ * await alertDialog(message) → undefined (single OK button)
15
+ * await confirmDialog(message, { danger }) → boolean (OK / Cancel)
16
+ * await promptDialog(message, { defaultValue })→ string|null (input + OK / Cancel)
17
+ *
18
+ * Reuses the shared .modal-dialog styles (modal_dialog.css) so any host app or
19
+ * engine that bundles the collavre stylesheet renders these consistently.
20
+ */
21
+
22
+ // Localized default button labels. Dialog *messages* are localized by the
23
+ // caller (server-side strings / data attributes); only the generic OK/Cancel
24
+ // buttons need a locale here. Locale comes from <html lang>, falling back to
25
+ // the webview/browser language.
26
+ const LABELS = {
27
+ ko: { ok: '확인', cancel: '취소' },
28
+ en: { ok: 'OK', cancel: 'Cancel' },
29
+ }
30
+
31
+ function localeLabels() {
32
+ const lang = (
33
+ document.documentElement.lang ||
34
+ navigator.language ||
35
+ 'en'
36
+ ).toLowerCase()
37
+ const key = Object.keys(LABELS).find((l) => lang.startsWith(l))
38
+ return LABELS[key] || LABELS.en
39
+ }
40
+
41
+ // Stack of open dialogs so keyboard handling only targets the topmost one.
42
+ const stack = []
43
+
44
+ /**
45
+ * Internal modal builder shared by alert/confirm/prompt.
46
+ *
47
+ * @param {object} config
48
+ * @param {string} config.message Body text (rendered as text, never HTML).
49
+ * @param {string|null} config.title Optional header title.
50
+ * @param {boolean} config.danger Style the confirm button as destructive.
51
+ * @param {boolean} config.showCancel Render a Cancel button (confirm/prompt).
52
+ * @param {string} config.confirmText Confirm button label.
53
+ * @param {string} config.cancelText Cancel button label.
54
+ * @param {object|null} config.input When set, render a text input:
55
+ * { defaultValue?: string, placeholder?: string }
56
+ * @returns {Promise<{ confirmed: boolean, value: string|null }>}
57
+ * confirmed=true on OK/Enter; false on Cancel/Escape/overlay dismiss.
58
+ * value=current input text (always '' when no input is rendered).
59
+ */
60
+ function runDialog(config) {
61
+ const labels = localeLabels()
62
+ const {
63
+ message,
64
+ title = null,
65
+ danger = false,
66
+ showCancel = false,
67
+ confirmText = labels.ok,
68
+ cancelText = labels.cancel,
69
+ input = null,
70
+ } = config
71
+
72
+ return new Promise((resolve) => {
73
+ const previousFocus = document.activeElement
74
+
75
+ const overlay = document.createElement('div')
76
+ overlay.className = 'modal-dialog-overlay open'
77
+
78
+ // A real <dialog> (not a <div>): showModal() promotes it into the browser
79
+ // top layer so it renders ABOVE native <dialog> lightboxes (also top layer)
80
+ // and z-index:9999 fullscreen popups. A plain z-index can never beat the
81
+ // top layer, so an in-app confirm gated behind those surfaces would be
82
+ // invisible and look hung — the exact silent-no-op failure this module
83
+ // exists to remove.
84
+ const dialog = document.createElement('dialog')
85
+ dialog.className = 'modal-dialog modal-dialog-compact open'
86
+ dialog.setAttribute('role', 'alertdialog')
87
+ dialog.setAttribute('aria-modal', 'true')
88
+
89
+ if (title) {
90
+ const header = document.createElement('div')
91
+ header.className = 'modal-dialog-header'
92
+ const titleEl = document.createElement('div')
93
+ titleEl.className = 'modal-dialog-title'
94
+ titleEl.textContent = title
95
+ header.appendChild(titleEl)
96
+ dialog.appendChild(header)
97
+ }
98
+
99
+ const body = document.createElement('div')
100
+ body.className = 'modal-dialog-body'
101
+
102
+ // Message text. textContent (not innerHTML): the message may contain
103
+ // untrusted content, and native dialogs never rendered HTML either.
104
+ // white-space: pre-wrap (CSS) preserves the \n line breaks they honored.
105
+ if (message != null && String(message) !== '') {
106
+ const text = document.createElement('div')
107
+ text.className = 'confirm-dialog-message'
108
+ text.textContent = String(message)
109
+ body.appendChild(text)
110
+ }
111
+
112
+ // Optional input field (promptDialog).
113
+ let inputEl = null
114
+ if (input) {
115
+ inputEl = document.createElement('input')
116
+ inputEl.type = 'text'
117
+ inputEl.className = 'modal-dialog-input'
118
+ if (input.placeholder) inputEl.placeholder = input.placeholder
119
+ inputEl.value = input.defaultValue != null ? String(input.defaultValue) : ''
120
+ // Spacing when a message precedes the input.
121
+ if (body.childNodes.length > 0) inputEl.style.marginTop = 'var(--space-3)'
122
+ body.appendChild(inputEl)
123
+ }
124
+
125
+ dialog.appendChild(body)
126
+
127
+ const footer = document.createElement('div')
128
+ footer.className = 'modal-dialog-footer'
129
+
130
+ let cancelBtn = null
131
+ if (showCancel) {
132
+ cancelBtn = document.createElement('button')
133
+ cancelBtn.type = 'button'
134
+ cancelBtn.className = 'modal-dialog-btn modal-dialog-btn-secondary'
135
+ cancelBtn.textContent = cancelText
136
+ footer.appendChild(cancelBtn)
137
+ }
138
+
139
+ const confirmBtn = document.createElement('button')
140
+ confirmBtn.type = 'button'
141
+ confirmBtn.className =
142
+ 'modal-dialog-btn ' +
143
+ (danger ? 'modal-dialog-btn-danger' : 'modal-dialog-btn-primary')
144
+ confirmBtn.textContent = confirmText
145
+ footer.appendChild(confirmBtn)
146
+
147
+ dialog.appendChild(footer)
148
+
149
+ const entry = { dialog }
150
+ let settled = false
151
+
152
+ function close(confirmed) {
153
+ if (settled) return
154
+ settled = true
155
+ const value = inputEl ? inputEl.value : ''
156
+ const i = stack.indexOf(entry)
157
+ if (i !== -1) stack.splice(i, 1)
158
+ document.removeEventListener('keydown', onKeydown, true)
159
+ overlay.remove() // no-op when never appended (top-layer path)
160
+ // Exit the top layer before detaching (close event is not observed here).
161
+ try {
162
+ if (dialog.open && typeof dialog.close === 'function') dialog.close()
163
+ } catch (_) {
164
+ /* unsupported — removal below still drops it from the top layer */
165
+ }
166
+ dialog.remove()
167
+ // Restore focus to whatever triggered the dialog.
168
+ try {
169
+ if (previousFocus && typeof previousFocus.focus === 'function') {
170
+ previousFocus.focus()
171
+ }
172
+ } catch (_) {
173
+ /* element gone — ignore */
174
+ }
175
+ resolve({ confirmed, value })
176
+ }
177
+
178
+ function onKeydown(e) {
179
+ // Only the topmost dialog reacts to keys.
180
+ if (stack[stack.length - 1] !== entry) return
181
+ if (e.key === 'Escape') {
182
+ e.preventDefault()
183
+ e.stopPropagation()
184
+ close(false)
185
+ } else if (e.key === 'Enter') {
186
+ // In a text input, Enter submits (parity with native prompt).
187
+ e.preventDefault()
188
+ e.stopPropagation()
189
+ close(true)
190
+ }
191
+ }
192
+
193
+ if (cancelBtn) cancelBtn.addEventListener('click', () => close(false))
194
+ confirmBtn.addEventListener('click', () => close(true))
195
+ // Click on the ::backdrop (its event target is the <dialog> itself, since
196
+ // the panel content is nested) dismisses — parity with the old overlay.
197
+ dialog.addEventListener('click', (e) => {
198
+ if (e.target === dialog) close(false)
199
+ })
200
+ // Escape on a modal <dialog> fires `cancel`; route it through our teardown
201
+ // (focus restore + promise resolution) instead of the bare native close.
202
+ dialog.addEventListener('cancel', (e) => {
203
+ e.preventDefault()
204
+ close(false)
205
+ })
206
+ document.addEventListener('keydown', onKeydown, true)
207
+
208
+ document.body.appendChild(dialog)
209
+ stack.push(entry)
210
+
211
+ // Promote into the top layer. Fall back to a z-index overlay backdrop only
212
+ // where showModal is unavailable (jsdom under tests, very old webviews).
213
+ let topLayer = false
214
+ try {
215
+ dialog.showModal()
216
+ topLayer = true
217
+ } catch (_) {
218
+ /* no showModal — manual backdrop + stacking below */
219
+ }
220
+ if (!topLayer) {
221
+ dialog.setAttribute('open', '')
222
+ overlay.addEventListener('click', () => close(false))
223
+ document.body.insertBefore(overlay, dialog)
224
+ }
225
+
226
+ // Focus the input (prompt) or the confirm button (alert/confirm) so Enter
227
+ // accepts, matching native dialog behavior.
228
+ if (inputEl) {
229
+ inputEl.focus()
230
+ inputEl.select()
231
+ } else {
232
+ confirmBtn.focus()
233
+ }
234
+ })
235
+ }
236
+
237
+ /**
238
+ * alertDialog — in-app replacement for window.alert().
239
+ * Resolves (undefined) when dismissed. Usually fire-and-forget at call sites,
240
+ * but returns a Promise so callers may `await` it when ordering matters.
241
+ *
242
+ * alertDialog('Failed to save')
243
+ * await alertDialog('Done', { title: 'Export' })
244
+ */
245
+ export function alertDialog(message, options = {}) {
246
+ const { title = null, confirmText, danger = false } = options
247
+ return runDialog({
248
+ message,
249
+ title,
250
+ danger,
251
+ showCancel: false,
252
+ confirmText,
253
+ }).then(() => undefined)
254
+ }
255
+
256
+ /**
257
+ * confirmDialog — in-app replacement for window.confirm().
258
+ * Resolves true when confirmed, false when cancelled/dismissed.
259
+ *
260
+ * if (await confirmDialog(message, { danger: true })) doDestructiveThing()
261
+ */
262
+ export function confirmDialog(message, options = {}) {
263
+ const { confirmText, cancelText, danger = false, title = null } = options
264
+ return runDialog({
265
+ message,
266
+ title,
267
+ danger,
268
+ showCancel: true,
269
+ confirmText,
270
+ cancelText,
271
+ }).then((r) => r.confirmed)
272
+ }
273
+
274
+ /**
275
+ * promptDialog — in-app replacement for window.prompt().
276
+ * Resolves the entered string when confirmed, or null when cancelled/dismissed
277
+ * (parity with native prompt, which returns null on cancel).
278
+ *
279
+ * const name = await promptDialog('New name?', { defaultValue: current })
280
+ * if (name !== null) rename(name)
281
+ */
282
+ export function promptDialog(message, options = {}) {
283
+ const {
284
+ defaultValue = '',
285
+ placeholder = '',
286
+ confirmText,
287
+ cancelText,
288
+ title = null,
289
+ } = options
290
+ return runDialog({
291
+ message,
292
+ title,
293
+ showCancel: true,
294
+ confirmText,
295
+ cancelText,
296
+ input: { defaultValue, placeholder },
297
+ }).then((r) => (r.confirmed ? r.value : null))
298
+ }
299
+
300
+ export default confirmDialog
@@ -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
+ }