├── .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 | goldmark-wikilink 7 | 8 | 14 | 45 | 46 | 47 |
48 |

goldmark-wikilink

49 |
50 | 51 |
52 |
53 |

Input

54 | 55 |
56 | 57 |
58 |

Output

59 |
60 |
61 |
62 | 63 | 64 | 75 | 76 | -------------------------------------------------------------------------------- /.github/workflows/doc.yml: -------------------------------------------------------------------------------- 1 | name: Publish documentation 2 | 3 | on: 4 | # Publish documentation when a new release is tagged. 5 | push: 6 | tags: ['v*'] 7 | 8 | # Allow manually publishing documentation from a specific hash. 9 | workflow_dispatch: 10 | inputs: 11 | head: 12 | description: "Git commit to publish documentation for." 13 | required: true 14 | type: string 15 | 16 | # If two concurrent runs are started, 17 | # prefer the latest one. 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | 24 | build: 25 | name: Build website 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v6 30 | with: 31 | # Check out head specified by workflow_dispatch, 32 | # or the tag if this fired from the push event. 33 | ref: ${{ inputs.head || github.ref }} 34 | - name: Set up mise 35 | uses: jdx/mise-action@v3 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | cache_key_prefix: mise-v0-stable 40 | - name: Generate API reference 41 | run: doc2go -home go.abhg.dev/goldmark/wikilink ./... 42 | - name: Build demo website 43 | run: | 44 | make -C demo 45 | cp -r demo/static _site/demo 46 | - name: Upload pages 47 | uses: actions/upload-pages-artifact@v4 48 | 49 | publish: 50 | name: Publish website 51 | # Don't run until the build has finished running. 52 | needs: build 53 | 54 | # Grants the GITHUB_TOKEN used by this job 55 | # permissions needed to publish the website. 56 | permissions: 57 | pages: write 58 | id-token: write 59 | 60 | # Deploy to the github-pages environment 61 | environment: 62 | name: github-pages 63 | url: ${{ steps.deployment.outputs.page_url }} 64 | 65 | runs-on: ubuntu-latest 66 | steps: 67 | - name: Deploy to GitHub Pages 68 | id: deployment 69 | uses: actions/deploy-pages@v4 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # goldmark-wikilink 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/go.abhg.dev/goldmark/wikilink.svg)](https://pkg.go.dev/go.abhg.dev/goldmark/wikilink) 4 | [![CI](https://github.com/abhinav/goldmark-wikilink/actions/workflows/ci.yml/badge.svg)](https://github.com/abhinav/goldmark-wikilink/actions/workflows/ci.yml) 5 | [![codecov](https://codecov.io/gh/abhinav/goldmark-wikilink/branch/main/graph/badge.svg?token=W98KYF8SPE)](https://codecov.io/gh/abhinav/goldmark-wikilink) 6 | 7 | goldmark-wikilink is an extension for the [goldmark] Markdown parser that 8 | supports parsing `[[...]]`-style wiki links 9 | and `![[...]]`-style embedded wiki links. 10 | 11 | [goldmark]: http://github.com/yuin/goldmark 12 | 13 | **Demo**: 14 | A web-based demonstration of the extension is available at 15 | . 16 | 17 | ## Installation 18 | 19 | ```bash 20 | go get go.abhg.dev/goldmark/wikilink@latest 21 | ``` 22 | 23 | ## Usage 24 | 25 | To use goldmark-wikilink, import the `wikilink` package. 26 | 27 | ```go 28 | import "go.abhg.dev/goldmark/wikilink" 29 | ``` 30 | 31 | Then include the `wikilink.Extender` in the list of extensions 32 | that you build your [`goldmark.Markdown`] with. 33 | 34 | [`goldmark.Markdown`]: https://pkg.go.dev/github.com/yuin/goldmark#Markdown 35 | 36 | ```go 37 | goldmark.New( 38 | goldmark.WithExtensions( 39 | &wikilink.Extender{}, 40 | ), 41 | // ... 42 | ) 43 | ``` 44 | 45 | ## Link resolution 46 | 47 | By default, wikilinks will be converted to URLs based on the page name, 48 | unless they already have an extension. 49 | 50 | [[Foo]] => "Foo.html" 51 | [[Foo bar]] => "Foo bar.html" 52 | [[Foo.pdf]] => "Foo.pdf" 53 | [[Foo.png]] => "Foo.png" 54 | 55 | You can change this by supplying a custom [`wikilink.Resolver`] 56 | to your `wikilink.Extender` when you install it. 57 | 58 | [`wikilink.Resolver`]: https://pkg.go.dev/go.abhg.dev/goldmark/wikilink#Resolver 59 | 60 | ```go 61 | goldmark.New( 62 | goldmark.WithExtensions( 63 | // ... 64 | &wikilink.Extender{ 65 | Resolver: myresolver, 66 | }, 67 | ), 68 | // ... 69 | ) 70 | ``` 71 | 72 | ## Embedding images 73 | 74 | Use the embedded link form (`![[...]]`) to add images to a document. 75 | 76 | ![[foo.png]] 77 | 78 | Add alt text to images with the `![[...|...]]` form: 79 | 80 | ![[foo.png|alt text]] 81 | -------------------------------------------------------------------------------- /mise.lock: -------------------------------------------------------------------------------- 1 | [[tools."aqua:golangci/golangci-lint"]] 2 | version = "2.7.2" 3 | backend = "aqua:golangci/golangci-lint" 4 | "platforms.linux-arm64" = { checksum = "sha256:7028e810837722683dab679fb121336cfa303fecff39dfe248e3e36bc18d941b", url = "https://github.com/golangci/golangci-lint/releases/download/v2.7.2/golangci-lint-2.7.2-linux-arm64.tar.gz"} 5 | "platforms.linux-x64" = { checksum = "sha256:ce46a1f1d890e7b667259f70bb236297f5cf8791a9b6b98b41b283d93b5b6e88", url = "https://github.com/golangci/golangci-lint/releases/download/v2.7.2/golangci-lint-2.7.2-linux-amd64.tar.gz"} 6 | "platforms.macos-arm64" = { checksum = "sha256:6ce86a00e22b3709f7b994838659c322fdc9eae09e263db50439ad4f6ec5785c", url = "https://github.com/golangci/golangci-lint/releases/download/v2.7.2/golangci-lint-2.7.2-darwin-arm64.tar.gz"} 7 | "platforms.macos-x64" = { checksum = "sha256:6966554840a02229a14c52641bc38c2c7a14d396f4c59ba0c7c8bb0675ca25c9", url = "https://github.com/golangci/golangci-lint/releases/download/v2.7.2/golangci-lint-2.7.2-darwin-amd64.tar.gz"} 8 | "platforms.windows-x64" = { checksum = "sha256:d48f456944c5850ca408feb0cac186345f0a6d8cf5dc31875c8f63d3dff5ee4c", url = "https://github.com/golangci/golangci-lint/releases/download/v2.7.2/golangci-lint-2.7.2-windows-amd64.zip"} 9 | 10 | [[tools.go]] 11 | version = "1.25.5" 12 | backend = "core:go" 13 | "platforms.linux-arm64" = { checksum = "sha256:b00b694903d126c588c378e72d3545549935d3982635ba3f7a964c9fa23fe3b9", url = "https://dl.google.com/go/go1.25.5.linux-arm64.tar.gz"} 14 | "platforms.linux-x64" = { checksum = "sha256:9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b", url = "https://dl.google.com/go/go1.25.5.linux-amd64.tar.gz"} 15 | "platforms.macos-arm64" = { checksum = "sha256:bed8ebe824e3d3b27e8471d1307f803fc6ab8e1d0eb7a4ae196979bd9b801dd3", url = "https://dl.google.com/go/go1.25.5.darwin-arm64.tar.gz"} 16 | "platforms.macos-x64" = { checksum = "sha256:b69d51bce599e5381a94ce15263ae644ec84667a5ce23d58dc2e63e2c12a9f56", url = "https://dl.google.com/go/go1.25.5.darwin-amd64.tar.gz"} 17 | "platforms.windows-x64" = { checksum = "sha256:ae756cce1cb80c819b4fe01b0353807178f532211b47f72d7fa77949de054ebb", url = "https://dl.google.com/go/go1.25.5.windows-amd64.zip"} 18 | 19 | [[tools."ubi:abhinav/doc2go"]] 20 | version = "0.11.0" 21 | backend = "ubi:abhinav/doc2go" 22 | 23 | [[tools."ubi:miniscruff/changie"]] 24 | version = "1.24.0" 25 | backend = "ubi:miniscruff/changie" 26 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package wikilink 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/yuin/goldmark/ast" 7 | "github.com/yuin/goldmark/parser" 8 | "github.com/yuin/goldmark/text" 9 | ) 10 | 11 | // Parser parses wikilinks. 12 | // 13 | // Install it on your goldmark Markdown object with Extender, or install it 14 | // directly on your goldmark Parser by using the WithInlineParsers option. 15 | // 16 | // wikilinkParser := util.Prioritized(&wikilink.Parser{...}, 199) 17 | // goldmarkParser.AddOptions(parser.WithInlineParsers(wikilinkParser)) 18 | // 19 | // Note that the priority for the wikilink parser must 199 or lower to take 20 | // precedence over the plain Markdown link parser which has a priority of 200. 21 | type Parser struct{} 22 | 23 | var _ parser.InlineParser = (*Parser)(nil) 24 | 25 | var ( 26 | _open = []byte("[[") 27 | _embedOpen = []byte("![[") 28 | _pipe = []byte{'|'} 29 | _hash = []byte{'#'} 30 | _close = []byte("]]") 31 | ) 32 | 33 | // Trigger returns characters that trigger this parser. 34 | func (p *Parser) Trigger() []byte { 35 | return []byte{'!', '['} 36 | } 37 | 38 | // Parse parses a wikilink in one of the following forms: 39 | // 40 | // [[...]] (simple) 41 | // ![[...]] (embedded) 42 | // 43 | // Both, simple and embedded wikilinks support the following syntax: 44 | // 45 | // [[target]] 46 | // [[target|label]] 47 | // 48 | // If the label is omitted, the target is used as the label. 49 | // 50 | // The target may optionally contain a fragment identifier: 51 | // 52 | // [[target#fragment]] 53 | func (p *Parser) Parse(_ ast.Node, block text.Reader, _ parser.Context) ast.Node { 54 | line, seg := block.PeekLine() 55 | stop := bytes.Index(line, _close) 56 | if stop < 0 { 57 | return nil // must close on the same line 58 | } 59 | 60 | var embed bool 61 | 62 | switch { 63 | case bytes.HasPrefix(line, _open): 64 | seg = text.NewSegment(seg.Start+len(_open), seg.Start+stop) 65 | case bytes.HasPrefix(line, _embedOpen): 66 | embed = true 67 | seg = text.NewSegment(seg.Start+len(_embedOpen), seg.Start+stop) 68 | default: 69 | return nil 70 | } 71 | 72 | n := &Node{Target: block.Value(seg), Embed: embed} 73 | if idx := bytes.Index(n.Target, _pipe); idx >= 0 { 74 | n.Target = n.Target[:idx] // [[ ... | 75 | seg = seg.WithStart(seg.Start + idx + 1) // | ... ]] 76 | } 77 | 78 | if len(n.Target) == 0 || seg.Len() == 0 { 79 | return nil // target and label must not be empty 80 | } 81 | 82 | // Target may be Foo#Bar, so break them apart. 83 | if idx := bytes.LastIndex(n.Target, _hash); idx >= 0 { 84 | n.Fragment = n.Target[idx+1:] // Foo#Bar => Bar 85 | n.Target = n.Target[:idx] // Foo#Bar => Foo 86 | } 87 | 88 | n.AppendChild(n, ast.NewTextSegment(seg)) 89 | block.Advance(stop + 2) 90 | return n 91 | } 92 | -------------------------------------------------------------------------------- /testdata/tests.yaml: -------------------------------------------------------------------------------- 1 | - desc: word 2 | give: | 3 | [[Simple]] link. 4 | want: | 5 |

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

112 | 113 | - desc: label/image 114 | give: | 115 | Image: ![[hello.png|alt text]]. 116 | want: | 117 |

Image: alt text.

118 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package wikilink 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "github.com/yuin/goldmark/ast" 9 | "github.com/yuin/goldmark/parser" 10 | "github.com/yuin/goldmark/text" 11 | ) 12 | 13 | func TestParser(t *testing.T) { 14 | t.Parallel() 15 | 16 | tests := []struct { 17 | desc string 18 | give string 19 | 20 | wantTarget string 21 | wantLabel string 22 | wantFragment string 23 | wantEmbed bool 24 | 25 | remainder string // unconsumed portion of tt.give 26 | }{ 27 | { 28 | desc: "simple", 29 | give: "[[foo]] bar", 30 | wantTarget: "foo", 31 | wantLabel: "foo", 32 | remainder: " bar", 33 | }, 34 | { 35 | desc: "spaces", 36 | give: "[[foo bar]]baz", 37 | wantTarget: "foo bar", 38 | wantLabel: "foo bar", 39 | remainder: "baz", 40 | }, 41 | { 42 | desc: "label", 43 | give: "[[foo|bar]]", 44 | wantTarget: "foo", 45 | wantLabel: "bar", 46 | }, 47 | { 48 | desc: "label with spaces", 49 | give: "[[foo bar|baz qux]] quux", 50 | wantTarget: "foo bar", 51 | wantLabel: "baz qux", 52 | remainder: " quux", 53 | }, 54 | { 55 | desc: "fragment", 56 | give: "[[foo#bar]] baz", 57 | wantTarget: "foo", 58 | wantLabel: "foo#bar", 59 | wantFragment: "bar", 60 | remainder: " baz", 61 | }, 62 | { 63 | desc: "fragment with label", 64 | give: "[[foo#bar|baz]]", 65 | wantTarget: "foo", 66 | wantLabel: "baz", 67 | wantFragment: "bar", 68 | }, 69 | { 70 | desc: "fragment without target", 71 | give: "[[#foo]]", 72 | wantTarget: "", 73 | wantLabel: "#foo", 74 | wantFragment: "foo", 75 | }, 76 | { 77 | desc: "fragment without target with label", 78 | give: "[[#foo|bar]]", 79 | wantTarget: "", 80 | wantLabel: "bar", 81 | wantFragment: "foo", 82 | }, 83 | { 84 | desc: "label with spaces. embedded", 85 | give: "![[foo bar|baz qux]] quux", 86 | wantTarget: "foo bar", 87 | wantLabel: "baz qux", 88 | remainder: " quux", 89 | wantEmbed: true, 90 | }, 91 | { 92 | desc: "fragment without target with label. embedded", 93 | give: "![[#foo|bar]]", 94 | wantTarget: "", 95 | wantLabel: "bar", 96 | wantFragment: "foo", 97 | wantEmbed: true, 98 | }, 99 | { 100 | desc: "fragment without target with label. embedded", 101 | give: "![[baz#foo|bar]]", 102 | wantTarget: "baz", 103 | wantLabel: "bar", 104 | wantFragment: "foo", 105 | wantEmbed: true, 106 | }, 107 | } 108 | 109 | for _, tt := range tests { 110 | tt := tt 111 | t.Run(tt.desc, func(t *testing.T) { 112 | t.Parallel() 113 | 114 | r := text.NewReader([]byte(tt.give)) 115 | 116 | var p Parser 117 | got := p.Parse(nil /* parent */, r, parser.NewContext()) 118 | require.NotNil(t, got, "expected Node, got nil") 119 | 120 | if n, ok := got.(*Node); assert.True(t, ok, "expected Node, got %T", got) { 121 | assert.Equal(t, tt.wantTarget, string(n.Target), "target mismatch") 122 | assert.Equal(t, tt.wantFragment, string(n.Fragment), "fragment mismatch") 123 | assert.Equal(t, tt.wantEmbed, n.Embed, "embed mismatch") 124 | } 125 | 126 | if assert.Equal(t, 1, got.ChildCount(), "children mismatch") { 127 | child := got.FirstChild() 128 | if label, ok := child.(*ast.Text); assert.True(t, ok, "expected Text, got %T", child) { 129 | assert.Equal(t, tt.wantLabel, string(r.Value(label.Segment)), "label mismatch") 130 | } 131 | } 132 | 133 | _, pos := r.Position() 134 | assert.Equal(t, tt.remainder, string(r.Value(pos)), 135 | "remaining text does not match") 136 | }) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /renderer.go: -------------------------------------------------------------------------------- 1 | package wikilink 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "path/filepath" 8 | "sync" 9 | 10 | "github.com/yuin/goldmark/ast" 11 | "github.com/yuin/goldmark/renderer" 12 | "github.com/yuin/goldmark/util" 13 | ) 14 | 15 | // Renderer renders wikilinks as HTML. 16 | // 17 | // Install it on your goldmark Markdown object with Extender, or directly on a 18 | // goldmark Renderer by using the WithNodeRenderers option. 19 | // 20 | // wikilinkRenderer := util.Prioritized(&wikilink.Renderer{...}, 199) 21 | // goldmarkRenderer.AddOptions(renderer.WithNodeRenderers(wikilinkRenderer)) 22 | type Renderer struct { 23 | // Resolver determines destinations for wikilink pages. 24 | // 25 | // If a Resolver returns an empty destination, the Renderer will skip 26 | // the link and render just its contents. That is, instead of, 27 | // 28 | // bar 29 | // 30 | // The renderer will render just the following. 31 | // 32 | // bar 33 | // 34 | // Defaults to DefaultResolver if unspecified. 35 | Resolver Resolver 36 | 37 | once sync.Once // guards init 38 | 39 | // hasDest records whether a node had a destination when we resolved 40 | // it. This is needed to decide whether a closing must be added 41 | // when exiting a Node render. 42 | hasDest sync.Map // *Node => struct{} 43 | } 44 | 45 | func (r *Renderer) init() { 46 | r.once.Do(func() { 47 | if r.Resolver == nil { 48 | r.Resolver = DefaultResolver 49 | } 50 | }) 51 | } 52 | 53 | // RegisterFuncs registers wikilink rendering functions with the provided 54 | // goldmark registerer. This teaches goldmark to call us when it encounters a 55 | // wikilink in the AST. 56 | func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 57 | reg.Register(Kind, r.Render) 58 | } 59 | 60 | // Render renders the provided Node. It must be a Wikilink [Node]. 61 | // 62 | // goldmark will call this method if this renderer was registered with it 63 | // using the WithNodeRenderers option. 64 | // 65 | // All nodes will be rendered as links (with tags), 66 | // except for embed links (![[..]]) that refer to images. 67 | // Those will be rendered as images (with tags). 68 | func (r *Renderer) Render(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 69 | r.init() 70 | 71 | n, ok := node.(*Node) 72 | if !ok { 73 | return ast.WalkStop, fmt.Errorf("unexpected node %T, expected *wikilink.Node", node) 74 | } 75 | 76 | if entering { 77 | return r.enter(w, n, src) 78 | } 79 | 80 | r.exit(w, n) 81 | return ast.WalkContinue, nil 82 | } 83 | 84 | func (r *Renderer) enter(w util.BufWriter, n *Node, src []byte) (ast.WalkStatus, error) { 85 | dest, err := r.Resolver.ResolveWikilink(n) 86 | if err != nil { 87 | return ast.WalkStop, fmt.Errorf("resolve %q: %w", n.Target, err) 88 | } 89 | if len(dest) == 0 { 90 | return ast.WalkContinue, nil 91 | } 92 | 93 | img := resolveAsImage(n) 94 | if !img { 95 | r.hasDest.Store(n, struct{}{}) 96 | _, _ = w.WriteString(``) 99 | return ast.WalkContinue, nil 100 | } 101 | 102 | _, _ = w.WriteString(`bar`) 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 | --------------------------------------------------------------------------------