├── .changes ├── unreleased │ └── .gitkeep ├── v0.1.0.md ├── v0.4.0.md ├── v0.3.0.md ├── v0.6.0.md ├── header.tpl.md ├── v0.2.0.md └── v0.5.0.md ├── .gitignore ├── demo ├── static │ ├── .gitignore │ └── index.html ├── go.mod ├── Makefile ├── go.sum └── main.go ├── mise.oldstable.toml ├── renovate.json ├── doc.go ├── go.mod ├── .golangci.yml ├── .changie.yaml ├── mise.toml ├── go.sum ├── ast_test.go ├── .github └── workflows │ ├── ci.yml │ └── doc.yml ├── extender.go ├── integration_test.go ├── CHANGELOG.md ├── LICENSE ├── resolver_test.go ├── ast.go ├── resolver.go ├── README.md ├── mise.lock ├── parser.go ├── testdata └── tests.yaml ├── parser_test.go ├── renderer.go └── renderer_test.go /.changes/unreleased/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /cover.out 3 | /cover.html 4 | -------------------------------------------------------------------------------- /demo/static/.gitignore: -------------------------------------------------------------------------------- 1 | /wasm_exec.js 2 | /main.wasm 3 | -------------------------------------------------------------------------------- /.changes/v0.1.0.md: -------------------------------------------------------------------------------- 1 | ## v0.1.0 - 2021-03-14 2 | - Initial release. 3 | -------------------------------------------------------------------------------- /mise.oldstable.toml: -------------------------------------------------------------------------------- 1 | # Run with 'MISE_ENV=oldstable' to run with the oldstable environment. 2 | [tools] 3 | go = "1.24" 4 | -------------------------------------------------------------------------------- /.changes/v0.4.0.md: -------------------------------------------------------------------------------- 1 | ## v0.4.0 - 2022-12-19 2 | ### Changed 3 | - Change the module path to `go.abhg.dev/goldmark/wikilink`. 4 | -------------------------------------------------------------------------------- /.changes/v0.3.0.md: -------------------------------------------------------------------------------- 1 | ## v0.3.0 - 2021-03-25 2 | ### Changed 3 | - Renderer: Don't render links if Resolver returns an empty destination. 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>abhinav/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package wikilink provides support for parsing [[...]]-style and ![[...]]-style 2 | // wiki links to the goldmark Markdown parser. 3 | package wikilink 4 | -------------------------------------------------------------------------------- /.changes/v0.6.0.md: -------------------------------------------------------------------------------- 1 | ## v0.6.0 - 2025-02-02 2 | ### Changed 3 | - Relicense to BSD3. 4 | - Raise minimum version of goldmark to v1.7.8. This requires handling Node.Text deprecation in your code. 5 | -------------------------------------------------------------------------------- /demo/go.mod: -------------------------------------------------------------------------------- 1 | module go.abhg.dev/goldmark/wikilink/demo 2 | 3 | go 1.22 4 | 5 | toolchain go1.25.5 6 | 7 | replace go.abhg.dev/goldmark/wikilink => ../ 8 | 9 | require ( 10 | github.com/yuin/goldmark v1.7.13 11 | go.abhg.dev/goldmark/wikilink v0.6.0 12 | ) 13 | -------------------------------------------------------------------------------- /demo/Makefile: -------------------------------------------------------------------------------- 1 | OUT = static 2 | 3 | .PHONY: all 4 | all: $(OUT)/wasm_exec.js $(OUT)/main.wasm 5 | 6 | $(OUT)/wasm_exec.js: 7 | @mkdir -p $(OUT) 8 | cp "$(shell go env GOROOT)/lib/wasm/wasm_exec.js" $@ 9 | 10 | $(OUT)/main.wasm: $(wildcard *.go) 11 | @mkdir -p $(OUT) 12 | GOOS=js GOARCH=wasm go build -o $@ 13 | -------------------------------------------------------------------------------- /.changes/header.tpl.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 6 | and is generated by [Changie](https://github.com/miniscruff/changie). 7 | -------------------------------------------------------------------------------- /.changes/v0.2.0.md: -------------------------------------------------------------------------------- 1 | ## v0.2.0 - 2021-03-23 2 | ### Added 3 | - Node: Add `Fragment` field to track the `#` portion of a link. 4 | 5 | ### Changed 6 | - Parser: Pull apart `#` portion of a link into Fragment field. 7 | - Renderer: Support links without titles. This makes wikilink references to 8 | headers in the same document possible with `[[#Foo]]` possible. 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.abhg.dev/goldmark/wikilink 2 | 3 | go 1.22 4 | 5 | toolchain go1.25.5 6 | 7 | require ( 8 | github.com/stretchr/testify v1.11.1 9 | github.com/yuin/goldmark v1.7.13 10 | gopkg.in/yaml.v3 v3.0.1 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /.changes/v0.5.0.md: -------------------------------------------------------------------------------- 1 | ## v0.5.0 - 2023-02-27 2 | ### Added 3 | - Support parsing embedded wikilinks in the form, `![[...]]`. 4 | - Support embedding images inside documents. 5 | 6 | ### Changed 7 | - The default resolver now adds the `.html` suffix to a target 8 | only if the target does not already have an extension. 9 | 10 | ### Fixed 11 | - Fix data race in node destination tracking in the Renderer. 12 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | issues: 4 | max-issues-per-linter: 0 5 | max-same-issues: 0 6 | 7 | linters: 8 | enable: 9 | - nolintlint 10 | - revive 11 | settings: 12 | govet: 13 | enable: 14 | - nilness 15 | - reflectvaluecompare 16 | - sortslice 17 | - unusedwrite 18 | exclusions: 19 | generated: lax 20 | 21 | formatters: 22 | enable: 23 | - gofumpt 24 | exclusions: 25 | generated: lax 26 | -------------------------------------------------------------------------------- /.changie.yaml: -------------------------------------------------------------------------------- 1 | changesDir: .changes 2 | unreleasedDir: unreleased 3 | headerPath: header.tpl.md 4 | changelogPath: CHANGELOG.md 5 | versionExt: md 6 | versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}' 7 | kindFormat: '### {{.Kind}}' 8 | changeFormat: '- {{.Body}}' 9 | kinds: 10 | - label: Added 11 | auto: minor 12 | - label: Changed 13 | auto: major 14 | - label: Deprecated 15 | auto: minor 16 | - label: Removed 17 | auto: major 18 | - label: Fixed 19 | auto: patch 20 | - label: Security 21 | auto: patch 22 | newlines: 23 | afterChangelogHeader: 0 24 | beforeChangelogVersion: 1 25 | endOfVersion: 1 26 | envPrefix: CHANGIE_ 27 | -------------------------------------------------------------------------------- /demo/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 6 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 7 | github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 8 | github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /demo/main.go: -------------------------------------------------------------------------------- 1 | // demo implements a WASM module that can be used to format markdown 2 | // with the goldmark-wikilink extension. 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "syscall/js" 8 | 9 | "github.com/yuin/goldmark" 10 | "go.abhg.dev/goldmark/wikilink" 11 | ) 12 | 13 | func main() { 14 | js.Global().Set("renderWikilinks", js.FuncOf(func(this js.Value, args []js.Value) any { 15 | return renderWikilinks(args[0].String()).Encode() 16 | })) 17 | 18 | select {} 19 | } 20 | 21 | type response struct { 22 | HTML string 23 | } 24 | 25 | func (r *response) Encode() js.Value { 26 | return js.ValueOf(map[string]any{ 27 | "html": r.HTML, 28 | }) 29 | } 30 | 31 | func renderWikilinks(markdown string) *response { 32 | md := goldmark.New( 33 | goldmark.WithExtensions( 34 | &wikilink.Extender{}, 35 | ), 36 | ) 37 | 38 | var buff bytes.Buffer 39 | md.Convert([]byte(markdown), &buff) 40 | return &response{ 41 | HTML: buff.String(), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | "ubi:abhinav/doc2go" = "latest" 3 | "aqua:golangci/golangci-lint" = "latest" 4 | "ubi:miniscruff/changie" = "latest" 5 | go = "latest" 6 | 7 | [tasks.build] 8 | run = "go build ./..." 9 | description = "Build the project" 10 | 11 | [tasks.test] 12 | description = "Run tests" 13 | run = "go test -race ./..." 14 | 15 | [tasks.cover] 16 | description = "Run tests with coverage" 17 | run = [ 18 | "go test -race -coverprofile=cover.out -coverpkg=./... ./...", 19 | "go tool cover -html=cover.out -o cover.html" 20 | ] 21 | 22 | [tasks.lint] 23 | description = "Run all linters" 24 | depends = ["lint:*"] 25 | 26 | [tasks."lint:tidy"] 27 | description = "Ensure go.mod is tidy" 28 | run = "go mod tidy -diff" 29 | 30 | [tasks."lint:golangci"] 31 | description = "Run golangci-lint" 32 | run = "golangci-lint run" 33 | 34 | [tasks."release:prepare"] 35 | description = "Prepare a release" 36 | run = [ 37 | "changie batch {{arg(name='version')}}", 38 | "changie merge", 39 | ] 40 | 41 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 6 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 7 | github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 8 | github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /ast_test.go: -------------------------------------------------------------------------------- 1 | package wikilink 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | "github.com/yuin/goldmark/ast" 10 | "github.com/yuin/goldmark/text" 11 | ) 12 | 13 | func TestNodeDump(t *testing.T) { 14 | src := []byte("[[My page]]") 15 | n := &Node{Target: src[2 : len(src)-2]} 16 | n.AppendChild(n, ast.NewTextSegment(text.NewSegment(2, len(src)-2))) 17 | 18 | // Node.Dump writes to stdout and provides now ay of overriding that 19 | // so we'll have to temporarily hijack os.Stdout. 20 | out, err := os.CreateTemp(t.TempDir(), "stdout") 21 | require.NoError(t, err, "create temporary file") 22 | defer func(out *os.File) { os.Stdout = out }(os.Stdout) 23 | os.Stdout = out 24 | 25 | n.Dump(src, 0) 26 | 27 | require.NoError(t, out.Close(), "close stdout") 28 | 29 | got, err := os.ReadFile(out.Name()) 30 | require.NoError(t, err, "read stdout from %q", out.Name()) 31 | 32 | want := strings.Join([]string{ 33 | "WikiLink {", 34 | " Target: My page", 35 | ` Text: "My page"`, 36 | "}", 37 | "", 38 | }, "\n") 39 | require.Equal(t, want, string(got), "dump output mismatch") 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ '*' ] 8 | 9 | jobs: 10 | 11 | lint: 12 | name: Lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | name: Check out repository 17 | - name: Set up mise 18 | uses: jdx/mise-action@v3 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | cache_key_prefix: mise-v0-stable 23 | - run: mise run lint 24 | 25 | test: 26 | name: Test (Go ${{ matrix.mise-env }}) 27 | runs-on: ubuntu-latest 28 | strategy: 29 | matrix: 30 | mise-env: ["stable", "oldstable"] 31 | 32 | env: 33 | MISE_ENV: ${{ matrix.mise-env }} 34 | 35 | steps: 36 | - uses: actions/checkout@v6 37 | - name: Set up mise 38 | uses: jdx/mise-action@v3 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | with: 42 | cache_key_prefix: mise-v0-${{ matrix.mise-env }} 43 | - name: Test 44 | run: mise run cover 45 | - name: Upload coverage 46 | uses: codecov/codecov-action@v5 47 | -------------------------------------------------------------------------------- /extender.go: -------------------------------------------------------------------------------- 1 | package wikilink 2 | 3 | import ( 4 | "github.com/yuin/goldmark" 5 | "github.com/yuin/goldmark/parser" 6 | "github.com/yuin/goldmark/renderer" 7 | "github.com/yuin/goldmark/util" 8 | ) 9 | 10 | // Extender extends a goldmark Markdown object with support for parsing and 11 | // rendering Wikilinks. 12 | type Extender struct { 13 | // Resoler specifies how to resolve destinations for linked pages. 14 | // 15 | // Uses DefaultResolver if unspecified. 16 | Resolver Resolver 17 | } 18 | 19 | // Extend extends the provided Markdown object with support for wikilinks. 20 | func (e *Extender) Extend(md goldmark.Markdown) { 21 | // The link parser is at priority 200 in goldmark so we need to be 22 | // lower than that to ensure that the "[" trigger fires. 23 | md.Parser().AddOptions( 24 | parser.WithInlineParsers( 25 | util.Prioritized(&Parser{}, 199), 26 | ), 27 | ) 28 | 29 | // The renderer priority matters less. Use the same just so that 30 | // there's a reasonable expected value. 31 | md.Renderer().AddOptions( 32 | renderer.WithNodeRenderers( 33 | util.Prioritized(&Renderer{ 34 | Resolver: e.Resolver, 35 | }, 199), 36 | ), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | package wikilink_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | "github.com/yuin/goldmark" 10 | "go.abhg.dev/goldmark/wikilink" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func TestIntegration(t *testing.T) { 15 | t.Parallel() 16 | 17 | testsdata, err := os.ReadFile("testdata/tests.yaml") 18 | require.NoError(t, err) 19 | 20 | var tests []struct { 21 | Desc string `yaml:"desc"` 22 | Give string `yaml:"give"` 23 | Want string `yaml:"want"` 24 | } 25 | require.NoError(t, yaml.Unmarshal(testsdata, &tests)) 26 | 27 | md := goldmark.New(goldmark.WithExtensions(&wikilink.Extender{ 28 | Resolver: _resolver, 29 | })) 30 | 31 | for _, tt := range tests { 32 | tt := tt 33 | t.Run(tt.Desc, func(t *testing.T) { 34 | t.Parallel() 35 | 36 | var buf bytes.Buffer 37 | require.NoError(t, md.Convert([]byte(tt.Give), &buf)) 38 | require.Equal(t, tt.Want, buf.String()) 39 | }) 40 | } 41 | } 42 | 43 | var ( 44 | _resolver = resolver{} 45 | 46 | // Links with this target will return a nil destination. 47 | _doesNotExistTarget = []byte("Does Not Exist") 48 | ) 49 | 50 | type resolver struct{} 51 | 52 | func (resolver) ResolveWikilink(n *wikilink.Node) ([]byte, error) { 53 | if bytes.Equal(n.Target, _doesNotExistTarget) { 54 | return nil, nil 55 | } 56 | 57 | return wikilink.DefaultResolver.ResolveWikilink(n) 58 | } 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 6 | and is generated by [Changie](https://github.com/miniscruff/changie). 7 | 8 | ## v0.6.0 - 2025-02-02 9 | ### Changed 10 | - Relicense to BSD3. 11 | - Raise minimum version of goldmark to v1.7.8. This requires handling Node.Text deprecation in your code. 12 | 13 | ## v0.5.0 - 2023-02-27 14 | ### Added 15 | - Support parsing embedded wikilinks in the form, `![[...]]`. 16 | - Support embedding images inside documents. 17 | 18 | ### Changed 19 | - The default resolver now adds the `.html` suffix to a target 20 | only if the target does not already have an extension. 21 | 22 | ### Fixed 23 | - Fix data race in node destination tracking in the Renderer. 24 | 25 | ## v0.4.0 - 2022-12-19 26 | ### Changed 27 | - Change the module path to `go.abhg.dev/goldmark/wikilink`. 28 | 29 | ## v0.3.0 - 2021-03-25 30 | ### Changed 31 | - Renderer: Don't render links if Resolver returns an empty destination. 32 | 33 | ## v0.2.0 - 2021-03-23 34 | ### Added 35 | - Node: Add `Fragment` field to track the `#` portion of a link. 36 | 37 | ### Changed 38 | - Parser: Pull apart `#` portion of a link into Fragment field. 39 | - Renderer: Support links without titles. This makes wikilink references to 40 | headers in the same document possible with `[[#Foo]]` possible. 41 | 42 | ## v0.1.0 - 2021-03-14 43 | - Initial release. 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Abhinav Gupta 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /resolver_test.go: -------------------------------------------------------------------------------- 1 | package wikilink 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type resolverFunc func(*Node) ([]byte, error) 12 | 13 | func (f resolverFunc) ResolveWikilink(n *Node) ([]byte, error) { 14 | return f(n) 15 | } 16 | 17 | func TestDefaultResolver(t *testing.T) { 18 | t.Parallel() 19 | 20 | tests := []struct { 21 | target string 22 | fragment string 23 | want string 24 | }{ 25 | { 26 | target: "foo", 27 | want: "foo.html", 28 | }, 29 | { 30 | target: "foo bar", 31 | want: "foo bar.html", 32 | }, 33 | { 34 | target: "foo/bar", 35 | want: "foo/bar.html", 36 | }, 37 | { 38 | target: "foo bar.pdf", 39 | want: "foo bar.pdf", 40 | }, 41 | { 42 | target: "foo/bar.png", 43 | want: "foo/bar.png", 44 | }, 45 | { 46 | target: "foo", 47 | fragment: "bar", 48 | want: "foo.html#bar", 49 | }, 50 | { 51 | target: "foo/bar", 52 | fragment: "baz", 53 | want: "foo/bar.html#baz", 54 | }, 55 | { 56 | fragment: "foo", 57 | want: "#foo", 58 | }, 59 | } 60 | 61 | for _, tt := range tests { 62 | tt := tt 63 | name := fmt.Sprintf("%v#%v", tt.target, tt.fragment) 64 | t.Run(name, func(t *testing.T) { 65 | t.Parallel() 66 | 67 | got, err := DefaultResolver.ResolveWikilink(&Node{ 68 | Target: []byte(tt.target), 69 | Fragment: []byte(tt.fragment), 70 | }) 71 | require.NoError(t, err, "resolve failed") 72 | assert.Equal(t, tt.want, string(got), "result mismatch") 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ast.go: -------------------------------------------------------------------------------- 1 | package wikilink 2 | 3 | import ( 4 | "github.com/yuin/goldmark/ast" 5 | ) 6 | 7 | // Kind is the kind of the wikilink AST node. 8 | var Kind = ast.NewNodeKind("WikiLink") 9 | 10 | // Node is a Wikilink AST node. 11 | // Wikilinks have two components: the target and the label. 12 | // 13 | // The target is the page to which this link points, 14 | // and the label is the text that displays for this link. 15 | // 16 | // For links in the following form, the label and the target are the same. 17 | // 18 | // [[Foo bar]] 19 | // 20 | // For links in the following form, the target is the portion of the link to 21 | // the left of the "|", and the label is the portion to the right. 22 | // 23 | // [[Foo bar|baz qux]] 24 | type Node struct { 25 | ast.BaseInline 26 | 27 | // Page to which this wikilink points. 28 | // 29 | // This may be blank for links to headers within the same document 30 | // like [[#Foo]]. 31 | Target []byte 32 | 33 | // Fragment portion of the link, if any. 34 | // 35 | // For links in the form, [[Foo bar#Baz qux]], this is the portion 36 | // after the "#". 37 | Fragment []byte 38 | 39 | // Whether this link starts with a bang (!). 40 | // 41 | // ![[foo.png]] 42 | // 43 | // This indicates that the resource should be embedded (e.g. images). 44 | Embed bool 45 | } 46 | 47 | var _ ast.Node = (*Node)(nil) 48 | 49 | // Kind reports the kind of this node. 50 | func (n *Node) Kind() ast.NodeKind { 51 | return Kind 52 | } 53 | 54 | // Dump dumps the Node to stdout. 55 | func (n *Node) Dump(src []byte, level int) { 56 | ast.DumpHelper(n, src, level, map[string]string{ 57 | "Target": string(n.Target), 58 | }, nil) 59 | } 60 | -------------------------------------------------------------------------------- /resolver.go: -------------------------------------------------------------------------------- 1 | package wikilink 2 | 3 | import "path/filepath" 4 | 5 | // DefaultResolver is a minimal wikilink resolver that resolves wikilinks 6 | // relative to the source page. 7 | // 8 | // It adds ".html" to the end of the target 9 | // if the target does not have an extension. 10 | // 11 | // For example, 12 | // 13 | // [[Foo]] // => "Foo.html" 14 | // [[Foo bar]] // => "Foo bar.html" 15 | // [[foo/Bar]] // => "foo/Bar.html" 16 | // [[foo.pdf]] // => "foo.pdf" 17 | // [[foo.png]] // => "foo.png" 18 | var DefaultResolver Resolver = defaultResolver{} 19 | 20 | // Resolver resolves pages referenced by wikilinks to their destinations. 21 | type Resolver interface { 22 | // ResolveWikilink returns the address of the page that the provided 23 | // wikilink points to. The destination will be URL-escaped before 24 | // being placed into a link. 25 | // 26 | // If ResolveWikilink returns a non-nil error, rendering will be 27 | // halted. 28 | // 29 | // If ResolveWikilink returns a nil destination and error, the 30 | // Renderer will omit the link and render its contents as a regular 31 | // string. 32 | ResolveWikilink(*Node) (destination []byte, err error) 33 | } 34 | 35 | var _html = []byte(".html") 36 | 37 | type defaultResolver struct{} 38 | 39 | func (defaultResolver) ResolveWikilink(n *Node) ([]byte, error) { 40 | dest := make([]byte, len(n.Target)+len(_html)+len(_hash)+len(n.Fragment)) 41 | var i int 42 | if len(n.Target) > 0 { 43 | i += copy(dest, n.Target) 44 | if filepath.Ext(string(n.Target)) == "" { 45 | i += copy(dest[i:], _html) 46 | } 47 | } 48 | if len(n.Fragment) > 0 { 49 | i += copy(dest[i:], _hash) 50 | i += copy(dest[i:], n.Fragment) 51 | } 52 | return dest[:i], nil 53 | } 54 | -------------------------------------------------------------------------------- /demo/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |Simple link.
6 | 7 | - desc: label 8 | give: | 9 | Links [[with|label]]. 10 | want: | 11 |Links label.
12 | 13 | - desc: spaces 14 | give: | 15 | Links [[can have spaces]]. 16 | want: | 17 |Links can have spaces.
18 | 19 | - desc: label/spaces 20 | give: | 21 | Labels [[can have|spaces too]]. 22 | want: | 23 |Labels spaces too.
24 | 25 | - desc: not multiline 26 | give: | 27 | Links can not [[go across 28 | lines]] 29 | want: | 30 |Links can not [[go across 31 | lines]]
32 | 33 | - desc: label/not multiline 34 | give: | 35 | Labels can not [[go across| 36 | lines]]. 37 | want: | 38 |Labels can not [[go across| 39 | lines]].
40 | 41 | - desc: no formatting 42 | give: | 43 | Formatting in [[links *is* _taken_ ~~verbatim~~]]. 44 | want: | 45 |Formatting in links *is* _taken_ ~~verbatim~~.
46 | 47 | - desc: target not empty 48 | give: | 49 | Empty links are not allowed [[]]. 50 | want: | 51 |Empty links are not allowed [[]].
52 | 53 | - desc: label/target not empty 54 | give: | 55 | Empty links are not allowed even with labels [[|Foo]]. 56 | want: | 57 |Empty links are not allowed even with labels [[|Foo]].
58 | 59 | - desc: label not empty 60 | give: | 61 | Empty labels are not allowed [[Foo|]]. 62 | want: | 63 |Empty labels are not allowed [[Foo|]].
64 | 65 | - desc: regular link 66 | give: | 67 | Does not mess up [regular links](dest.html). 68 | want: | 69 |Does not mess up regular links.
70 | 71 | - desc: fragment 72 | give: | 73 | Supports [[Fragments#In Links]]. 74 | want: | 75 |Supports Fragments#In Links.
76 | 77 | - desc: label/fragment 78 | give: | 79 | Links [[with fragments#can have|labels]]. 80 | want: | 81 |Links labels.
82 | 83 | - desc: fragment only 84 | give: | 85 | [[#Relative]] links. 86 | want: | 87 |#Relative links.
88 | 89 | - desc: label/fragment only 90 | give: | 91 | Relative [[#Links|with labels]]. 92 | want: | 93 |Relative with labels.
94 | 95 | - desc: unresolved 96 | give: | 97 | Page that [[Does Not Exist]]. 98 | want: | 99 |Page that Does Not Exist.
100 | 101 | - desc: label/unresolved 102 | give: | 103 | Page that [[Does Not Exist|has a label]]. 104 | want: | 105 |Page that has a label.
106 | 107 | - desc: image 108 | give: | 109 | Image: ![[hello.png]]. 110 | want: | 111 |Image:
.
Image:
.
`)
116 | return ast.WalkSkipChildren, nil
117 | }
118 |
119 | func (r *Renderer) exit(w util.BufWriter, n *Node) {
120 | if _, ok := r.hasDest.LoadAndDelete(n); ok {
121 | _, _ = w.WriteString("")
122 | }
123 | }
124 |
125 | // returns true if the wikilink should be resolved to an image node
126 | func resolveAsImage(n *Node) bool {
127 | if !n.Embed {
128 | return false
129 | }
130 |
131 | filename := string(n.Target)
132 | switch ext := filepath.Ext(filename); ext {
133 | // Common image file types taken from
134 | // https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
135 | case ".apng", ".avif", ".gif", ".jpg", ".jpeg", ".jfif", ".pjpeg", ".pjp", ".png", ".svg", ".webp":
136 | return true
137 | default:
138 | return false
139 | }
140 | }
141 |
142 | func nodeText(src []byte, n ast.Node) []byte {
143 | var buf bytes.Buffer
144 | writeNodeText(src, &buf, n)
145 | return buf.Bytes()
146 | }
147 |
148 | func writeNodeText(src []byte, dst io.Writer, n ast.Node) {
149 | switch n := n.(type) {
150 | case *ast.Text:
151 | _, _ = dst.Write(n.Segment.Value(src))
152 | case *ast.String:
153 | _, _ = dst.Write(n.Value)
154 | default:
155 | for c := n.FirstChild(); c != nil; c = c.NextSibling() {
156 | writeNodeText(src, dst, c)
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/renderer_test.go:
--------------------------------------------------------------------------------
1 | package wikilink
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "errors"
7 | "io"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | "github.com/yuin/goldmark/ast"
13 | )
14 |
15 | func TestRenderer(t *testing.T) {
16 | t.Parallel()
17 |
18 | t.Run("default resolver", func(t *testing.T) {
19 | t.Parallel()
20 |
21 | tests := []struct {
22 | desc string
23 | give *Node
24 | wantEntering string
25 | wantExiting string
26 | }{
27 | {
28 | desc: "page",
29 | give: &Node{
30 | Target: []byte("foo"),
31 | },
32 | wantEntering: ``,
33 | wantExiting: ``,
34 | },
35 | {
36 | desc: "image link",
37 | give: &Node{
38 | Target: []byte("foo.png"),
39 | },
40 | wantEntering: ``,
41 | wantExiting: ``,
42 | },
43 | {
44 | desc: "image embed",
45 | give: &Node{
46 | Target: []byte("foo.png"),
47 | Embed: true,
48 | },
49 | wantEntering: `
`,
50 | wantExiting: ``,
51 | },
52 | {
53 | desc: "image embed url escape",
54 | give: &Node{
55 | Target: []byte("my cat picture 1.jpeg"),
56 | Embed: true,
57 | },
58 | wantEntering: `
`,
59 | wantExiting: ``,
60 | },
61 | {
62 | desc: "pdf link",
63 | give: &Node{
64 | Target: []byte("foo.pdf"),
65 | },
66 | wantEntering: ``,
67 | wantExiting: ``,
68 | },
69 | {
70 | desc: "pdf embed", // unsupported at this time
71 | give: &Node{
72 | Target: []byte("foo.pdf"),
73 | Embed: true,
74 | },
75 | wantEntering: ``,
76 | wantExiting: ``,
77 | },
78 | {
79 | desc: "page fragment",
80 | give: &Node{
81 | Target: []byte("foo"),
82 | Fragment: []byte("frag"),
83 | },
84 | wantEntering: ``,
85 | wantExiting: ``,
86 | },
87 | {
88 | desc: "page fragment embed", // unsupported at this time
89 | give: &Node{
90 | Target: []byte("foo"),
91 | Fragment: []byte("frag"),
92 | Embed: true,
93 | },
94 | wantEntering: ``,
95 | wantExiting: ``,
96 | },
97 | }
98 |
99 | for _, tt := range tests {
100 | t.Run(tt.desc, func(t *testing.T) {
101 | var (
102 | r Renderer
103 | buff bytes.Buffer
104 | )
105 | w := bufio.NewWriter(&buff)
106 |
107 | _, err := r.Render(w, nil /* source */, tt.give, true /* entering */)
108 | require.NoError(t, err, "should not fail")
109 | require.NoError(t, w.Flush(), "flush")
110 |
111 | assert.Equal(t, tt.wantEntering, buff.String(), "output mismatch")
112 | buff.Reset()
113 |
114 | _, err = r.Render(w, nil /* source */, tt.give, false /* exiting */)
115 | require.NoError(t, err, "should not fail")
116 | require.NoError(t, w.Flush(), "flush")
117 |
118 | assert.Equal(t, tt.wantExiting, buff.String(), "output mismatch")
119 | })
120 | }
121 | })
122 |
123 | t.Run("custom resolver", func(t *testing.T) {
124 | t.Parallel()
125 |
126 | var (
127 | buff bytes.Buffer
128 | w = bufio.NewWriter(&buff)
129 | resolved bool
130 | )
131 | defer func() {
132 | assert.True(t, resolved, "custom resolver was never invoked")
133 | }()
134 |
135 | n := &Node{Target: []byte("foo")}
136 | r := Renderer{
137 | Resolver: resolverFunc(func(n *Node) ([]byte, error) {
138 | assert.False(t, resolved, "resolver invoked too many times")
139 | resolved = true
140 |
141 | assert.Equal(t, "foo", string(n.Target), "target mismatch")
142 | return []byte("bar.html"), nil
143 | }),
144 | }
145 |
146 | _, err := r.Render(w, nil /* source */, n, true /* entering */)
147 | require.NoError(t, err, "should not fail")
148 | require.NoError(t, w.Flush(), "flush")
149 |
150 | assert.Equal(t, ``, buff.String(),
151 | "output mismatch")
152 | })
153 |
154 | t.Run("no link", func(t *testing.T) {
155 | t.Parallel()
156 | var (
157 | buff bytes.Buffer
158 | w = bufio.NewWriter(&buff)
159 | )
160 |
161 | n := &Node{Target: []byte("foo")}
162 | r := Renderer{
163 | Resolver: resolverFunc(noopResolver),
164 | }
165 |
166 | _, err := r.Render(w, nil /* source */, n, true /* entering */)
167 | require.NoError(t, err, "should not fail")
168 |
169 | _, err = r.Render(w, nil /* source */, n, false /* entering */)
170 | require.NoError(t, err, "should not fail")
171 |
172 | require.NoError(t, w.Flush(), "flush")
173 | assert.Empty(t, buff.String(), "output should be empty")
174 | })
175 | }
176 |
177 | func TestRenderer_IncorrectNode(t *testing.T) {
178 | t.Parallel()
179 |
180 | var r Renderer
181 | _, err := r.Render(bufio.NewWriter(io.Discard), nil /* src */, ast.NewText(), true /* enter */)
182 | require.Error(t, err, "render with incorrect node must fail")
183 | assert.Contains(t, err.Error(), "unexpected node")
184 | }
185 |
186 | func TestRenderer_ResolveError(t *testing.T) {
187 | t.Parallel()
188 |
189 | r := Renderer{
190 | Resolver: resolverFunc(func(*Node) ([]byte, error) {
191 | return nil, errors.New("great sadness")
192 | }),
193 | }
194 | _, err := r.Render(
195 | bufio.NewWriter(io.Discard),
196 | nil, // source
197 | &Node{Target: []byte("foo")},
198 | true, // entering
199 | )
200 | require.Error(t, err, "render with incorrect node must fail")
201 | assert.Contains(t, err.Error(), "great sadness")
202 | }
203 |
204 | func noopResolver(*Node) ([]byte, error) {
205 | return nil, nil
206 | }
207 |
--------------------------------------------------------------------------------