@checkstack/ui 0.2.4 → 0.3.1

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 (33) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +10 -3
  3. package/src/components/CodeEditor/CodeEditor.tsx +420 -0
  4. package/src/components/CodeEditor/index.ts +10 -0
  5. package/src/components/CodeEditor/languageSupport/enterBehavior.test.ts +173 -0
  6. package/src/components/CodeEditor/languageSupport/enterBehavior.ts +35 -0
  7. package/src/components/CodeEditor/languageSupport/index.ts +22 -0
  8. package/src/components/CodeEditor/languageSupport/json-utils.ts +117 -0
  9. package/src/components/CodeEditor/languageSupport/json.test.ts +274 -0
  10. package/src/components/CodeEditor/languageSupport/json.ts +139 -0
  11. package/src/components/CodeEditor/languageSupport/markdown-utils.ts +65 -0
  12. package/src/components/CodeEditor/languageSupport/markdown.test.ts +245 -0
  13. package/src/components/CodeEditor/languageSupport/markdown.ts +134 -0
  14. package/src/components/CodeEditor/languageSupport/types.ts +48 -0
  15. package/src/components/CodeEditor/languageSupport/xml-utils.ts +94 -0
  16. package/src/components/CodeEditor/languageSupport/xml.test.ts +239 -0
  17. package/src/components/CodeEditor/languageSupport/xml.ts +116 -0
  18. package/src/components/CodeEditor/languageSupport/yaml-utils.ts +101 -0
  19. package/src/components/CodeEditor/languageSupport/yaml.test.ts +203 -0
  20. package/src/components/CodeEditor/languageSupport/yaml.ts +120 -0
  21. package/src/components/DynamicForm/DynamicForm.tsx +2 -24
  22. package/src/components/DynamicForm/DynamicOptionsField.tsx +48 -21
  23. package/src/components/DynamicForm/FormField.tsx +38 -70
  24. package/src/components/DynamicForm/JsonField.tsx +19 -25
  25. package/src/components/DynamicForm/KeyValueEditor.tsx +314 -0
  26. package/src/components/DynamicForm/MultiTypeEditorField.tsx +465 -0
  27. package/src/components/DynamicForm/index.ts +13 -0
  28. package/src/components/DynamicForm/types.ts +14 -8
  29. package/src/components/DynamicForm/utils.test.ts +390 -0
  30. package/src/components/DynamicForm/utils.ts +142 -3
  31. package/src/index.ts +1 -1
  32. package/src/components/TemplateEditor.test.ts +0 -156
  33. package/src/components/TemplateEditor.tsx +0 -435
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Utilities for CodeEditor Enter key behavior.
3
+ * Extracted for testability.
4
+ */
5
+
6
+ /**
7
+ * Check if the cursor position is between matching brackets or tags.
8
+ * Used to determine if Enter should "split" the brackets/tags.
9
+ *
10
+ * @param textBefore - Text before the cursor position
11
+ * @param textAfter - Text after the cursor position
12
+ * @returns true if cursor is between matching brackets/tags
13
+ *
14
+ * @example
15
+ * // Returns true for:
16
+ * // {|} - cursor between curly braces
17
+ * // [|] - cursor between square brackets
18
+ * // <tag>|</tag> - cursor between opening and closing tags
19
+ */
20
+ export function isBetweenBrackets(
21
+ textBefore: string,
22
+ textAfter: string,
23
+ ): boolean {
24
+ const charBefore = textBefore.slice(-1);
25
+ const charAfter = textAfter.charAt(0);
26
+
27
+ return (
28
+ // Curly braces: {|}
29
+ (charBefore === "{" && charAfter === "}") ||
30
+ // Square brackets: [|]
31
+ (charBefore === "[" && charAfter === "]") ||
32
+ // XML/HTML tags: <tag>|</tag>
33
+ (charBefore === ">" && charAfter === "<" && textAfter.startsWith("</"))
34
+ );
35
+ }
@@ -0,0 +1,22 @@
1
+ export type { LanguageSupport, DecorationRange } from "./types";
2
+ export {
3
+ jsonLanguageSupport,
4
+ isValidJsonTemplatePosition,
5
+ calculateJsonIndentation,
6
+ } from "./json";
7
+ export {
8
+ yamlLanguageSupport,
9
+ isValidYamlTemplatePosition,
10
+ calculateYamlIndentation,
11
+ } from "./yaml";
12
+ export {
13
+ xmlLanguageSupport,
14
+ isValidXmlTemplatePosition,
15
+ calculateXmlIndentation,
16
+ } from "./xml";
17
+ export {
18
+ markdownLanguageSupport,
19
+ isValidMarkdownTemplatePosition,
20
+ calculateMarkdownIndentation,
21
+ } from "./markdown";
22
+ export { isBetweenBrackets } from "./enterBehavior";
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pure utility functions for JSON parsing that don't depend on CodeMirror.
3
+ * These are extracted to allow testing without triggering CodeMirror's module loading.
4
+ */
5
+
6
+ /**
7
+ * Check if cursor is in a valid JSON template position.
8
+ * Returns true if:
9
+ * 1. Inside a string value (for string templates like "{{payload.name}}")
10
+ * 2. In a value position after a colon but outside quotes (for number templates like {{payload.count}})
11
+ * 3. Inside an array element position
12
+ *
13
+ * @internal Exported for testing
14
+ */
15
+ export function isValidJsonTemplatePosition(text: string): boolean {
16
+ let insideString = false;
17
+ let inValuePosition = false;
18
+ // Stack to track object/array nesting: 'o' for object, 'a' for array
19
+ const nestingStack: ("o" | "a")[] = [];
20
+
21
+ for (let i = 0; i < text.length; i++) {
22
+ const char = text[i];
23
+ const prevChar = i > 0 ? text[i - 1] : "";
24
+
25
+ if (char === '"' && prevChar !== "\\") {
26
+ insideString = !insideString;
27
+ } else if (!insideString) {
28
+ switch (char) {
29
+ case ":": {
30
+ inValuePosition = true;
31
+ break;
32
+ }
33
+ case ",": {
34
+ // After comma: in arrays we stay in value position, in objects we go to key position
35
+ const currentContext = nestingStack.at(-1);
36
+ inValuePosition = currentContext === "a";
37
+ break;
38
+ }
39
+ case "{": {
40
+ // Start of object - next is a key position, not a value
41
+ nestingStack.push("o");
42
+ inValuePosition = false;
43
+ break;
44
+ }
45
+ case "[": {
46
+ // Start of array - next is a value position
47
+ nestingStack.push("a");
48
+ inValuePosition = true;
49
+ break;
50
+ }
51
+ case "}": {
52
+ nestingStack.pop();
53
+ inValuePosition = false;
54
+ break;
55
+ }
56
+ case "]": {
57
+ nestingStack.pop();
58
+ inValuePosition = false;
59
+ break;
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ // Valid if inside a string OR in a value position (for bare number templates)
66
+ return insideString || inValuePosition;
67
+ }
68
+
69
+ /**
70
+ * Calculate indentation for JSON, properly handling template expressions.
71
+ * Counts structural { and [ while ignoring {{ template braces.
72
+ * @internal Exported for testing
73
+ */
74
+ export function calculateJsonIndentation(
75
+ textBefore: string,
76
+ indentUnit: number,
77
+ ): number {
78
+ let depth = 0;
79
+ let insideString = false;
80
+ let i = 0;
81
+
82
+ while (i < textBefore.length) {
83
+ const char = textBefore[i];
84
+ const nextChar = i < textBefore.length - 1 ? textBefore[i + 1] : "";
85
+ const prevChar = i > 0 ? textBefore[i - 1] : "";
86
+
87
+ // Handle string boundaries
88
+ if (char === '"' && prevChar !== "\\") {
89
+ insideString = !insideString;
90
+ i++;
91
+ continue;
92
+ }
93
+
94
+ if (!insideString) {
95
+ // Skip template braces {{ and }}
96
+ if (char === "{" && nextChar === "{") {
97
+ i += 2;
98
+ continue;
99
+ }
100
+ if (char === "}" && nextChar === "}") {
101
+ i += 2;
102
+ continue;
103
+ }
104
+
105
+ // Count structural braces
106
+ if (char === "{" || char === "[") {
107
+ depth++;
108
+ } else if (char === "}" || char === "]") {
109
+ depth = Math.max(0, depth - 1);
110
+ }
111
+ }
112
+
113
+ i++;
114
+ }
115
+
116
+ return depth * indentUnit;
117
+ }
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ isValidJsonTemplatePosition,
5
+ calculateJsonIndentation,
6
+ } from "./json-utils";
7
+
8
+ describe("isValidJsonTemplatePosition", () => {
9
+ describe("string values (inside quotes)", () => {
10
+ it("returns true when inside a string value after colon", () => {
11
+ // Cursor at: {"name": "hello|
12
+ expect(isValidJsonTemplatePosition('{"name": "hello')).toBe(true);
13
+ });
14
+
15
+ it("returns true when at start of string value", () => {
16
+ // Cursor at: {"name": "|
17
+ expect(isValidJsonTemplatePosition('{"name": "')).toBe(true);
18
+ });
19
+
20
+ it("returns true for nested object string values", () => {
21
+ // Cursor at: {"user": {"name": "test|
22
+ expect(isValidJsonTemplatePosition('{"user": {"name": "test')).toBe(true);
23
+ });
24
+
25
+ it("returns true for string with escaped quotes", () => {
26
+ // Cursor at: {"msg": "say \"hello|
27
+ expect(isValidJsonTemplatePosition('{"msg": "say \\"hello')).toBe(true);
28
+ });
29
+
30
+ it("returns true when cursor is in middle of string value", () => {
31
+ // Cursor at: {"name": "hel|lo"
32
+ expect(isValidJsonTemplatePosition('{"name": "hel')).toBe(true);
33
+ });
34
+ });
35
+
36
+ describe("bare number value positions (outside quotes)", () => {
37
+ it("returns true for bare value position after colon", () => {
38
+ // Cursor at: {"count": |
39
+ // This is what the autocomplete will check (text BEFORE the {{ starts)
40
+ expect(isValidJsonTemplatePosition('{"count": ')).toBe(true);
41
+ });
42
+
43
+ it("returns true with auto-closed brackets (text before first {)", () => {
44
+ // When user types { in value position, editor auto-inserts }
45
+ // But the autocomplete checks text BEFORE the {{ pattern
46
+ // So it would check: {"count":
47
+ expect(isValidJsonTemplatePosition('{"count": ')).toBe(true);
48
+ });
49
+
50
+ it("returns false when {{ already typed (looks like objects to parser)", () => {
51
+ // This function itself returns false for {{ because it looks like objects
52
+ // BUT the autocomplete logic uses textBeforeTemplate which excludes {{
53
+ expect(isValidJsonTemplatePosition('{"count": {{')).toBe(false);
54
+ });
55
+
56
+ it("returns true for nested object bare value", () => {
57
+ // Cursor at: {"data": {"count": |
58
+ expect(isValidJsonTemplatePosition('{"data": {"count": ')).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("array element positions", () => {
63
+ it("returns true at start of array", () => {
64
+ // Cursor at: [|
65
+ expect(isValidJsonTemplatePosition("[")).toBe(true);
66
+ });
67
+
68
+ it("returns true inside array string element", () => {
69
+ // Cursor at: ["hello|
70
+ expect(isValidJsonTemplatePosition('["hello')).toBe(true);
71
+ });
72
+
73
+ it("returns true for bare array element position", () => {
74
+ // Cursor at: [|
75
+ expect(isValidJsonTemplatePosition("[ ")).toBe(true);
76
+ });
77
+
78
+ it("returns true after comma in array (value position)", () => {
79
+ // Cursor at: [1, |
80
+ expect(isValidJsonTemplatePosition("[1, ")).toBe(true);
81
+ });
82
+
83
+ it("returns true in nested array", () => {
84
+ // Cursor at: [[|
85
+ expect(isValidJsonTemplatePosition("[[")).toBe(true);
86
+ });
87
+
88
+ it("returns true for array in object value", () => {
89
+ // Cursor at: {"items": [|
90
+ expect(isValidJsonTemplatePosition('{"items": [')).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe("invalid positions (keys and structural)", () => {
95
+ it("returns false at start of object (key position)", () => {
96
+ // Cursor at: {|
97
+ expect(isValidJsonTemplatePosition("{")).toBe(false);
98
+ });
99
+
100
+ it("returns false for property key position", () => {
101
+ // Cursor at: {"|
102
+ expect(isValidJsonTemplatePosition('{"')).toBe(true); // Inside quotes counts
103
+ });
104
+
105
+ it("returns false after comma in object (key position)", () => {
106
+ // Cursor at: {"a": 1, |
107
+ expect(isValidJsonTemplatePosition('{"a": 1, ')).toBe(false);
108
+ });
109
+
110
+ it("returns false after closing brace", () => {
111
+ // Cursor at: {"a": 1}|
112
+ expect(isValidJsonTemplatePosition('{"a": 1}')).toBe(false);
113
+ });
114
+
115
+ it("returns false after closing bracket", () => {
116
+ // Cursor at: [1, 2]|
117
+ expect(isValidJsonTemplatePosition("[1, 2]")).toBe(false);
118
+ });
119
+
120
+ it("returns false for empty string", () => {
121
+ expect(isValidJsonTemplatePosition("")).toBe(false);
122
+ });
123
+
124
+ it("returns false for just whitespace", () => {
125
+ expect(isValidJsonTemplatePosition(" ")).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe("complex nested structures", () => {
130
+ it("handles deeply nested objects correctly", () => {
131
+ // Valid: inside string value in nested structure
132
+ expect(isValidJsonTemplatePosition('{"a": {"b": {"c": "test')).toBe(true);
133
+ });
134
+
135
+ it("handles mixed arrays and objects", () => {
136
+ // Valid: inside array that's a value
137
+ expect(isValidJsonTemplatePosition('{"items": [{"name": "test')).toBe(
138
+ true,
139
+ );
140
+ });
141
+
142
+ it("handles array of objects - key position after comma", () => {
143
+ // Invalid: after comma inside object (key position)
144
+ expect(isValidJsonTemplatePosition('[{"a": 1}, {"')).toBe(true); // Inside quotes
145
+ });
146
+
147
+ it("handles array of objects - after object close", () => {
148
+ // This is after the comma in array, which IS a value position
149
+ expect(isValidJsonTemplatePosition('[{"a": 1}, ')).toBe(true);
150
+ });
151
+ });
152
+
153
+ describe("edge cases", () => {
154
+ it("handles colon inside string correctly", () => {
155
+ // The colon is inside the string, not a separator
156
+ expect(isValidJsonTemplatePosition('{"url": "http:')).toBe(true);
157
+ });
158
+
159
+ it("handles multiple colons", () => {
160
+ expect(isValidJsonTemplatePosition('{"a": 1, "b": ')).toBe(true);
161
+ });
162
+
163
+ it("handles string value followed by comma correctly", () => {
164
+ // After the comma in an object, we're in key position
165
+ expect(isValidJsonTemplatePosition('{"a": "1", ')).toBe(false);
166
+ });
167
+
168
+ it("handles incomplete structures", () => {
169
+ expect(isValidJsonTemplatePosition('{"a')).toBe(true); // Inside key string
170
+ });
171
+ });
172
+ });
173
+
174
+ describe("calculateJsonIndentation", () => {
175
+ const INDENT_UNIT = 2;
176
+
177
+ describe("basic indentation", () => {
178
+ it("returns 0 for empty string", () => {
179
+ expect(calculateJsonIndentation("", INDENT_UNIT)).toBe(0);
180
+ });
181
+
182
+ it("returns indentUnit after opening brace", () => {
183
+ expect(calculateJsonIndentation("{", INDENT_UNIT)).toBe(INDENT_UNIT);
184
+ });
185
+
186
+ it("returns indentUnit after opening bracket", () => {
187
+ expect(calculateJsonIndentation("[", INDENT_UNIT)).toBe(INDENT_UNIT);
188
+ });
189
+
190
+ it("returns 0 after closed object", () => {
191
+ expect(calculateJsonIndentation("{}", INDENT_UNIT)).toBe(0);
192
+ });
193
+
194
+ it("returns 0 after closed array", () => {
195
+ expect(calculateJsonIndentation("[]", INDENT_UNIT)).toBe(0);
196
+ });
197
+ });
198
+
199
+ describe("nested structures", () => {
200
+ it("returns 2 * indentUnit for nested object", () => {
201
+ expect(calculateJsonIndentation('{"a": {', INDENT_UNIT)).toBe(
202
+ 2 * INDENT_UNIT,
203
+ );
204
+ });
205
+
206
+ it("returns 2 * indentUnit for nested array", () => {
207
+ expect(calculateJsonIndentation('{"items": [', INDENT_UNIT)).toBe(
208
+ 2 * INDENT_UNIT,
209
+ );
210
+ });
211
+
212
+ it("calculates correct depth for complex nesting", () => {
213
+ // {"a": {"b": [{"c":
214
+ expect(calculateJsonIndentation('{"a": {"b": [{"c": ', INDENT_UNIT)).toBe(
215
+ 4 * INDENT_UNIT,
216
+ );
217
+ });
218
+ });
219
+
220
+ describe("template handling (regression tests)", () => {
221
+ it("ignores {{ template opening braces", () => {
222
+ // Template {{ should NOT increase depth
223
+ expect(calculateJsonIndentation('{"foo": {{', INDENT_UNIT)).toBe(
224
+ INDENT_UNIT,
225
+ );
226
+ });
227
+
228
+ it("ignores }} template closing braces", () => {
229
+ // Template }} should NOT decrease depth
230
+ expect(
231
+ calculateJsonIndentation('{"foo": {{payload.title}}', INDENT_UNIT),
232
+ ).toBe(INDENT_UNIT);
233
+ });
234
+
235
+ it("maintains correct indentation after template value", () => {
236
+ // After a complete template, depth should still be 1 (inside the object)
237
+ expect(
238
+ calculateJsonIndentation('{"foo": {{payload.title}},\n', INDENT_UNIT),
239
+ ).toBe(INDENT_UNIT);
240
+ });
241
+
242
+ it("handles template followed by more properties", () => {
243
+ // Cursor after template value, ready for next property
244
+ const text = `{
245
+ "foo": "bar",
246
+ "bar": {{payload.title}},
247
+ `;
248
+ expect(calculateJsonIndentation(text, INDENT_UNIT)).toBe(INDENT_UNIT);
249
+ });
250
+
251
+ it("handles multiple templates in same object", () => {
252
+ const text = `{
253
+ "a": {{payload.a}},
254
+ "b": {{payload.b}},
255
+ `;
256
+ expect(calculateJsonIndentation(text, INDENT_UNIT)).toBe(INDENT_UNIT);
257
+ });
258
+ });
259
+
260
+ describe("string handling", () => {
261
+ it("ignores braces inside strings", () => {
262
+ // Braces inside strings should not affect depth
263
+ expect(calculateJsonIndentation('{"msg": "hello {"', INDENT_UNIT)).toBe(
264
+ INDENT_UNIT,
265
+ );
266
+ });
267
+
268
+ it("ignores template-like patterns inside strings", () => {
269
+ expect(
270
+ calculateJsonIndentation('{"msg": "use {{var}}"', INDENT_UNIT),
271
+ ).toBe(INDENT_UNIT);
272
+ });
273
+ });
274
+ });
@@ -0,0 +1,139 @@
1
+ import { json } from "@codemirror/lang-json";
2
+ import { Decoration } from "@codemirror/view";
3
+ import type { LanguageSupport, DecorationRange } from "./types";
4
+
5
+ // Re-export pure utils for backwards compatibility
6
+ export {
7
+ isValidJsonTemplatePosition,
8
+ calculateJsonIndentation,
9
+ } from "./json-utils";
10
+
11
+ // Decoration marks for JSON syntax highlighting using inline styles
12
+ // (higher specificity than Lezer parser's CSS classes)
13
+ const jsonStringMark = Decoration.mark({
14
+ attributes: { style: "color: hsl(142.1, 76.2%, 36.3%)" },
15
+ });
16
+ const jsonPropertyMark = Decoration.mark({
17
+ attributes: { style: "color: hsl(280, 65%, 60%)" },
18
+ });
19
+ const jsonNumberMark = Decoration.mark({
20
+ attributes: { style: "color: hsl(217.2, 91.2%, 59.8%)" },
21
+ });
22
+ const jsonKeywordMark = Decoration.mark({
23
+ attributes: { style: "color: hsl(280, 65%, 60%)" },
24
+ });
25
+ const templateMark = Decoration.mark({
26
+ attributes: { style: "color: hsl(190, 70%, 50%); font-weight: 500" },
27
+ });
28
+
29
+ /**
30
+ * Build comprehensive JSON + template decorations.
31
+ * This uses regex-based matching to apply consistent syntax highlighting
32
+ * that won't be confused by template expressions.
33
+ */
34
+ function buildJsonDecorations(doc: string): DecorationRange[] {
35
+ const ranges: DecorationRange[] = [];
36
+
37
+ // Match templates first (highest priority)
38
+ const templateRegex = /\{\{[\w.[\]]*\}\}/g;
39
+ let match;
40
+ while ((match = templateRegex.exec(doc)) !== null) {
41
+ ranges.push({
42
+ from: match.index,
43
+ to: match.index + match[0].length,
44
+ decoration: templateMark,
45
+ });
46
+ }
47
+
48
+ // Match property names: "key":
49
+ const propertyRegex = /"([^"\\]|\\.)*"\s*:/g;
50
+ while ((match = propertyRegex.exec(doc)) !== null) {
51
+ // Only highlight the string part, not the colon
52
+ const colonPos = match[0].lastIndexOf(":");
53
+ ranges.push({
54
+ from: match.index,
55
+ to: match.index + colonPos,
56
+ decoration: jsonPropertyMark,
57
+ });
58
+ }
59
+
60
+ // Match string values (strings NOT followed by colon)
61
+ const stringRegex = /"([^"\\]|\\.)*"/g;
62
+ while ((match = stringRegex.exec(doc)) !== null) {
63
+ // Check if this is a property (followed by :) - skip if so
64
+ const afterMatch = doc.slice(match.index + match[0].length).match(/^\s*:/);
65
+ if (!afterMatch) {
66
+ ranges.push({
67
+ from: match.index,
68
+ to: match.index + match[0].length,
69
+ decoration: jsonStringMark,
70
+ });
71
+ }
72
+ }
73
+
74
+ // Match numbers
75
+ const numberRegex = /-?\d+\.?\d*(?:[eE][+-]?\d+)?/g;
76
+ while ((match = numberRegex.exec(doc)) !== null) {
77
+ // Make sure it's not inside a string or template
78
+ const before = doc.slice(0, match.index);
79
+ const inString = (before.match(/"/g) || []).length % 2 === 1;
80
+ const inTemplate =
81
+ before.lastIndexOf("{{") > before.lastIndexOf("}}") &&
82
+ before.includes("{{");
83
+ if (!inString && !inTemplate) {
84
+ ranges.push({
85
+ from: match.index,
86
+ to: match.index + match[0].length,
87
+ decoration: jsonNumberMark,
88
+ });
89
+ }
90
+ }
91
+
92
+ // Match keywords: true, false, null
93
+ const keywordRegex = /\b(true|false|null)\b/g;
94
+ while ((match = keywordRegex.exec(doc)) !== null) {
95
+ const before = doc.slice(0, match.index);
96
+ const inString = (before.match(/"/g) || []).length % 2 === 1;
97
+ if (!inString) {
98
+ ranges.push({
99
+ from: match.index,
100
+ to: match.index + match[0].length,
101
+ decoration: jsonKeywordMark,
102
+ });
103
+ }
104
+ }
105
+
106
+ // Sort by position (must be in order for RangeSetBuilder)
107
+ ranges.sort((a, b) => a.from - b.from || a.to - b.to);
108
+
109
+ // Remove overlaps (templates take priority since they're added first)
110
+ const filtered: DecorationRange[] = [];
111
+ for (const range of ranges) {
112
+ const overlaps = filtered.some(
113
+ (existing) =>
114
+ (range.from >= existing.from && range.from < existing.to) ||
115
+ (range.to > existing.from && range.to <= existing.to),
116
+ );
117
+ if (!overlaps) {
118
+ filtered.push(range);
119
+ }
120
+ }
121
+
122
+ return filtered;
123
+ }
124
+
125
+ // Import the pure utils for use in the language support object
126
+ import {
127
+ isValidJsonTemplatePosition,
128
+ calculateJsonIndentation,
129
+ } from "./json-utils";
130
+
131
+ /**
132
+ * JSON language support for CodeEditor with template expression handling.
133
+ */
134
+ export const jsonLanguageSupport: LanguageSupport = {
135
+ extension: json(),
136
+ buildDecorations: buildJsonDecorations,
137
+ isValidTemplatePosition: isValidJsonTemplatePosition,
138
+ calculateIndentation: calculateJsonIndentation,
139
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure utility functions for Markdown parsing that don't depend on CodeMirror.
3
+ * These are extracted to allow testing without triggering CodeMirror's module loading.
4
+ */
5
+
6
+ /**
7
+ * Check if cursor is in a valid Markdown template position.
8
+ * Templates can appear almost anywhere in Markdown (text content).
9
+ * @internal Exported for testing
10
+ */
11
+ export function isValidMarkdownTemplatePosition(text: string): boolean {
12
+ // In Markdown, templates are valid almost everywhere except:
13
+ // - Inside code blocks (``` ... ```)
14
+ // - Inside inline code (` ... `)
15
+
16
+ // Check for unclosed code blocks
17
+ const codeBlockMatches = text.match(/```/g) || [];
18
+ if (codeBlockMatches.length % 2 === 1) {
19
+ return false; // Inside a code block
20
+ }
21
+
22
+ // Check if we're inside inline code on the current line
23
+ const lastNewline = text.lastIndexOf("\n");
24
+ const currentLine = text.slice(lastNewline + 1);
25
+ const backticks = (currentLine.match(/`/g) || []).length;
26
+
27
+ // If odd number of backticks, we're inside inline code
28
+ if (backticks % 2 === 1) {
29
+ return false;
30
+ }
31
+
32
+ // Valid in all other positions
33
+ return true;
34
+ }
35
+
36
+ /**
37
+ * Calculate indentation for Markdown.
38
+ * Markdown uses list indentation.
39
+ * @internal Exported for testing
40
+ */
41
+ export function calculateMarkdownIndentation(
42
+ textBefore: string,
43
+ _indentUnit: number,
44
+ ): number {
45
+ const lines = textBefore.split("\n");
46
+ if (lines.length === 0) return 0;
47
+
48
+ // Find the last non-empty line
49
+ for (let i = lines.length - 1; i >= 0; i--) {
50
+ const line = lines[i];
51
+ if (line.trim().length === 0) continue;
52
+
53
+ // Count leading spaces
54
+ const leadingSpaces = line.match(/^(\s*)/)?.[1].length ?? 0;
55
+
56
+ // If line is a list item, next line should be indented if continuing
57
+ if (/^\s*[-*+]\s/.test(line) || /^\s*\d+\.\s/.test(line)) {
58
+ return leadingSpaces;
59
+ }
60
+
61
+ return leadingSpaces;
62
+ }
63
+
64
+ return 0;
65
+ }