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
|
@@ -1,66 +1,39 @@
|
|
|
1
1
|
import { marked } from 'marked'
|
|
2
2
|
import DOMPurify from 'dompurify'
|
|
3
3
|
import { addTableDownloadButtons } from './table_download'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
78
57
|
return `<div class="mermaid-chart">${escaped}</div>`
|
|
79
58
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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, '&')
|
|
133
|
+
.replace(/</g, '<')
|
|
134
|
+
.replace(/>/g, '>')
|
|
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
|
+
});
|