From 145e26698791221b007c7dd460fb506cb0237235 Mon Sep 17 00:00:00 2001
From: wxiaoguang
Date: Thu, 7 Nov 2024 11:57:07 +0800
Subject: [PATCH] Support quote selected comments to reply (#32431)
Many existing tests were quite hacky, these could be improved later.
![image](https://github.com/user-attachments/assets/93aebb4f-9de5-4cb8-910b-50c64cbcd25a)
---
modules/markup/html.go | 5 +-
modules/markup/html_codepreview_test.go | 2 +-
modules/markup/html_internal_test.go | 12 +-
modules/markup/html_test.go | 16 ++-
modules/markup/markdown/markdown_test.go | 12 +-
modules/markup/sanitizer_default.go | 1 +
modules/templates/util_render_test.go | 16 +--
routers/api/v1/misc/markup_test.go | 6 +-
templates/repo/diff/comments.tmpl | 2 +-
templates/repo/issue/view_content.tmpl | 2 +-
.../repo/issue/view_content/comments.tmpl | 4 +-
.../repo/issue/view_content/context_menu.tmpl | 18 +--
.../repo/issue/view_content/conversation.tmpl | 2 +-
web_src/js/features/repo-issue-edit.ts | 84 ++++++++-----
web_src/js/markup/html2markdown.test.ts | 24 ++++
web_src/js/markup/html2markdown.ts | 119 ++++++++++++++++++
16 files changed, 255 insertions(+), 70 deletions(-)
create mode 100644 web_src/js/markup/html2markdown.test.ts
create mode 100644 web_src/js/markup/html2markdown.ts
diff --git a/modules/markup/html.go b/modules/markup/html.go
index a9c3dc9ba28..e2eefefc4ba 100644
--- a/modules/markup/html.go
+++ b/modules/markup/html.go
@@ -442,7 +442,10 @@ func createLink(href, content, class string) *html.Node {
a := &html.Node{
Type: html.ElementNode,
Data: atom.A.String(),
- Attr: []html.Attribute{{Key: "href", Val: href}},
+ Attr: []html.Attribute{
+ {Key: "href", Val: href},
+ {Key: "data-markdown-generated-content"},
+ },
}
if class != "" {
diff --git a/modules/markup/html_codepreview_test.go b/modules/markup/html_codepreview_test.go
index d33630d0401..a90de278f57 100644
--- a/modules/markup/html_codepreview_test.go
+++ b/modules/markup/html_codepreview_test.go
@@ -30,5 +30,5 @@ func TestRenderCodePreview(t *testing.T) {
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
}
test("http://localhost:3000/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", "code preview
")
- test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20
`)
+ test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20
`)
}
diff --git a/modules/markup/html_internal_test.go b/modules/markup/html_internal_test.go
index 74089cffddf..8f516751b08 100644
--- a/modules/markup/html_internal_test.go
+++ b/modules/markup/html_internal_test.go
@@ -33,11 +33,9 @@ func numericIssueLink(baseURL, class string, index int, marker string) string {
// link an HTML link
func link(href, class, contents string) string {
- if class != "" {
- class = " class=\"" + class + "\""
- }
-
- return fmt.Sprintf("%s", href, class, contents)
+ extra := ` data-markdown-generated-content=""`
+ extra += util.Iif(class != "", ` class="`+class+`"`, "")
+ return fmt.Sprintf(`%s`, href, extra, contents)
}
var numericMetas = map[string]string{
@@ -353,7 +351,9 @@ func TestRender_FullIssueURLs(t *testing.T) {
Metas: localMetas,
}, []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result)
assert.NoError(t, err)
- assert.Equal(t, expected, result.String())
+ actual := result.String()
+ actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, expected, actual)
}
test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6",
"Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6")
diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go
index 32858dbd6b3..82aded4407c 100644
--- a/modules/markup/html_test.go
+++ b/modules/markup/html_test.go
@@ -116,7 +116,9 @@ func TestRender_CrossReferences(t *testing.T) {
Metas: localMetas,
}, input)
assert.NoError(t, err)
- assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ actual := strings.TrimSpace(buffer)
+ actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, strings.TrimSpace(expected), actual)
}
test(
@@ -156,7 +158,9 @@ func TestRender_links(t *testing.T) {
},
}, input)
assert.NoError(t, err)
- assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+ actual := strings.TrimSpace(buffer)
+ actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, strings.TrimSpace(expected), actual)
}
oldCustomURLSchemes := setting.Markdown.CustomURLSchemes
@@ -267,7 +271,9 @@ func TestRender_email(t *testing.T) {
},
}, input)
assert.NoError(t, err)
- assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res))
+ actual := strings.TrimSpace(res)
+ actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, strings.TrimSpace(expected), actual)
}
// Text that should be turned into email link
@@ -616,7 +622,9 @@ func TestPostProcess_RenderDocument(t *testing.T) {
Metas: localMetas,
}, strings.NewReader(input), &res)
assert.NoError(t, err)
- assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res.String()))
+ actual := strings.TrimSpace(res.String())
+ actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, strings.TrimSpace(expected), actual)
}
// Issue index shouldn't be post processing in a document.
diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go
index cfb821ab19f..ad38e7a088f 100644
--- a/modules/markup/markdown/markdown_test.go
+++ b/modules/markup/markdown/markdown_test.go
@@ -311,7 +311,8 @@ func TestTotal_RenderWiki(t *testing.T) {
IsWiki: true,
}, sameCases[i])
assert.NoError(t, err)
- assert.Equal(t, template.HTML(answers[i]), line)
+ actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, answers[i], actual)
}
testCases := []string{
@@ -336,7 +337,8 @@ func TestTotal_RenderWiki(t *testing.T) {
IsWiki: true,
}, testCases[i])
assert.NoError(t, err)
- assert.Equal(t, template.HTML(testCases[i+1]), line)
+ actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
+ assert.EqualValues(t, testCases[i+1], actual)
}
}
@@ -356,7 +358,8 @@ func TestTotal_RenderString(t *testing.T) {
Metas: localMetas,
}, sameCases[i])
assert.NoError(t, err)
- assert.Equal(t, template.HTML(answers[i]), line)
+ actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, answers[i], actual)
}
testCases := []string{}
@@ -996,7 +999,8 @@ space
for i, c := range cases {
result, err := markdown.RenderString(&markup.RenderContext{Ctx: context.Background(), Links: c.Links, IsWiki: c.IsWiki}, input)
assert.NoError(t, err, "Unexpected error in testcase: %v", i)
- assert.Equal(t, c.Expected, string(result), "Unexpected result in testcase %v", i)
+ actual := strings.ReplaceAll(string(result), ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, c.Expected, actual, "Unexpected result in testcase %v", i)
}
}
diff --git a/modules/markup/sanitizer_default.go b/modules/markup/sanitizer_default.go
index 669dc24eae3..476ae5e26f0 100644
--- a/modules/markup/sanitizer_default.go
+++ b/modules/markup/sanitizer_default.go
@@ -107,6 +107,7 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
"start", "summary", "tabindex", "target",
"title", "type", "usemap", "valign", "value",
"vspace", "width", "itemprop",
+ "data-markdown-generated-content",
}
generalSafeElements := []string{
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
index 3e4ea04c631..0a6e89c5f2e 100644
--- a/modules/templates/util_render_test.go
+++ b/modules/templates/util_render_test.go
@@ -129,18 +129,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
@mention-user test
#123
space`
-
- assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitBody(testInput(), testMetas))
+ actual := strings.ReplaceAll(string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas)), ` data-markdown-generated-content=""`, "")
+ assert.EqualValues(t, expected, actual)
}
func TestRenderCommitMessage(t *testing.T) {
- expected := `space @mention-user `
+ expected := `space @mention-user `
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas))
}
func TestRenderCommitMessageLinkSubject(t *testing.T) {
- expected := `space @mention-user`
+ expected := `space @mention-user`
assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas))
}
@@ -168,7 +168,8 @@ mail@domain.com
space
`
expected = strings.ReplaceAll(expected, "", " ")
- assert.EqualValues(t, expected, newTestRenderUtils().RenderIssueTitle(testInput(), testMetas))
+ actual := strings.ReplaceAll(string(newTestRenderUtils().RenderIssueTitle(testInput(), testMetas)), ` data-markdown-generated-content=""`, "")
+ assert.EqualValues(t, expected, actual)
}
func TestRenderMarkdownToHtml(t *testing.T) {
@@ -193,7 +194,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
#123
space
`
- assert.Equal(t, expected, string(newTestRenderUtils().MarkdownToHtml(testInput())))
+ actual := strings.ReplaceAll(string(newTestRenderUtils().MarkdownToHtml(testInput())), ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, expected, actual)
}
func TestRenderLabels(t *testing.T) {
@@ -211,5 +213,5 @@ func TestRenderLabels(t *testing.T) {
func TestUserMention(t *testing.T) {
rendered := newTestRenderUtils().MarkdownToHtml("@no-such-user @mention-user @mention-user")
- assert.EqualValues(t, `@no-such-user @mention-user @mention-user
`, strings.TrimSpace(string(rendered)))
+ assert.EqualValues(t, `@no-such-user @mention-user @mention-user
`, strings.TrimSpace(string(rendered)))
}
diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go
index e2ab7141b72..abffdf35161 100644
--- a/routers/api/v1/misc/markup_test.go
+++ b/routers/api/v1/misc/markup_test.go
@@ -38,7 +38,8 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
web.SetForm(ctx, &options)
Markup(ctx)
- assert.Equal(t, expectedBody, resp.Body.String())
+ actual := strings.ReplaceAll(resp.Body.String(), ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, expectedBody, actual)
assert.Equal(t, expectedCode, resp.Code)
resp.Body.Reset()
}
@@ -58,7 +59,8 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody
ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
web.SetForm(ctx, &options)
Markdown(ctx)
- assert.Equal(t, responseBody, resp.Body.String())
+ actual := strings.ReplaceAll(resp.Body.String(), ` data-markdown-generated-content=""`, "")
+ assert.Equal(t, responseBody, actual)
assert.Equal(t, responseCode, resp.Code)
resp.Body.Reset()
}
diff --git a/templates/repo/diff/comments.tmpl b/templates/repo/diff/comments.tmpl
index 0ed231b07e9..2d716688b91 100644
--- a/templates/repo/diff/comments.tmpl
+++ b/templates/repo/diff/comments.tmpl
@@ -49,7 +49,7 @@
{{end}}
{{end}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}}
- {{template "repo/issue/view_content/context_menu" dict "ctxData" $.root "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
+ {{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl
index 74440c5ef2e..1f9bbd86aa4 100644
--- a/templates/repo/issue/view_content.tmpl
+++ b/templates/repo/issue/view_content.tmpl
@@ -48,7 +48,7 @@
{{if not $.Repository.IsArchived}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}}
{{end}}
- {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}}
+ {{template "repo/issue/view_content/context_menu" dict "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}}
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index 30475331544..477b6b33c6a 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -55,7 +55,7 @@
{{if not $.Repository.IsArchived}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
{{end}}
- {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" true "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
+ {{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
@@ -430,7 +430,7 @@
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
{{if not $.Repository.IsArchived}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
- {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" false "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
+ {{template "repo/issue/view_content/context_menu" dict "item" . "delete" false "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
{{end}}
diff --git a/templates/repo/issue/view_content/context_menu.tmpl b/templates/repo/issue/view_content/context_menu.tmpl
index 9e38442c369..749a2fa0ddc 100644
--- a/templates/repo/issue/view_content/context_menu.tmpl
+++ b/templates/repo/issue/view_content/context_menu.tmpl
@@ -5,29 +5,29 @@
diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts
index af97ee4eab1..9d146951bd4 100644
--- a/web_src/js/features/repo-issue-edit.ts
+++ b/web_src/js/features/repo-issue-edit.ts
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import {handleReply} from './repo-issue.ts';
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {POST} from '../modules/fetch.ts';
@@ -7,11 +6,14 @@ import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
import {initCommentContent, initMarkupContent} from '../markup/content.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
+import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
-async function onEditContent(event) {
- event.preventDefault();
+async function tryOnEditContent(e) {
+ const clickTarget = e.target.closest('.edit-content');
+ if (!clickTarget) return;
- const segment = this.closest('.header').nextElementSibling;
+ e.preventDefault();
+ const segment = clickTarget.closest('.header').nextElementSibling;
const editContentZone = segment.querySelector('.edit-content-zone');
const renderContent = segment.querySelector('.render-content');
const rawContent = segment.querySelector('.raw-content');
@@ -102,33 +104,53 @@ async function onEditContent(event) {
triggerUploadStateChanged(comboMarkdownEditor.container);
}
+function extractSelectedMarkdown(container: HTMLElement) {
+ const selection = window.getSelection();
+ if (!selection.rangeCount) return '';
+ const range = selection.getRangeAt(0);
+ if (!container.contains(range.commonAncestorContainer)) return '';
+
+ // todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content
+ // otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes
+ const contents = selection.getRangeAt(0).cloneContents();
+ const el = document.createElement('div');
+ el.append(contents);
+ return convertHtmlToMarkdown(el);
+}
+
+async function tryOnQuoteReply(e) {
+ const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
+ if (!clickTarget) return;
+
+ e.preventDefault();
+ const contentToQuoteId = clickTarget.getAttribute('data-target');
+ const targetRawToQuote = document.querySelector(`#${contentToQuoteId}.raw-content`);
+ const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector('.render-content.markup');
+ let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
+ if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
+ const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`;
+
+ let editor;
+ if (clickTarget.classList.contains('quote-reply-diff')) {
+ const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
+ editor = await handleReply(replyBtn);
+ } else {
+ // for normal issue/comment page
+ editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'));
+ }
+
+ if (editor.value()) {
+ editor.value(`${editor.value()}\n\n${quotedContent}`);
+ } else {
+ editor.value(quotedContent);
+ }
+ editor.focus();
+ editor.moveCursorToEnd();
+}
+
export function initRepoIssueCommentEdit() {
- // Edit issue or comment content
- $(document).on('click', '.edit-content', onEditContent);
-
- // Quote reply
- $(document).on('click', '.quote-reply', async function (event) {
- event.preventDefault();
- const target = this.getAttribute('data-target');
- const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> ');
- const content = `> ${quote}\n\n`;
-
- let editor;
- if (this.classList.contains('quote-reply-diff')) {
- const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
- editor = await handleReply(replyBtn);
- } else {
- // for normal issue/comment page
- editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
- }
- if (editor) {
- if (editor.value()) {
- editor.value(`${editor.value()}\n\n${content}`);
- } else {
- editor.value(content);
- }
- editor.focus();
- editor.moveCursorToEnd();
- }
+ document.addEventListener('click', (e) => {
+ tryOnEditContent(e); // Edit issue or comment content
+ tryOnQuoteReply(e); // Quote reply to the comment editor
});
}
diff --git a/web_src/js/markup/html2markdown.test.ts b/web_src/js/markup/html2markdown.test.ts
new file mode 100644
index 00000000000..99a63956a03
--- /dev/null
+++ b/web_src/js/markup/html2markdown.test.ts
@@ -0,0 +1,24 @@
+import {convertHtmlToMarkdown} from './html2markdown.ts';
+import {createElementFromHTML} from '../utils/dom.ts';
+
+const h = createElementFromHTML;
+
+test('convertHtmlToMarkdown', () => {
+ expect(convertHtmlToMarkdown(h(`h
`))).toBe('# h');
+ expect(convertHtmlToMarkdown(h(`txt`))).toBe('**txt**');
+ expect(convertHtmlToMarkdown(h(`txt`))).toBe('_txt_');
+ expect(convertHtmlToMarkdown(h(`txt`))).toBe('~~txt~~');
+
+ expect(convertHtmlToMarkdown(h(`txt`))).toBe('[txt](link)');
+ expect(convertHtmlToMarkdown(h(`https://link`))).toBe('https://link');
+
+ expect(convertHtmlToMarkdown(h(``))).toBe('![image](link)');
+ expect(convertHtmlToMarkdown(h(``))).toBe('![name](link)');
+ expect(convertHtmlToMarkdown(h(``))).toBe('');
+
+ expect(convertHtmlToMarkdown(h(`txt
`))).toBe('txt\n');
+ expect(convertHtmlToMarkdown(h(`a\nb
`))).toBe('> a\n> b\n');
+
+ expect(convertHtmlToMarkdown(h(`- a
`))).toBe('1. a\n * b\n\n');
+ expect(convertHtmlToMarkdown(h(`- a
`))).toBe('1. [x] a\n');
+});
diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts
new file mode 100644
index 00000000000..c690e0c8b11
--- /dev/null
+++ b/web_src/js/markup/html2markdown.ts
@@ -0,0 +1,119 @@
+import {htmlEscape} from 'escape-goat';
+
+type Processors = {
+ [tagName: string]: (el: HTMLElement) => string | HTMLElement | void;
+}
+
+type ProcessorContext = {
+ elementIsFirst: boolean;
+ elementIsLast: boolean;
+ listNestingLevel: number;
+}
+
+function prepareProcessors(ctx:ProcessorContext): Processors {
+ const processors = {
+ H1(el) {
+ const level = parseInt(el.tagName.slice(1));
+ el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`;
+ },
+ STRONG(el) {
+ return `**${el.textContent}**`;
+ },
+ EM(el) {
+ return `_${el.textContent}_`;
+ },
+ DEL(el) {
+ return `~~${el.textContent}~~`;
+ },
+
+ A(el) {
+ const text = el.textContent || 'link';
+ const href = el.getAttribute('href');
+ if (/^https?:/.test(text) && text === href) {
+ return text;
+ }
+ return href ? `[${text}](${href})` : text;
+ },
+ IMG(el) {
+ const alt = el.getAttribute('alt') || 'image';
+ const src = el.getAttribute('src');
+ const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : '';
+ const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : '';
+ if (widthAttr || heightAttr) {
+ return ``;
+ }
+ return `![${alt}](${src})`;
+ },
+
+ P(el) {
+ el.textContent = `${el.textContent}\n`;
+ },
+ BLOCKQUOTE(el) {
+ el.textContent = `${el.textContent.replace(/^/mg, '> ')}\n`;
+ },
+
+ OL(el) {
+ const preNewLine = ctx.listNestingLevel ? '\n' : '';
+ el.textContent = `${preNewLine}${el.textContent}\n`;
+ },
+ LI(el) {
+ const parent = el.parentNode;
+ const bullet = parent.tagName === 'OL' ? `1. ` : '* ';
+ const nestingIdentLevel = Math.max(0, ctx.listNestingLevel - 1);
+ el.textContent = `${' '.repeat(nestingIdentLevel * 4)}${bullet}${el.textContent}${ctx.elementIsLast ? '' : '\n'}`;
+ return el;
+ },
+ INPUT(el) {
+ return el.checked ? '[x] ' : '[ ] ';
+ },
+
+ CODE(el) {
+ const text = el.textContent;
+ if (el.parentNode && el.parentNode.tagName === 'PRE') {
+ el.textContent = `\`\`\`\n${text}\n\`\`\`\n`;
+ return el;
+ }
+ if (text.includes('`')) {
+ return `\`\` ${text} \`\``;
+ }
+ return `\`${text}\``;
+ },
+ };
+ processors['UL'] = processors.OL;
+ for (let level = 2; level <= 6; level++) {
+ processors[`H${level}`] = processors.H1;
+ }
+ return processors;
+}
+
+function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement) {
+ if (el.hasAttribute('data-markdown-generated-content')) return el.textContent;
+ if (el.tagName === 'A' && el.children.length === 1 && el.children[0].tagName === 'IMG') {
+ return processElement(ctx, processors, el.children[0] as HTMLElement);
+ }
+
+ const isListContainer = el.tagName === 'OL' || el.tagName === 'UL';
+ if (isListContainer) ctx.listNestingLevel++;
+ for (let i = 0; i < el.children.length; i++) {
+ ctx.elementIsFirst = i === 0;
+ ctx.elementIsLast = i === el.children.length - 1;
+ processElement(ctx, processors, el.children[i] as HTMLElement);
+ }
+ if (isListContainer) ctx.listNestingLevel--;
+
+ if (processors[el.tagName]) {
+ const ret = processors[el.tagName](el);
+ if (ret && ret !== el) {
+ el.replaceWith(typeof ret === 'string' ? document.createTextNode(ret) : ret);
+ }
+ }
+}
+
+export function convertHtmlToMarkdown(el: HTMLElement): string {
+ const div = document.createElement('div');
+ div.append(el);
+ const ctx = {} as ProcessorContext;
+ ctx.listNestingLevel = 0;
+ processElement(ctx, prepareProcessors(ctx), el);
+ return div.textContent;
+}