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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Shared code-language resolution for syntax highlighting.
|
|
2
|
+
//
|
|
3
|
+
// Both the Lexical editor (registerCodeHighlighting) and the rendered creative
|
|
4
|
+
// view (lib/utils/markdown.js highlightCodeBlocks) tokenize code with the SAME
|
|
5
|
+
// global Prism instance. @lexical/code only preloads a fixed grammar set and its
|
|
6
|
+
// `loadCodeLanguage` is a no-op, so any language we want to highlight must be
|
|
7
|
+
// registered on the shared `prismjs` singleton up front. The side-effect imports
|
|
8
|
+
// below add the common languages @lexical/code omits (ruby, bash, json, yaml,
|
|
9
|
+
// go, php); importing this module anywhere makes them available to BOTH surfaces.
|
|
10
|
+
// `prismjs` core must load first — the component files mutate the global Prism.
|
|
11
|
+
import "prismjs"
|
|
12
|
+
import "prismjs/components/prism-ruby"
|
|
13
|
+
import "prismjs/components/prism-bash"
|
|
14
|
+
import "prismjs/components/prism-json"
|
|
15
|
+
import "prismjs/components/prism-yaml"
|
|
16
|
+
import "prismjs/components/prism-go"
|
|
17
|
+
import "prismjs/components/prism-php"
|
|
18
|
+
|
|
19
|
+
// A curated highlight.js instance used ONLY for language auto-detection (not for
|
|
20
|
+
// rendering — rendering is Prism). A small, codebase-relevant subset keeps
|
|
21
|
+
// `highlightAuto` from matching obscure languages (the full set mislabels plain
|
|
22
|
+
// JS as "arcade"); detection accuracy comes from limiting the candidates.
|
|
23
|
+
import hljs from "highlight.js/lib/core"
|
|
24
|
+
import javascript from "highlight.js/lib/languages/javascript"
|
|
25
|
+
import typescript from "highlight.js/lib/languages/typescript"
|
|
26
|
+
import ruby from "highlight.js/lib/languages/ruby"
|
|
27
|
+
import python from "highlight.js/lib/languages/python"
|
|
28
|
+
import css from "highlight.js/lib/languages/css"
|
|
29
|
+
import xml from "highlight.js/lib/languages/xml"
|
|
30
|
+
import json from "highlight.js/lib/languages/json"
|
|
31
|
+
import yaml from "highlight.js/lib/languages/yaml"
|
|
32
|
+
import bash from "highlight.js/lib/languages/bash"
|
|
33
|
+
import sql from "highlight.js/lib/languages/sql"
|
|
34
|
+
import go from "highlight.js/lib/languages/go"
|
|
35
|
+
import java from "highlight.js/lib/languages/java"
|
|
36
|
+
|
|
37
|
+
const DETECT_LANGUAGES = {
|
|
38
|
+
javascript, typescript, ruby, python, css, xml, json, yaml, bash, sql, go, java
|
|
39
|
+
}
|
|
40
|
+
for (const [name, def] of Object.entries(DETECT_LANGUAGES)) {
|
|
41
|
+
if (!hljs.getLanguage(name)) hljs.registerLanguage(name, def)
|
|
42
|
+
}
|
|
43
|
+
const DETECT_SUBSET = Object.keys(DETECT_LANGUAGES)
|
|
44
|
+
|
|
45
|
+
// @lexical/code's tokenizer default: an unlabeled or new code block tokenizes as
|
|
46
|
+
// JavaScript. We treat that exact value (and an empty/missing language) as
|
|
47
|
+
// "unconfirmed" — eligible for re-detection — while honoring any other explicit
|
|
48
|
+
// fence language the user set.
|
|
49
|
+
export const DEFAULT_CODE_LANGUAGE = "javascript"
|
|
50
|
+
const RE_DETECTABLE = new Set(["", "javascript"])
|
|
51
|
+
|
|
52
|
+
// Tracks which CodeNodes received their language from an EXPLICIT source label
|
|
53
|
+
// (a fence ```ruby / ```javascript, a <pre lang>, a <code class="language-X">)
|
|
54
|
+
// during import, keyed per editor by node key. Detection must skip these so an
|
|
55
|
+
// explicit choice is never overridden by content auto-detection.
|
|
56
|
+
//
|
|
57
|
+
// This is the linchpin for honoring "javascript": @lexical/code bakes
|
|
58
|
+
// "javascript" onto unlabeled/new code blocks (its tokenizer default), making
|
|
59
|
+
// "javascript" ambiguous — it could be the baked default OR a deliberate
|
|
60
|
+
// choice. The source label disambiguates them, but only at import time, before
|
|
61
|
+
// baking. We capture that signal here so the post-bake detection transform can
|
|
62
|
+
// tell "user wrote ```javascript" (honor) from "baked default" (re-detect).
|
|
63
|
+
const importResolvedLanguages = new WeakMap()
|
|
64
|
+
|
|
65
|
+
export function markLanguageResolved(editor, key) {
|
|
66
|
+
let keys = importResolvedLanguages.get(editor)
|
|
67
|
+
if (!keys) {
|
|
68
|
+
keys = new Set()
|
|
69
|
+
importResolvedLanguages.set(editor, keys)
|
|
70
|
+
}
|
|
71
|
+
keys.add(key)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isLanguageResolved(editor, key) {
|
|
75
|
+
const keys = importResolvedLanguages.get(editor)
|
|
76
|
+
return Boolean(keys && keys.has(key))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function clearLanguageResolved(editor) {
|
|
80
|
+
importResolvedLanguages.delete(editor)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Canonicalize fence aliases to the Prism grammar name. highlight.js uses a few
|
|
84
|
+
// different names (xml↔markup, shell↔bash) so detection results are mapped too.
|
|
85
|
+
const FENCE_ALIAS = {
|
|
86
|
+
rb: "ruby",
|
|
87
|
+
py: "python",
|
|
88
|
+
yml: "yaml",
|
|
89
|
+
sh: "bash",
|
|
90
|
+
shell: "bash",
|
|
91
|
+
zsh: "bash",
|
|
92
|
+
ts: "typescript",
|
|
93
|
+
js: "javascript",
|
|
94
|
+
jsx: "javascript",
|
|
95
|
+
tsx: "typescript",
|
|
96
|
+
html: "markup",
|
|
97
|
+
htm: "markup",
|
|
98
|
+
xml: "markup",
|
|
99
|
+
golang: "go"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeFenceLang(lang) {
|
|
103
|
+
if (!lang) return ""
|
|
104
|
+
const l = String(lang).trim().toLowerCase()
|
|
105
|
+
if (!/^[\w+-]+$/.test(l)) return ""
|
|
106
|
+
return FENCE_ALIAS[l] || l
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Bridge an imported HTML fragment so @lexical/code's importer picks up the
|
|
110
|
+
// fence language whichever renderer produced the HTML. @lexical/code's
|
|
111
|
+
// $convertPreElement reads ONLY `data-language` on the <pre>, but the two
|
|
112
|
+
// renderers that feed the editor encode the language differently:
|
|
113
|
+
// - commonmarker (server reopen): <pre lang="ruby">…
|
|
114
|
+
// - marked / renderMarkdown (the <pre><code class="language-ruby">…
|
|
115
|
+
// markdown→rich toggle in the editor)
|
|
116
|
+
// Without this, the markdown→rich switch dropped the explicit fence language
|
|
117
|
+
// (it lived on the <code>, not the <pre>), so the block fell back to content
|
|
118
|
+
// detection / the javascript default — i.e. it reverted to the previous result.
|
|
119
|
+
// We normalize either form onto `data-language`. Unlabeled blocks are left
|
|
120
|
+
// alone for the content-detection transform to handle.
|
|
121
|
+
export function bridgeCodeFenceLanguages(container) {
|
|
122
|
+
if (!container) return
|
|
123
|
+
container.querySelectorAll("pre:not([data-language])").forEach((pre) => {
|
|
124
|
+
let lang = normalizeFenceLang(pre.getAttribute("lang"))
|
|
125
|
+
if (!lang) {
|
|
126
|
+
const code = pre.querySelector('code[class*="language-"]')
|
|
127
|
+
const match = code && /(?:^|\s)language-([\w+-]+)/.exec(code.className || "")
|
|
128
|
+
if (match) lang = normalizeFenceLang(match[1])
|
|
129
|
+
}
|
|
130
|
+
if (lang) pre.setAttribute("data-language", lang)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Minimum margin by which the detected language's relevance must beat plain
|
|
135
|
+
// JavaScript's own relevance for the SAME text before we override the
|
|
136
|
+
// JavaScript default. This keeps real JS as JS (margin 0) while confidently
|
|
137
|
+
// switching Ruby/bash/yaml/python (margins 3–12).
|
|
138
|
+
const DETECT_MARGIN = 3
|
|
139
|
+
const MIN_DETECT_LENGTH = 12
|
|
140
|
+
|
|
141
|
+
// Resolve the highlighting language for a code block.
|
|
142
|
+
// - An explicit, non-default fence language ("ruby", "python", …) is honored.
|
|
143
|
+
// - An unlabeled block, or one stuck on the "javascript" default, is
|
|
144
|
+
// re-detected from its content; we only switch away from JavaScript when
|
|
145
|
+
// detection is confident (see DETECT_MARGIN), otherwise the original value
|
|
146
|
+
// is kept. Returns "" for an unlabeled block we can't confidently detect
|
|
147
|
+
// (render as plaintext rather than forcing JavaScript).
|
|
148
|
+
export function detectCodeLanguage(code, declared) {
|
|
149
|
+
const norm = normalizeFenceLang(declared)
|
|
150
|
+
if (norm && !RE_DETECTABLE.has(norm)) return norm
|
|
151
|
+
|
|
152
|
+
const text = (code || "").trim()
|
|
153
|
+
if (text.length < MIN_DETECT_LENGTH) return norm
|
|
154
|
+
|
|
155
|
+
let best
|
|
156
|
+
try {
|
|
157
|
+
best = hljs.highlightAuto(text, DETECT_SUBSET)
|
|
158
|
+
} catch (_e) {
|
|
159
|
+
return norm
|
|
160
|
+
}
|
|
161
|
+
if (!best || !best.language) return norm
|
|
162
|
+
|
|
163
|
+
const cand = FENCE_ALIAS[best.language] || best.language
|
|
164
|
+
if (cand === "javascript") return norm
|
|
165
|
+
|
|
166
|
+
let jsRelevance = 0
|
|
167
|
+
try {
|
|
168
|
+
jsRelevance = hljs.highlight(text, { language: "javascript" }).relevance
|
|
169
|
+
} catch (_e) {
|
|
170
|
+
jsRelevance = 0
|
|
171
|
+
}
|
|
172
|
+
return best.relevance - jsRelevance >= DETECT_MARGIN ? cand : norm
|
|
173
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Prism token type → CSS class map for code-block syntax highlighting.
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth shared by the Lexical editor
|
|
4
|
+
// (components/InlineLexicalEditor.jsx, passed as theme.codeHighlight to
|
|
5
|
+
// registerCodeHighlighting) and the rendered creative view
|
|
6
|
+
// (lib/utils/markdown.js highlightCodeBlocks). Both tokenize code with the same
|
|
7
|
+
// Prism instance and tag each token with these `lexical-token-*` classes, so
|
|
8
|
+
// edit mode and rendered mode are colored identically by the shared
|
|
9
|
+
// `.lexical-token-*` rules in actiontext.css. One map means the two surfaces
|
|
10
|
+
// can never drift apart.
|
|
11
|
+
export const CODE_TOKEN_THEME = {
|
|
12
|
+
atrule: "lexical-token-atrule",
|
|
13
|
+
attr: "lexical-token-attr",
|
|
14
|
+
boolean: "lexical-token-boolean",
|
|
15
|
+
builtin: "lexical-token-builtin",
|
|
16
|
+
cdata: "lexical-token-cdata",
|
|
17
|
+
char: "lexical-token-char",
|
|
18
|
+
class: "lexical-token-class",
|
|
19
|
+
comment: "lexical-token-comment",
|
|
20
|
+
constant: "lexical-token-constant",
|
|
21
|
+
deleted: "lexical-token-deleted",
|
|
22
|
+
doctype: "lexical-token-doctype",
|
|
23
|
+
entity: "lexical-token-entity",
|
|
24
|
+
function: "lexical-token-function",
|
|
25
|
+
important: "lexical-token-important",
|
|
26
|
+
inserted: "lexical-token-inserted",
|
|
27
|
+
keyword: "lexical-token-keyword",
|
|
28
|
+
namespace: "lexical-token-namespace",
|
|
29
|
+
number: "lexical-token-number",
|
|
30
|
+
operator: "lexical-token-operator",
|
|
31
|
+
prolog: "lexical-token-prolog",
|
|
32
|
+
property: "lexical-token-property",
|
|
33
|
+
punctuation: "lexical-token-punctuation",
|
|
34
|
+
regex: "lexical-token-regex",
|
|
35
|
+
selector: "lexical-token-selector",
|
|
36
|
+
string: "lexical-token-string",
|
|
37
|
+
symbol: "lexical-token-symbol",
|
|
38
|
+
tag: "lexical-token-tag",
|
|
39
|
+
url: "lexical-token-url",
|
|
40
|
+
variable: "lexical-token-variable"
|
|
41
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEditor,
|
|
3
|
+
$getRoot,
|
|
4
|
+
$createParagraphNode,
|
|
5
|
+
$createTextNode,
|
|
6
|
+
$createRangeSelection,
|
|
7
|
+
$setSelection,
|
|
8
|
+
DecoratorNode
|
|
9
|
+
} from "lexical"
|
|
10
|
+
|
|
11
|
+
// Minimal top-level decorator (stands in for ImageNode/VideoNode/AttachmentNode)
|
|
12
|
+
// so we can assert the toggle never removes media blocks. Its text content is
|
|
13
|
+
// empty, exactly like real media nodes.
|
|
14
|
+
class TestMediaNode extends DecoratorNode {
|
|
15
|
+
static getType() {
|
|
16
|
+
return "test-media"
|
|
17
|
+
}
|
|
18
|
+
static clone(node) {
|
|
19
|
+
return new TestMediaNode(node.__key)
|
|
20
|
+
}
|
|
21
|
+
createDOM() {
|
|
22
|
+
return document.createElement("div")
|
|
23
|
+
}
|
|
24
|
+
updateDOM() {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
decorate() {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text"
|
|
32
|
+
import { CodeNode, CodeHighlightNode, $createCodeNode, $isCodeNode } from "@lexical/code"
|
|
33
|
+
import {
|
|
34
|
+
ListNode,
|
|
35
|
+
ListItemNode,
|
|
36
|
+
$createListNode,
|
|
37
|
+
$createListItemNode,
|
|
38
|
+
$isListNode
|
|
39
|
+
} from "@lexical/list"
|
|
40
|
+
import {
|
|
41
|
+
TableNode,
|
|
42
|
+
TableRowNode,
|
|
43
|
+
TableCellNode,
|
|
44
|
+
$createTableNodeWithDimensions,
|
|
45
|
+
$isTableNode
|
|
46
|
+
} from "@lexical/table"
|
|
47
|
+
import { $toggleCodeBlockForSelection } from "../code_block_toggle"
|
|
48
|
+
|
|
49
|
+
function buildEditor() {
|
|
50
|
+
const editor = createEditor({
|
|
51
|
+
namespace: "test",
|
|
52
|
+
onError(error) {
|
|
53
|
+
throw error
|
|
54
|
+
},
|
|
55
|
+
nodes: [
|
|
56
|
+
HeadingNode,
|
|
57
|
+
QuoteNode,
|
|
58
|
+
CodeNode,
|
|
59
|
+
CodeHighlightNode,
|
|
60
|
+
ListNode,
|
|
61
|
+
ListItemNode,
|
|
62
|
+
TableNode,
|
|
63
|
+
TableRowNode,
|
|
64
|
+
TableCellNode,
|
|
65
|
+
TestMediaNode
|
|
66
|
+
]
|
|
67
|
+
})
|
|
68
|
+
registerRichText(editor)
|
|
69
|
+
return editor
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function appendParagraph(root, text) {
|
|
73
|
+
const paragraph = $createParagraphNode()
|
|
74
|
+
paragraph.append($createTextNode(text))
|
|
75
|
+
root.append(paragraph)
|
|
76
|
+
return paragraph
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("$toggleCodeBlockForSelection", () => {
|
|
80
|
+
it("merges every selected paragraph into a single code block (not just the last line)", () => {
|
|
81
|
+
const editor = buildEditor()
|
|
82
|
+
editor.update(
|
|
83
|
+
() => {
|
|
84
|
+
const root = $getRoot()
|
|
85
|
+
root.clear()
|
|
86
|
+
const a = appendParagraph(root, "line one")
|
|
87
|
+
appendParagraph(root, "line two")
|
|
88
|
+
const c = appendParagraph(root, "line three")
|
|
89
|
+
const selection = $createRangeSelection()
|
|
90
|
+
selection.anchor.set(a.getFirstChild().getKey(), 0, "text")
|
|
91
|
+
selection.focus.set(c.getFirstChild().getKey(), "line three".length, "text")
|
|
92
|
+
$setSelection(selection)
|
|
93
|
+
$toggleCodeBlockForSelection()
|
|
94
|
+
},
|
|
95
|
+
{ discrete: true }
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
editor.read(() => {
|
|
99
|
+
const children = $getRoot().getChildren()
|
|
100
|
+
expect(children).toHaveLength(1)
|
|
101
|
+
expect($isCodeNode(children[0])).toBe(true)
|
|
102
|
+
expect(children[0].getTextContent()).toBe("line one\nline two\nline three")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("converts a single collapsed-selection paragraph into a code block", () => {
|
|
107
|
+
const editor = buildEditor()
|
|
108
|
+
editor.update(
|
|
109
|
+
() => {
|
|
110
|
+
const root = $getRoot()
|
|
111
|
+
root.clear()
|
|
112
|
+
const p = appendParagraph(root, "hello")
|
|
113
|
+
p.getFirstChild().selectEnd()
|
|
114
|
+
$toggleCodeBlockForSelection()
|
|
115
|
+
},
|
|
116
|
+
{ discrete: true }
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
editor.read(() => {
|
|
120
|
+
const children = $getRoot().getChildren()
|
|
121
|
+
expect(children).toHaveLength(1)
|
|
122
|
+
expect($isCodeNode(children[0])).toBe(true)
|
|
123
|
+
expect(children[0].getTextContent()).toBe("hello")
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("toggles a selected code block back into one paragraph per line", () => {
|
|
128
|
+
const editor = buildEditor()
|
|
129
|
+
editor.update(
|
|
130
|
+
() => {
|
|
131
|
+
const root = $getRoot()
|
|
132
|
+
root.clear()
|
|
133
|
+
const codeNode = $createCodeNode()
|
|
134
|
+
codeNode.append($createTextNode("a\nb"))
|
|
135
|
+
root.append(codeNode)
|
|
136
|
+
codeNode.selectEnd()
|
|
137
|
+
$toggleCodeBlockForSelection()
|
|
138
|
+
},
|
|
139
|
+
{ discrete: true }
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
editor.read(() => {
|
|
143
|
+
const children = $getRoot().getChildren()
|
|
144
|
+
expect(children.some($isCodeNode)).toBe(false)
|
|
145
|
+
expect(children.map((child) => child.getTextContent())).toEqual(["a", "b"])
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("absorbs a mixed selection (heading + paragraph) into one code block", () => {
|
|
150
|
+
const editor = buildEditor()
|
|
151
|
+
editor.update(
|
|
152
|
+
() => {
|
|
153
|
+
const root = $getRoot()
|
|
154
|
+
root.clear()
|
|
155
|
+
const heading = new HeadingNode("h1")
|
|
156
|
+
heading.append($createTextNode("Title"))
|
|
157
|
+
root.append(heading)
|
|
158
|
+
const body = appendParagraph(root, "body")
|
|
159
|
+
const selection = $createRangeSelection()
|
|
160
|
+
selection.anchor.set(heading.getFirstChild().getKey(), 0, "text")
|
|
161
|
+
selection.focus.set(body.getFirstChild().getKey(), "body".length, "text")
|
|
162
|
+
$setSelection(selection)
|
|
163
|
+
$toggleCodeBlockForSelection()
|
|
164
|
+
},
|
|
165
|
+
{ discrete: true }
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
editor.read(() => {
|
|
169
|
+
const children = $getRoot().getChildren()
|
|
170
|
+
expect(children).toHaveLength(1)
|
|
171
|
+
expect($isCodeNode(children[0])).toBe(true)
|
|
172
|
+
expect(children[0].getTextContent()).toBe("Title\nbody")
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("does not flatten a table when the caret is inside a table cell", () => {
|
|
177
|
+
const editor = buildEditor()
|
|
178
|
+
editor.update(
|
|
179
|
+
() => {
|
|
180
|
+
const root = $getRoot()
|
|
181
|
+
root.clear()
|
|
182
|
+
const table = $createTableNodeWithDimensions(2, 2, false)
|
|
183
|
+
root.append(table)
|
|
184
|
+
const cellParagraph = table.getFirstChild().getFirstChild().getFirstChild()
|
|
185
|
+
cellParagraph.append($createTextNode("cell"))
|
|
186
|
+
cellParagraph.getFirstChild().selectEnd()
|
|
187
|
+
$toggleCodeBlockForSelection()
|
|
188
|
+
},
|
|
189
|
+
{ discrete: true }
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
editor.read(() => {
|
|
193
|
+
const children = $getRoot().getChildren()
|
|
194
|
+
expect(children.some($isTableNode)).toBe(true)
|
|
195
|
+
expect(children.some($isCodeNode)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("does not fold table-cell content into the merge when a table sits inside the range", () => {
|
|
200
|
+
const editor = buildEditor()
|
|
201
|
+
editor.update(
|
|
202
|
+
() => {
|
|
203
|
+
const root = $getRoot()
|
|
204
|
+
root.clear()
|
|
205
|
+
const before = appendParagraph(root, "before")
|
|
206
|
+
const table = $createTableNodeWithDimensions(2, 2, false)
|
|
207
|
+
root.append(table)
|
|
208
|
+
// Put text in the first cell so we can assert it is never merged out.
|
|
209
|
+
const cellParagraph = table.getFirstChild().getFirstChild().getFirstChild()
|
|
210
|
+
cellParagraph.clear()
|
|
211
|
+
cellParagraph.append($createTextNode("cell"))
|
|
212
|
+
const after = appendParagraph(root, "after")
|
|
213
|
+
// Endpoints are the outside paragraphs; the range walks through the
|
|
214
|
+
// table interior. getTableCell content top-levels to the cell paragraph,
|
|
215
|
+
// which must be excluded from the merge (it is table-scoped).
|
|
216
|
+
const selection = $createRangeSelection()
|
|
217
|
+
selection.anchor.set(before.getFirstChild().getKey(), 0, "text")
|
|
218
|
+
selection.focus.set(after.getFirstChild().getKey(), "after".length, "text")
|
|
219
|
+
$setSelection(selection)
|
|
220
|
+
$toggleCodeBlockForSelection()
|
|
221
|
+
},
|
|
222
|
+
{ discrete: true }
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
editor.read(() => {
|
|
226
|
+
const children = $getRoot().getChildren()
|
|
227
|
+
// Table survives in place; its cell text is NOT pulled into any code block,
|
|
228
|
+
// and "after" is not moved above the table (document order preserved).
|
|
229
|
+
expect(children.map((c) => c.getType())).toEqual(["code", "table", "code"])
|
|
230
|
+
const table = children.find($isTableNode)
|
|
231
|
+
expect(table.getTextContent()).toContain("cell")
|
|
232
|
+
const codeBlocks = children.filter($isCodeNode)
|
|
233
|
+
expect(codeBlocks.map((c) => c.getTextContent())).toEqual(["before", "after"])
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("leaves the whole list intact when only one bullet is selected", () => {
|
|
238
|
+
const editor = buildEditor()
|
|
239
|
+
editor.update(
|
|
240
|
+
() => {
|
|
241
|
+
const root = $getRoot()
|
|
242
|
+
root.clear()
|
|
243
|
+
const list = $createListNode("bullet")
|
|
244
|
+
const first = $createListItemNode()
|
|
245
|
+
first.append($createTextNode("one"))
|
|
246
|
+
const second = $createListItemNode()
|
|
247
|
+
second.append($createTextNode("two"))
|
|
248
|
+
list.append(first, second)
|
|
249
|
+
root.append(list)
|
|
250
|
+
// Caret sits in the first bullet only.
|
|
251
|
+
first.getFirstChild().selectEnd()
|
|
252
|
+
$toggleCodeBlockForSelection()
|
|
253
|
+
},
|
|
254
|
+
{ discrete: true }
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
editor.read(() => {
|
|
258
|
+
const children = $getRoot().getChildren()
|
|
259
|
+
// The list survives whole — the unselected second bullet is not absorbed
|
|
260
|
+
// into a code block, and no code block is created from a single bullet.
|
|
261
|
+
expect(children.some($isListNode)).toBe(true)
|
|
262
|
+
expect(children.some($isCodeNode)).toBe(false)
|
|
263
|
+
expect(children.find($isListNode).getTextContent()).toContain("two")
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("merges surrounding text but leaves a list in place (structural, order preserved)", () => {
|
|
268
|
+
const editor = buildEditor()
|
|
269
|
+
editor.update(
|
|
270
|
+
() => {
|
|
271
|
+
const root = $getRoot()
|
|
272
|
+
root.clear()
|
|
273
|
+
const before = appendParagraph(root, "before")
|
|
274
|
+
const list = $createListNode("bullet")
|
|
275
|
+
const item = $createListItemNode()
|
|
276
|
+
item.append($createTextNode("bullet"))
|
|
277
|
+
list.append(item)
|
|
278
|
+
root.append(list)
|
|
279
|
+
const after = appendParagraph(root, "after")
|
|
280
|
+
const selection = $createRangeSelection()
|
|
281
|
+
selection.anchor.set(before.getFirstChild().getKey(), 0, "text")
|
|
282
|
+
selection.focus.set(after.getFirstChild().getKey(), "after".length, "text")
|
|
283
|
+
$setSelection(selection)
|
|
284
|
+
$toggleCodeBlockForSelection()
|
|
285
|
+
},
|
|
286
|
+
{ discrete: true }
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
editor.read(() => {
|
|
290
|
+
const children = $getRoot().getChildren()
|
|
291
|
+
// List stays in place; "after" is not pulled above it (order preserved).
|
|
292
|
+
expect(children.map((c) => c.getType())).toEqual(["code", "list", "code"])
|
|
293
|
+
const codeBlocks = children.filter($isCodeNode)
|
|
294
|
+
expect(codeBlocks.map((c) => c.getTextContent())).toEqual(["before", "after"])
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it("merges surrounding text but leaves a selected media (decorator) block intact", () => {
|
|
299
|
+
const editor = buildEditor()
|
|
300
|
+
editor.update(
|
|
301
|
+
() => {
|
|
302
|
+
const root = $getRoot()
|
|
303
|
+
root.clear()
|
|
304
|
+
const before = appendParagraph(root, "before")
|
|
305
|
+
const media = new TestMediaNode()
|
|
306
|
+
root.append(media)
|
|
307
|
+
const after = appendParagraph(root, "after")
|
|
308
|
+
const selection = $createRangeSelection()
|
|
309
|
+
selection.anchor.set(before.getFirstChild().getKey(), 0, "text")
|
|
310
|
+
selection.focus.set(after.getFirstChild().getKey(), "after".length, "text")
|
|
311
|
+
$setSelection(selection)
|
|
312
|
+
$toggleCodeBlockForSelection()
|
|
313
|
+
},
|
|
314
|
+
{ discrete: true }
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
editor.read(() => {
|
|
318
|
+
const children = $getRoot().getChildren()
|
|
319
|
+
// Media block must survive AND stay in document order. Text on each side of
|
|
320
|
+
// it becomes its own code block — "after" is never pulled above the media.
|
|
321
|
+
expect(children.map((c) => c.getType())).toEqual(["code", "test-media", "code"])
|
|
322
|
+
const codeBlocks = children.filter($isCodeNode)
|
|
323
|
+
expect(codeBlocks.map((c) => c.getTextContent())).toEqual(["before", "after"])
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
})
|