This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new be874da37 fix(ai): preserve fenced code during Markdown normalization
(#6449)
be874da37 is described below
commit be874da37e101af1db604631cfedfe6c131eb5eb
Author: Loyal-Young <[email protected]>
AuthorDate: Thu Oct 15 20:27:47 2026 +0800
fix(ai): preserve fenced code during Markdown normalization (#6449)
* fix(ai): preserve fenced code during Markdown normalization
* fix(ai): drop the lookbehind assertion from Markdown line splitting
esbuild cannot lower a lookbehind assertion, so targeting the Vite default
build set turned `/(?<=\n)/` into `new RegExp("(?<=\\n)")`, which still
throws
on Safari versions older than 16.4. Splitting on the newline and rejoining
with it produces byte-identical output, including for CRLF input where the
`\r` stays at the end of the preceding line.
---------
Co-authored-by: lizhimins <[email protected]>
---
.../ai/render/__tests__/markdownFences.test.ts | 53 ++++++++++++++++++++++
web/src/pages/ai/render/markdown.ts | 29 ++++++++++--
2 files changed, 79 insertions(+), 3 deletions(-)
diff --git a/web/src/pages/ai/render/__tests__/markdownFences.test.ts
b/web/src/pages/ai/render/__tests__/markdownFences.test.ts
new file mode 100644
index 000000000..38d396af2
--- /dev/null
+++ b/web/src/pages/ai/render/__tests__/markdownFences.test.ts
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { normalizeAiMarkdown } from '../markdown';
+
+describe('Markdown fenced code preservation', () => {
+ it.each(['```', '~~~', '````', '~~~~'])('preservesCodeBodyTest: %s', (fence)
=> {
+ const input =
`${fence}diff\n-removed\n+added\n#comment\n**literal**\n${fence}\n`;
+ expect(normalizeAiMarkdown(input)).toBe(input);
+ });
+
+ it.each(['\n', '\r\n'])('preservesUnclosedStreamingFenceTest: %j', (eol) => {
+ const input = ['```sh', '#!/bin/sh', '-1', '+2'].join(eol);
+ expect(normalizeAiMarkdown(input)).toBe(input);
+ });
+
+ it('shortOrDifferentFenceCannotEndCodeTest', () => {
+ const input = '````text\n```\n#inside\n~~~\n+inside\n````\n##结论';
+ expect(normalizeAiMarkdown(input)).toBe(input.replace('##结论', '## 结论'));
+ });
+
+ it('fenceWithTrailingTextCannotEndCodeTest', () => {
+ const input = '```text\n```not-a-close\n-unchanged\n```';
+ expect(normalizeAiMarkdown(input)).toBe(input);
+ });
+
+ it('allowsIndentedOpeningAndLongerClosingFenceTest', () => {
+ const input = ' ~~~text\n#inside\n ~~~~ \n-项目';
+ expect(normalizeAiMarkdown(input)).toBe(input.replace('-项目', '- 项目'));
+ });
+
+ it('repairsProseAndMalformedOpeningWhilePreservingCodeTest', () => {
+ const input = '##结论\n-项目\n```bashprintf ok\n#comment\n```\n+下一项';
+ expect(normalizeAiMarkdown(input)).toBe(
+ '## 结论\n- 项目\n```bash\nprintf ok\n#comment\n```\n+ 下一项',
+ );
+ });
+});
diff --git a/web/src/pages/ai/render/markdown.ts
b/web/src/pages/ai/render/markdown.ts
index 65cd4f517..d509bfb39 100644
--- a/web/src/pages/ai/render/markdown.ts
+++ b/web/src/pages/ai/render/markdown.ts
@@ -28,8 +28,31 @@
* every assistant text block, live or replayed.
*/
export function normalizeAiMarkdown(content: string): string {
+ let fence: { marker: string; length: number } | null = null;
+ // Split on the newline itself rather than a lookbehind (`/(?<=\n)/`):
esbuild cannot downlevel
+ // lookbehind assertions, and the Vite default build target still covers
Safari versions that
+ // throw a SyntaxError while merely parsing such a literal.
return content
- .replace(/^(#{1,6})(?=\S)/gm, '$1 ')
- .replace(/^([-+*])(?=\S)/gm, '$1 ')
- .replace(/^```(bash|sh|shell|json|ya?ml|sql|text)(?=\S)/gim, '```$1\n');
+ .split('\n')
+ .map((line) => {
+ const marker = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)/.exec(line);
+ if (fence) {
+ if (
+ marker &&
+ marker[1][0] === fence.marker &&
+ marker[1].length >= fence.length &&
+ !marker[2].trim()
+ ) {
+ fence = null;
+ }
+ return line;
+ }
+ // A backtick fence cannot contain backticks in its info string. Tilde
fences can.
+ if (marker && (marker[1][0] === '~' || !marker[2].includes('`'))) {
+ fence = { marker: marker[1][0], length: marker[1].length };
+ return line.replace(/^```(bash|sh|shell|json|ya?ml|sql|text)(?=\S)/i,
'```$1\n');
+ }
+ return line.replace(/^(#{1,6})(?=\S)/, '$1 ').replace(/^([-+*])(?=\S)/,
'$1 ');
+ })
+ .join('\n');
}