testomatio-editor-blocks 0.4.60 → 0.4.62

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.
@@ -22,7 +22,7 @@ const headingPrefixes = {
22
22
  5: "#####",
23
23
  6: "######",
24
24
  };
25
- const SPECIAL_CHAR_REGEX = /([*_`~()<\\])/g;
25
+ const SPECIAL_CHAR_REGEX = /([*_`~()\\])/g;
26
26
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
27
27
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
28
28
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
@@ -157,6 +157,7 @@ function applyTextStyles(text, styles) {
157
157
  return wrapped.join("\n");
158
158
  }
159
159
  function inlineToMarkdown(content) {
160
+ var _a;
160
161
  if (!content || !Array.isArray(content)) {
161
162
  return "";
162
163
  }
@@ -174,7 +175,9 @@ function inlineToMarkdown(content) {
174
175
  i += 2;
175
176
  continue;
176
177
  }
177
- result.push(applyTextStyles(escapeMarkdown(item.text), item.styles));
178
+ const isCode = ((_a = item.styles) === null || _a === void 0 ? void 0 : _a.code) === true;
179
+ const rendered = isCode ? item.text : escapeMarkdown(item.text);
180
+ result.push(applyTextStyles(rendered, item.styles));
178
181
  i += 1;
179
182
  continue;
180
183
  }
@@ -261,7 +264,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
261
264
  case "codeBlock": {
262
265
  const language = block.props.language || "";
263
266
  const fence = "```" + language;
264
- const body = inlineToMarkdown(block.content);
267
+ const body = inlineContentToPlainText(block.content);
265
268
  lines.push(fence);
266
269
  if (body.length > 0) {
267
270
  lines.push(body);
@@ -695,6 +698,11 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
695
698
  var _a, _b, _c, _d;
696
699
  const items = [];
697
700
  let index = startIndex;
701
+ // The minimum leading-space count for items at this list level. Initialized to
702
+ // the parent's expected indent, but updated to the first item's actual indent
703
+ // so a uniformly indented list stays flat instead of nesting under itself.
704
+ let baseIndent = indentLevel * 2;
705
+ let firstItemSeen = false;
698
706
  while (index < lines.length) {
699
707
  const rawLine = lines[index];
700
708
  const trimmed = rawLine.trim();
@@ -712,7 +720,7 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
712
720
  }
713
721
  const nextLine = lines[lookahead];
714
722
  const nextIndent = countIndent(nextLine);
715
- if (nextIndent < indentLevel * 2) {
723
+ if (nextIndent < baseIndent) {
716
724
  break;
717
725
  }
718
726
  const nextType = detectListType(nextLine.trim());
@@ -723,13 +731,15 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
723
731
  continue;
724
732
  }
725
733
  let indent = countIndent(rawLine);
726
- if (indent < indentLevel * 2) {
734
+ if (indent < baseIndent) {
727
735
  break;
728
736
  }
729
- // Check if this line should be parsed as nested content
730
- // Only go deeper if indent is at least 2 more than the next level's expected indent
731
- const nextLevelExpectedIndent = (indentLevel + 1) * 2;
732
- if (indent >= nextLevelExpectedIndent && items.length > 0) {
737
+ if (!firstItemSeen) {
738
+ baseIndent = indent;
739
+ firstItemSeen = true;
740
+ }
741
+ // Only go deeper if indent is at least 2 more than this list's base indent
742
+ if (indent >= baseIndent + 2 && items.length > 0) {
733
743
  const lastItem = items.at(-1);
734
744
  if (!lastItem) {
735
745
  break;
@@ -877,11 +887,18 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
877
887
  rawTrimmed.startsWith("|")) {
878
888
  break;
879
889
  }
880
- // Check for expected result labels with different formatting
881
- const expectedMatch = rawTrimmed.match(EXPECTED_LABEL_REGEX);
882
- const expectedStarMatch = rawTrimmed.match(/^\*expected\s*\*:\s*(.*)$/i) ||
883
- rawTrimmed.match(/^\*expected\*:\s*(.*)$/i) ||
884
- rawTrimmed.match(/^\*{1,2}expected\s*:\*{1,2}\s*(.*)$/i);
890
+ // Check for expected result labels with different formatting.
891
+ // Strip an optional leading bullet marker so patterns like
892
+ // "* *Expected*: ..." (a sub-bullet with an italic Expected label) are
893
+ // recognized via the same matchers as the bare label forms.
894
+ const bulletPrefixMatch = rawTrimmed.match(/^[*-]\s+/);
895
+ const lineForExpectedCheck = bulletPrefixMatch
896
+ ? rawTrimmed.slice(bulletPrefixMatch[0].length)
897
+ : rawTrimmed;
898
+ const expectedMatch = lineForExpectedCheck.match(EXPECTED_LABEL_REGEX);
899
+ const expectedStarMatch = lineForExpectedCheck.match(/^\*expected\s*\*:\s*(.*)$/i) ||
900
+ lineForExpectedCheck.match(/^\*expected\*:\s*(.*)$/i) ||
901
+ lineForExpectedCheck.match(/^\*{1,2}expected\s*:\*{1,2}\s*(.*)$/i);
885
902
  if (expectedMatch || expectedStarMatch) {
886
903
  inExpectedResult = true;
887
904
  // Prefer the star match (more specific about formatting) to avoid leaking markers
@@ -890,7 +907,7 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
890
907
  content = (expectedStarMatch[1] || '').trim();
891
908
  }
892
909
  else {
893
- content = rawTrimmed.slice(expectedMatch[0].length).trim();
910
+ content = lineForExpectedCheck.slice(expectedMatch[0].length).trim();
894
911
  }
895
912
  // Add the content (if any) from this line
896
913
  if (content) {
@@ -1054,7 +1071,23 @@ function parseCodeBlock(lines, index) {
1054
1071
  if (!trimmed.startsWith("```")) {
1055
1072
  return null;
1056
1073
  }
1057
- const language = trimmed.slice(3).trim();
1074
+ const afterOpening = trimmed.slice(3);
1075
+ const closeMatch = afterOpening.match(/```\s*$/);
1076
+ if (closeMatch) {
1077
+ const content = afterOpening.slice(0, afterOpening.length - closeMatch[0].length);
1078
+ return {
1079
+ block: {
1080
+ type: "codeBlock",
1081
+ props: { language: "" },
1082
+ content: content.length
1083
+ ? [{ type: "text", text: content, styles: {} }]
1084
+ : undefined,
1085
+ children: [],
1086
+ },
1087
+ nextIndex: index + 1,
1088
+ };
1089
+ }
1090
+ const language = afterOpening.trim();
1058
1091
  const body = [];
1059
1092
  let next = index + 1;
1060
1093
  while (next < lines.length && !lines[next].startsWith("```")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.60",
3
+ "version": "0.4.62",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
@@ -62,7 +62,7 @@ describe("blocksToMarkdown", () => {
62
62
  expect(blocksToMarkdown(blocks)).toBe("<!-- ai/agent generated description -->");
63
63
  });
64
64
 
65
- it("preserves HTML comments inline among text and still escapes stray angle brackets", () => {
65
+ it("preserves HTML comments inline among text without escaping stray angle brackets", () => {
66
66
  const blocks: CustomEditorBlock[] = [
67
67
  {
68
68
  id: "c2",
@@ -75,7 +75,98 @@ describe("blocksToMarkdown", () => {
75
75
  },
76
76
  ];
77
77
 
78
- expect(blocksToMarkdown(blocks)).toBe("before <!-- note --> after \\<div>");
78
+ expect(blocksToMarkdown(blocks)).toBe("before <!-- note --> after <div>");
79
+ });
80
+
81
+ it("does not escape `<` in plain text (e.g. comparison operators)", () => {
82
+ const blocks: CustomEditorBlock[] = [
83
+ {
84
+ id: "p_lt",
85
+ type: "paragraph",
86
+ props: baseProps,
87
+ content: [
88
+ { type: "text", text: "< 768px is mobile", styles: {} },
89
+ ],
90
+ children: [],
91
+ },
92
+ ];
93
+
94
+ expect(blocksToMarkdown(blocks)).toBe("< 768px is mobile");
95
+ });
96
+
97
+ it("does not escape `<` in table cells (viewport breakpoints case)", () => {
98
+ const markdown = [
99
+ "| Viewport Width | Layout Expected | Nav Behavior |",
100
+ "| --- | --- | --- |",
101
+ "| < 768px | Single column | Hamburger menu |",
102
+ "| 768px – 1024px | Two column | Collapsed sidebar |",
103
+ "| > 1024px | Full desktop layout | Full nav visible |",
104
+ ].join("\n");
105
+
106
+ const blocks = markdownToBlocks(markdown);
107
+ const out = blocksToMarkdown(blocks as CustomEditorBlock[]);
108
+ expect(out).toBe(markdown);
109
+ expect(out).not.toContain("\\<");
110
+ });
111
+
112
+ it("does not escape markdown chars inside inline code", () => {
113
+ const blocks: CustomEditorBlock[] = [
114
+ {
115
+ id: "p_code",
116
+ type: "paragraph",
117
+ props: baseProps,
118
+ content: [
119
+ { type: "text", text: "**bold**", styles: { code: true } },
120
+ ],
121
+ children: [],
122
+ },
123
+ ];
124
+
125
+ expect(blocksToMarkdown(blocks)).toBe("`**bold**`");
126
+ });
127
+
128
+ it("does not escape markdown chars inside inline code in a table (syntax/rendered case)", () => {
129
+ const markdown = [
130
+ "| Syntax | Rendered As |",
131
+ "| --- | --- |",
132
+ "| `**bold**` | **bold** |",
133
+ "| `*italic*` | _italic_ |",
134
+ "| `~~strike~~` | ~~strike~~ |",
135
+ ].join("\n");
136
+
137
+ const blocks = markdownToBlocks(markdown);
138
+ const out = blocksToMarkdown(blocks as CustomEditorBlock[]);
139
+ expect(out).toBe(markdown);
140
+ expect(out).not.toMatch(/\\[*_~]/);
141
+ });
142
+
143
+ it("does not escape markdown chars inside fenced code blocks", () => {
144
+ const markdown = [
145
+ "```",
146
+ "**bold** _italic_ ~~strike~~ <div>",
147
+ "```",
148
+ ].join("\n");
149
+
150
+ const blocks = markdownToBlocks(markdown);
151
+ const out = blocksToMarkdown(blocks as CustomEditorBlock[]);
152
+ expect(out).toBe(markdown);
153
+ expect(out).not.toMatch(/\\[*_~<]/);
154
+ });
155
+
156
+ it("still escapes literal backticks inside inline code", () => {
157
+ const blocks: CustomEditorBlock[] = [
158
+ {
159
+ id: "p_tick",
160
+ type: "paragraph",
161
+ props: baseProps,
162
+ content: [
163
+ { type: "text", text: "a`b", styles: { code: true } },
164
+ ],
165
+ children: [],
166
+ },
167
+ ];
168
+
169
+ expect(blocksToMarkdown(blocks)).toBe("`a\\`b`");
79
170
  });
80
171
 
81
172
  it("places bold markers outside leading/trailing spaces", () => {
@@ -758,6 +849,86 @@ describe("markdownToBlocks", () => {
758
849
  ]);
759
850
  });
760
851
 
852
+ it("parses sub-bulleted *Expected*: lines as the step's expected result", () => {
853
+ const markdown = [
854
+ "### Steps",
855
+ "",
856
+ "* Navigate to the “Transfer Funds” page.",
857
+ " * *Expected*: The Transfer Funds page loads, showing fields for source account, destination account, and amount.",
858
+ "",
859
+ "* Select the user’s own source account from the dropdown list.",
860
+ " * *Expected*: The selected account is displayed and its current balance is shown.",
861
+ ].join("\n");
862
+
863
+ const blocks = markdownToBlocks(markdown);
864
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
865
+ expect(stepBlocks).toEqual([
866
+ {
867
+ type: "testStep",
868
+ props: {
869
+ stepTitle: "Navigate to the “Transfer Funds” page.",
870
+ stepData: "",
871
+ expectedResult:
872
+ "The Transfer Funds page loads, showing fields for source account, destination account, and amount.\n",
873
+ listStyle: "bullet",
874
+ },
875
+ children: [],
876
+ },
877
+ {
878
+ type: "testStep",
879
+ props: {
880
+ stepTitle: "Select the user’s own source account from the dropdown list.",
881
+ stepData: "",
882
+ expectedResult:
883
+ "The selected account is displayed and its current balance is shown.",
884
+ listStyle: "bullet",
885
+ },
886
+ children: [],
887
+ },
888
+ ]);
889
+ });
890
+
891
+ it("parses sub-bulleted **Expected**: (bold) lines as the step's expected result", () => {
892
+ const markdown = [
893
+ "### Steps",
894
+ "",
895
+ "* Open the Login page.",
896
+ " * **Expected**: The Login page loads successfully.",
897
+ ].join("\n");
898
+
899
+ const blocks = markdownToBlocks(markdown);
900
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
901
+ expect(stepBlocks).toEqual([
902
+ {
903
+ type: "testStep",
904
+ props: {
905
+ stepTitle: "Open the Login page.",
906
+ stepData: "",
907
+ expectedResult: "The Login page loads successfully.",
908
+ listStyle: "bullet",
909
+ },
910
+ children: [],
911
+ },
912
+ ]);
913
+ });
914
+
915
+ it("round-trips sub-bulleted *Expected*: lines through the canonical serialized form", () => {
916
+ const markdown = [
917
+ "### Steps",
918
+ "",
919
+ "* Navigate to the “Transfer Funds” page.",
920
+ " * *Expected*: The Transfer Funds page loads.",
921
+ ].join("\n");
922
+
923
+ const firstPass = markdownToBlocks(markdown);
924
+ const serialized = blocksToMarkdown(firstPass as CustomEditorBlock[]);
925
+ const secondPass = markdownToBlocks(serialized);
926
+
927
+ const firstSteps = firstPass.filter((b) => b.type === "testStep");
928
+ const secondSteps = secondPass.filter((b) => b.type === "testStep");
929
+ expect(secondSteps).toEqual(firstSteps);
930
+ });
931
+
761
932
  it("parses a step with empty title but with step data", () => {
762
933
  const markdown = ["### Steps", "", "* ", " Navigate to the page"].join("\n");
763
934
 
@@ -1241,6 +1412,25 @@ describe("markdownToBlocks", () => {
1241
1412
  expect(nestedChildren.some((child) => child.type === "bulletListItem")).toBe(true);
1242
1413
  });
1243
1414
 
1415
+ it("parses a uniformly indented list as a flat top-level list", () => {
1416
+ const markdown = [
1417
+ "# Requirements",
1418
+ "",
1419
+ " * User has an active account on the platform.",
1420
+ " * User has sufficient funds in the source account.",
1421
+ " * QR code contains valid transfer details and is scannable.",
1422
+ " * The device has camera access and QR scanning capability.",
1423
+ " * The user is authenticated and authorized to perform transfers.",
1424
+ ].join("\n");
1425
+
1426
+ const blocks = markdownToBlocks(markdown);
1427
+ const bulletItems = blocks.filter((b) => b.type === "bulletListItem");
1428
+ expect(bulletItems).toHaveLength(5);
1429
+ for (const item of bulletItems) {
1430
+ expect(item.children ?? []).toEqual([]);
1431
+ }
1432
+ });
1433
+
1244
1434
  it("does not freeze on indented list items without a parent", () => {
1245
1435
  const markdown = [
1246
1436
  "### Requirements",
@@ -2412,6 +2602,59 @@ describe("markdownToBlocks", () => {
2412
2602
  // Most importantly: should not have a standalone "!" at the end
2413
2603
  expect(roundTripMarkdown).not.toMatch(/\n!\s*$/);
2414
2604
  });
2605
+
2606
+ it("parses a single-line fenced code block", () => {
2607
+ const markdown = "```{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two```";
2608
+ const blocks = markdownToBlocks(markdown);
2609
+ expect(blocks).toEqual([
2610
+ {
2611
+ type: "codeBlock",
2612
+ props: { language: "" },
2613
+ content: [
2614
+ {
2615
+ type: "text",
2616
+ text: "{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two",
2617
+ styles: {},
2618
+ },
2619
+ ],
2620
+ children: [],
2621
+ },
2622
+ ]);
2623
+ });
2624
+
2625
+ it("parses an empty single-line fence without swallowing following lines", () => {
2626
+ const markdown = ["``````", "next line"].join("\n");
2627
+ const blocks = markdownToBlocks(markdown);
2628
+ expect(blocks).toHaveLength(2);
2629
+ expect(blocks[0]).toEqual({
2630
+ type: "codeBlock",
2631
+ props: { language: "" },
2632
+ content: undefined,
2633
+ children: [],
2634
+ });
2635
+ expect(blocks[1].type).toBe("paragraph");
2636
+ });
2637
+
2638
+ it("normalizes a single-line fenced code block to multi-line on round-trip", () => {
2639
+ const markdown = "```hello world```";
2640
+ const blocks = markdownToBlocks(markdown);
2641
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(
2642
+ ["```", "hello world", "```"].join("\n"),
2643
+ );
2644
+ });
2645
+
2646
+ it("still treats an opening fence with a language identifier as multi-line", () => {
2647
+ const markdown = ["```js", "const x = 1;", "```"].join("\n");
2648
+ const blocks = markdownToBlocks(markdown);
2649
+ expect(blocks).toEqual([
2650
+ {
2651
+ type: "codeBlock",
2652
+ props: { language: "js" },
2653
+ content: [{ type: "text", text: "const x = 1;", styles: {} }],
2654
+ children: [],
2655
+ },
2656
+ ]);
2657
+ });
2415
2658
  });
2416
2659
 
2417
2660
  describe("file block serialization", () => {
@@ -60,7 +60,7 @@ const headingPrefixes: Record<number, string> = {
60
60
  6: "######",
61
61
  };
62
62
 
63
- const SPECIAL_CHAR_REGEX = /([*_`~()<\\])/g;
63
+ const SPECIAL_CHAR_REGEX = /([*_`~()\\])/g;
64
64
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
65
65
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
66
66
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
@@ -234,7 +234,9 @@ function inlineToMarkdown(content: CustomEditorBlock["content"]): string {
234
234
  i += 2;
235
235
  continue;
236
236
  }
237
- result.push(applyTextStyles(escapeMarkdown(item.text), item.styles));
237
+ const isCode = (item.styles as any)?.code === true;
238
+ const rendered = isCode ? item.text : escapeMarkdown(item.text);
239
+ result.push(applyTextStyles(rendered, item.styles));
238
240
  i += 1;
239
241
  continue;
240
242
  }
@@ -334,7 +336,7 @@ function serializeBlock(
334
336
  case "codeBlock": {
335
337
  const language = (block.props as any).language || "";
336
338
  const fence = "```" + language;
337
- const body = inlineToMarkdown(block.content);
339
+ const body = inlineContentToPlainText(block.content);
338
340
  lines.push(fence);
339
341
  if (body.length > 0) {
340
342
  lines.push(body);
@@ -843,6 +845,11 @@ function parseList(
843
845
  ): ListParseResult {
844
846
  const items: CustomPartialBlock[] = [];
845
847
  let index = startIndex;
848
+ // The minimum leading-space count for items at this list level. Initialized to
849
+ // the parent's expected indent, but updated to the first item's actual indent
850
+ // so a uniformly indented list stays flat instead of nesting under itself.
851
+ let baseIndent = indentLevel * 2;
852
+ let firstItemSeen = false;
846
853
 
847
854
  while (index < lines.length) {
848
855
  const rawLine = lines[index];
@@ -862,7 +869,7 @@ function parseList(
862
869
  }
863
870
  const nextLine = lines[lookahead];
864
871
  const nextIndent = countIndent(nextLine);
865
- if (nextIndent < indentLevel * 2) {
872
+ if (nextIndent < baseIndent) {
866
873
  break;
867
874
  }
868
875
  const nextType = detectListType(nextLine.trim());
@@ -875,14 +882,17 @@ function parseList(
875
882
 
876
883
  let indent = countIndent(rawLine);
877
884
 
878
- if (indent < indentLevel * 2) {
885
+ if (indent < baseIndent) {
879
886
  break;
880
887
  }
881
888
 
882
- // Check if this line should be parsed as nested content
883
- // Only go deeper if indent is at least 2 more than the next level's expected indent
884
- const nextLevelExpectedIndent = (indentLevel + 1) * 2;
885
- if (indent >= nextLevelExpectedIndent && items.length > 0) {
889
+ if (!firstItemSeen) {
890
+ baseIndent = indent;
891
+ firstItemSeen = true;
892
+ }
893
+
894
+ // Only go deeper if indent is at least 2 more than this list's base indent
895
+ if (indent >= baseIndent + 2 && items.length > 0) {
886
896
  const lastItem = items.at(-1);
887
897
  if (!lastItem) {
888
898
  break;
@@ -1061,11 +1071,18 @@ function parseTestStep(
1061
1071
  break;
1062
1072
  }
1063
1073
 
1064
- // Check for expected result labels with different formatting
1065
- const expectedMatch = rawTrimmed.match(EXPECTED_LABEL_REGEX);
1066
- const expectedStarMatch = rawTrimmed.match(/^\*expected\s*\*:\s*(.*)$/i) ||
1067
- rawTrimmed.match(/^\*expected\*:\s*(.*)$/i) ||
1068
- rawTrimmed.match(/^\*{1,2}expected\s*:\*{1,2}\s*(.*)$/i);
1074
+ // Check for expected result labels with different formatting.
1075
+ // Strip an optional leading bullet marker so patterns like
1076
+ // "* *Expected*: ..." (a sub-bullet with an italic Expected label) are
1077
+ // recognized via the same matchers as the bare label forms.
1078
+ const bulletPrefixMatch = rawTrimmed.match(/^[*-]\s+/);
1079
+ const lineForExpectedCheck = bulletPrefixMatch
1080
+ ? rawTrimmed.slice(bulletPrefixMatch[0].length)
1081
+ : rawTrimmed;
1082
+ const expectedMatch = lineForExpectedCheck.match(EXPECTED_LABEL_REGEX);
1083
+ const expectedStarMatch = lineForExpectedCheck.match(/^\*expected\s*\*:\s*(.*)$/i) ||
1084
+ lineForExpectedCheck.match(/^\*expected\*:\s*(.*)$/i) ||
1085
+ lineForExpectedCheck.match(/^\*{1,2}expected\s*:\*{1,2}\s*(.*)$/i);
1069
1086
 
1070
1087
  if (expectedMatch || expectedStarMatch) {
1071
1088
  inExpectedResult = true;
@@ -1074,7 +1091,7 @@ function parseTestStep(
1074
1091
  if (expectedStarMatch) {
1075
1092
  content = (expectedStarMatch[1] || '').trim();
1076
1093
  } else {
1077
- content = rawTrimmed.slice(expectedMatch![0].length).trim();
1094
+ content = lineForExpectedCheck.slice(expectedMatch![0].length).trim();
1078
1095
  }
1079
1096
 
1080
1097
  // Add the content (if any) from this line
@@ -1256,7 +1273,24 @@ function parseCodeBlock(lines: string[], index: number): { block: CustomPartialB
1256
1273
  return null;
1257
1274
  }
1258
1275
 
1259
- const language = trimmed.slice(3).trim();
1276
+ const afterOpening = trimmed.slice(3);
1277
+ const closeMatch = afterOpening.match(/```\s*$/);
1278
+ if (closeMatch) {
1279
+ const content = afterOpening.slice(0, afterOpening.length - closeMatch[0].length);
1280
+ return {
1281
+ block: {
1282
+ type: "codeBlock",
1283
+ props: { language: "" },
1284
+ content: content.length
1285
+ ? [{ type: "text", text: content, styles: {} }]
1286
+ : undefined,
1287
+ children: [],
1288
+ },
1289
+ nextIndex: index + 1,
1290
+ };
1291
+ }
1292
+
1293
+ const language = afterOpening.trim();
1260
1294
  const body: string[] = [];
1261
1295
  let next = index + 1;
1262
1296
  while (next < lines.length && !lines[next].startsWith("```") ) {