diff --git a/.gitignore b/.gitignore
index bb17779..b4afdb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,5 +14,4 @@
# Dependency directories (remove the comment below to include it)
# vendor/
-# Documentation notebook marker
-docs/.zk
+.zk
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bdff922..48e8c48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,10 +8,14 @@ All notable changes to this project will be documented in this file.
* Pair `--match` with `--exact-match` / `-e` to search for (case insensitive) exact occurrences in your notes.
* This can be useful when looking for terms including special characters, such as `[[name]]`.
+* Generating links to notes.
+ * Use the `{{link}}` template variable when [formatting notes](docs/template-format.md) to print a link to the note, relative to the working directory.
+ * Use the `{{format-link path title}}` template helper to render a custom link.
+ * Customize the link format from the [note formats settings](docs/note-format.md). You can for example choose regular Markdown links, Wiki-links or a custom format.
### Changed
-* The local configuration is not required anymore in a notebook's `.zk` directory.
+* The local configuration file (`.zk/config.toml`) is not required anymore in a notebook's `.zk` directory.
* `--notebook-dir` does not change the working directory anymore, instead it sets manually the current notebook and disable auto-discovery. Use the new `--working-dir`/`-W` flag to run `zk` as if it was started from this path instead of the current working directory.
* For convenience, `ZK_NOTEBOOK_DIR` behaves like setting a `--working-dir` fallback, instead of `--notebook-dir`. This way, paths will be relative to the root of the notebook.
* A practical use case is to use `zk list -W .` when outside a notebook. This will list the notes in `ZK_NOTEBOOK_DIR` but print paths relative to the current directory, making them actionable from your terminal emulator.
diff --git a/docs/note-format.md b/docs/note-format.md
index 6e84c08..e96f97e 100644
--- a/docs/note-format.md
+++ b/docs/note-format.md
@@ -2,12 +2,28 @@
To keep your notebooks [future-proof](future-proof.md), `zk` uses a simple plain text format for your notes. Only Markdown is supported at the moment, but more formats may be added in the future.
+## Markdown
+
You can set up some features of `zk`'s Markdown parser from your [configuration file](config.md), under the `[format.markdown]` section.
-| Setting | Default | Description |
-|------------------|---------|------------------------------------------------------------------------|
-| `hashtags ` | `true` | Enable `#hashtags` support |
-| `colon-tags` | `false` | Enable `:colon:separated:tags:` support |
-| `multiword-tags` | `false` | Enable Bear's [`#multi-word tags#`][1]. Hashtags must also be enabled. |
+| Setting | Default | Description |
+|-----------------------|-----------------|--------------------------------------------------------------------------------|
+| `link-format` | `"markdown"` | Format used to generate internal links (`markdown`, `wiki` or custom template) |
+| `link-encode-path` | `-`1 | Percent-encode paths of generated internal links |
+| `link-drop-extension` | `true` | Remove the path file extension of generated internal links |
+| `hashtags ` | `true` | Enable `#hashtags` support |
+| `colon-tags` | `false` | Enable `:colon:separated:tags:` support |
+| `multiword-tags` | `false` | Enable Bear's [`#multi-word tags#`][1]. Hashtags must also be enabled. |
+
+1. Paths are not percent-encoded by default, unless the `link-format` is `markdown`.
[1]: https://blog.bear.app/2017/11/bear-tips-how-to-create-multi-word-tags/
+
+### Customizing the Markdown links generated by `zk`
+
+By default, `zk` will generate regular Markdown links for internal links. If you prefer to use `[[Wiki Links]]` instead, set the `link-format` setting to `wiki`. If you want to override completely the link format, you can also set `link-format` to a [custom template](template.md). Two variables `path` and `title` are available in the template, for example to generate a wiki-link with a title:
+
+```toml
+[format.markdown]
+link-format = "[[{{path}}|{{title}}]]"
+```
diff --git a/docs/template-format.md b/docs/template-format.md
index 8357cf3..bd6e39a 100644
--- a/docs/template-format.md
+++ b/docs/template-format.md
@@ -2,19 +2,21 @@
The following variables are available in the templates used when formatting notes, for example with `zk list --format `.
-| Variable | Type | Description |
-|---------------|----------|---------------------------------------------------------------------|
-| `path` | string | File path to the note, relative to the current directory |
-| `title` | string | Note title |
-| `lead` | string | First paragraph extracted from the note content |
-| `body` | string | All of the note content, minus the heading |
-| `snippets` | [string] | List of context-sensitive relevant excerpts from the note |
-| `raw-content` | string | The full raw content of the note file |
-| `word-count` | int | Number of words in the note |
-| `tags` | [string] | List of tags found in the note |
-| `metadata` | map | YAML frontmatter metadata, e.g. `metadata.description`1 |
-| `created` | date | Date of creation of the note |
-| `modified` | date | Last date of modification of the note |
-| `checksum` | string | SHA-256 checksum of the note file |
+| Variable | Type | Description |
+|---------------|----------|--------------------------------------------------------------------------|
+| `path` | string | File path to the note, relative to the current directory |
+| `title` | string | Note title |
+| `link` | string | Markdown link to the note, relative to the current directory1 |
+| `lead` | string | First paragraph extracted from the note content |
+| `body` | string | All of the note content, minus the heading |
+| `snippets` | [string] | List of context-sensitive relevant excerpts from the note |
+| `raw-content` | string | The full raw content of the note file |
+| `word-count` | int | Number of words in the note |
+| `tags` | [string] | List of tags found in the note |
+| `metadata` | map | YAML frontmatter metadata, e.g. `metadata.description`2 |
+| `created` | date | Date of creation of the note |
+| `modified` | date | Last date of modification of the note |
+| `checksum` | string | SHA-256 checksum of the note file |
-1. YAML keys are normalized to lower case.
+1. The format of the generated Markdown links can be customized in the [note format configuration](note-format.md).
+2. YAML keys are normalized to lower case.
diff --git a/docs/template.md b/docs/template.md
index ca5d67e..be36299 100644
--- a/docs/template.md
+++ b/docs/template.md
@@ -9,6 +9,21 @@
Besides the default Handlebars helpers, `zk` ships with additional helpers which you might find useful. They are available to all templates.
+### Format Link helper
+
+The `{{format-link}}` helper renders an internal link to another note, according to the user preferences set in the [note formats configuration](note-format.md).
+
+```
+{{format-link "path/to note.md" "An interesting note"}}
+
+can generate (depending on the user config):
+
+[An interesting note](path/to%20note.md)
+[[path/to note]]
+```
+
+The second parameter `title` is optional.
+
### Date helper
The `{{date}}` helper formats the given date for display.
diff --git a/internal/adapter/handlebars/handlebars.go b/internal/adapter/handlebars/handlebars.go
index bf615f7..b924f70 100644
--- a/internal/adapter/handlebars/handlebars.go
+++ b/internal/adapter/handlebars/handlebars.go
@@ -47,17 +47,14 @@ type Loader struct {
strings map[string]*Template
files map[string]*Template
lookupPaths []string
- lang string
styler core.Styler
- logger util.Logger
+ helpers map[string]interface{}
}
type LoaderOpts struct {
// LookupPaths is used to resolve relative template paths.
LookupPaths []string
- Lang string
Styler core.Styler
- Logger util.Logger
}
// NewLoader creates a new instance of Loader.
@@ -67,12 +64,16 @@ func NewLoader(opts LoaderOpts) *Loader {
strings: make(map[string]*Template),
files: make(map[string]*Template),
lookupPaths: opts.LookupPaths,
- lang: opts.Lang,
styler: opts.Styler,
- logger: opts.Logger,
+ helpers: map[string]interface{}{},
}
}
+// RegisterHelper declares a new template helper to be used with this loader only.
+func (l *Loader) RegisterHelper(name string, helper interface{}) {
+ l.helpers[name] = helper
+}
+
// LoadTemplate implements core.TemplateLoader.
func (l *Loader) LoadTemplate(content string) (core.Template, error) {
wrap := errors.Wrapperf("load template failed")
@@ -144,10 +145,6 @@ func (l *Loader) locateTemplate(path string) (string, bool) {
}
func (l *Loader) newTemplate(vendorTempl *raymond.Template) *Template {
- vendorTempl.RegisterHelpers(map[string]interface{}{
- "style": helpers.NewStyleHelper(l.styler, l.logger),
- "slug": helpers.NewSlugHelper(l.lang, l.logger),
- })
-
+ vendorTempl.RegisterHelpers(l.helpers)
return &Template{vendorTempl, l.styler}
}
diff --git a/internal/adapter/handlebars/handlebars_test.go b/internal/adapter/handlebars/handlebars_test.go
index a1a816b..e69e20d 100644
--- a/internal/adapter/handlebars/handlebars_test.go
+++ b/internal/adapter/handlebars/handlebars_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"time"
+ "github.com/mickael-menu/zk/internal/adapter/handlebars/helpers"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/fixtures"
@@ -34,7 +35,7 @@ func (s *styler) MustStyle(text string, rules ...core.Style) string {
}
func testString(t *testing.T, template string, context interface{}, expected string) {
- sut := testLoader([]string{})
+ sut := testLoader(LoaderOpts{})
templ, err := sut.LoadTemplate(template)
assert.Nil(t, err)
@@ -45,7 +46,7 @@ func testString(t *testing.T, template string, context interface{}, expected str
}
func testFile(t *testing.T, name string, context interface{}, expected string) {
- sut := testLoader([]string{})
+ sut := testLoader(LoaderOpts{})
templ, err := sut.LoadTemplateAt(fixtures.Path(name))
assert.Nil(t, err)
@@ -63,7 +64,7 @@ func TestLookupPaths(t *testing.T) {
path2 := filepath.Join(root, "1")
os.MkdirAll(filepath.Join(path2, "subdir"), os.ModePerm)
- sut := testLoader([]string{path1, path2})
+ sut := testLoader(LoaderOpts{LookupPaths: []string{path1, path2}})
test := func(path string, expected string) {
tpl, err := sut.LoadTemplateAt(path)
@@ -169,6 +170,17 @@ func TestListHelper(t *testing.T) {
test([]string{"An item\non several\nlines\n"}, " ‣ An item\n on several\n lines\n")
}
+func TestLinkHelper(t *testing.T) {
+ sut := testLoader(LoaderOpts{})
+
+ templ, err := sut.LoadTemplate(`{{format-link "path/to note.md" "An interesting subject"}}`)
+ assert.Nil(t, err)
+
+ actual, err := templ.Render(map[string]interface{}{})
+ assert.Nil(t, err)
+ assert.Equal(t, actual, "path/to note.md - An interesting subject")
+}
+
func TestSlugHelper(t *testing.T) {
// inline
testString(t,
@@ -226,11 +238,23 @@ func TestStyleHelper(t *testing.T) {
testString(t, "{{#style 'single'}}A multiline\ntext{{/style}}", nil, "single(A multiline\ntext)")
}
-func testLoader(lookupPaths []string) *Loader {
- return NewLoader(LoaderOpts{
- LookupPaths: lookupPaths,
- Lang: "en",
- Styler: &styler{},
- Logger: &util.NullLogger,
- })
+func testLoader(opts LoaderOpts) *Loader {
+ if opts.LookupPaths == nil {
+ opts.LookupPaths = []string{}
+ }
+ if opts.Styler == nil {
+ opts.Styler = &styler{}
+ }
+
+ loader := NewLoader(opts)
+
+ loader.RegisterHelper("style", helpers.NewStyleHelper(opts.Styler, &util.NullLogger))
+ loader.RegisterHelper("slug", helpers.NewSlugHelper("en", &util.NullLogger))
+
+ formatter := func(path, title string) (string, error) {
+ return path + " - " + title, nil
+ }
+ loader.RegisterHelper("format-link", helpers.NewLinkHelper(formatter, &util.NullLogger))
+
+ return loader
}
diff --git a/internal/adapter/handlebars/helpers/link.go b/internal/adapter/handlebars/helpers/link.go
new file mode 100644
index 0000000..da370f7
--- /dev/null
+++ b/internal/adapter/handlebars/helpers/link.go
@@ -0,0 +1,25 @@
+package helpers
+
+import (
+ "github.com/mickael-menu/zk/internal/core"
+ "github.com/mickael-menu/zk/internal/util"
+)
+
+// NewLinkHelper creates a new template helper to generate an internal link
+// using a LinkFormatter.
+//
+// {{link "path/to/note.md" "An interesting subject"}} -> (depends on the LinkFormatter)
+// [[path/to/note]]
+// [An interesting subject](path/to/note)
+func NewLinkHelper(formatter core.LinkFormatter, logger util.Logger) interface{} {
+ return func(path string, opt interface{}) string {
+ title, _ := opt.(string)
+ link, err := formatter(path, title)
+ if err != nil {
+ logger.Err(err)
+ return ""
+ }
+
+ return link
+ }
+}
diff --git a/internal/adapter/lsp/server.go b/internal/adapter/lsp/server.go
index c90875c..a0825dc 100644
--- a/internal/adapter/lsp/server.go
+++ b/internal/adapter/lsp/server.go
@@ -3,7 +3,6 @@ package lsp
import (
"fmt"
"io/ioutil"
- "net/url"
"path/filepath"
"strings"
@@ -194,7 +193,9 @@ func NewServer(opts ServerOpts) *Server {
return server.buildTagCompletionList(notebook, ":")
}
case "[":
- return server.buildLinkCompletionList(doc, notebook, params)
+ if doc.LookBehind(params.Position, 2) == "[[" {
+ return server.buildLinkCompletionList(doc, notebook, params)
+ }
}
return nil, nil
@@ -371,6 +372,11 @@ func (s *Server) buildInsertForTag(name string, triggerChar string, config core.
}
func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, params *protocol.CompletionParams) ([]protocol.CompletionItem, error) {
+ linkFormatter, err := notebook.NewLinkFormatter()
+ if err != nil {
+ return nil, err
+ }
+
notes, err := notebook.FindNotes(core.NoteFindOpts{})
if err != nil {
return nil, err
@@ -378,9 +384,20 @@ func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook,
var items []protocol.CompletionItem
for _, note := range notes {
+ textEdit, err := s.buildTextEditForLink(notebook, note, doc, params.Position, linkFormatter)
+ if err != nil {
+ s.logger.Err(errors.Wrapf(err, "failed to build TextEdit for note at %s", note.Path))
+ continue
+ }
+
+ label := note.Title
+ if label == "" {
+ label = note.Path
+ }
+
items = append(items, protocol.CompletionItem{
- Label: note.Title,
- TextEdit: s.buildTextEditForLink(notebook, note, doc, params.Position),
+ Label: label,
+ TextEdit: textEdit,
Documentation: protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: note.RawContent,
@@ -391,32 +408,30 @@ func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook,
return items, nil
}
-func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position) interface{} {
- isWikiLink := (document.LookBehind(pos, 2) == "[[")
- var text string
-
+func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position, linkFormatter core.LinkFormatter) (interface{}, error) {
path := filepath.Join(notebook.Path, note.Path)
path = s.fs.Canonical(path)
path, err := filepath.Rel(filepath.Dir(document.Path), path)
if err != nil {
path = note.Path
}
- ext := filepath.Ext(path)
- path = strings.TrimSuffix(path, ext)
- if isWikiLink {
- text = path + "]]"
- } else {
- path = strings.ReplaceAll(url.PathEscape(path), "%2F", "/")
- text = note.Title + "](" + path + ")"
+
+ link, err := linkFormatter(path, note.Title)
+ if err != nil {
+ return nil, err
}
+ // Overwrite [[ trigger
+ start := pos
+ start.Character -= 2
+
return protocol.TextEdit{
Range: protocol.Range{
- Start: pos,
+ Start: start,
End: pos,
},
- NewText: text,
- }
+ NewText: link,
+ }, nil
}
func positionInRange(content string, rng protocol.Range, pos protocol.Position) bool {
diff --git a/internal/cli/cmd/edit.go b/internal/cli/cmd/edit.go
index 2d2bfc1..dd27b19 100644
--- a/internal/cli/cmd/edit.go
+++ b/internal/cli/cmd/edit.go
@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
+ "os"
"path/filepath"
"github.com/mickael-menu/zk/internal/adapter/fzf"
@@ -72,7 +73,7 @@ func (cmd *Edit) Run(container *cli.Container) error {
return editor.Open(paths...)
} else {
- fmt.Println("Found 0 note")
+ fmt.Fprintln(os.Stderr, "Found 0 note")
return nil
}
}
diff --git a/internal/cli/cmd/list.go b/internal/cli/cmd/list.go
index d8392e2..7fd33ba 100644
--- a/internal/cli/cmd/list.go
+++ b/internal/cli/cmd/list.go
@@ -110,6 +110,7 @@ func (cmd *List) noteTemplate() string {
var defaultNoteFormats = map[string]string{
"path": `{{path}}`,
+ "link": `{{link}}`,
"oneline": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`,
diff --git a/internal/cli/cmd/list_test.go b/internal/cli/cmd/list_test.go
index 7bae4fc..83133aa 100644
--- a/internal/cli/cmd/list_test.go
+++ b/internal/cli/cmd/list_test.go
@@ -21,6 +21,7 @@ func TestListFormatPredefined(t *testing.T) {
// Known formats
test("path", `{{path}}`)
+ test("link", `{{link}}`)
test("oneline", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`)
diff --git a/internal/cli/container.go b/internal/cli/container.go
index 11500d9..369aa41 100644
--- a/internal/cli/container.go
+++ b/internal/cli/container.go
@@ -9,6 +9,7 @@ import (
"github.com/mickael-menu/zk/internal/adapter/fs"
"github.com/mickael-menu/zk/internal/adapter/fzf"
"github.com/mickael-menu/zk/internal/adapter/handlebars"
+ hbhelpers "github.com/mickael-menu/zk/internal/adapter/handlebars/helpers"
"github.com/mickael-menu/zk/internal/adapter/markdown"
"github.com/mickael-menu/zk/internal/adapter/sqlite"
"github.com/mickael-menu/zk/internal/adapter/term"
@@ -84,15 +85,24 @@ func NewContainer(version string) (*Container, error) {
ColontagEnabled: config.Format.Markdown.ColonTags,
}),
TemplateLoaderFactory: func(language string) (core.TemplateLoader, error) {
- return handlebars.NewLoader(handlebars.LoaderOpts{
+ loader := handlebars.NewLoader(handlebars.LoaderOpts{
LookupPaths: []string{
filepath.Join(globalConfigDir(), "templates"),
filepath.Join(path, ".zk/templates"),
},
- Lang: config.Note.Lang,
Styler: styler,
- Logger: logger,
- }), nil
+ })
+
+ loader.RegisterHelper("style", hbhelpers.NewStyleHelper(styler, logger))
+ loader.RegisterHelper("slug", hbhelpers.NewSlugHelper(language, logger))
+
+ linkFormatter, err := core.NewLinkFormatter(config.Format.Markdown, loader)
+ if err != nil {
+ return nil, err
+ }
+ loader.RegisterHelper("format-link", hbhelpers.NewLinkHelper(linkFormatter, logger))
+
+ return loader, nil
},
IDGeneratorFactory: func(opts core.IDOptions) func() string {
return rand.NewIDGenerator(opts)
diff --git a/internal/core/config.go b/internal/core/config.go
index d59e1a2..74e4726 100644
--- a/internal/core/config.go
+++ b/internal/core/config.go
@@ -38,9 +38,12 @@ func NewDefaultConfig() Config {
Groups: map[string]GroupConfig{},
Format: FormatConfig{
Markdown: MarkdownConfig{
- Hashtags: true,
- ColonTags: false,
- MultiwordTags: false,
+ Hashtags: true,
+ ColonTags: false,
+ MultiwordTags: false,
+ LinkFormat: "markdown",
+ LinkEncodePath: true,
+ LinkDropExtension: true,
},
},
Filters: map[string]string{},
@@ -102,6 +105,15 @@ type MarkdownConfig struct {
ColonTags bool
// MultiwordTags indicates whether #multi-word tags# are supported.
MultiwordTags bool
+
+ // Format used to generate links between notes.
+ // Either "wiki", "markdown" or a custom template. Default is "markdown".
+ LinkFormat string
+ // Indicates whether a link's path will be percent-encoded.
+ // Defaults to true for "markdown" format only, false otherwise.
+ LinkEncodePath bool
+ // Indicates whether a link's path file extension will be removed.
+ LinkDropExtension bool
}
// ToolConfig holds the external tooling configuration.
@@ -232,6 +244,20 @@ func ParseConfig(content []byte, path string, parentConfig Config) (Config, erro
if markdown.MultiwordTags != nil {
config.Format.Markdown.MultiwordTags = *markdown.MultiwordTags
}
+ if markdown.LinkFormat != nil && *markdown.LinkFormat == "" {
+ *markdown.LinkFormat = "markdown"
+ }
+ if markdown.LinkFormat != nil {
+ config.Format.Markdown.LinkFormat = *markdown.LinkFormat
+ }
+ if markdown.LinkEncodePath != nil {
+ config.Format.Markdown.LinkEncodePath = *markdown.LinkEncodePath
+ } else if markdown.LinkFormat != nil {
+ config.Format.Markdown.LinkEncodePath = (*markdown.LinkFormat == "markdown")
+ }
+ if markdown.LinkDropExtension != nil {
+ config.Format.Markdown.LinkDropExtension = *markdown.LinkDropExtension
+ }
// Tool
tool := tomlConf.Tool
@@ -342,9 +368,12 @@ type tomlFormatConfig struct {
}
type tomlMarkdownConfig struct {
- Hashtags *bool `toml:"hashtags"`
- ColonTags *bool `toml:"colon-tags"`
- MultiwordTags *bool `toml:"multiword-tags"`
+ Hashtags *bool `toml:"hashtags"`
+ ColonTags *bool `toml:"colon-tags"`
+ MultiwordTags *bool `toml:"multiword-tags"`
+ LinkFormat *string `toml:"link-format"`
+ LinkEncodePath *bool `toml:"link-encode-path"`
+ LinkDropExtension *bool `toml:"link-drop-extension"`
}
type tomlToolConfig struct {
diff --git a/internal/core/config_test.go b/internal/core/config_test.go
index cae645f..acae498 100644
--- a/internal/core/config_test.go
+++ b/internal/core/config_test.go
@@ -29,9 +29,12 @@ func TestParseDefaultConfig(t *testing.T) {
Groups: make(map[string]GroupConfig),
Format: FormatConfig{
Markdown: MarkdownConfig{
- Hashtags: true,
- ColonTags: false,
- MultiwordTags: false,
+ Hashtags: true,
+ ColonTags: false,
+ MultiwordTags: false,
+ LinkFormat: "markdown",
+ LinkEncodePath: true,
+ LinkDropExtension: true,
},
},
Tool: ToolConfig{
@@ -68,6 +71,9 @@ func TestParseComplete(t *testing.T) {
hashtags = false
colon-tags = true
multiword-tags = true
+ link-format = "custom"
+ link-encode-path = true
+ link-drop-extension = false
[tool]
editor = "vim"
@@ -185,9 +191,12 @@ func TestParseComplete(t *testing.T) {
},
Format: FormatConfig{
Markdown: MarkdownConfig{
- Hashtags: false,
- ColonTags: true,
- MultiwordTags: true,
+ Hashtags: false,
+ ColonTags: true,
+ MultiwordTags: true,
+ LinkFormat: "custom",
+ LinkEncodePath: true,
+ LinkDropExtension: false,
},
},
Tool: ToolConfig{
@@ -297,9 +306,12 @@ func TestParseMergesGroupConfig(t *testing.T) {
},
Format: FormatConfig{
Markdown: MarkdownConfig{
- Hashtags: true,
- ColonTags: false,
- MultiwordTags: false,
+ Hashtags: true,
+ ColonTags: false,
+ MultiwordTags: false,
+ LinkFormat: "markdown",
+ LinkEncodePath: true,
+ LinkDropExtension: true,
},
},
Filters: make(map[string]string),
@@ -367,6 +379,25 @@ func TestParseIDCase(t *testing.T) {
test("unknown", CaseLower)
}
+// If link-encode-path is not set explicitly, it defaults to true for
+// "markdown" format and false for anything else.
+func TestParseMarkdownLinkEncodePath(t *testing.T) {
+ test := func(format string, expected bool) {
+ toml := fmt.Sprintf(`
+ [format.markdown]
+ link-format = "%s"
+ `, format)
+ conf, err := ParseConfig([]byte(toml), ".zk/config.toml", NewDefaultConfig())
+ assert.Nil(t, err)
+ assert.Equal(t, conf.Format.Markdown.LinkEncodePath, expected)
+ }
+
+ test("", true)
+ test("markdown", true)
+ test("wiki", false)
+ test("custom", false)
+}
+
func TestGroupConfigClone(t *testing.T) {
original := GroupConfig{
Paths: []string{"original"},
diff --git a/internal/core/link_format.go b/internal/core/link_format.go
new file mode 100644
index 0000000..95fa485
--- /dev/null
+++ b/internal/core/link_format.go
@@ -0,0 +1,82 @@
+package core
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/mickael-menu/zk/internal/util/errors"
+ "github.com/mickael-menu/zk/internal/util/paths"
+)
+
+// LinkFormatter formats internal links according to user configuration.
+type LinkFormatter func(path string, title string) (string, error)
+
+// NewLinkFormatter generates a new LinkFormatter from the user Markdown
+// configuration.
+func NewLinkFormatter(config MarkdownConfig, templateLoader TemplateLoader) (LinkFormatter, error) {
+ var formatter LinkFormatter
+ var err error
+ switch config.LinkFormat {
+ case "markdown", "":
+ formatter, err = newMarkdownLinkFormatter(config)
+ case "wiki":
+ formatter, err = newWikiLinkFormatter(config)
+ default:
+ formatter, err = newCustomLinkFormatter(config, templateLoader)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return func(path, title string) (string, error) {
+ if config.LinkDropExtension {
+ path = paths.DropExt(path)
+ }
+ if config.LinkEncodePath {
+ path = strings.ReplaceAll(url.PathEscape(path), "%2F", "/")
+ }
+
+ return formatter(path, title)
+ }, nil
+}
+
+func newMarkdownLinkFormatter(config MarkdownConfig) (LinkFormatter, error) {
+ return func(path, title string) (string, error) {
+ if !config.LinkEncodePath {
+ path = strings.ReplaceAll(path, `\`, `\\`)
+ path = strings.ReplaceAll(path, `)`, `\)`)
+ }
+ title = strings.ReplaceAll(title, `\`, `\\`)
+ title = strings.ReplaceAll(title, `]`, `\]`)
+ return fmt.Sprintf("[%s](%s)", title, path), nil
+ }, nil
+}
+
+func newWikiLinkFormatter(config MarkdownConfig) (LinkFormatter, error) {
+ return func(path, title string) (string, error) {
+ if !config.LinkEncodePath {
+ path = strings.ReplaceAll(path, `\`, `\\`)
+ path = strings.ReplaceAll(path, `]]`, `\]]`)
+ }
+ return "[[" + path + "]]", nil
+ }, nil
+}
+
+func newCustomLinkFormatter(config MarkdownConfig, templateLoader TemplateLoader) (LinkFormatter, error) {
+ wrap := errors.Wrapperf("failed to render custom link with format: %s", config.LinkFormat)
+ template, err := templateLoader.LoadTemplate(config.LinkFormat)
+ if err != nil {
+ return nil, wrap(err)
+ }
+
+ return func(path, title string) (string, error) {
+ return template.Render(customLinkRenderContext{Path: path, Title: title})
+ }, nil
+}
+
+type customLinkRenderContext struct {
+ Path string
+ Title string
+}
diff --git a/internal/core/link_format_test.go b/internal/core/link_format_test.go
new file mode 100644
index 0000000..2f0f879
--- /dev/null
+++ b/internal/core/link_format_test.go
@@ -0,0 +1,116 @@
+package core
+
+import (
+ "testing"
+
+ "github.com/mickael-menu/zk/internal/util/test/assert"
+)
+
+func TestMarkdownLinkFormatter(t *testing.T) {
+ newTester := func(encodePath, dropExtension bool) func(path, title, expected string) {
+ formatter, err := NewLinkFormatter(MarkdownConfig{
+ LinkFormat: "markdown",
+ LinkEncodePath: encodePath,
+ LinkDropExtension: dropExtension,
+ }, &NullTemplateLoader)
+ assert.Nil(t, err)
+
+ return func(path, title, expected string) {
+ actual, err := formatter(path, title)
+ assert.Nil(t, err)
+ assert.Equal(t, actual, expected)
+ }
+ }
+
+ test := newTester(false, false)
+ test("path/to note.md", "", "[](path/to note.md)")
+ test("", "", "[]()")
+ test("path/to note.md", "An interesting subject", "[An interesting subject](path/to note.md)")
+ test(`path/(no\te).md`, `An [interesting] \subject`, `[An [interesting\] \\subject](path/(no\\te\).md)`)
+ test = newTester(true, false)
+ test("path/to note.md", "An interesting subject", "[An interesting subject](path/to%20note.md)")
+ test(`path/(no\te).md`, `An [interesting] \subject`, `[An [interesting\] \\subject](path/%28no%5Cte%29.md)`)
+ test = newTester(false, true)
+ test("path/to note.md", "An interesting subject", "[An interesting subject](path/to note)")
+ test = newTester(true, true)
+ test("path/to note.md", "An interesting subject", "[An interesting subject](path/to%20note)")
+}
+
+func TestWikiLinkFormatter(t *testing.T) {
+ newTester := func(encodePath, dropExtension bool) func(path, title, expected string) {
+ formatter, err := NewLinkFormatter(MarkdownConfig{
+ LinkFormat: "wiki",
+ LinkEncodePath: encodePath,
+ LinkDropExtension: dropExtension,
+ }, &NullTemplateLoader)
+ assert.Nil(t, err)
+
+ return func(path, title, expected string) {
+ actual, err := formatter(path, title)
+ assert.Nil(t, err)
+ assert.Equal(t, actual, expected)
+ }
+ }
+
+ test := newTester(false, false)
+ test("", "", "[[]]")
+ test("path/to note.md", "title", "[[path/to note.md]]")
+ test(`path/[no\te].md`, "title", `[[path/[no\\te].md]]`)
+ test(`path/[[no\te]].md`, "title", `[[path/[[no\\te\]].md]]`)
+ test = newTester(true, false)
+ test("path/to note.md", "title", "[[path/to%20note.md]]")
+ test(`path/[no\te].md`, "title", "[[path/%5Bno%5Cte%5D.md]]")
+ test(`path/[[no\te]].md`, "title", "[[path/%5B%5Bno%5Cte%5D%5D.md]]")
+ test = newTester(false, true)
+ test("path/to note.md", "title", "[[path/to note]]")
+ test = newTester(true, true)
+ test("path/to note.md", "title", "[[path/to%20note]]")
+}
+
+func TestCustomLinkFormatter(t *testing.T) {
+ newTester := func(encodePath, dropExtension bool) func(path, title string, expected customLinkRenderContext) {
+ return func(path, title string, expected customLinkRenderContext) {
+ loader := newTemplateLoaderMock()
+ template := loader.SpyString("custom")
+
+ formatter, err := NewLinkFormatter(MarkdownConfig{
+ LinkFormat: "custom",
+ LinkEncodePath: encodePath,
+ LinkDropExtension: dropExtension,
+ }, loader)
+ assert.Nil(t, err)
+
+ actual, err := formatter(path, title)
+ assert.Nil(t, err)
+ assert.Equal(t, actual, "custom")
+ assert.Equal(t, template.Contexts, []interface{}{expected})
+ }
+ }
+
+ test := newTester(false, false)
+ test("path/to note.md", "", customLinkRenderContext{Path: "path/to note.md"})
+ test("", "", customLinkRenderContext{})
+ test("path/to note.md", "An interesting subject", customLinkRenderContext{
+ Title: "An interesting subject",
+ Path: "path/to note.md",
+ })
+ test(`path/(no\te).md`, `An [interesting] \subject`, customLinkRenderContext{
+ Title: `An [interesting] \subject`,
+ Path: `path/(no\te).md`,
+ })
+ test = newTester(true, false)
+ test("path/to note.md", "An interesting subject", customLinkRenderContext{
+ Title: "An interesting subject",
+ Path: "path/to%20note.md",
+ })
+ test = newTester(false, true)
+ test("path/to note.md", "An interesting subject", customLinkRenderContext{
+ Title: "An interesting subject",
+ Path: "path/to note",
+ })
+ test = newTester(true, true)
+ test("path/to note.md", "An interesting subject", customLinkRenderContext{
+ Title: "An interesting subject",
+ Path: "path/to%20note",
+ })
+}
diff --git a/internal/core/note_format.go b/internal/core/note_format.go
index b99fe20..e014a00 100644
--- a/internal/core/note_format.go
+++ b/internal/core/note_format.go
@@ -1,6 +1,8 @@
package core
import (
+ "encoding/json"
+ "fmt"
"path/filepath"
"regexp"
"time"
@@ -9,7 +11,7 @@ import (
// NoteFormatter formats notes to be printed on the screen.
type NoteFormatter func(note ContextualNote) (string, error)
-func newNoteFormatter(basePath string, template Template, fs FileStorage) (NoteFormatter, error) {
+func newNoteFormatter(basePath string, template Template, linkFormatter LinkFormatter, fs FileStorage) (NoteFormatter, error) {
termRepl, err := template.Styler().Style("$1", StyleTerm)
if err != nil {
return nil, err
@@ -27,8 +29,12 @@ func newNoteFormatter(basePath string, template Template, fs FileStorage) (NoteF
}
return template.Render(noteFormatRenderContext{
- Path: path,
- Title: note.Title,
+ Path: path,
+ Title: note.Title,
+ Link: newLazyStringer(func() string {
+ link, _ := linkFormatter(path, note.Title)
+ return link
+ }),
Lead: note.Lead,
Body: note.Body,
Snippets: snippets,
@@ -50,6 +56,7 @@ var noteTermRegex = regexp.MustCompile(`(.*?)`)
type noteFormatRenderContext struct {
Path string
Title string
+ Link fmt.Stringer
Lead string
Body string
Snippets []string
@@ -62,3 +69,15 @@ type noteFormatRenderContext struct {
Checksum string
Env map[string]string
}
+
+func (c noteFormatRenderContext) Equal(other noteFormatRenderContext) bool {
+ json1, err := json.Marshal(c)
+ if err != nil {
+ return false
+ }
+ json2, err := json.Marshal(other)
+ if err != nil {
+ return false
+ }
+ return string(json1) == string(json2)
+}
diff --git a/internal/core/note_format_test.go b/internal/core/note_format_test.go
index 2f8cac0..4ab5290 100644
--- a/internal/core/note_format_test.go
+++ b/internal/core/note_format_test.go
@@ -4,6 +4,8 @@ import (
"testing"
"time"
+ "github.com/mickael-menu/zk/internal/util/opt"
+ "github.com/mickael-menu/zk/internal/util/paths"
"github.com/mickael-menu/zk/internal/util/test/assert"
)
@@ -70,6 +72,7 @@ func TestNewNoteFormatter(t *testing.T) {
noteFormatRenderContext{
Path: "note1",
Title: "Note 1",
+ Link: opt.NewString("[Note 1](note1)"),
Lead: "Lead 1",
Body: "Body 1",
Snippets: []string{"snippet1", "snippet2"},
@@ -87,6 +90,7 @@ func TestNewNoteFormatter(t *testing.T) {
noteFormatRenderContext{
Path: "dir/note2",
Title: "Note 2",
+ Link: opt.NewString("[Note 2](dir/note2)"),
Lead: "Lead 2",
Body: "Body 2",
Snippets: []string{},
@@ -117,6 +121,7 @@ func TestNoteFormatterMakesPathRelative(t *testing.T) {
assert.Equal(t, test.template.Contexts, []interface{}{
noteFormatRenderContext{
Path: expected,
+ Link: opt.NewString("[](" + paths.DropExt(expected) + ")"),
Snippets: []string{},
},
})
@@ -146,6 +151,7 @@ func TestNoteFormatterStylesSnippetTerm(t *testing.T) {
assert.Equal(t, test.template.Contexts, []interface{}{
noteFormatRenderContext{
Path: ".",
+ Link: opt.NewString("[]()"),
Snippets: []string{expected},
},
})
diff --git a/internal/core/notebook.go b/internal/core/notebook.go
index 006546d..917d845 100644
--- a/internal/core/notebook.go
+++ b/internal/core/notebook.go
@@ -291,5 +291,20 @@ func (n *Notebook) NewNoteFormatter(templateString string) (NoteFormatter, error
return nil, err
}
- return newNoteFormatter(n.Path, template, n.fs)
+ linkFormatter, err := NewLinkFormatter(n.Config.Format.Markdown, templates)
+ if err != nil {
+ return nil, err
+ }
+
+ return newNoteFormatter(n.Path, template, linkFormatter, n.fs)
+}
+
+// NewLinkFormatter returns a LinkFormatter used to generate internal links between notes.
+func (n *Notebook) NewLinkFormatter() (LinkFormatter, error) {
+ templates, err := n.templateLoaderFactory(n.Config.Note.Lang)
+ if err != nil {
+ return nil, err
+ }
+
+ return NewLinkFormatter(n.Config.Format.Markdown, templates)
}
diff --git a/internal/core/notebook_store.go b/internal/core/notebook_store.go
index 425c922..d3f9373 100644
--- a/internal/core/notebook_store.go
+++ b/internal/core/notebook_store.go
@@ -218,9 +218,20 @@ template = "default.md"
# MARKDOWN SETTINGS
[format.markdown]
-# Enable support for #hashtags
-hashtags = true
-# Enable support for :colon:separated:tags:
+
+# Format used to generate links between notes.
+# Either "wiki", "markdown" or a custom template. Default is "markdown".
+#link-format = "wiki"
+# Indicates whether a link's path will be percent-encoded.
+# Defaults to true for "markdown" format and false for "wiki" format.
+#link-encode-path = true
+# Indicates whether a link's path file extension will be removed.
+# Defaults to true.
+#link-drop-extension = true
+
+# Enable support for #hashtags.
+#hashtags = true
+# Enable support for :colon:separated:tags:.
#colon-tags = true
# Enable support for Bear's #multi-word tags#
# Hashtags must be enabled for multi-word tags to work.
diff --git a/internal/core/template.go b/internal/core/template.go
index abc9537..c8019ee 100644
--- a/internal/core/template.go
+++ b/internal/core/template.go
@@ -50,3 +50,16 @@ type TemplateLoader interface {
// TemplateLoaderFactory creates a new instance of an implementation of the
// TemplateLoader port.
type TemplateLoaderFactory func(language string) (TemplateLoader, error)
+
+// NullTemplateLoader a TemplateLoader always returning a NullTemplate.
+var NullTemplateLoader = nullTemplateLoader{}
+
+type nullTemplateLoader struct{}
+
+func (t nullTemplateLoader) LoadTemplate(template string) (Template, error) {
+ return &NullTemplate, nil
+}
+
+func (t nullTemplateLoader) LoadTemplateAt(path string) (Template, error) {
+ return &NullTemplate, nil
+}
diff --git a/internal/core/util.go b/internal/core/util.go
new file mode 100644
index 0000000..9d55ea5
--- /dev/null
+++ b/internal/core/util.go
@@ -0,0 +1,28 @@
+package core
+
+// lazyStringer implements Stringer and wait for String() to be called the first
+// time before computing its value.
+type lazyStringer struct {
+ value *string
+ render func() string
+}
+
+func newLazyStringer(render func() string) *lazyStringer {
+ return &lazyStringer{render: render}
+}
+
+// String implements Stringer.
+func (s *lazyStringer) String() string {
+ if s == nil {
+ return ""
+ }
+ if s.value == nil {
+ str := s.render()
+ s.value = &str
+ }
+ return *s.value
+}
+
+func (s *lazyStringer) MarshalJSON() ([]byte, error) {
+ return []byte(`"` + s.String() + `"`), nil
+}
diff --git a/internal/util/paths/paths.go b/internal/util/paths/paths.go
index 17ff8b5..80d4c2c 100644
--- a/internal/util/paths/paths.go
+++ b/internal/util/paths/paths.go
@@ -46,9 +46,14 @@ func fileInfo(path string) (*os.FileInfo, error) {
// FilenameStem returns the filename component of the given path,
// after removing its file extension.
func FilenameStem(path string) string {
- filename := filepath.Base(path)
- ext := filepath.Ext(filename)
- return strings.TrimSuffix(filename, ext)
+ path = DropExt(path)
+ return filepath.Base(path)
+}
+
+// DropExt returns the path after removing any file extension.
+func DropExt(path string) string {
+ ext := filepath.Ext(path)
+ return strings.TrimSuffix(path, ext)
}
// WriteString writes the given content into a new file at the given path,