Generate internal links to notes (#32)

pull/35/head
Mickaël Menu 3 years ago committed by GitHub
parent 083c0dae73
commit 2bb4cbdff4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

3
.gitignore vendored

@ -14,5 +14,4 @@
# Dependency directories (remove the comment below to include it)
# vendor/
# Documentation notebook marker
docs/.zk
.zk

@ -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.

@ -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` | `-`<sup>1</sup> | 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}}]]"
```

@ -2,19 +2,21 @@
The following variables are available in the templates used when formatting notes, for example with `zk list --format <template>`.
| 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`<sup>1</sup> |
| `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 directory<sup>1</sup> |
| `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`<sup>2</sup> |
| `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.

@ -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.

@ -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}
}

@ -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
}

@ -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
}
}

@ -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 {

@ -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
}
}

@ -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"}})`,

@ -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"}})`)

@ -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)

@ -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 {

@ -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"},

@ -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
}

@ -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",
})
}

@ -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(`<zk:match>(.*?)</zk:match>`)
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)
}

@ -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},
},
})

@ -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)
}

@ -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.

@ -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
}

@ -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
}

@ -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,

Loading…
Cancel
Save