collavre 0.20.3 → 0.22.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/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { $isElementNode, $isTextNode } from "lexical"
|
|
2
|
+
|
|
3
|
+
// Per text node, which format dimensions a nearer (more nested) span already
|
|
4
|
+
// decided this import. Child span `after` callbacks run before ancestors', so a
|
|
5
|
+
// nested span claims its dimensions first and ancestors must not override them.
|
|
6
|
+
const decidedFormats = new WeakMap()
|
|
7
|
+
|
|
8
|
+
// Replicates Lexical's (non-exported) inline-style format reading for <span>:
|
|
9
|
+
// font-weight / font-style / text-decoration / vertical-align. Pasted content
|
|
10
|
+
// (Google Docs, Word) carries bold/italic as inline styles, not <b>/<i> tags,
|
|
11
|
+
// so our custom span import must compose these formats with the color binding.
|
|
12
|
+
//
|
|
13
|
+
// Lexical only turns formats ON, so a nested reset (font-weight:normal) can't
|
|
14
|
+
// un-bold itself once an ancestor re-applies bold. We treat a dimension as
|
|
15
|
+
// decided by the FIRST (nearest) span that declares it at all, so the inner
|
|
16
|
+
// span owns it; an omitted declaration leaves it open to inherit from ancestors.
|
|
17
|
+
// For inherited CSS props (font-weight, font-style) a reset value also actively
|
|
18
|
+
// CLEARS a format an ancestor tag (<b>/<i>) already toggled. text-decoration /
|
|
19
|
+
// vertical-align stay add-only (they propagate additively in CSS).
|
|
20
|
+
function applyTextFormatFromStyle(node, domStyle) {
|
|
21
|
+
const fontWeight = domStyle.fontWeight
|
|
22
|
+
const textDecoration = domStyle.textDecoration
|
|
23
|
+
const fontStyle = domStyle.fontStyle
|
|
24
|
+
const verticalAlign = domStyle.verticalAlign
|
|
25
|
+
const decorations = (textDecoration || "").split(" ")
|
|
26
|
+
|
|
27
|
+
// [declared by this span?, value matches format?, format name, resettable?]
|
|
28
|
+
const rules = [
|
|
29
|
+
[Boolean(fontWeight), fontWeight === "700" || fontWeight === "bold", "bold", true],
|
|
30
|
+
[Boolean(textDecoration), decorations.includes("line-through"), "strikethrough", false],
|
|
31
|
+
[Boolean(fontStyle), fontStyle === "italic", "italic", true],
|
|
32
|
+
[Boolean(textDecoration), decorations.includes("underline"), "underline", false],
|
|
33
|
+
[Boolean(verticalAlign), verticalAlign === "sub", "subscript", false],
|
|
34
|
+
[Boolean(verticalAlign), verticalAlign === "super", "superscript", false]
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
let decided = decidedFormats.get(node)
|
|
38
|
+
rules.forEach(([declared, shouldApply, format, resettable]) => {
|
|
39
|
+
if (decided && decided.has(format)) return
|
|
40
|
+
if (shouldApply && !node.hasFormat(format)) {
|
|
41
|
+
node.toggleFormat(format)
|
|
42
|
+
} else if (resettable && declared && !shouldApply && node.hasFormat(format)) {
|
|
43
|
+
node.toggleFormat(format) // explicit reset clears an ancestor's format
|
|
44
|
+
}
|
|
45
|
+
if (declared) {
|
|
46
|
+
if (!decided) {
|
|
47
|
+
decided = new Set()
|
|
48
|
+
decidedFormats.set(node, decided)
|
|
49
|
+
}
|
|
50
|
+
decided.add(format)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The exact (property, value) pairs applyTextFormatFromStyle turns into a format
|
|
56
|
+
// or reset. Only these are kept OUT of the carried node style (carrying them too
|
|
57
|
+
// would double-represent the format). Value-aware on purpose: an unmapped value
|
|
58
|
+
// (font-weight:800, font-style:oblique, vertical-align:middle) must be carried
|
|
59
|
+
// as inline style or it's silently dropped on reopen.
|
|
60
|
+
const FORMAT_MAPPED_VALUES = {
|
|
61
|
+
"font-weight": new Set(["700", "bold", "normal"]),
|
|
62
|
+
"font-style": new Set(["italic", "normal"]),
|
|
63
|
+
"vertical-align": new Set(["sub", "super"])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// text-decoration is dropped wholesale: its tokens become formats that Lexical
|
|
67
|
+
// re-exports as its own text-decoration, so a remainder token would collide.
|
|
68
|
+
// white-space is Lexical's exportDOM artifact, not user intent.
|
|
69
|
+
const DROPPED_STYLE_PROPS = new Set(["text-decoration", "white-space"])
|
|
70
|
+
|
|
71
|
+
// Every inline declaration to carry onto the produced text nodes: all of them
|
|
72
|
+
// except format-mapped values and the dropped props above. Preserves color,
|
|
73
|
+
// background, font-size/family, text-transform, etc. without cherry-picking,
|
|
74
|
+
// mirroring the full-style copy the removed positional collector did.
|
|
75
|
+
function carriedStyleDeclarations(domStyle) {
|
|
76
|
+
const map = new Map()
|
|
77
|
+
for (let i = 0; i < domStyle.length; i++) {
|
|
78
|
+
const prop = domStyle.item(i)
|
|
79
|
+
if (DROPPED_STYLE_PROPS.has(prop)) continue
|
|
80
|
+
const value = domStyle.getPropertyValue(prop)
|
|
81
|
+
const mapped = FORMAT_MAPPED_VALUES[prop]
|
|
82
|
+
if (mapped && mapped.has(value)) continue
|
|
83
|
+
map.set(prop, value)
|
|
84
|
+
}
|
|
85
|
+
return map
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseStyle(styleText) {
|
|
89
|
+
const map = new Map()
|
|
90
|
+
;(styleText || "").split(";").forEach((decl) => {
|
|
91
|
+
const idx = decl.indexOf(":")
|
|
92
|
+
if (idx === -1) return
|
|
93
|
+
const key = decl.slice(0, idx).trim()
|
|
94
|
+
const value = decl.slice(idx + 1).trim()
|
|
95
|
+
if (key) map.set(key, value)
|
|
96
|
+
})
|
|
97
|
+
return map
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function serializeStyle(map) {
|
|
101
|
+
return Array.from(map.entries())
|
|
102
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
103
|
+
.join("; ")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Merge (not overwrite) inherited declarations: a node that already carries its
|
|
107
|
+
// own value (from a deeper colored span converted first) keeps it — inner wins,
|
|
108
|
+
// matching CSS inheritance. The parent only fills in what's missing.
|
|
109
|
+
function applyStyleToTextNodes(nodes, inherited, domStyle) {
|
|
110
|
+
nodes.forEach((node) => {
|
|
111
|
+
if ($isTextNode(node)) {
|
|
112
|
+
const merged = parseStyle(node.getStyle())
|
|
113
|
+
inherited.forEach((value, key) => {
|
|
114
|
+
if (!merged.has(key)) merged.set(key, value)
|
|
115
|
+
})
|
|
116
|
+
node.setStyle(serializeStyle(merged))
|
|
117
|
+
applyTextFormatFromStyle(node, domStyle)
|
|
118
|
+
} else if ($isElementNode(node)) {
|
|
119
|
+
applyStyleToTextNodes(node.getChildren(), inherited, domStyle)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Custom <span> import: binds the span's inline style (color, background, and
|
|
125
|
+
// every other non-format/non-white-space prop) to its text nodes AT IMPORT TIME.
|
|
126
|
+
// The editor used to re-apply collected styles positionally to
|
|
127
|
+
// root.getAllTextNodes(), which drifts whenever Lexical's importer doesn't map
|
|
128
|
+
// DOM text nodes 1:1 (white-space:pre-wrap splits on \n/\t; whitespace-only
|
|
129
|
+
// nodes are dropped). Binding during conversion keeps style on the right node.
|
|
130
|
+
//
|
|
131
|
+
// We only take over when the span carries color/background — Lexical's default
|
|
132
|
+
// conversion already handles colorless spans, so deferring avoids behavior change.
|
|
133
|
+
export function colorAwareSpanImport(domNode) {
|
|
134
|
+
const { color, backgroundColor } = domNode.style
|
|
135
|
+
if (!color && !backgroundColor) {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const inherited = carriedStyleDeclarations(domNode.style)
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
conversion: () => ({
|
|
143
|
+
node: null,
|
|
144
|
+
after: (childLexicalNodes) => {
|
|
145
|
+
applyStyleToTextNodes(childLexicalNodes, inherited, domNode.style)
|
|
146
|
+
return childLexicalNodes
|
|
147
|
+
}
|
|
148
|
+
}),
|
|
149
|
+
priority: 1
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const lexicalHtmlConfig = {
|
|
154
|
+
import: {
|
|
155
|
+
span: colorAwareSpanImport
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// colorAwareSpanImport only runs for <span>. Color on a non-span element (a
|
|
160
|
+
// colored <p>/<li>/<h1>, or a materialized data-lexical-color on a block) would
|
|
161
|
+
// be dropped, since Lexical's default block conversions ignore color. Before
|
|
162
|
+
// import (and AFTER syncLexicalStyleAttributes materializes data-lexical-*), wrap
|
|
163
|
+
// each colored non-span's direct text children in a span carrying its full style,
|
|
164
|
+
// so the span importer binds it. Nested colored elements keep their own style and
|
|
165
|
+
// win via the merge in applyStyleToTextNodes. Block-level props (text-align,
|
|
166
|
+
// margin) ride along harmlessly on the inline wrapper.
|
|
167
|
+
const TEXT_NODE = 3
|
|
168
|
+
|
|
169
|
+
export function normalizeColoredContainers(root) {
|
|
170
|
+
if (!root || typeof root.querySelectorAll !== "function") return
|
|
171
|
+
const ownerDocument = root.ownerDocument || document
|
|
172
|
+
|
|
173
|
+
root.querySelectorAll("[style]").forEach((element) => {
|
|
174
|
+
if (element.tagName === "SPAN") return
|
|
175
|
+
const { color, backgroundColor } = element.style
|
|
176
|
+
if (!color && !backgroundColor) return
|
|
177
|
+
|
|
178
|
+
Array.from(element.childNodes).forEach((child) => {
|
|
179
|
+
if (child.nodeType !== TEXT_NODE || !child.nodeValue) return
|
|
180
|
+
const span = ownerDocument.createElement("span")
|
|
181
|
+
span.setAttribute("style", element.getAttribute("style"))
|
|
182
|
+
element.replaceChild(span, child)
|
|
183
|
+
span.appendChild(child)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Minimize the HTML that Lexical's `$generateHtmlFromNodes` produces before it
|
|
2
|
+
// is persisted as a creative description / comment body.
|
|
3
|
+
//
|
|
4
|
+
// Lexical emits verbose markup: every text node is wrapped in
|
|
5
|
+
// `<span style="white-space: pre-wrap;">`, bold/italic double-wrap as
|
|
6
|
+
// `<b><strong class="lexical-text-bold">`, and the whole document is nested in
|
|
7
|
+
// an extra `<div>` wrapper. None of that is needed once the content is rendered
|
|
8
|
+
// inside a `.creative-content` container.
|
|
9
|
+
//
|
|
10
|
+
// The goal (per product spec) is: a single line of text needs no markup at all,
|
|
11
|
+
// and styled regions keep a minimal `<span class="…">` / semantic tag. We only
|
|
12
|
+
// strip genuine redundancy — we never drop a tag or class that the renderer or
|
|
13
|
+
// the editor's re-import (`$generateNodesFromDOM`) relies on.
|
|
14
|
+
|
|
15
|
+
// Editor-only inline style declarations that carry no meaning once rendered.
|
|
16
|
+
// `white-space: pre-wrap` is usually redundant: plain text wraps normally and
|
|
17
|
+
// code blocks preserve whitespace via the kept `.lexical-code-block` rule (which
|
|
18
|
+
// children inherit). It is NOT redundant when the text carries significant
|
|
19
|
+
// whitespace (see `hasSignificantWhitespace`): the render container
|
|
20
|
+
// (`.creative-content`) has no `pre-wrap`, so dropping it would collapse runs of
|
|
21
|
+
// spaces / indentation that the user actually typed.
|
|
22
|
+
const REDUNDANT_STYLE_PROPS = ["white-space"]
|
|
23
|
+
|
|
24
|
+
// Editor-only attributes that never affect rendering of persisted content.
|
|
25
|
+
const REDUNDANT_ATTRS = ["spellcheck"]
|
|
26
|
+
|
|
27
|
+
// Same-format wrapper pairs Lexical emits as outer/inner duplicates. Either tag
|
|
28
|
+
// alone still conveys the format on re-import, so the attribute-less one can go.
|
|
29
|
+
const SAME_FORMAT_PAIRS = [
|
|
30
|
+
["b", "strong"],
|
|
31
|
+
["i", "em"]
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function hasNoAttributes(el) {
|
|
35
|
+
return el.attributes.length === 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Whitespace whose rendering changes once `white-space: pre-wrap` is dropped:
|
|
39
|
+
// a run of 2+ whitespace (collapses to one space) or a tab/newline/form-feed.
|
|
40
|
+
// A single leading/trailing space between inline runs renders fine without
|
|
41
|
+
// `pre-wrap`, so it is not significant and must not block minimization of the
|
|
42
|
+
// common case (e.g. the "Hello " before a bold word).
|
|
43
|
+
function hasSignificantWhitespace(text) {
|
|
44
|
+
return /\s{2,}|[\t\n\r\f]/.test(text)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Block-level tags terminate an inline formatting context: whitespace never
|
|
48
|
+
// combines across them, so the document-order scan below must not look past one.
|
|
49
|
+
const BLOCK_TAGS = new Set([
|
|
50
|
+
"BODY", "DIV", "P", "H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "LI",
|
|
51
|
+
"BLOCKQUOTE", "PRE", "TABLE", "THEAD", "TBODY", "TR", "TD", "TH", "FIGURE", "HR"
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
function isBlock(node) {
|
|
55
|
+
return node != null && node.nodeType === 1 && BLOCK_TAGS.has(node.tagName)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The rendered character immediately adjacent to `el` in document order, within
|
|
59
|
+
// the same inline formatting context. `dir` is "previousSibling"/"nextSibling".
|
|
60
|
+
// Climbs out of inline wrappers (e.g. `<b><strong>`) so a space nested one level
|
|
61
|
+
// deep still sees its neighbour, but stops at a block edge (returns "").
|
|
62
|
+
function adjacentInlineChar(el, dir) {
|
|
63
|
+
let node = el
|
|
64
|
+
while (node && !node[dir]) {
|
|
65
|
+
node = node.parentNode
|
|
66
|
+
if (isBlock(node)) return ""
|
|
67
|
+
}
|
|
68
|
+
const sibling = node && node[dir]
|
|
69
|
+
if (!sibling) return ""
|
|
70
|
+
const text = sibling.textContent || ""
|
|
71
|
+
return dir === "previousSibling" ? text.slice(-1) : text.charAt(0)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Significant whitespace can straddle an inline formatting boundary: e.g. the
|
|
75
|
+
// live editor serializes a bolded " bar" as `foo ` + `<b><strong> bar</strong></b>`,
|
|
76
|
+
// where each node holds a single edge space. Dropping `pre-wrap` from both sides
|
|
77
|
+
// would collapse the pair to one space. Detect each side independently so the
|
|
78
|
+
// kept `pre-wrap` lands on BOTH elements and the run survives unambiguously.
|
|
79
|
+
function hasBoundarySignificantWhitespace(el) {
|
|
80
|
+
const text = el.textContent || ""
|
|
81
|
+
if (/^\s/.test(text) && /\s/.test(adjacentInlineChar(el, "previousSibling"))) {
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
if (/\s$/.test(text) && /\s/.test(adjacentInlineChar(el, "nextSibling"))) {
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stripRedundantStyle(el) {
|
|
91
|
+
if (!el.hasAttribute("style")) return
|
|
92
|
+
const keepWhiteSpace =
|
|
93
|
+
hasSignificantWhitespace(el.textContent) ||
|
|
94
|
+
hasBoundarySignificantWhitespace(el)
|
|
95
|
+
REDUNDANT_STYLE_PROPS.forEach((prop) => {
|
|
96
|
+
if (prop === "white-space" && keepWhiteSpace) return
|
|
97
|
+
el.style.removeProperty(prop)
|
|
98
|
+
})
|
|
99
|
+
if (el.style.length === 0 || !el.getAttribute("style")?.trim()) {
|
|
100
|
+
el.removeAttribute("style")
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isSameFormatPair(tagA, tagB) {
|
|
105
|
+
const a = tagA.toLowerCase()
|
|
106
|
+
const b = tagB.toLowerCase()
|
|
107
|
+
return SAME_FORMAT_PAIRS.some(
|
|
108
|
+
([x, y]) => (a === x && b === y) || (a === y && b === x)
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function unwrap(el) {
|
|
113
|
+
const parent = el.parentNode
|
|
114
|
+
if (!parent) return
|
|
115
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el)
|
|
116
|
+
parent.removeChild(el)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Depth-first cleanup: normalize attributes, then collapse redundant wrappers.
|
|
120
|
+
function cleanElement(el) {
|
|
121
|
+
// Recurse first so inner redundancy is resolved before we inspect a parent.
|
|
122
|
+
Array.from(el.children).forEach(cleanElement)
|
|
123
|
+
|
|
124
|
+
if (el.tagName === "BODY") return
|
|
125
|
+
|
|
126
|
+
REDUNDANT_ATTRS.forEach((attr) => el.removeAttribute(attr))
|
|
127
|
+
stripRedundantStyle(el)
|
|
128
|
+
|
|
129
|
+
const tag = el.tagName.toLowerCase()
|
|
130
|
+
|
|
131
|
+
// Attribute-less <span> conveys nothing — unwrap it (plain text, link inner).
|
|
132
|
+
if (tag === "span" && hasNoAttributes(el)) {
|
|
133
|
+
unwrap(el)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Collapse Lexical's outer/inner duplicate format wrappers (e.g. <b><strong>).
|
|
138
|
+
// Drop whichever element carries no attributes; the other keeps the format.
|
|
139
|
+
const onlyChild =
|
|
140
|
+
el.children.length === 1 && el.childNodes.length === 1 ? el.children[0] : null
|
|
141
|
+
if (onlyChild && isSameFormatPair(tag, onlyChild.tagName)) {
|
|
142
|
+
if (hasNoAttributes(el)) {
|
|
143
|
+
unwrap(el)
|
|
144
|
+
} else if (hasNoAttributes(onlyChild)) {
|
|
145
|
+
unwrap(onlyChild)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const MEDIA_SELECTOR = "img, action-text-attachment, figure, [data-trix-attachment]"
|
|
151
|
+
|
|
152
|
+
// A single top-level paragraph is just a line of text — emit its inline content
|
|
153
|
+
// without the <p> wrapper. Structural blocks (headings, lists, quotes, code,
|
|
154
|
+
// multiple paragraphs) are left intact.
|
|
155
|
+
function unwrapSingleParagraph(root) {
|
|
156
|
+
const blocks = Array.from(root.children)
|
|
157
|
+
if (blocks.length === 1 && blocks[0].tagName === "P") {
|
|
158
|
+
const paragraph = blocks[0]
|
|
159
|
+
// An empty line (just a <br> / whitespace, no media) collapses to "" so the
|
|
160
|
+
// stored value matches isHtmlEmpty and re-imports to a fresh paragraph.
|
|
161
|
+
if (!paragraph.textContent.trim() && !paragraph.querySelector(MEDIA_SELECTOR)) {
|
|
162
|
+
return ""
|
|
163
|
+
}
|
|
164
|
+
// A soft line break (<br>) inside the line must keep its <p> wrapper: a bare
|
|
165
|
+
// top-level <br> cannot be re-grouped into a paragraph on re-import and would
|
|
166
|
+
// break the document shape / split the line.
|
|
167
|
+
if (paragraph.querySelector("br")) {
|
|
168
|
+
return root.innerHTML
|
|
169
|
+
}
|
|
170
|
+
return paragraph.innerHTML
|
|
171
|
+
}
|
|
172
|
+
return root.innerHTML
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// `root` is the element whose children are the serialized content blocks
|
|
176
|
+
// (i.e. the throwaway <div> wrapper Lexical's output was parsed into). Mutates
|
|
177
|
+
// `root` in place and returns the minimized HTML string.
|
|
178
|
+
export function minimizeContentHtml(root) {
|
|
179
|
+
if (!root) return ""
|
|
180
|
+
Array.from(root.children).forEach(cleanElement)
|
|
181
|
+
return unwrapSingleParagraph(root)
|
|
182
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$applyNodeReplacement,
|
|
3
|
+
DecoratorNode,
|
|
4
|
+
} from "lexical"
|
|
5
|
+
|
|
6
|
+
export class VideoNode extends DecoratorNode {
|
|
7
|
+
__src
|
|
8
|
+
__filename
|
|
9
|
+
|
|
10
|
+
static getType() {
|
|
11
|
+
return "video"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static clone(node) {
|
|
15
|
+
return new VideoNode(node.__src, node.__filename, node.__key)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static importJSON(serializedNode) {
|
|
19
|
+
const { src, filename } = serializedNode
|
|
20
|
+
return $createVideoNode({ src, filename })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
exportDOM() {
|
|
24
|
+
const element = document.createElement("video")
|
|
25
|
+
element.setAttribute("src", this.__src)
|
|
26
|
+
element.setAttribute("controls", "")
|
|
27
|
+
return { element }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static importDOM() {
|
|
31
|
+
return {
|
|
32
|
+
video: () => ({ conversion: convertVideoElement, priority: 1 }),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
constructor(src, filename, key) {
|
|
37
|
+
super(key)
|
|
38
|
+
this.__src = src
|
|
39
|
+
this.__filename = filename
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
exportJSON() {
|
|
43
|
+
return {
|
|
44
|
+
src: this.getSrc(),
|
|
45
|
+
filename: this.__filename,
|
|
46
|
+
type: "video",
|
|
47
|
+
version: 1,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
createDOM(config) {
|
|
52
|
+
const span = document.createElement("span")
|
|
53
|
+
const className = config.theme?.video
|
|
54
|
+
if (className !== undefined) span.className = className
|
|
55
|
+
return span
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
updateDOM() {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getSrc() {
|
|
63
|
+
return this.__src
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getFilename() {
|
|
67
|
+
return this.__filename
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
decorate() {
|
|
71
|
+
return (
|
|
72
|
+
<video
|
|
73
|
+
src={this.__src}
|
|
74
|
+
controls
|
|
75
|
+
style={{ maxWidth: "100%", borderRadius: "4px" }}
|
|
76
|
+
/>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function convertVideoElement(domNode) {
|
|
82
|
+
if (domNode instanceof HTMLVideoElement) {
|
|
83
|
+
const src = domNode.getAttribute("src")
|
|
84
|
+
if (!src) return null
|
|
85
|
+
return { node: $createVideoNode({ src, filename: null }) }
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function $createVideoNode({ src, filename }) {
|
|
91
|
+
return $applyNodeReplacement(new VideoNode(src, filename))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function $isVideoNode(node) {
|
|
95
|
+
return node instanceof VideoNode
|
|
96
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import CommandArgsForm from '../command_args_form'
|
|
5
|
+
|
|
6
|
+
const baseCommand = (overrides = {}) => ({
|
|
7
|
+
name: 'foo_tool',
|
|
8
|
+
label: '/foo_tool',
|
|
9
|
+
input_schema: [
|
|
10
|
+
{ name: 'creative_id', type: 'string', required: false },
|
|
11
|
+
{ name: 'topic_id', type: 'string', required: false },
|
|
12
|
+
{ name: 'note', type: 'string', required: false }
|
|
13
|
+
],
|
|
14
|
+
...overrides
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const fieldFor = (paramName) =>
|
|
18
|
+
document.querySelector(`[data-param-name="${paramName}"]`)
|
|
19
|
+
|
|
20
|
+
describe('CommandArgsForm context auto-fill', () => {
|
|
21
|
+
let container
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
container = document.createElement('div')
|
|
25
|
+
document.body.appendChild(container)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
document.body.innerHTML = ''
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('fills creative_id and topic_id from contextValuesFn', () => {
|
|
33
|
+
const form = new CommandArgsForm({
|
|
34
|
+
container,
|
|
35
|
+
contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455' })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
form.show(baseCommand())
|
|
39
|
+
|
|
40
|
+
expect(fieldFor('creative_id').value).toBe('12508')
|
|
41
|
+
expect(fieldFor('topic_id').value).toBe('8455')
|
|
42
|
+
expect(fieldFor('note').value).toBe('')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('explicit default_value beats context auto-fill', () => {
|
|
46
|
+
const form = new CommandArgsForm({
|
|
47
|
+
container,
|
|
48
|
+
contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
form.show(baseCommand({
|
|
52
|
+
input_schema: [
|
|
53
|
+
{ name: 'creative_id', type: 'string', required: false, default_value: '999' },
|
|
54
|
+
{ name: 'topic_id', type: 'string', required: false }
|
|
55
|
+
]
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
expect(fieldFor('creative_id').value).toBe('999')
|
|
59
|
+
expect(fieldFor('topic_id').value).toBe('8455')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('does not fill unrelated params even if names overlap', () => {
|
|
63
|
+
const form = new CommandArgsForm({
|
|
64
|
+
container,
|
|
65
|
+
contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455', note: 'ctx' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
form.show(baseCommand())
|
|
69
|
+
|
|
70
|
+
expect(fieldFor('note').value).toBe('')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('missing context values leave fields empty', () => {
|
|
74
|
+
const form = new CommandArgsForm({
|
|
75
|
+
container,
|
|
76
|
+
contextValuesFn: () => ({ creative_id: null, topic_id: '' })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
form.show(baseCommand())
|
|
80
|
+
|
|
81
|
+
expect(fieldFor('creative_id').value).toBe('')
|
|
82
|
+
expect(fieldFor('topic_id').value).toBe('')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('contextValuesFn throwing does not break form', () => {
|
|
86
|
+
const form = new CommandArgsForm({
|
|
87
|
+
container,
|
|
88
|
+
contextValuesFn: () => { throw new Error('boom') }
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(() => form.show(baseCommand())).not.toThrow()
|
|
92
|
+
expect(fieldFor('creative_id').value).toBe('')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('no contextValuesFn provided behaves like before', () => {
|
|
96
|
+
const form = new CommandArgsForm({ container })
|
|
97
|
+
|
|
98
|
+
form.show(baseCommand())
|
|
99
|
+
|
|
100
|
+
expect(fieldFor('creative_id').value).toBe('')
|
|
101
|
+
expect(fieldFor('topic_id').value).toBe('')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { isHtmlEmpty } from '../html_content_empty';
|
|
5
|
+
|
|
6
|
+
describe('isHtmlEmpty', () => {
|
|
7
|
+
test('returns true for null/empty string', () => {
|
|
8
|
+
expect(isHtmlEmpty('')).toBe(true);
|
|
9
|
+
expect(isHtmlEmpty(null)).toBe(true);
|
|
10
|
+
expect(isHtmlEmpty(undefined)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('returns true for whitespace-only HTML', () => {
|
|
14
|
+
expect(isHtmlEmpty('<p> </p>')).toBe(true);
|
|
15
|
+
expect(isHtmlEmpty('<div><br></div>')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('returns false when there is text content', () => {
|
|
19
|
+
expect(isHtmlEmpty('<p>hello</p>')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('returns false for image-only HTML', () => {
|
|
23
|
+
expect(isHtmlEmpty('<p><img src="/blob/abc.png"></p>')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('returns false for action-text-attachment-only HTML', () => {
|
|
27
|
+
expect(isHtmlEmpty(
|
|
28
|
+
'<action-text-attachment sgid="abc" url="/blob" filename="file.png"></action-text-attachment>'
|
|
29
|
+
)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('returns false for figure.attachment-only HTML (trix-style)', () => {
|
|
33
|
+
expect(isHtmlEmpty(
|
|
34
|
+
'<figure class="attachment attachment--file" data-trix-attachment=\'{"sgid":"abc"}\'></figure>'
|
|
35
|
+
)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('returns false for [data-trix-attachment]-only HTML', () => {
|
|
39
|
+
expect(isHtmlEmpty('<div data-trix-attachment=\'{"sgid":"x"}\'></div>')).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { reconcileMarkdownSource } from '../markdown_source_reconcile';
|
|
5
|
+
|
|
6
|
+
const DATA_URI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
7
|
+
const BLOB_PATH = '/rails/active_storage/blobs/redirect/abc123def/image.png';
|
|
8
|
+
const DATA_URI_2 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
9
|
+
const BLOB_PATH_2 = '/rails/active_storage/blobs/redirect/xyz789/two.gif';
|
|
10
|
+
|
|
11
|
+
describe('reconcileMarkdownSource', () => {
|
|
12
|
+
test('returns rewritten value when current still equals saved', () => {
|
|
13
|
+
const saved = `Hello  world`;
|
|
14
|
+
const rewritten = `Hello  world`;
|
|
15
|
+
expect(reconcileMarkdownSource(saved, rewritten, saved)).toBe(rewritten);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('returns null when no rewrites happened', () => {
|
|
19
|
+
expect(reconcileMarkdownSource('a', 'a', 'a')).toBe(null);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('returns null when saved has no data URI and current diverged from saved', () => {
|
|
23
|
+
expect(reconcileMarkdownSource('saved', 'rewritten', 'user typed something else')).toBe(null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('merges substitutions when user typed AFTER the data URI mid-save', () => {
|
|
27
|
+
const saved = ``;
|
|
28
|
+
const rewritten = ``;
|
|
29
|
+
const current = `\n\nNew paragraph user typed during save.`;
|
|
30
|
+
const expected = `\n\nNew paragraph user typed during save.`;
|
|
31
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('merges substitutions when user typed BEFORE the data URI mid-save', () => {
|
|
35
|
+
const saved = `start  end`;
|
|
36
|
+
const rewritten = `start  end`;
|
|
37
|
+
const current = `inserted at top\n\nstart  end`;
|
|
38
|
+
const expected = `inserted at top\n\nstart  end`;
|
|
39
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('handles multiple data URIs in a single save', () => {
|
|
43
|
+
const saved = `A  B  C`;
|
|
44
|
+
const rewritten = `A  B  C`;
|
|
45
|
+
const current = `A  B  C\n\nextra text`;
|
|
46
|
+
const expected = `A  B  C\n\nextra text`;
|
|
47
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('handles reference-style data URI definitions', () => {
|
|
51
|
+
const saved = `![pic][p]\n\n[p]: ${DATA_URI}`;
|
|
52
|
+
const rewritten = `![pic][p]\n\n[p]: ${BLOB_PATH}`;
|
|
53
|
+
const current = `![pic][p]\n\nuser typed more\n\n[p]: ${DATA_URI}`;
|
|
54
|
+
const expected = `![pic][p]\n\nuser typed more\n\n[p]: ${BLOB_PATH}`;
|
|
55
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('returns null when user removed the data URI before response', () => {
|
|
59
|
+
const saved = ``;
|
|
60
|
+
const rewritten = ``;
|
|
61
|
+
const current = 'user wiped everything';
|
|
62
|
+
expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('returns null on non-string inputs', () => {
|
|
66
|
+
expect(reconcileMarkdownSource(null, 'a', 'a')).toBe(null);
|
|
67
|
+
expect(reconcileMarkdownSource('a', null, 'a')).toBe(null);
|
|
68
|
+
expect(reconcileMarkdownSource('a', 'a', null)).toBe(null);
|
|
69
|
+
});
|
|
70
|
+
});
|