From 2a33b765c4d5a5252be7359988c56a94cd8e5d1c Mon Sep 17 00:00:00 2001
From: Will Kahn-Greene 1. not a list a` |
+| `>x`, `> x` | a blockquote (no space needed) |
+| `***`, `- - -` | `
` |
+| `a` + hard break + `---` | `a
` (setext) |
+| `a` + hard break + `===` | `a
` (setext) |
+| `a \| b` + hard break + `--- \| ---` | a table |
+| `a` + hard break + `# b` | `b
`: a heading can interrupt a paragraph |
+| `~~~` at a line start | an empty code macro |
+| `2*3*4`, `_x_`, `a _b_ c` | `` |
+| `a ~b~ c`, `a ~~b~~ c` | ``: goldmark accepts a single tilde |
+| `` `x` `` | `` |
+| `x`, `` | raw HTML (a comment is then stripped by Confluence) |
+| `Item
`: a trailing ` #` is a closing sequence |
+
+**Text that is already safe**, which the escaping must leave alone so that
+exported Markdown stays readable:
+
+| text | publishes as |
+|---|---|
+| `~5 min to ~10 min` | text: neither tilde can close |
+| `a * b * c` | text: a `*` with space on both sides cannot open or close |
+| `snake_case_word`, `foo_bar_` | text: an intraword `_` cannot open or close |
+| `a < b`, `a`, `\-`, `\+`, `\*`, `\_`, `\~`, `` \` ``, `\<`,
+`\©`, `\[`, `\]` (inside link text), `\\`, `\=`, `\|`, `## Item \#`,
+`https\://`, `www\.`, `a\@b.com`. `\
`) or `\` (an escape from D2). None of
+them matches D3:
+- a mark is never followed by a space, because `renderMark` moves edge
+ whitespace outside its delimiters (#204);
+- a whitespace-only mark renders as its whitespace, so no `***` or `****` is
+ emitted;
+- nothing markfluence emits inline starts with a digit, `#`, `>`, `-`, `+`,
+ `=`, `|` or `:`.
+
+The pass splits only at hard breaks (`" \n"`), never at every newline. So a
+raw serialized inline element whose CDATA holds a newline is never split, and
+its content never gains a backslash.
+
+**D5. Link text escapes `]` too, and `escapeLinkText` becomes the full
+escaper.** Today `escapeLinkText` escapes `\`, `[` and `]` and nothing else.
+Its comment records that a title like `*Foo*` renders as emphasis and was
+knowingly left alone. With escaping at the text node, link text needs only one
+addition: `]`, which ends a link's text early. The renderer tracks whether it
+is inside link text (a depth counter on `mdRenderer`, set by
+`inlineTextForLink`). `inlineTextForLink`'s `onlyText` split goes away: it
+existed so that already-rendered markup was not escaped, and text is now
+escaped at its source, before any markup is added around it. The raw sources,
+which are strings rather than text nodes, go through the same escaper with the
+link rule on: a page title, a space key, an anchor, a CDATA link body, a
+fallback, and a mention's display name.
+
+`MentionMarkdown` therefore changes for a display name holding `*`, `_`, `` `
+``, `<`, `&` or `~`. `user-find` prints the same function's output, so the
+guarantee that the line an author pastes is byte-identical to what `read`
+writes holds by construction. The line itself changes for those names.
+
+**D6. A heading's closing sequence.** `## Item #` publishes as "Item". When a
+heading's rendered text ends in whitespace followed by one or more `#`, the
+first `#` of that run is escaped: `## Item \#`. `C#` has no space before the
+`#`, so it is not a closing sequence and stays as it is.
+
+**D7. Bare URLs and email addresses are escaped out of autolinking.** GFM
+autolinks `http://`, `https://`, `ftp://`, `www.` and email addresses, so
+plain text holding a URL publishes as a link. An escape suppresses it
+(Verified): `https\://`, `www\.`, `a\@b.com`. This is the one rule whose cost
+is visible, since `https\://example.com` reads badly in an exported file. It
+is included anyway:
+- storage holds a URL as plain text only when someone chose that; the editor
+ turns a typed URL into a link;
+- turning it into a link is a content change, of the kind this plan exists to
+ stop;
+- without it, a page holding one is not a fixed point after one read.
+
+It escapes the `:` of `scheme://` for the three schemes goldmark links, the
+`.` of a `www.` that starts a word, and the `@` of anything goldmark's email
+pattern would match.
+
+**D8. What is never escaped:**
+- **code spans and code blocks:** their text is literal already;
+- **link destinations:** they are URLs, encoded by `encodeDestination`;
+- **raw storage:** `serialize` and `renderRawBlock`'s tags go through
+ `xmlTextEscape` or pass through verbatim;
+- **the TOC token, frontmatter and image alt text:** frontmatter is YAML with
+ its own writer; alt text is covered in Not in scope.
+
+Text inside a raw block's content container, such as a raw table cell or a
+layout cell, is Markdown and is escaped like any other: those bodies go
+through `blockStrings`.
+
+**D9. Pipe-table cells get the inline layer and not the line-start pass.** A
+GFM table cell is inline, so `1. x` in a cell is text. `renderCellLines` goes
+through `renderInlineChildren`, which picks up D2 without any change.
+`escapeCellPipe` still runs afterwards, and D2 escapes no `|`, so the two do
+not compound.
+
+## Implementation
+
+1. `escape.go`: `escapeText(s string, inLink bool) string` (D2, D5, D7) and
+ `escapeLineStarts(s string) string` (D3). Both are pure functions over
+ strings, tested on their own.
+2. `renderInline`'s text case and `blockStrings`' loose text call
+ `escapeText`, passing `r.linkDepth > 0` as `inLink`.
+3. A single helper, called wherever a paragraph's rendered text becomes a
+ Markdown block, applies `escapeLineStarts`. The call sites:
+ - `renderBlock`'s `p` case and its inline-element-at-block-level case;
+ - `renderListItem`'s text segments;
+ - `blockStrings`' loose text;
+ - `rawCellBlocks`' paragraphs and loose runs.
+ Callouts and layout cells reach it through `blockStrings`, and each call
+ site gets a test.
+4. Headings apply D6.
+5. `inlineTextForLink` increments `linkDepth` and drops the `onlyText` split.
+ `escapeLinkText` becomes `escapeText(s, true)`, and its comment is
+ rewritten.
+6. The table property generator drops its #203 restriction and gains words
+ holding the characters above. `markText` stays as #204 left it.
+
+## Tests
+
+- **Unit tests** for `escapeText` and `escapeLineStarts`, one row for each
+ rule and each "stays readable" row in What was checked. Each row checks the
+ escaped form *and* that publishing it gives back the original text, so no
+ rule can emit an escape that does not round-trip.
+- **A `storage2md/escaping` case** with one paragraph per row of the first
+ table, plus list items, a raw table cell, a layout cell, a callout, a link
+ text, a heading ending in ` #`, and a mention whose name holds `*`. Being a
+ storage2md case it also runs through `TestRoundTripMarkdownIsAFixedPoint`
+ and the output-parses-as-Markdown check.
+- **Existing goldens** that hold these characters in text change. Each change
+ is reviewed and listed in the PR: every one should be a new backslash and
+ nothing else.
+- **The property test** with the restriction lifted. It must fail against
+ `main`'s converter and pass with the fix, the way #204's did (checked by
+ swapping in `main`'s `storage_to_md.go`, not by stashing).
+- `user-find`'s test that its line matches `read`'s still passes unchanged.
+
+## Documentation
+
+- `docs/markdown-file.md`: a short section saying that exported Markdown
+ escapes text that would otherwise read as Markdown, with the D7 cost stated,
+ so an author who sees `\*` or `https\://` in an exported file knows why.
+- `CLAUDE.md`: the converter bullet gains `escape.go` and the two layers. The
+ regression-suite paragraph drops #203 from the gaps the generator avoids.
+- `escapeLinkText`'s comment, which records the `*Foo*` gap as known, is
+ rewritten.
+
+## Not in scope
+
+- **Image alt text.** The forward converter reads alt from the source bytes,
+ so an escape there publishes the backslash. A `]` in alt text already breaks
+ the image today. Fixing it means unescaping on the forward side first, a
+ change to what `update` publishes for existing files, and it deserves its
+ own issue.
+- **A code span holding a backtick.** `renderInline` writes `` `x` `` with a
+ single backtick whatever the content, so code holding a backtick breaks. It
+ is a separate, older gap with a known fix (a longer fence), not escaping.
+- **The mention marker.** A link to an Atlassian Home profile whose text
+ starts with `@` publishes as a mention, by design (#91). Escaping that `@`
+ would break the convention for real mentions.
+- **#213** (two hard breaks in a row) and **#214** (an empty paragraph before a
+ list in a cell) are left as they are, although both are in the same
+ functions.
+
+## Commits
+
+Each commit passes `make check` on its own. So `escape.go` arrives with its
+first caller, because golangci-lint's `unused` check fails a function nothing
+calls.
+
+1. `docs(plans): 056 escape text that read writes`
+2. `fix(convert): escape inline syntax in text on read` (`escape.go`'s
+ `escapeText`, D2, D5, D9, unit tests, goldens)
+3. `fix(convert): escape block markers at a line start on read` (D3, D6)
+4. `fix(convert): keep bare URLs as text on read` (D7)
+5. `test(convert): let the table property test write block markers`
+6. `docs: what read escapes`
From 1c9d15168b2359f077370e4547861fb60c9613ac Mon Sep 17 00:00:00 2001
From: Will Kahn-Greene
Emphasis: *not emphasis*, 2*3*4, a _b_ c, and a * b * c stays.
+Strikethrough: a ~b~ c, a ~~b~~ c, and about ~5 min stays.
+Code: `not code`.
+HTML: <b>not bold</b>, <!-- not a comment -->, <ac:link>not storage</ac:link>, and a < b stays.
+Entities: © and ©, and AT&T stays.
+Brackets: [x], [x][y], and x] stays.
+Backslashes: a\*b, and C:\path stays.
+A link: Q1 *Draft*], and one with markup: bold _text_.
+In a callout: *x*
| Pipe cell |
|---|
| *x* and a|b |
Raw cell: *x* |
Layout cell: *x*
| + +Raw cell: \*x\* + + | +
1. not a list
" became a list, "# x" a heading, "> 90 days" a blockquote, and "---" after a hard break turned the line above into a setext heading. escapeLineStarts runs over each paragraph's rendered text, split at hard breaks, and escapes a marker at the start of each line: ordered and bulleted list markers, ATX headings, blockquotes, setext underlines, thematic breaks, fences and table delimiter rows. It runs on rendered Markdown because only the assembled paragraph knows where its lines start, which is safe because nothing markfluence emits inline begins with a block marker (_plans/056, D3/D4). It applies to paragraphs, list-item text, loose text, and raw cells' loose runs. A heading's trailing " #" was read as a closing sequence and dropped, so "## Item #" published as "Item"; its first "#" is now escaped. Fixes #203. --- internal/convert/escape.go | 63 +++++++++++++++++ internal/convert/escape_test.go | 68 +++++++++++++++++++ internal/convert/storage_to_md.go | 11 +-- internal/convert/storage_to_md_table.go | 2 +- .../storage2md/escaping/input.storage | 7 ++ .../testdata/storage2md/escaping/output.md | 39 +++++++++++ 6 files changed, 184 insertions(+), 6 deletions(-) diff --git a/internal/convert/escape.go b/internal/convert/escape.go index 309400d..11be38c 100644 --- a/internal/convert/escape.go +++ b/internal/convert/escape.go @@ -114,3 +114,66 @@ func isAlnum(r rune) bool { return r != -1 && (unicode.IsLetter(r) || unicode.Is func isASCIIPunct(r rune) bool { return r < 128 && strings.ContainsRune("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", r) } + +// escapeLineStarts escapes a block marker at the start of each line of a +// paragraph's rendered text, where a line starts at the paragraph's start and +// after every hard break. A marker there would open a block on publish: "1. x" +// a list, "# x" a heading, "---" under a line a setext heading (_plans/056, +// D3). It runs over rendered Markdown rather than text nodes, because only the +// assembled paragraph knows where its lines start; that is safe because +// nothing markfluence emits inline begins with a block marker (D4). It splits +// only at hard breaks, so raw storage holding a newline is never touched. +func escapeLineStarts(s string) string { + lines := strings.Split(s, hardBreak) + for i, line := range lines { + lines[i] = escapeLineStart(line) + } + return strings.Join(lines, hardBreak) +} + +// escapeLineStart escapes the block marker, if any, that line starts with. +func escapeLineStart(line string) string { + body := strings.TrimLeft(line, " ") + indent := line[:len(line)-len(body)] + if m := orderedMarkerRE.FindStringSubmatchIndex(body); m != nil { + // Escape the delimiter: "1\. x". + return indent + body[:m[2]] + `\` + body[m[2]:] + } + for _, re := range blockStartREs { + if re.MatchString(body) { + return indent + `\` + body + } + } + return line +} + +// orderedMarkerRE matches an ordered list marker; its group is the delimiter. +var orderedMarkerRE = regexp.MustCompile(`^[0-9]{1,9}([.)])(?: |$)`) + +// blockStartREs are the other lines that open a block, each escaped by a +// backslash before its first character. +var blockStartREs = []*regexp.Regexp{ + regexp.MustCompile(`^#{1,6}(?: |$)`), // an ATX heading + regexp.MustCompile(`^>`), // a blockquote, space or not + regexp.MustCompile(`^[-+*](?: |$)`), // a bulleted list item + regexp.MustCompile(`^(?:-+|=+) *$`), // a setext underline or thematic break + regexp.MustCompile(`^(?:[-*_] *){3,}$`), // a thematic break + regexp.MustCompile(`^(?:~~~|` + "```" + `)`), // a code fence + // A table delimiter row, under a line that then becomes the header. + regexp.MustCompile(`^[|:-][|: -]*$`), +} + +// escapeHeadingClose escapes a trailing run of "#" after whitespace in a +// heading's text, which ATX headings read as a closing sequence and drop: +// "## Item #" publishes as "Item" (_plans/056, D6). "C#" has no whitespace +// before its "#" and is not a closing sequence. +func escapeHeadingClose(s string) string { + if m := headingCloseRE.FindStringIndex(s); m != nil { + i := m[0] + len(strings.TrimRight(s[m[0]:], "#")) + return s[:i] + `\` + s[i:] + } + return s +} + +// headingCloseRE matches whitespace then a run of "#" ending the text. +var headingCloseRE = regexp.MustCompile(`\s#+$`) diff --git a/internal/convert/escape_test.go b/internal/convert/escape_test.go index 1e618bc..346ff2a 100644 --- a/internal/convert/escape_test.go +++ b/internal/convert/escape_test.go @@ -2,6 +2,7 @@ package convert_test import ( "html" + "regexp" "strings" "testing" @@ -83,3 +84,70 @@ func TestMentionMarkdownEscapesTheName(t *testing.T) { t.Errorf("MentionMarkdown = %q, want %q", got, want) } } + +// lineStartEscapes are storage paragraphs whose text would open a block at a +// line start, and the Markdown read must write for each. +var lineStartEscapes = []struct{ storage, want string }{ + {`1. not a list
`, `1\. not a list`}, + {`1) not a list
`, `1\) not a list`}, + {`10. not a list
`, `10\. not a list`}, + {`- not a list
`, `\- not a list`}, + {`+ not a list
`, `\+ not a list`}, + {`* not a list
`, `\* not a list`}, + {`# not a heading
`, `\# not a heading`}, + {`> 5 errors
`, `\> 5 errors`}, + {`>90 days
`, `\>90 days`}, + {`---
`, `\---`}, + {`- - -
`, `\- - -`}, + {`***
`, `\*\*\*`}, + {`~~~
`, `\~\~\~`}, + {`a
---
a
===
a | b
--- | ---
a
# b
a
2. b
a
1. b
#hashtag and C#
`, `#hashtag and C#`}, + {`-1 is negative
`, `-1 is negative`}, + {`) is rendered as its own paragraph. - return r.renderInline(n) + return escapeLineStarts(r.renderInline(n)) case "ac:layout", "ac:layout-section", "ac:layout-cell": return r.renderRawBlock(n) case "div": @@ -391,7 +392,7 @@ func (r *mdRenderer) renderListItem(li *snode, cont string) string { flushLine := func() { flushRun() if s := strings.TrimSpace(line.String()); s != "" { - segs = append(segs, itemSeg{text: s, kind: segText}) + segs = append(segs, itemSeg{text: escapeLineStarts(s), kind: segText}) } line.Reset() } diff --git a/internal/convert/storage_to_md_table.go b/internal/convert/storage_to_md_table.go index 0e7d98d..fc8db1b 100644 --- a/internal/convert/storage_to_md_table.go +++ b/internal/convert/storage_to_md_table.go @@ -539,7 +539,7 @@ func (r *mdRenderer) rawCellBlocks(c *snode) []string { var blocks []string flush := func() { if s := strings.TrimSpace(r.renderInlineChildren(&snode{kids: run})); s != "" { - blocks = append(blocks, s) + blocks = append(blocks, escapeLineStarts(s)) } run = nil } diff --git a/internal/convert/testdata/storage2md/escaping/input.storage b/internal/convert/testdata/storage2md/escaping/input.storage index f797ff0..958cb31 100644 --- a/internal/convert/testdata/storage2md/escaping/input.storage +++ b/internal/convert/testdata/storage2md/escaping/input.storage @@ -13,3 +13,10 @@
| Pipe cell |
|---|
| *x* and a|b |
Raw cell: *x* |
Layout cell: *x*
1. not a list
+# not a heading, and a line after a break:
# not a heading either
> 90 days
+- not a list in a callout
1. not a list in a raw cell | > loose text in a raw cell |
+ not a list in a layout cell
| + +1\. not a list in a raw cell + + | ++ +\> loose text in a raw cell + + | +
1. not a list in a raw cell | > loose text in a raw cell |
+ not a list in a layout cell
Plain text, not links: https://example.com, www.example.com and ops@example.com.
diff --git a/internal/convert/testdata/storage2md/escaping/output.md b/internal/convert/testdata/storage2md/escaping/output.md index 59cfb9a..3fb9898 100644 --- a/internal/convert/testdata/storage2md/escaping/output.md +++ b/internal/convert/testdata/storage2md/escaping/output.md @@ -83,3 +83,5 @@ Layout cell: \*x\* ## Item \# + +Plain text, not links: https\://example.com, www\.example.com and ops\@example.com. From f88cfb6f89da1bc939c74e1b33611ff665178525 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greenea
2. b
a
1. b
#hashtag and C#
`, `#hashtag and C#`}, + {`---
`, `**---**`}, {`-1 is negative
`, `-1 is negative`}, {`+ not a list in a layout cell
Plain text, not links: https://example.com, www.example.com and ops@example.com.
+A status macro:
| A list in a pipe cell |
|---|
|
A backslash before a mark's moved space: a \ x, and two runs of one mark: \ down.
+---
+