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,121 @@
1
+ /** @jest-environment jsdom */
2
+ // Does the LIVE-entered Lexical editor color a JS-fenced block token-for-token
3
+ // the same as the rendered view? User report: render → edit shows different
4
+ // colors than the view, but refresh/cancel/title agree. This pins whether the
5
+ // divergence is in token CLASSES (language/tokenizer) or purely CSS container.
6
+ import { createEditor, $getRoot, $createParagraphNode, $isTextNode, $isElementNode, $isLineBreakNode } from "lexical"
7
+ import { HeadingNode, QuoteNode } from "@lexical/rich-text"
8
+ import { ListItemNode, ListNode } from "@lexical/list"
9
+ import { LinkNode, AutoLinkNode } from "@lexical/link"
10
+ import { CodeNode, CodeHighlightNode, registerCodeHighlighting, $isCodeNode, $isCodeHighlightNode } from "@lexical/code"
11
+ import { $generateNodesFromDOM } from "@lexical/html"
12
+ import { renderMarkdown, highlightCodeBlocks } from "../../utils/markdown"
13
+ import { CODE_TOKEN_THEME } from "../code_token_theme"
14
+ import {
15
+ bridgeCodeFenceLanguages, detectCodeLanguage, normalizeFenceLang,
16
+ markLanguageResolved, isLanguageResolved, clearLanguageResolved,
17
+ } from "../code_languages"
18
+
19
+ const RUBY = `# frozen_string_literal: true
20
+
21
+ module CollavreCompat
22
+ module_function
23
+
24
+ def call(receiver, method_name, *args, **kwargs)
25
+ params = receiver.method(method_name).parameters
26
+ accepted = params.filter_map { |type, name| name if %i[key keyreq].include?(type) }
27
+ receiver.public_send(method_name, *args, **kwargs)
28
+ end
29
+ end`
30
+
31
+ function buildEditor() {
32
+ const editor = createEditor({
33
+ namespace: "test",
34
+ nodes: [HeadingNode, QuoteNode, CodeNode, CodeHighlightNode, ListItemNode, ListNode, LinkNode, AutoLinkNode],
35
+ onError: (e) => { throw e },
36
+ })
37
+ const el = document.createElement("div")
38
+ el.contentEditable = "true"
39
+ document.body.appendChild(el)
40
+ editor.setRootElement(el)
41
+ registerCodeHighlighting(editor)
42
+ editor.registerNodeTransform(CodeNode, (node) => {
43
+ if (isLanguageResolved(editor, node.getKey())) return
44
+ const current = node.getLanguage()
45
+ const norm = normalizeFenceLang(current)
46
+ if (norm && norm !== "javascript") return
47
+ const detected = detectCodeLanguage(node.getTextContent(), current)
48
+ if (detected && detected !== "javascript" && detected !== current) node.setLanguage(detected)
49
+ })
50
+ return { editor, el }
51
+ }
52
+
53
+ function loadHtml(editor, html) {
54
+ editor.update(() => {
55
+ const root = $getRoot()
56
+ clearLanguageResolved(editor)
57
+ root.getChildren().forEach((c) => c.remove())
58
+ const doc = new DOMParser().parseFromString(html || "", "text/html")
59
+ const container = doc.body
60
+ bridgeCodeFenceLanguages(container)
61
+ const nodes = $generateNodesFromDOM(editor, container)
62
+ const mark = (list) => list.forEach((node) => {
63
+ if ($isCodeNode(node)) { if (node.getLanguage()) markLanguageResolved(editor, node.getKey()) }
64
+ else if ($isElementNode(node) && typeof node.getChildren === "function") mark(node.getChildren())
65
+ })
66
+ mark(nodes)
67
+ let pending = null
68
+ const flush = () => { if (pending) { root.append(pending); pending = null } }
69
+ nodes.forEach((node) => {
70
+ const inline = $isTextNode(node) || $isLineBreakNode(node) || ($isElementNode(node) && node.isInline())
71
+ if (inline) { if (!pending) pending = $createParagraphNode(); pending.append(node); return }
72
+ flush(); root.append(node)
73
+ })
74
+ flush()
75
+ }, { discrete: true })
76
+ }
77
+
78
+ // Sequence of [class, text] for every COLORED token span in the VIEW DOM,
79
+ // ignoring structure (linebreaks/tabs/untokenized whitespace) — what determines color.
80
+ function coloredViewTokens(rootEl) {
81
+ const code = rootEl.querySelector("code") || rootEl
82
+ return Array.from(code.querySelectorAll("span"))
83
+ .filter((s) => /(^|\s)lexical-token-/.test(s.className))
84
+ .map((s) => [s.className, s.textContent])
85
+ }
86
+
87
+ // Same sequence from the EDITOR's node state: each CodeHighlightNode maps its
88
+ // highlightType through the SHARED CODE_TOKEN_THEME exactly as the rendered span
89
+ // would. Untyped highlight nodes (whitespace) carry no class → excluded, mirroring
90
+ // the view's bare-text whitespace.
91
+ function coloredEditorTokens(editor) {
92
+ const out = []
93
+ editor.read(() => {
94
+ $getRoot().getChildren().forEach((c) => {
95
+ if (!$isCodeNode(c)) return
96
+ c.getChildren().forEach((child) => {
97
+ if (!$isCodeHighlightNode(child)) return
98
+ const type = child.getHighlightType()
99
+ const cls = type ? CODE_TOKEN_THEME[type] : undefined
100
+ if (cls) out.push([cls, child.getTextContent()])
101
+ })
102
+ })
103
+ })
104
+ return out
105
+ }
106
+
107
+ describe("edit ↔ view token-color parity for explicit javascript", () => {
108
+ test("live-entered editor and rendered view emit the same colored-token sequence", () => {
109
+ const { editor } = buildEditor()
110
+ loadHtml(editor, renderMarkdown("```javascript\n" + RUBY + "\n```"))
111
+ const editorTokens = coloredEditorTokens(editor)
112
+
113
+ const viewDiv = document.createElement("div")
114
+ viewDiv.innerHTML = '<pre lang="javascript"><code>' + RUBY.replace(/&/g, "&amp;").replace(/</g, "&lt;") + "</code></pre>"
115
+ highlightCodeBlocks(viewDiv)
116
+ const viewTokens = coloredViewTokens(viewDiv)
117
+
118
+ expect(editorTokens.length).toBeGreaterThan(0)
119
+ expect(editorTokens).toEqual(viewTokens)
120
+ })
121
+ })
@@ -0,0 +1,152 @@
1
+ /** @jest-environment jsdom */
2
+ // Integration test for the markdown→rich language round-trip. Mirrors the
3
+ // InlineLexicalEditor wiring: bridge the imported HTML, mark code blocks whose
4
+ // language came from an explicit source label, register the detection transform
5
+ // that honors resolved languages and only re-detects unlabeled/new blocks.
6
+ import { createEditor, $getRoot, $createParagraphNode, $createTextNode, $isTextNode, $isElementNode, $isLineBreakNode } from "lexical"
7
+ import { HeadingNode, QuoteNode } from "@lexical/rich-text"
8
+ import { ListItemNode, ListNode } from "@lexical/list"
9
+ import { LinkNode, AutoLinkNode } from "@lexical/link"
10
+ import { CodeNode, CodeHighlightNode, registerCodeHighlighting, $isCodeNode, $createCodeNode } from "@lexical/code"
11
+ import { $generateNodesFromDOM } from "@lexical/html"
12
+ import { $convertToMarkdownString } from "@lexical/markdown"
13
+ import { MARKDOWN_TRANSFORMERS, normalizeMarkdownBlankLines } from "../../lexical/markdown_serialize"
14
+ import { renderMarkdown } from "../../utils/markdown"
15
+ import {
16
+ bridgeCodeFenceLanguages, detectCodeLanguage, normalizeFenceLang,
17
+ markLanguageResolved, isLanguageResolved, clearLanguageResolved,
18
+ } from "../code_languages"
19
+
20
+ const RUBY = `module CollavreCompat
21
+ module_function
22
+ def call(receiver, method_name, *args, **kwargs)
23
+ params = receiver.method(method_name).parameters
24
+ receiver.public_send(method_name, *args, **kwargs)
25
+ end
26
+ end`
27
+
28
+ function buildEditor() {
29
+ const editor = createEditor({
30
+ namespace: "test",
31
+ nodes: [HeadingNode, QuoteNode, CodeNode, CodeHighlightNode, ListItemNode, ListNode, LinkNode, AutoLinkNode],
32
+ onError: (e) => { throw e },
33
+ })
34
+ const el = document.createElement("div")
35
+ el.contentEditable = "true"
36
+ document.body.appendChild(el)
37
+ editor.setRootElement(el)
38
+ registerCodeHighlighting(editor)
39
+ editor.registerNodeTransform(CodeNode, (node) => {
40
+ if (isLanguageResolved(editor, node.getKey())) return
41
+ const current = node.getLanguage()
42
+ const norm = normalizeFenceLang(current)
43
+ if (norm && norm !== "javascript") return
44
+ const detected = detectCodeLanguage(node.getTextContent(), current)
45
+ if (detected && detected !== "javascript" && detected !== current) {
46
+ node.setLanguage(detected)
47
+ }
48
+ })
49
+ return editor
50
+ }
51
+
52
+ function loadHtml(editor, html) {
53
+ editor.update(() => {
54
+ const root = $getRoot()
55
+ clearLanguageResolved(editor)
56
+ root.getChildren().forEach((c) => c.remove())
57
+ const doc = new DOMParser().parseFromString(html || "", "text/html")
58
+ const container = doc.body
59
+ bridgeCodeFenceLanguages(container)
60
+ const nodes = $generateNodesFromDOM(editor, container)
61
+ const mark = (list) => list.forEach((node) => {
62
+ if ($isCodeNode(node)) { if (node.getLanguage()) markLanguageResolved(editor, node.getKey()) }
63
+ else if ($isElementNode(node) && typeof node.getChildren === "function") mark(node.getChildren())
64
+ })
65
+ mark(nodes)
66
+ let pending = null
67
+ const flush = () => { if (pending) { root.append(pending); pending = null } }
68
+ nodes.forEach((node) => {
69
+ const inline = $isTextNode(node) || $isLineBreakNode(node) || ($isElementNode(node) && node.isInline())
70
+ if (inline) { if (!pending) pending = $createParagraphNode(); pending.append(node); return }
71
+ flush(); root.append(node)
72
+ })
73
+ flush()
74
+ }, { discrete: true })
75
+ }
76
+
77
+ function codeLang(editor) {
78
+ let lang = null
79
+ editor.read(() => { $getRoot().getChildren().forEach((c) => { if ($isCodeNode(c)) lang = c.getLanguage() }) })
80
+ return lang
81
+ }
82
+
83
+ function fence(editor) {
84
+ let md = ""
85
+ editor.update(() => { md = normalizeMarkdownBlankLines($convertToMarkdownString(MARKDOWN_TRANSFORMERS)) }, { discrete: true })
86
+ return md.split("\n")[0]
87
+ }
88
+
89
+ describe("code-block language round-trip (markdown ↔ rich)", () => {
90
+ test("explicit javascript fence over ruby content is HONORED (not re-detected)", () => {
91
+ const editor = buildEditor()
92
+ loadHtml(editor, renderMarkdown("```javascript\n" + RUBY + "\n```"))
93
+ expect(codeLang(editor)).toBe("javascript")
94
+ expect(fence(editor)).toBe("```javascript")
95
+ })
96
+
97
+ test("explicit python fence over ruby content is honored", () => {
98
+ const editor = buildEditor()
99
+ loadHtml(editor, renderMarkdown("```python\n" + RUBY + "\n```"))
100
+ expect(codeLang(editor)).toBe("python")
101
+ })
102
+
103
+ test("unlabeled ruby content is auto-detected to ruby", () => {
104
+ const editor = buildEditor()
105
+ loadHtml(editor, renderMarkdown("```\n" + RUBY + "\n```"))
106
+ expect(codeLang(editor)).toBe("ruby")
107
+ })
108
+
109
+ test("a NEW (typed) code block still auto-detects from content", () => {
110
+ const editor = buildEditor()
111
+ editor.update(() => {
112
+ const root = $getRoot()
113
+ clearLanguageResolved(editor)
114
+ root.getChildren().forEach((c) => c.remove())
115
+ const code = $createCodeNode() // no language: simulates toolbar insert
116
+ code.append($createTextNode(RUBY))
117
+ root.append(code)
118
+ }, { discrete: true })
119
+ expect(codeLang(editor)).toBe("ruby")
120
+ })
121
+
122
+ test("server-reopen form <pre lang=javascript> is also honored", () => {
123
+ const editor = buildEditor()
124
+ loadHtml(editor, '<pre lang="javascript"><code>' + RUBY + "</code></pre>")
125
+ expect(codeLang(editor)).toBe("javascript")
126
+ })
127
+ })
128
+
129
+ import { highlightCodeBlocks } from "../../utils/markdown"
130
+
131
+ function renderView(html) {
132
+ const div = document.createElement("div")
133
+ div.innerHTML = html
134
+ highlightCodeBlocks(div)
135
+ return div.querySelector("pre code").innerHTML
136
+ }
137
+
138
+ describe("rendered view honors explicit language (edit/view parity)", () => {
139
+ const RUBY_TOKENS = `module M\n def call(x)\n end\nend`
140
+ test("explicit javascript is NOT silently re-detected to ruby in the view", () => {
141
+ const asJs = renderView('<pre lang="javascript"><code>' + RUBY_TOKENS + "</code></pre>")
142
+ const asRuby = renderView('<pre lang="ruby"><code>' + RUBY_TOKENS + "</code></pre>")
143
+ // Different tokenizers → different markup. If js were re-detected to ruby
144
+ // they would be identical (the old bug).
145
+ expect(asJs).not.toBe(asRuby)
146
+ })
147
+ test("unlabeled block is content-detected (matches the ruby tokenization)", () => {
148
+ const unlabeled = renderView("<pre><code>" + RUBY_TOKENS + "</code></pre>")
149
+ const asRuby = renderView('<pre lang="ruby"><code>' + RUBY_TOKENS + "</code></pre>")
150
+ expect(unlabeled).toBe(asRuby)
151
+ })
152
+ })
@@ -0,0 +1,93 @@
1
+ import { detectCodeLanguage, normalizeFenceLang, bridgeCodeFenceLanguages, DEFAULT_CODE_LANGUAGE } from "../code_languages"
2
+
3
+ const RUBY = `# frozen_string_literal: true
4
+ module CollavreCompat
5
+ module_function
6
+
7
+ def call(receiver, method_name, *args, **kwargs)
8
+ params = receiver.method(method_name).parameters
9
+ receiver.public_send(method_name, *args, **kwargs)
10
+ end
11
+ end`
12
+
13
+ describe("normalizeFenceLang", () => {
14
+ it("canonicalizes aliases", () => {
15
+ expect(normalizeFenceLang("rb")).toBe("ruby")
16
+ expect(normalizeFenceLang("YML")).toBe("yaml")
17
+ expect(normalizeFenceLang("sh")).toBe("bash")
18
+ expect(normalizeFenceLang("html")).toBe("markup")
19
+ })
20
+
21
+ it("returns empty for missing or unsafe input", () => {
22
+ expect(normalizeFenceLang("")).toBe("")
23
+ expect(normalizeFenceLang(null)).toBe("")
24
+ expect(normalizeFenceLang("not a lang")).toBe("")
25
+ })
26
+ })
27
+
28
+ describe("detectCodeLanguage", () => {
29
+ it("honors an explicit, non-default fence language without re-detecting", () => {
30
+ // Even though this looks like Ruby, an explicit `python` fence is respected.
31
+ expect(detectCodeLanguage(RUBY, "python")).toBe("python")
32
+ expect(detectCodeLanguage(RUBY, "rb")).toBe("ruby")
33
+ })
34
+
35
+ it("re-detects Ruby content stuck on the javascript default", () => {
36
+ expect(detectCodeLanguage(RUBY, "javascript")).toBe("ruby")
37
+ expect(detectCodeLanguage(RUBY, "")).toBe("ruby")
38
+ expect(detectCodeLanguage(RUBY, undefined)).toBe("ruby")
39
+ })
40
+
41
+ it("keeps real JavaScript as javascript (detection does not beat JS for JS)", () => {
42
+ const js = "function add(a, b) {\n const total = a + b\n return total\n}"
43
+ expect(detectCodeLanguage(js, "javascript")).toBe("javascript")
44
+ })
45
+
46
+ it("does not detect from a too-short snippet", () => {
47
+ expect(detectCodeLanguage("x = 1", "javascript")).toBe("javascript")
48
+ expect(detectCodeLanguage("x = 1", "")).toBe("")
49
+ })
50
+
51
+ it("exposes the javascript default constant", () => {
52
+ expect(DEFAULT_CODE_LANGUAGE).toBe("javascript")
53
+ })
54
+ })
55
+
56
+ describe("bridgeCodeFenceLanguages", () => {
57
+ function parse(html) {
58
+ const doc = new DOMParser().parseFromString(html, "text/html")
59
+ return doc.body
60
+ }
61
+
62
+ it("bridges commonmarker's <pre lang> to data-language", () => {
63
+ const c = parse('<pre lang="ruby"><code>x = 1</code></pre>')
64
+ bridgeCodeFenceLanguages(c)
65
+ expect(c.querySelector("pre").getAttribute("data-language")).toBe("ruby")
66
+ })
67
+
68
+ it("bridges marked's <code class=\"language-X\"> to data-language on <pre>", () => {
69
+ // This is the markdown→rich toggle case: renderMarkdown emits the fence
70
+ // language on the <code>, not the <pre>, so it was dropped on reopen.
71
+ const c = parse('<pre><code class="hljs language-ruby">x = 1</code></pre>')
72
+ bridgeCodeFenceLanguages(c)
73
+ expect(c.querySelector("pre").getAttribute("data-language")).toBe("ruby")
74
+ })
75
+
76
+ it("canonicalizes aliases while bridging", () => {
77
+ const c = parse('<pre><code class="language-rb">x = 1</code></pre>')
78
+ bridgeCodeFenceLanguages(c)
79
+ expect(c.querySelector("pre").getAttribute("data-language")).toBe("ruby")
80
+ })
81
+
82
+ it("does not overwrite an existing data-language", () => {
83
+ const c = parse('<pre lang="python" data-language="ruby"><code>x = 1</code></pre>')
84
+ bridgeCodeFenceLanguages(c)
85
+ expect(c.querySelector("pre").getAttribute("data-language")).toBe("ruby")
86
+ })
87
+
88
+ it("leaves a language-less block alone (detection handles it later)", () => {
89
+ const c = parse("<pre><code>x = 1</code></pre>")
90
+ bridgeCodeFenceLanguages(c)
91
+ expect(c.querySelector("pre").hasAttribute("data-language")).toBe(false)
92
+ })
93
+ })
@@ -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
+ }