Repository: kovetskiy/mark (go)

Issue:
Relative links support
Hi team,
And thank you for the awesome tool!
Do you have any plans to support relative link in articles? Or maybe it is already there, but I didn't get it?

Right now such type of links:
`[Configuration](docs/configuration.md)`
turns into 
`(https://<confluence_link>/display/<space_name>/docs/configuration.md)`

Pull request description:
Support for relative links
Fixes #25

Patch:
diff --git a/.gitignore b/.gitignore
index a59522e..04d6b3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 /mark
 /docker
 /testdata
+.idea/
diff --git a/Makefile b/Makefile
index 36d9485..9ede00b 100644
--- a/Makefile
+++ b/Makefile
@@ -4,8 +4,6 @@ VERSION = $(shell git describe --tags --abbrev=0)
 
 GO111MODULE = on
 
-BRANCH = $(shell git rev-parse --abbrev-ref HEAD)
-
 REMOTE = kovetskiy
 
 version:
diff --git a/README.md b/README.md
index c5e5134..84382c7 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,33 @@ for example:
        Ticket: ${0} -->
 ```
 
+### Code Blocks
+
+If you have long code blocks, you can make them collapsible with the [Code Block Macro]:
+
+    ```bash collapse
+    ...
+    some long bash code block
+    ...
+    ```
+
+And you can also add a title:
+
+    ```bash collapse title Some long long bash function
+    ...
+    some long bash code block
+    ...
+    ```
+
+You can collapse or have a title without language or any mix, but the language
+must stay in the front _if it is given_:
+
+    [<language>] ["collapse"] ["title" <your title>]
+
+[Code Block Macro]: https://confluence.atlassian.com/doc/code-block-macro-139390.html
+
+## Template & Macros
+
 By default, mark provides several built-in templates and macros:
 
 * template `ac:status` to include badge-like text, which accepts following
@@ -162,6 +189,29 @@ This is my article.
 <!-- Include: ac:toc -->
 ```
 
+If default TOC looks don't find a way to your heart, try [parametrizing it][Confluence TOC Macro], for example:
+
+```markdown
+<!-- Macro: :toc:
+     Template: ac:toc
+     Printable: 'false'
+     MinLevel: 2 -->
+
+# This is my nice title
+
+:toc:
+```
+
+You can call the `Macro` as you like but the `Template` field must have the `ac:toc` value.
+Also, note the single quotes around `'false'`.
+
+See [Confluence TOC Macro] for the list of parameters - keep in mind that here
+they start with capital letters. Every skipped field will have the default
+value, so feel free to include only the ones that you require.
+
+
+[Confluence TOC Macro]:https://confluence.atlassian.com/conf59/table-of-contents-macro-792499210.html
+
 ### Insert Jira Ticket
 
 **article.md**
@@ -199,7 +249,8 @@ $ docker run --rm -i kovetskiy/mark:latest mark <params>
 
 ```
 mark [options] [-u <username>] [-p <password>] [-k] [-l <url>] -f <file>
-mark [options] [-u <username>] [-p <password>] [-k] [-n] -c <file>
+mark [options] [-u <username>] [-p <password>] [-k] [-b <url>] -f <file>
+mark [options] [-u <username>] [-p <password>] [--drop-h1] -f <file>
 mark -v | --version
 mark -h | --help
 ```
@@ -208,14 +259,17 @@ mark -h | --help
 - `-p <password>` — Use specified password for updating Confluence page.
 - `-l <url>` — Edit specified Confluence page.
     If -l is not specified, file should contain metadata (see above).
+- `-b <url>` or `--base-url <url>` – Base URL for Confluence.
+    Alternative option for base_url config field.
 - `-f <file>` — Use specified markdown file for converting to html.
 - `-c <file>` — Specify configuration file which should be used for reading
     Confluence page URL and markdown file path.
 - `-k` — Lock page editing to current user only to prevent accidental
     manual edits over Confluence Web UI.
+- `--drop-h1` – Don't include H1 headings in Confluence output.
 - `--dry-run` — Show resulting HTML and don't update Confluence page content.
 - `--trace` — Enable trace logs.
-- `-v | --version`  — Show version.
+- `-v | --version` — Show version.
 - `-h | --help` — Show help screen and call 911.
 
 You can store user credentials in the configuration file, which should be
diff --git a/go.mod b/go.mod
index d11febe..0021bd7 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,6 @@ require (
 	github.com/reconquest/pkg v0.0.0-20201028091908-8e9a5e0226ef
 	github.com/reconquest/regexputil-go v0.0.0-20160905154124-38573e70c1f4
 	github.com/russross/blackfriday v1.5.2
-	github.com/stretchr/testify v1.5.1 // indirect
+	github.com/stretchr/testify v1.5.1
 	gopkg.in/yaml.v2 v2.2.8
 )
diff --git a/go.sum b/go.sum
index 715d930..82167a8 100644
--- a/go.sum
+++ b/go.sum
@@ -11,8 +11,6 @@ github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwn
 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
 github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334 h1:VHgatEHNcBFEB7inlalqfNqw65aNkM1lGX2yt3NmbS8=
 github.com/iancoleman/strcase v0.0.0-20191112232945-16388991a334/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=
-github.com/kovetskiy/gopencils v0.0.0-20201103141120-610929377f9b h1:+PnJcuiUVcU3ixOvpvhyjswKPkxBVN+a2CaFCNNBfvw=
-github.com/kovetskiy/gopencils v0.0.0-20201103141120-610929377f9b/go.mod h1:rn9YsgK4kxBDPZn+hOwSmg6MdtWfF2ejC3tvgDjWyBM=
 github.com/kovetskiy/gopencils v0.0.0-20201105104258-2a0bfdd710fb h1:e8UwTXL3Nauw5T847OMHZRhAfQy4ntgQ7PUwgZ2ct4w=
 github.com/kovetskiy/gopencils v0.0.0-20201105104258-2a0bfdd710fb/go.mod h1:rn9YsgK4kxBDPZn+hOwSmg6MdtWfF2ejC3tvgDjWyBM=
 github.com/kovetskiy/ko v0.0.0-20190324102900-26b8dd0988bf h1:4QsqgCcPoqDB91dcp4GffoV6TjwfVURaWpjKWFi0ae0=
diff --git a/main.go b/main.go
index 57471ba..24084d5 100644
--- a/main.go
+++ b/main.go
@@ -108,7 +108,6 @@ By default, mark provides several built-in templates and macros:
 Usage:
   mark [options] [-u <username>] [-p <token>] [-k] [-l <url>] -f <file>
   mark [options] [-u <username>] [-p <password>] [-k] [-b <url>] -f <file>
-  mark [options] [-u <username>] [-p <password>] [-k] [-n] -c <file>
   mark -v | --version
   mark -h | --help
 
@@ -123,6 +122,7 @@ Options:
   -f <file>            Use specified markdown file for converting to html.
   -k                   Lock page editing to current user only to prevent accidental
                         manual edits over Confluence Web UI.
+  --drop-h1            Don't include H1 headings in Confluence output.
   --dry-run            Resolve page and ancestry, show resulting HTML and exit.
   --compile-only       Show resulting HTML and don't update Confluence page content.
   --debug              Enable debug logs.
@@ -143,6 +143,7 @@ func main() {
 		compileOnly   = args["--compile-only"].(bool)
 		dryRun        = args["--dry-run"].(bool)
 		editLock      = args["-k"].(bool)
+		dropH1        = args["--drop-h1"].(bool)
 	)
 
 	if args["--debug"].(bool) {
@@ -212,6 +213,12 @@ func main() {
 		}
 	}
 
+	links, err := mark.ResolveRelativeLinks(api, markdown, ".")
+	if err != nil {
+		log.Fatalf(err, "unable to resolve relative links")
+	}
+	markdown = mark.ReplaceRelativeLinks(markdown, links)
+
 	if dryRun {
 		compileOnly = true
 
@@ -286,6 +293,11 @@ func main() {
 
 	markdown = mark.CompileAttachmentLinks(markdown, attaches)
 
+	if dropH1 {
+		log.Info("Leading H1 heading will be excluded from the Confluence output")
+		markdown = mark.DropDocumentLeadingH1(markdown)
+	}
+
 	html := mark.CompileMarkdown(markdown, stdlib)
 
 	{
diff --git a/pkg/confluence/api.go b/pkg/confluence/api.go
index 40327b0..56174de 100644
--- a/pkg/confluence/api.go
+++ b/pkg/confluence/api.go
@@ -26,7 +26,8 @@ type API struct {
 
 	// it's deprecated accordingly to Atlassian documentation,
 	// but it's only way to set permissions
-	json *gopencils.Resource
+	json    *gopencils.Resource
+	BaseURL string
 }
 
 type PageInfo struct {
@@ -87,8 +88,9 @@ func NewAPI(baseURL string, username string, password string) *API {
 	}
 
 	return &API{
-		rest: rest,
-		json: json,
+		rest:    rest,
+		json:    json,
+		BaseURL: strings.TrimSuffix(baseURL, "/"),
 	}
 }
 
diff --git a/pkg/mark/link.go b/pkg/mark/link.go
new file mode 100644
index 0000000..48c98bb
--- /dev/null
+++ b/pkg/mark/link.go
@@ -0,0 +1,105 @@
+package mark
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+
+	"github.com/kovetskiy/mark/pkg/confluence"
+)
+
+type Link struct {
+	MDLink string
+	Link   string
+}
+
+// ResolveRelativeLinks finds links in the markdown, and replaces one pointing
+// to other Markdowns either by own link (if not created to Confluence yet) or
+// witn actual Confluence link
+func ResolveRelativeLinks(
+	api *confluence.API,
+	markdown []byte,
+	base string,
+) (links []Link, collectedErrors error) {
+	currentMarkdownMetadata, onlyMarkdown, err := ExtractMeta(markdown)
+	if err != nil {
+		return links, fmt.Errorf("unable to get metadata from handled markdown file. Error %w", err)
+	}
+
+	currentPageLinkString, collectedErrors := getConfluenceLink(api, currentMarkdownMetadata.Space, currentMarkdownMetadata.Title, collectedErrors)
+
+	submatchall := collectLinksFromMarkdown(string(onlyMarkdown))
+
+	for _, element := range submatchall {
+		link := Link{
+			MDLink: element[1],
+			Link:   currentPageLinkString,
+		}
+		// If link points to markdown like target, we build link for that in Confluence
+		if len(element[2]) > 0 {
+			possibleMDFile := element[2]
+			filepath := filepath.Join(base, possibleMDFile)
+			if _, err := os.Stat(filepath); err == nil {
+				linkMarkdown, err := ioutil.ReadFile(filepath)
+				if err != nil {
+					collectedErrors = fmt.Errorf("%w\n unable to read markdown file "+filepath, collectedErrors)
+					continue
+				}
+				// This helps to determine if found link points to file that's not markdown
+				// or have mark required metadata
+				meta, _, err := ExtractMeta(linkMarkdown)
+				if err != nil {
+					collectedErrors = fmt.Errorf("%w\n unable to get metadata from markdown file "+filepath, collectedErrors)
+					continue
+				}
+
+				link.Link, collectedErrors = getConfluenceLink(api, meta.Space, meta.Title, collectedErrors)
+			}
+		}
+
+		if len(element[3]) > 0 {
+			link.Link = currentPageLinkString + "#" + element[2]
+		}
+
+		links = append(links, link)
+	}
+	return links, collectedErrors
+}
+
+// ReplaceRelativeLinks replaces relative links between md files (in same
+// directory structure) with links working in Confluence
+func ReplaceRelativeLinks(markdown []byte, links []Link) []byte {
+	for _, link := range links {
+		markdown = bytes.ReplaceAll(
+			markdown,
+			[]byte(fmt.Sprintf("](%s)", link.MDLink)),
+			[]byte(fmt.Sprintf("](%s)", link.Link)),
+		)
+	}
+	return markdown
+}
+
+// collectLinksFromMarkdown collects all links from given markdown file
+// (including images and external links)
+func collectLinksFromMarkdown(markdown string) [][]string {
+	re := regexp.MustCompile("\\[[^\\]]+\\]\\((([^\\)#]+)?#?([^\\)]+)?)\\)")
+	return re.FindAllStringSubmatch(markdown, -1)
+}
+
+// getConfluenceLink build (to be) link for Conflunce, and tries to verify from API if there's real link available
+func getConfluenceLink(api *confluence.API, space, title string, collectedErrors error) (string, error) {
+	link := fmt.Sprintf("%s/display/%s/%s", api.BaseURL, space, url.QueryEscape(title))
+	confluencePage, err := api.FindPage(space, title)
+	if err != nil {
+		collectedErrors = fmt.Errorf("%w\n "+err.Error(), collectedErrors)
+	} else if confluencePage != nil {
+		// Needs baseURL, as REST api response URL doesn't contain subpath ir confluence is server from that
+		link = api.BaseURL + confluencePage.Links.Full
+	}
+
+	return link, collectedErrors
+}
diff --git a/pkg/mark/markdown.go b/pkg/mark/markdown.go
index 622a9e1..3005595 100644
--- a/pkg/mark/markdown.go
+++ b/pkg/mark/markdown.go
@@ -3,6 +3,8 @@ package mark
 import (
 	"bytes"
 	"regexp"
+	"strconv"
+	"strings"
 
 	"github.com/kovetskiy/mark/pkg/mark/stdlib"
 	"github.com/reconquest/pkg/log"
@@ -15,6 +17,37 @@ type ConfluenceRenderer struct {
 	Stdlib *stdlib.Lib
 }
 
+func ParseLanguage(lang string) string {
+	// lang takes the following form: language? "collapse"? ("title"? <any string>*)?
+	// let's split it by spaces
+	paramlist := strings.Fields(lang)
+
+	// get the word in question, aka the first one
+	first := lang
+	if len(paramlist) > 0 {
+		first = paramlist[0]
+	}
+
+	if first == "collapse" || first == "title" {
+		// collapsing or including a title without a language
+		return ""
+	}
+	// the default case with language being the first one
+	return first
+}
+
+func ParseTitle(lang string) string {
+	index := strings.Index(lang, "title")
+	if index >= 0 {
+		// it's found, check if title is given and return it
+		start := index + 6
+		if len(lang) > start {
+			return lang[start:]
+		}
+	}
+	return ""
+}
+
 func (renderer ConfluenceRenderer) BlockCode(
 	out *bytes.Buffer,
 	text []byte,
@@ -25,9 +58,13 @@ func (renderer ConfluenceRenderer) BlockCode(
 		"ac:code",
 		struct {
 			Language string
+			Collapse string
+			Title    string
 			Text     string
 		}{
-			lang,
+			ParseLanguage(lang),
+			strconv.FormatBool(strings.Contains(lang, "collapse")),
+			ParseTitle(lang),
 			string(text),
 		},
 	)
@@ -90,3 +127,15 @@ func CompileMarkdown(
 
 	return string(html)
 }
+
+// DropDocumentLeadingH1 will drop leading H1 headings to prevent
+// duplication of or visual conflict with page titles.
+// NOTE: This is intended only to operate on the whole markdown document.
+// Operating on individual lines will clear them if the begin with `#`.
+func DropDocumentLeadingH1(
+	markdown []byte,
+) []byte {
+	h1 := regexp.MustCompile(`^#[^#].*\n`)
+	markdown = h1.ReplaceAll(markdown, []byte(""))
+	return markdown
+}
diff --git a/pkg/mark/stdlib/stdlib.go b/pkg/mark/stdlib/stdlib.go
index 1644ce0..ede7b95 100644
--- a/pkg/mark/stdlib/stdlib.go
+++ b/pkg/mark/stdlib/stdlib.go
@@ -105,11 +105,12 @@ func templates(api *confluence.API) (*template.Template, error) {
 
 		// This template is used for rendering code in ```
 		`ac:code`: text(
-			`<ac:structured-macro ac:name="code">`,
-			`<ac:parameter ac:name="language">{{ .Language }}</ac:parameter>`,
-			`<ac:parameter ac:name="collapse">false</ac:parameter>`,
-			`<ac:plain-text-body><![CDATA[{{ .Text | cdata }}]]></ac:plain-text-body>`,
-			`</ac:structured-macro>`,
+			`<ac:structured-macro ac:name="code">{{printf "\n"}}`,
+			`<ac:parameter ac:name="language">{{ .Language }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="collapse">{{ .Collapse }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="title">{{ .Title }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:plain-text-body><![CDATA[{{ .Text | cdata }}]]></ac:plain-text-body>{{printf "\n"}}`,
+			`</ac:structured-macro>{{printf "\n"}}`,
 		),
 
 		`ac:status`: text(
@@ -139,14 +140,17 @@ func templates(api *confluence.API) (*template.Template, error) {
 		/* https://confluence.atlassian.com/conf59/table-of-contents-macro-792499210.html */
 
 		`ac:toc`: text(
-			`<ac:structured-macro ac:name="toc">`,
-			`<ac:parameter ac:name="printable">true</ac:parameter>`,
-			`<ac:parameter ac:name="style">disc</ac:parameter>`,
-			`<ac:parameter ac:name="maxLevel">7</ac:parameter>`,
-			`<ac:parameter ac:name="minLevel">1</ac:parameter>`,
-			`<ac:parameter ac:name="exclude">{{ .Exclude }}</ac:parameter>`,
-			`<ac:parameter ac:name="outline">false</ac:parameter>`,
-			`</ac:structured-macro>`,
+			`<ac:structured-macro ac:name="toc">{{printf "\n"}}`,
+			`<ac:parameter ac:name="printable">{{ or .Printable "true" }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="style">{{ or .Style "disc" }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="maxLevel">{{ or .MaxLevel "7" }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="indent">{{ or .Indent "" }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="minLevel">{{ or .MinLevel "1" }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="exclude">{{ or .Exclude "" }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="type">{{ or .Type "list" }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="outline">{{ or .Outline "clear" }}</ac:parameter>{{printf "\n"}}`,
+			`<ac:parameter ac:name="include">{{ or .Include "" }}</ac:parameter>{{printf "\n"}}`,
+			`</ac:structured-macro>{{printf "\n"}}`,
 		),
 
 		// TODO(seletskiy): more templates here


Test patch:
diff --git a/pkg/mark/link_test.go b/pkg/mark/link_test.go
new file mode 100644
index 0000000..2b84de2
--- /dev/null
+++ b/pkg/mark/link_test.go
@@ -0,0 +1,33 @@
+package mark
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestLinkFind(t *testing.T) {
+	markdown := `
+	[example1](../path/to/example.md#second-heading)
+	[example2](../path/to/example.md)
+	[example3](#heading-in-document)
+	[Text link that should be put as attachment](../path/to/example.txt)
+	[Image link that should be put as attachment](../path/to/example.png)
+	`
+
+	links := collectLinksFromMarkdown(markdown)
+
+	assert.Equal(t, "../path/to/example.md#second-heading", links[0][1])
+	assert.Equal(t, "../path/to/example.md", links[0][2])
+	assert.Equal(t, "second-heading", links[0][3])
+
+	assert.Equal(t, "../path/to/example.md", links[1][1])
+	assert.Equal(t, "../path/to/example.md", links[1][2])
+	assert.Equal(t, "", links[1][3])
+
+	assert.Equal(t, "#heading-in-document", links[2][1])
+	assert.Equal(t, "", links[2][2])
+	assert.Equal(t, "heading-in-document", links[2][3])
+
+	assert.Equal(t, len(links), 5)
+}