From c207b94e0c99080fe7f43c3049469883fe4dde15 Mon Sep 17 00:00:00 2001
From: Giteabot <teabot@gitea.io>
Date: Tue, 13 Jun 2023 04:23:21 -0400
Subject: [PATCH] Fix task list checkbox toggle to work with YAML front matter
 (#25184) (#25227)

Backport #25184 by @jtran

Fixes #25160.

`data-source-position` of checkboxes in a task list was incorrect
whenever there was YAML front matter. This would result in issue content
or PR descriptions getting corrupted with random `x` or space characters
when a user checked or unchecked a task.

Co-authored-by: Jonathan Tran <jon@allspice.io>
---
 modules/markup/markdown/ast.go           |  4 ++-
 modules/markup/markdown/goldmark.go      | 12 ++++----
 modules/markup/markdown/markdown.go      |  9 ++++++
 modules/markup/markdown/markdown_test.go | 37 ++++++++++++++++++++++++
 modules/markup/markdown/renderconfig.go  |  3 ++
 web_src/js/markup/tasklist.js            |  8 +++++
 6 files changed, 66 insertions(+), 7 deletions(-)

diff --git a/modules/markup/markdown/ast.go b/modules/markup/markdown/ast.go
index e844f801c4..3e6e291ab2 100644
--- a/modules/markup/markdown/ast.go
+++ b/modules/markup/markdown/ast.go
@@ -76,7 +76,8 @@ func IsSummary(node ast.Node) bool {
 // TaskCheckBoxListItem is a block that represents a list item of a markdown block with a checkbox
 type TaskCheckBoxListItem struct {
 	*ast.ListItem
-	IsChecked bool
+	IsChecked      bool
+	SourcePosition int
 }
 
 // KindTaskCheckBoxListItem is the NodeKind for TaskCheckBoxListItem
@@ -86,6 +87,7 @@ var KindTaskCheckBoxListItem = ast.NewNodeKind("TaskCheckBoxListItem")
 func (n *TaskCheckBoxListItem) Dump(source []byte, level int) {
 	m := map[string]string{}
 	m["IsChecked"] = strconv.FormatBool(n.IsChecked)
+	m["SourcePosition"] = strconv.FormatInt(int64(n.SourcePosition), 10)
 	ast.DumpHelper(n, source, level, m, nil)
 }
 
diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go
index f03a780900..ff4e6b1bd0 100644
--- a/modules/markup/markdown/goldmark.go
+++ b/modules/markup/markdown/goldmark.go
@@ -177,6 +177,11 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
 					newChild := NewTaskCheckBoxListItem(listItem)
 					newChild.IsChecked = taskCheckBox.IsChecked
 					newChild.SetAttributeString("class", []byte("task-list-item"))
+					segments := newChild.FirstChild().Lines()
+					if segments.Len() > 0 {
+						segment := segments.At(0)
+						newChild.SourcePosition = rc.metaLength + segment.Start
+					}
 					v.AppendChild(v, newChild)
 				}
 			}
@@ -457,12 +462,7 @@ func (r *HTMLRenderer) renderTaskCheckBoxListItem(w util.BufWriter, source []byt
 		} else {
 			_, _ = w.WriteString("<li>")
 		}
-		_, _ = w.WriteString(`<input type="checkbox" disabled=""`)
-		segments := node.FirstChild().Lines()
-		if segments.Len() > 0 {
-			segment := segments.At(0)
-			_, _ = w.WriteString(fmt.Sprintf(` data-source-position="%d"`, segment.Start))
-		}
+		fmt.Fprintf(w, `<input type="checkbox" disabled="" data-source-position="%d"`, n.SourcePosition)
 		if n.IsChecked {
 			_, _ = w.WriteString(` checked=""`)
 		}
diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go
index d4a7195dc5..43885889d1 100644
--- a/modules/markup/markdown/markdown.go
+++ b/modules/markup/markdown/markdown.go
@@ -178,6 +178,9 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
 	}
 	buf = giteautil.NormalizeEOL(buf)
 
+	// Preserve original length.
+	bufWithMetadataLength := len(buf)
+
 	rc := &RenderConfig{
 		Meta: renderMetaModeFromString(string(ctx.RenderMetaAs)),
 		Icon: "table",
@@ -185,6 +188,12 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
 	}
 	buf, _ = ExtractMetadataBytes(buf, rc)
 
+	metaLength := bufWithMetadataLength - len(buf)
+	if metaLength < 0 {
+		metaLength = 0
+	}
+	rc.metaLength = metaLength
+
 	pc.Set(renderConfigKey, rc)
 
 	if err := converter.Convert(buf, lw, parser.WithContext(pc)); err != nil {
diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go
index e81869d7a4..4bd2ca8d41 100644
--- a/modules/markup/markdown/markdown_test.go
+++ b/modules/markup/markdown/markdown_test.go
@@ -520,3 +520,40 @@ func TestMathBlock(t *testing.T) {
 
 	}
 }
+
+func TestTaskList(t *testing.T) {
+	testcases := []struct {
+		testcase string
+		expected string
+	}{
+		{
+			// data-source-position should take into account YAML frontmatter.
+			`---
+foo: bar
+---
+- [ ] task 1`,
+			`<details><summary><i class="icon table"></i></summary><table>
+<thead>
+<tr>
+<th>foo</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>bar</td>
+</tr>
+</tbody>
+</table>
+</details><ul>
+<li class="task-list-item"><input type="checkbox" disabled="" data-source-position="19"/>task 1</li>
+</ul>
+`,
+		},
+	}
+
+	for _, test := range testcases {
+		res, err := RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, test.testcase)
+		assert.NoError(t, err, "Unexpected error in testcase: %q", test.testcase)
+		assert.Equal(t, test.expected, res, "Unexpected result in testcase %q", test.testcase)
+	}
+}
diff --git a/modules/markup/markdown/renderconfig.go b/modules/markup/markdown/renderconfig.go
index 691df74312..f4c48d1b3d 100644
--- a/modules/markup/markdown/renderconfig.go
+++ b/modules/markup/markdown/renderconfig.go
@@ -20,6 +20,9 @@ type RenderConfig struct {
 	TOC      string // "false": hide,  "side"/empty: in sidebar,  "main"/"true": in main view
 	Lang     string
 	yamlNode *yaml.Node
+
+	// Used internally.  Cannot be controlled by frontmatter.
+	metaLength int
 }
 
 func renderMetaModeFromString(s string) markup.RenderMetaMode {
diff --git a/web_src/js/markup/tasklist.js b/web_src/js/markup/tasklist.js
index 0f03837baa..ad1c6964a7 100644
--- a/web_src/js/markup/tasklist.js
+++ b/web_src/js/markup/tasklist.js
@@ -29,6 +29,14 @@ export function initMarkupTasklist() {
 
         const encoder = new TextEncoder();
         const buffer = encoder.encode(oldContent);
+        // Indexes may fall off the ends and return undefined.
+        if (buffer[position - 1] !== '['.codePointAt(0) ||
+          buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) ||
+          buffer[position + 1] !== ']'.codePointAt(0)) {
+          // Position is probably wrong.  Revert and don't allow change.
+          checkbox.checked = !checkbox.checked;
+          throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`);
+        }
         buffer.set(encoder.encode(checkboxCharacter), position);
         const newContent = new TextDecoder().decode(buffer);